@bongos/core 1.24.51 → 1.24.53

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 (95) hide show
  1. package/.bongos-core.json +125 -95
  2. package/docs/copy-inventory.md +225 -242
  3. package/docs/copy-registry.json +278 -452
  4. package/docs/file-map.md +1 -1
  5. package/docs/module-api-changelog.md +4 -0
  6. package/docs/page-inventory.json +33 -0
  7. package/docs/page-readings.json +515 -531
  8. package/modules/discord/webhook.js +28 -6
  9. package/modules/hall-ui/public/ai-studio.html +1 -0
  10. package/modules/hall-ui/public/approval-queue.css +3 -11
  11. package/modules/hall-ui/public/approval-queue.js +13 -20
  12. package/modules/hall-ui/public/atlas.html +1 -0
  13. package/modules/hall-ui/public/blockers.html +1 -0
  14. package/modules/hall-ui/public/board-room.html +1 -0
  15. package/modules/hall-ui/public/bulk-toolbar.js +5 -1
  16. package/modules/hall-ui/public/city.html +1 -0
  17. package/modules/hall-ui/public/collab.html +1 -0
  18. package/modules/hall-ui/public/commands.html +1 -0
  19. package/modules/hall-ui/public/controls.css +100 -0
  20. package/modules/hall-ui/public/copy-desk.css +7 -11
  21. package/modules/hall-ui/public/copy-desk.html +17 -16
  22. package/modules/hall-ui/public/copy-desk.js +13 -9
  23. package/modules/hall-ui/public/deploy.html +1 -0
  24. package/modules/hall-ui/public/goal-inbox.js +32 -17
  25. package/modules/hall-ui/public/goal-map.html +1 -0
  26. package/modules/hall-ui/public/goals-page.js +7 -1
  27. package/modules/hall-ui/public/goals.html +1 -0
  28. package/modules/hall-ui/public/government.html +1 -0
  29. package/modules/hall-ui/public/government.js +10 -2
  30. package/modules/hall-ui/public/hall-dialog.js +255 -0
  31. package/modules/hall-ui/public/hall-kit.js +9 -2
  32. package/modules/hall-ui/public/idea.html +1 -0
  33. package/modules/hall-ui/public/ideas.html +1 -0
  34. package/modules/hall-ui/public/index.html +1 -0
  35. package/modules/hall-ui/public/market-watch-item.html +2 -1
  36. package/modules/hall-ui/public/market-watch-lib.js +16 -9
  37. package/modules/hall-ui/public/market-watch-rules.html +1 -0
  38. package/modules/hall-ui/public/market-watch.css +2 -3
  39. package/modules/hall-ui/public/market-watch.html +2 -1
  40. package/modules/hall-ui/public/modules.html +1 -0
  41. package/modules/hall-ui/public/money.html +1 -0
  42. package/modules/hall-ui/public/my-agents.html +1 -0
  43. package/modules/hall-ui/public/oversight.css +3 -33
  44. package/modules/hall-ui/public/primer.html +1 -0
  45. package/modules/hall-ui/public/profile.html +1 -0
  46. package/modules/hall-ui/public/project-settings.html +1 -0
  47. package/modules/hall-ui/public/roster.html +1 -0
  48. package/modules/hall-ui/public/settings-ai-keys.js +5 -1
  49. package/modules/hall-ui/public/settings-passkeys.js +5 -1
  50. package/modules/hall-ui/public/settings-sessions.js +10 -2
  51. package/modules/hall-ui/public/settings.html +1 -0
  52. package/modules/hall-ui/public/settings.js +5 -1
  53. package/modules/hall-ui/public/street.html +1 -0
  54. package/modules/hall-ui/public/studio.css +6 -32
  55. package/modules/hall-ui/public/studio.html +4 -3
  56. package/modules/hall-ui/public/studio.js +40 -35
  57. package/modules/hall-ui/public/style.css +2 -8
  58. package/modules/hall-ui/public/task.html +1 -0
  59. package/modules/hall-ui/public/task.js +26 -12
  60. package/modules/hall-ui/public/thinking.html +1 -0
  61. package/modules/hall-ui/public/tweak-editor.html +1 -0
  62. package/modules/hall-ui/public/watch.html +1 -0
  63. package/modules/hall-ui/public/work.html +1 -0
  64. package/modules/hall-ui/public/work.js +7 -1
  65. package/modules/public-landing/public/index.html +11 -8
  66. package/modules/ui-design/public/brand/og-card.png +0 -0
  67. package/package-lock.json +2 -2
  68. package/package.json +1 -1
  69. package/public-docs/index.html +107 -12
  70. package/release-notes.json +12 -0
  71. package/scripts/bongos/gen-brand.js +40 -6
  72. package/scripts/discord/lib.js +17 -3
  73. package/scripts/status-mirror-sync.js +11 -7
  74. package/src/bongos/serve-internal.js +3 -2
  75. package/src/error-page.js +120 -0
  76. package/src/link-preview.js +71 -0
  77. package/src/module-api.js +1 -1
  78. package/src/platform-server.js +22 -8
  79. package/tests/brand_surfaces.mjs +213 -0
  80. package/tests/bulk_toolbar.mjs +1 -1
  81. package/tests/discord_board_broadcast.mjs +23 -10
  82. package/tests/docs_openapi_route.mjs +2 -1
  83. package/tests/hall_approval_queue.mjs +8 -2
  84. package/tests/hall_audit.mjs +1 -1
  85. package/tests/hall_city.mjs +1 -1
  86. package/tests/hall_collab_world.mjs +9 -6
  87. package/tests/hall_dialog.mjs +401 -0
  88. package/tests/hall_error_envelope.mjs +1 -1
  89. package/tests/hall_oversight_world.mjs +9 -7
  90. package/tests/hall_record_world.mjs +1 -1
  91. package/tests/hall_street.mjs +1 -1
  92. package/tests/hall_studio_home.mjs +1 -1
  93. package/tests/home_attention_cards.mjs +2 -0
  94. package/tests/kind_axis_unset.mjs +1 -1
  95. package/tests/market_watch_pages.mjs +3 -1
@@ -5,31 +5,126 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <title>{{productName}} — API docs</title>
7
7
  <meta name="description" content="{{productName}} API reference (OpenAPI 3.1)." />
8
+ <link rel="icon" href="/modules/ui-design/public/brand/favicon.svg" type="image/svg+xml">
9
+ <!-- The brand (task 1005091): the shared scales, and — spliced by the server at the end
10
+ of <head> — the pack's colours, its font links and its favicon. Redoc paints the
11
+ reference below with the same tokens, read once at load in whichever mode the
12
+ reader is in (the script at the foot). No CDN: Redoc and the spec are served here. -->
13
+ <link rel="stylesheet" href="/modules/ui-design/public/tokens.css">
8
14
  <style>
9
- /* Self-contained: no external fonts/CSS. Redoc paints the rest. */
10
- html, body { margin: 0; padding: 0; }
15
+ /* The fallback contract tier at zero specificity: the pack's spliced :root wins. */
16
+ :where(html) {
17
+ --bg: #edecee; --bg-card: #fbfafc; --bg-deep: #e2e1e4; --ink: #17181a; --ink-soft: #55565a;
18
+ --ink-faint: #65656a; --rule: #c6c6c8; --rule-soft: #dcdbdd; --accent: #fb9569;
19
+ --accent-deep: #a3481a; --sea: #5b6fd0;
20
+ --font-display: "Manrope", "Segoe UI", system-ui, sans-serif;
21
+ --font-body: "Manrope", "Segoe UI", system-ui, sans-serif;
22
+ }
23
+ html {
24
+ color-scheme: light;
25
+ --font-mono: "JetBrains Mono", ui-monospace, "Cascadia Mono", Consolas, monospace;
26
+ --accent-ink: color-mix(in srgb, var(--accent) 45%, #240e00);
27
+ --focus: color-mix(in srgb, var(--accent-deep) 82%, #3a1400);
28
+ --code-bg: color-mix(in srgb, var(--ink) 5%, var(--bg-card));
29
+ }
30
+ /* dark: written twice, byte-identical (the hall's pair) */
31
+ html[data-theme="dark"] {
32
+ color-scheme: dark;
33
+ --bg: #000000; --bg-card: #101012; --bg-deep: #000000; --ink: #ffffff; --ink-soft: #c0c0c0;
34
+ --ink-faint: #a8a8a8; --rule: rgba(255, 255, 255, .16); --rule-soft: rgba(255, 255, 255, .08);
35
+ --accent-ink: var(--accent); --focus: var(--accent);
36
+ --code-bg: color-mix(in srgb, var(--ink) 8%, var(--bg-card));
37
+ }
38
+ @media (prefers-color-scheme: dark) {
39
+ html:not([data-theme]) {
40
+ color-scheme: dark;
41
+ --bg: #000000; --bg-card: #101012; --bg-deep: #000000; --ink: #ffffff; --ink-soft: #c0c0c0;
42
+ --ink-faint: #a8a8a8; --rule: rgba(255, 255, 255, .16); --rule-soft: rgba(255, 255, 255, .08);
43
+ --accent-ink: var(--accent); --focus: var(--accent);
44
+ --code-bg: color-mix(in srgb, var(--ink) 8%, var(--bg-card));
45
+ }
46
+ }
47
+ html, body { margin: 0; padding: 0; background: var(--bg); color: var(--ink); }
48
+ body { font-family: var(--font-body); }
11
49
  #topbar {
12
- display: flex; align-items: center; gap: 16px;
13
- padding: 10px 20px; font: 14px/1.4 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
14
- border-bottom: 1px solid rgba(0,0,0,0.1); background: #fff; color: #1a1a1a;
50
+ display: flex; align-items: center; gap: var(--ds-space-sm);
51
+ min-height: 56px; padding: 0 var(--ds-space-gutter-narrow);
52
+ border-bottom: var(--ds-line) solid var(--rule-soft); background: var(--bg);
53
+ font-size: var(--ds-text-md);
54
+ }
55
+ #topbar .mark { width: 28px; height: 28px; flex-shrink: 0; --mark-ink: var(--ink); }
56
+ #topbar strong { font-family: var(--font-display); font-size: var(--ds-text-xl); font-weight: 500; }
57
+ #topbar .muted {
58
+ font-family: var(--font-mono); font-size: var(--ds-text-2xs); letter-spacing: .12em;
59
+ text-transform: uppercase; color: var(--ink-soft);
15
60
  }
16
- #topbar strong { font-size: 15px; }
17
- #topbar a { color: #2b6cb0; text-decoration: none; }
18
- #topbar a:hover { text-decoration: underline; }
19
61
  #topbar .spacer { flex: 1; }
20
- #topbar .muted { color: #666; }
62
+ #topbar a {
63
+ display: inline-flex; align-items: center; min-height: 24px;
64
+ color: var(--accent-ink); text-decoration: underline; text-underline-offset: 3px;
65
+ text-decoration-color: color-mix(in srgb, var(--accent-ink) 45%, transparent);
66
+ }
67
+ #topbar a:hover { text-decoration-color: currentColor; }
68
+ a:focus-visible { outline: 2px solid var(--focus); outline-offset: 3px; box-shadow: 0 0 0 8px var(--bg); }
69
+ @media (max-width: 640px) { #topbar .muted, #topbar .origin { display: none; } }
21
70
  </style>
22
71
  </head>
23
72
  <body>
24
73
  <div id="topbar">
74
+ <svg class="mark" viewBox="0 0 200 200" aria-hidden="true"><use href="/modules/ui-design/public/brand/mark.svg#mark"/></svg>
25
75
  <strong>{{productName}} — API</strong>
26
76
  <span class="muted">OpenAPI 3.1</span>
27
77
  <span class="spacer"></span>
28
78
  <a href="/docs/openapi.json" download>Download spec</a>
29
- <a href="{{publicOrigin}}">{{publicOrigin}}</a>
79
+ <a class="origin" href="{{publicOrigin}}">{{publicOrigin}}</a>
30
80
  </div>
31
- <!-- Redoc renders the whole spec below. spec-url is same-origin (served by /docs/openapi.json). -->
32
- <redoc spec-url="/docs/openapi.json" expand-responses="200,201" required-props-first="true"></redoc>
81
+ <!-- Redoc renders the whole spec below. The spec is same-origin (served by /docs/openapi.json). -->
82
+ <div id="redoc"></div>
33
83
  <script src="/docs/redoc.standalone.js"></script>
84
+ <script>
85
+ (function () {
86
+ // Redoc takes a theme object, not CSS variables, so the page's tokens are read once
87
+ // here, resolved in the reader's mode (task 1005091). An unset token reads '' and
88
+ // Redoc keeps its own default for it. Redoc's colour maths reads only plain colours,
89
+ // and a token can be a color-mix(), so each colour is painted onto a one-pixel canvas
90
+ // and read back as rgba(); a face is passed as written.
91
+ var cs = getComputedStyle(document.documentElement);
92
+ var raw = function (n) { return cs.getPropertyValue(n).trim(); };
93
+ var ctx = document.createElement('canvas').getContext('2d', { willReadFrequently: true });
94
+ var probe = document.createElement('span');
95
+ document.body.appendChild(probe);
96
+ var t = function (n) {
97
+ var v = raw(n);
98
+ if (!v || !ctx) return undefined;
99
+ if (/font/.test(n)) return v;
100
+ probe.style.color = v;
101
+ ctx.clearRect(0, 0, 1, 1);
102
+ ctx.fillStyle = getComputedStyle(probe).color;
103
+ ctx.fillRect(0, 0, 1, 1);
104
+ var p = ctx.getImageData(0, 0, 1, 1).data;
105
+ return 'rgba(' + p[0] + ', ' + p[1] + ', ' + p[2] + ', ' + Math.round((p[3] / 255) * 100) / 100 + ')';
106
+ };
107
+ var theme = {
108
+ colors: {
109
+ primary: { main: t('--accent-ink') },
110
+ text: { primary: t('--ink'), secondary: t('--ink-soft') },
111
+ border: { dark: t('--rule'), light: t('--rule-soft') },
112
+ },
113
+ typography: {
114
+ fontFamily: t('--font-body'),
115
+ headings: { fontFamily: t('--font-display'), fontWeight: '400' },
116
+ code: { fontFamily: t('--font-mono'), color: t('--ink'), backgroundColor: t('--code-bg') },
117
+ links: { color: t('--accent-ink'), visited: t('--accent-ink'), hover: t('--ink') },
118
+ },
119
+ sidebar: { backgroundColor: t('--bg-card'), textColor: t('--ink'), activeTextColor: t('--accent-ink') },
120
+ rightPanel: { backgroundColor: t('--bg-deep'), textColor: t('--ink') },
121
+ schema: { nestedBackground: t('--bg-card'), typeNameColor: t('--ink-soft'), linesColor: t('--rule') },
122
+ };
123
+ probe.remove();
124
+ if (window.Redoc) {
125
+ window.Redoc.init('/docs/openapi.json', { expandResponses: '200,201', requiredPropsFirst: true, theme: theme }, document.getElementById('redoc'));
126
+ }
127
+ })();
128
+ </script>
34
129
  </body>
35
130
  </html>
@@ -11277,5 +11277,17 @@
11277
11277
  "id": "1005090",
11278
11278
  "text": "The public status page now talks plainly: 'Top builders', 'Recently shipped', 'Spending' instead of the old ancient-city words, and costs show in dollars."
11279
11279
  }
11280
+ ],
11281
+ "1.24.52": [
11282
+ {
11283
+ "id": "1005085",
11284
+ "text": "The hall's pop-up questions now use one Cloud Bongos dialog instead of the browser's grey box: every 'are you sure?' says plainly what will happen, and the six home-made pop-ups on different pages now share one look and the sa"
11285
+ }
11286
+ ],
11287
+ "1.24.53": [
11288
+ {
11289
+ "id": "1005091",
11290
+ "text": "The API docs, error pages, shared links and Discord posts now carry the Cloud Bongos look: brand fonts and colours in light and dark, a proper 'page not found' screen, a branded preview card when a link is pasted into a chat,"
11291
+ }
11280
11292
  ]
11281
11293
  }
@@ -14,18 +14,21 @@
14
14
  // mark-dot.svg the moon only, one ink the ring breathes, the moon turns)
15
15
  // favicon.svg the small cut, Ink by day and the Night ink by night
16
16
  // apple-touch-icon.png 180x180, the light mark on the pack's ground (--png; needs a browser)
17
+ // og-card.png 1200x630, the link-preview card every public page names (task 1005091):
18
+ // the dark mark and the lowercase wordmark on the void (--png; needs a browser)
17
19
  //
18
20
  // Deterministic text, so `--check` is a clean gate (tests/brand_mark.mjs runs it). The PNG is
19
21
  // rendered by the system browser and only written with --png.
20
22
  //
21
23
  // Usage:
22
24
  // node scripts/bongos/gen-brand.js # write the SVG variants
23
- // node scripts/bongos/gen-brand.js --png # … and render apple-touch-icon.png
25
+ // node scripts/bongos/gen-brand.js --png # … and render apple-touch-icon.png + og-card.png
24
26
  // node scripts/bongos/gen-brand.js --check # write nothing; exit 2 if a variant is stale
25
27
 
26
28
  const fs = require('node:fs');
27
29
  const path = require('node:path');
28
30
  const { NIGHT_INK, readMarkSource, markParts, markFile, faviconSvg } = require('../../src/brand-mark');
31
+ const { escapeHtml } = require('../../src/html-escape');
29
32
 
30
33
  const ROOT = path.resolve(__dirname, '..', '..');
31
34
 
@@ -38,9 +41,40 @@ function brandDir() {
38
41
  return hit;
39
42
  }
40
43
 
44
+ function readPack() {
45
+ return JSON.parse(fs.readFileSync(path.join(ROOT, 'config', 'branding.neutral.json'), 'utf8'));
46
+ }
41
47
  function packColors() {
42
- const pack = JSON.parse(fs.readFileSync(path.join(ROOT, 'config', 'branding.neutral.json'), 'utf8'));
43
- return pack.theme.ui.colors;
48
+ return readPack().theme.ui.colors;
49
+ }
50
+
51
+ // The link-preview card's page: the void, the dark mark, the product name as the wordmark
52
+ // (lowercase, the display voice — DESIGN.md "Naming") in the pack's display face. Pure.
53
+ function ogCardHtml({ markSvg, name, fontLinks = [], fontFamily = 'sans-serif' }) {
54
+ const esc = escapeHtml;
55
+ return '<html><head>' + fontLinks.map((h) => `<link rel="stylesheet" href="${esc(h)}">`).join('') +
56
+ '</head><body style="margin:0;width:1200px;height:630px;background:#000000;display:flex;align-items:center;justify-content:center;gap:56px">' +
57
+ `<img src="data:image/svg+xml;base64,${Buffer.from(markSvg).toString('base64')}" style="width:220px;height:220px">` +
58
+ `<span style="font-family:${esc(fontFamily)};font-weight:300;font-size:132px;letter-spacing:-0.042em;line-height:1;color:#ffffff;text-transform:lowercase">${esc(name)}</span>` +
59
+ '</body></html>';
60
+ }
61
+
62
+ async function renderOgCard(dir) {
63
+ const { launchBrowser } = require('../../modules/ui-design/kit/lib'); // eslint-disable-line global-require
64
+ const pack = readPack();
65
+ const browser = await launchBrowser();
66
+ 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 });
77
+ } finally { await browser.close(); }
44
78
  }
45
79
 
46
80
  // { filename: text } for every SVG variant. Pure given the source and the colours.
@@ -90,10 +124,10 @@ async function main() {
90
124
  process.stdout.write('gen-brand: the logo and favicon set match mark.svg\n');
91
125
  return;
92
126
  }
93
- if (args.includes('--png')) await renderTouchIcon(dir, colors);
94
- process.stdout.write(`gen-brand: ${stale.length ? `wrote ${stale.join(', ')}` : 'nothing to write'}${args.includes('--png') ? ' + apple-touch-icon.png' : ''}\n`);
127
+ if (args.includes('--png')) { await renderTouchIcon(dir, colors); await renderOgCard(dir); }
128
+ process.stdout.write(`gen-brand: ${stale.length ? `wrote ${stale.join(', ')}` : 'nothing to write'}${args.includes('--png') ? ' + apple-touch-icon.png + og-card.png' : ''}\n`);
95
129
  }
96
130
 
97
131
  if (require.main === module) main().catch((e) => { process.stderr.write(`${e.message}\n`); process.exit(1); });
98
132
 
99
- module.exports = { brandFiles, packColors };
133
+ module.exports = { brandFiles, packColors, ogCardHtml };
@@ -47,6 +47,19 @@ function loadWebhooks() {
47
47
  return cfg;
48
48
  }
49
49
 
50
+ // THE BRAND ON A POST (task 1005091). A broadcast goes out as an EMBED, not as bare
51
+ // message text: the same words as the embed's description, with the pack's accent as
52
+ // the embed's colour bar, so every post in a channel reads as the product's. The
53
+ // description cap is 4096 (an embed's own limit). Mentions inside an embed never ring,
54
+ // which is #871's rule anyway; allowed_mentions stays parse:[] belt-and-braces.
55
+ // KEEP IN SYNC with modules/discord/webhook.js embedBody.
56
+ function brandColor() {
57
+ let accent = '';
58
+ try { accent = (require('../../src/branding').clientBranding().theme || {}).colors.accent || ''; } catch { accent = ''; } // eslint-disable-line global-require
59
+ const m = /^#([0-9a-f]{6})$/i.exec(String(accent).trim());
60
+ return m ? parseInt(m[1], 16) : null;
61
+ }
62
+
50
63
  async function postWebhook(channel, text) {
51
64
  if (!CHANNELS.has(channel)) {
52
65
  throw new Error(`Unknown Discord channel '${channel}'. Known: ${[...CHANNELS].join(', ')}`);
@@ -58,8 +71,9 @@ async function postWebhook(channel, text) {
58
71
  `or env DISCORD_${channel.toUpperCase()}_WEBHOOK. See scripts/discord/README.md.`
59
72
  );
60
73
  }
61
- // Discord hard-caps message content at 2000 chars.
62
- const content = String(text).slice(0, 2000);
74
+ const embed = { description: String(text).slice(0, 4096) };
75
+ const color = brandColor();
76
+ if (color !== null) embed.color = color;
63
77
  // SECURITY (#871): suppress ALL mention pings. The content is DB-sourced (a
64
78
  // task value_summary, an idea, etc.) and attacker-influenceable, so without
65
79
  // this a crafted message containing @everyone / @here / <@&role> would ping the
@@ -68,7 +82,7 @@ async function postWebhook(channel, text) {
68
82
  const res = await fetch(url, {
69
83
  method: 'POST',
70
84
  headers: { 'Content-Type': 'application/json' },
71
- body: JSON.stringify({ content, allowed_mentions: { parse: [] } }),
85
+ body: JSON.stringify({ embeds: [embed], allowed_mentions: { parse: [] } }),
72
86
  });
73
87
  if (!res.ok) {
74
88
  const body = await res.text().catch(() => '');
@@ -57,6 +57,7 @@ const { stampHtml } = require('../src/asset-version');
57
57
  // The server's own serving transforms: the mirror is built with them, not with a
58
58
  // copy of them, so the off-box page and the live page are one page (task 1005089).
59
59
  const { applyBrandTokens, themeMarkup } = require('../src/bongos/serve-internal');
60
+ const { linkPreviewTags } = require('../src/link-preview');
60
61
 
61
62
  // The shared files the page links from other modules, published at the SAME paths
62
63
  // so the page's root-absolute hrefs resolve on the mirror's host as on the server.
@@ -146,16 +147,19 @@ function buildMirrorIndex({ srcHtml, apiBase, statusDataUrl, brand }) {
146
147
  if (html.includes('offbox-status')) {
147
148
  throw new Error('modules/status-ui/public/index.html already contains offbox markup — the source should be the droplet (no-banner) copy. Aborting.');
148
149
  }
149
- // The off-box globals, then the pack's theme exactly as the server splices it —
150
- // last in <head>, after the page's own sheets, so the pack wins here as it does live.
151
- html = injectOnce(html, '</head>', headInject(apiBase) + brandingInject(brand) + themeMarkup((brand || {}).theme), 'before');
150
+ // Stamp the instance branding tokens FIRST — the Pages host won't run the server's
151
+ // injection, so the mirror must resolve {{worldName}}/{{currency}}/… itself, and the
152
+ // link-preview card reads the stamped title as the server's does.
153
+ html = brandTokens(html, brand, apiBase);
154
+ // The off-box globals, then the link-preview card and the pack's theme exactly as the
155
+ // server splices them — last in <head>, after the page's own sheets, so the pack wins
156
+ // here as it does live (task 1005091 added the card).
157
+ html = injectOnce(html, '</head>', headInject(apiBase) + brandingInject(brand) +
158
+ linkPreviewTags(html, brand || {}, apiBase) + themeMarkup((brand || {}).theme), 'before');
152
159
  html = injectOnce(html, '<body>', BANNER_DIV, 'after');
153
160
  // Insert the driver right before the main status.js script tag.
154
161
  const scriptAnchor = '<script src="/status.js';
155
- html = injectOnce(html, scriptAnchor, bannerDriver(statusDataUrl, siteLabel(apiBase)), 'before');
156
- // Stamp the instance branding tokens LAST — the Pages host won't run the server's
157
- // injection, so the mirror must resolve {{worldName}}/{{currency}}/… itself.
158
- return brandTokens(html, brand, apiBase);
162
+ return injectOnce(html, scriptAnchor, bannerDriver(statusDataUrl, siteLabel(apiBase)), 'before');
159
163
  }
160
164
 
161
165
  // ---- CLI -------------------------------------------------------------------
@@ -45,6 +45,7 @@ const { buildInfo } = require('../build-info');
45
45
  const { buildBongosDownloads, installScriptReader } = require('../bongos-downloads');
46
46
  const { clientBranding, branding, applyBrandConditionals } = require('../branding');
47
47
  const { nightTintCss } = require('../night-tint');
48
+ const { linkPreviewTags } = require('../link-preview');
48
49
  const { prelaunchGate } = require('./prelaunch-gate');
49
50
  const { platformVisibilityGate } = require('./platform-visibility-gate');
50
51
  const { schemaPending } = require('./schema-lag');
@@ -189,9 +190,9 @@ function injectBrandingGlobal(html, brand = clientBranding(), mods = clientModul
189
190
  // window.__MODULES__ — the enabled feature-module set (R54 task 1193, ADR 0062
190
191
  // §4), injected the same way as __BRANDING__ so hall/status scripts can hide
191
192
  // UI sections for off modules synchronously (no fetch, no race). R60 wires the
192
- // actual section-hiding; this exposes the data.
193
+ // actual section-hiding; this exposes the data. The link-preview card (task 1005091) rides along.
193
194
  const stamped = applyBrandTokens(html, brand);
194
- const tag = `${clientBundleScriptTag()}<script>window.__BRANDING__=${jsonForScript(brand)};window.__MODULES__=${jsonForScript(mods)};</script>${themeMarkup(brand.theme)}`;
195
+ const tag = `${clientBundleScriptTag()}<script>window.__BRANDING__=${jsonForScript(brand)};window.__MODULES__=${jsonForScript(mods)};</script>${linkPreviewTags(stamped, brand, safePublicOrigin())}${themeMarkup(brand.theme)}`;
195
196
  return stamped.includes('</head>') ? stamped.replace('</head>', `${tag}</head>`) : tag + stamped;
196
197
  }
197
198
 
@@ -0,0 +1,120 @@
1
+ // src/error-page.js — the branded 404 and 500 pages (task 1005091, goal 1000138).
2
+ //
3
+ // A person who mistypes an address, or lands on a page that broke, used to get a bare
4
+ // "404 — not found" in plain text: the one screen of the product with no product on it.
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.
7
+ //
8
+ // WHO GETS IT: a browser asking for a page. An API-shaped path keeps its JSON (the
9
+ // self-describing 404 of api-path-404.js; the trailing error net answers the R01
10
+ // envelope), and a client that does not accept HTML keeps plain text — a page is for a
11
+ // person, never a substitute for an error a program has to parse.
12
+ //
13
+ // THE PAGE is a template stamped by the server's own branding transform (passed in as
14
+ // `brandHtml`, serve-internal's injectBrandingGlobal), so the product name, the pack's
15
+ // palette and font links and the favicon come from the one place every page gets them.
16
+ // The words for "not found" are the pack's own (`copy.notFound`). Rendered once per
17
+ // status and kept: the pack is a boot-time singleton.
18
+
19
+ 'use strict';
20
+
21
+ const { escapeHtml } = require('./html-escape');
22
+
23
+ const COPY = {
24
+ 404: {
25
+ label: '404',
26
+ sub: 'The address may be mistyped, or the page has moved.',
27
+ },
28
+ 500: {
29
+ label: '500',
30
+ heading: 'Something went wrong on our side.',
31
+ sub: 'Try again in a moment. If it keeps happening, it has been logged.',
32
+ },
33
+ };
34
+
35
+ /** The unbranded page for a status: tokens ({{productName}}) are stamped by brandHtml. Pure. */
36
+ function errorPageTemplate(status, { notFound = 'Page not found.' } = {}) {
37
+ const c = COPY[status === 404 ? 404 : 500];
38
+ const heading = status === 404 ? notFound : c.heading;
39
+ return `<!doctype html>
40
+ <html lang="en">
41
+ <head>
42
+ <meta charset="utf-8">
43
+ <meta name="viewport" content="width=device-width, initial-scale=1">
44
+ <meta name="robots" content="noindex">
45
+ <title>${escapeHtml(heading)} · {{productName}}</title>
46
+ <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">
48
+ <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
+ *{box-sizing:border-box}
55
+ 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}
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
+ .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}
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
+ .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)}
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
+ 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}
65
+ a:focus-visible{outline:2px solid var(--focus);outline-offset:3px;box-shadow:0 0 0 8px var(--bg)}
66
+ </style>
67
+ </head>
68
+ <body>
69
+ <header>
70
+ <svg class="mark" viewBox="0 0 200 200" aria-hidden="true"><use href="/modules/ui-design/public/brand/mark.svg#mark"/></svg>
71
+ <a class="name" href="/">{{productName}}</a>
72
+ </header>
73
+ <main>
74
+ <div class="card">
75
+ <p class="code">${c.label}</p>
76
+ <h1>${escapeHtml(heading)}</h1>
77
+ <p class="sub">${escapeHtml(c.sub)}</p>
78
+ <a class="back" href="/">Go to the front page</a>
79
+ </div>
80
+ </main>
81
+ </body>
82
+ </html>
83
+ `;
84
+ }
85
+
86
+ /** Does this request want a page? A browser GET/HEAD that accepts HTML. */
87
+ function wantsHtml(req) {
88
+ return (req.method === 'GET' || req.method === 'HEAD') && !!req.accepts('html') && req.accepts(['json', 'html']) === 'html';
89
+ }
90
+
91
+ /**
92
+ * The two handlers a server mounts last: notFound (the 404 tail) and onError (the 4-arg
93
+ * net). Everything they depend on is injected so a test drives them with no server.
94
+ */
95
+ function errorHandlers({ brandHtml, notFoundCopy, isApiShapedPath, apiNotFoundBody, log }) {
96
+ const pages = {};
97
+ const page = (status) => {
98
+ if (!pages[status]) pages[status] = brandHtml(errorPageTemplate(status, { notFound: notFoundCopy() }));
99
+ return pages[status];
100
+ };
101
+ function notFound(req, res) {
102
+ res.status(404);
103
+ if (isApiShapedPath(req.path)) return res.json(apiNotFoundBody(req.path));
104
+ if (wantsHtml(req)) return res.type('html').send(page(404));
105
+ return res.type('text/plain').send('404 — not found');
106
+ }
107
+ // eslint-disable-next-line no-unused-vars
108
+ function onError(err, req, res, next) {
109
+ if (res.headersSent) return next(err);
110
+ const status = Number.isInteger(err && err.status) && err.status >= 400 && err.status < 600 ? err.status : 500;
111
+ if (status >= 500) log(err, req);
112
+ res.status(status);
113
+ if (isApiShapedPath(req.path) || /^\/api(\/|$)/.test(req.path)) return res.json({ error: { code: status >= 500 ? 'internal_error' : 'bad_request', message: status >= 500 ? 'internal_error' : 'bad_request' } });
114
+ if (wantsHtml(req)) return res.type('html').send(page(status === 404 ? 404 : 500));
115
+ return res.type('text/plain').send(`${status} — ${status >= 500 ? 'server error' : 'error'}`);
116
+ }
117
+ return { notFound, onError };
118
+ }
119
+
120
+ module.exports = { errorPageTemplate, errorHandlers };
@@ -0,0 +1,71 @@
1
+ // src/link-preview.js — the one link-preview card every public page carries (task 1005091,
2
+ // goal 1000138).
3
+ //
4
+ // When someone pastes a link to the projects hub, the community page, the status page, the
5
+ // API docs or the raise page into a chat, the preview is built from Open Graph and Twitter
6
+ // card tags. Without them a chat shows a bare URL, or the first image it happens to find. So
7
+ // every served page gets the same card: the page's own <title> and description, the
8
+ // instance's product name as the site, and one 1200x630 image drawn from the shared mark
9
+ // (modules/ui-design/public/brand/og-card.png, written by scripts/bongos/gen-brand.js --png
10
+ // in the pack's colours).
11
+ //
12
+ // ONE TEMPLATE, APPLIED WHERE PAGES ARE SERVED: serve-internal.js injectBrandingGlobal (the
13
+ // landing and hub pages, the status page, the API docs), the off-box status mirror (so it
14
+ // still matches the live page), and the raise views through the module doorway. A page that
15
+ // already writes its own card (the apex landing, which has its own og-card.jpg) keeps it:
16
+ // the template steps aside when it finds an og:title.
17
+ //
18
+ // Pure: everything it reads is a parameter. The image URL is absolute when the instance has
19
+ // a public origin (a chat fetches it from elsewhere), root-relative otherwise.
20
+
21
+ 'use strict';
22
+
23
+ const { escapeHtml } = require('./html-escape');
24
+
25
+ const CARD_PATH = '/modules/ui-design/public/brand/og-card.png';
26
+ const CARD_SIZE = { width: 1200, height: 630 };
27
+
28
+ // The page's own title and description, as text. They arrive already escaped (they were
29
+ // written into the page), so the entities are decoded once and re-escaped on the way out.
30
+ const DECODE = { amp: '&', lt: '<', gt: '>', quot: '"', '#39': "'" };
31
+ const decode = (s) => String(s || '').replace(/&(amp|lt|gt|quot|#39);/g, (_, k) => DECODE[k]).trim();
32
+ function pageMeta(html) {
33
+ const title = /<title>([\s\S]*?)<\/title>/i.exec(html);
34
+ const desc = /<meta\s+name="description"\s+content="([^"]*)"/i.exec(html);
35
+ return { title: decode(title && title[1]), description: decode(desc && desc[1]) };
36
+ }
37
+
38
+ /** The card's tags for a page, or '' when the page already writes its own. */
39
+ function linkPreviewTags(html, brand = {}, origin = '') {
40
+ const page = String(html == null ? '' : html);
41
+ if (/<meta\s+property="og:title"/i.test(page)) return '';
42
+ const site = (brand.identity && brand.identity.productName) || '';
43
+ const { title, description } = pageMeta(page);
44
+ const t = title || site;
45
+ const d = description || (brand.copy && brand.copy.tagline) || '';
46
+ const image = `${String(origin || '').replace(/\/+$/, '')}${CARD_PATH}`;
47
+ const alt = site ? `The ${site} mark` : 'The mark';
48
+ const tag = (attr, key, value) => `<meta ${attr}="${key}" content="${escapeHtml(value)}">`;
49
+ return [
50
+ tag('property', 'og:type', 'website'),
51
+ site ? tag('property', 'og:site_name', site) : '',
52
+ tag('property', 'og:title', t),
53
+ d ? tag('property', 'og:description', d) : '',
54
+ tag('property', 'og:image', image),
55
+ tag('property', 'og:image:width', String(CARD_SIZE.width)),
56
+ tag('property', 'og:image:height', String(CARD_SIZE.height)),
57
+ tag('property', 'og:image:alt', alt),
58
+ tag('name', 'twitter:card', 'summary_large_image'),
59
+ tag('name', 'twitter:title', t),
60
+ d ? tag('name', 'twitter:description', d) : '',
61
+ tag('name', 'twitter:image', image),
62
+ ].join('');
63
+ }
64
+
65
+ /** The page with the card inserted at the end of its <head> (unchanged when it has one). */
66
+ function applyLinkPreview(html, brand = {}, origin = '') {
67
+ const tags = linkPreviewTags(html, brand, origin);
68
+ return tags && String(html).includes('</head>') ? String(html).replace('</head>', `${tags}</head>`) : String(html);
69
+ }
70
+
71
+ module.exports = { linkPreviewTags, applyLinkPreview, CARD_PATH };
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.51'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.24.53'; // 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');
@@ -46,6 +46,11 @@ const path = require('path');
46
46
  const express = require('express');
47
47
  const { mountInternalSurfaces, isStatusHost, isBuildersHost, injectBrandingGlobal } = require('./bongos/serve-internal');
48
48
  const { isApiShapedPath, apiNotFoundBody } = require('./bongos/api-path-404');
49
+ const { errorHandlers } = require('./error-page');
50
+ const { applyLinkPreview } = require('./link-preview');
51
+ const { clientBranding, branding } = require('./branding');
52
+ // The instance's public origin, for an absolute link-preview image URL ('' when unset).
53
+ const safeOrigin = () => { try { return branding().domains.publicOrigin || ''; } catch { return ''; } };
49
54
  const moduleLoader = require('./module-loader/loader');
50
55
  const { start: startErrorTally, startDailySend: startErrorReports } = require('./module-loader/error-tally');
51
56
  const { isModuleEnabled } = require('./modules');
@@ -317,9 +322,14 @@ function buildPlatformApp() {
317
322
  // per request, because modules mount inside mountInternalSurfaces above. No
318
323
  // provider (the module is off, as on every instance but the hub) → next(),
319
324
  // and /raise falls through to the 404 tail like any unknown path.
325
+ // The module renders its own HTML, so the platform's one link-preview card (task
326
+ // 1005091) is added here, on the way out, to any page it sends.
320
327
  app.use('/raise', (req, res, next) => {
321
328
  const raise = isApexHost(req) && seams.resolveOptional('raise.web');
322
329
  if (!raise) return next();
330
+ const send = res.send.bind(res);
331
+ res.send = (body) => send(typeof body === 'string' && body.includes('</head>')
332
+ ? applyLinkPreview(body, clientBranding(), safeOrigin()) : body);
323
333
  Promise.resolve(raise.handle(req, res, next)).catch(next);
324
334
  });
325
335
  // THE ACCOUNT PAGE (task 1004259, ADR 0337 D4.3): where a person edits what
@@ -402,15 +412,19 @@ function buildPlatformApp() {
402
412
  // or the builders' hall lands here. There is NO game to fall through to, so:
403
413
  // * an API-shaped path gets a self-describing JSON 404 (never HTML), the
404
414
  // same contract server.js gives so a mis-prefixed CLI call is legible;
405
- // * everything else gets a plain-text 404 — a game-less instance ships no
406
- // themed island page (public/404.html lives in the game module).
407
- app.use((req, res) => {
408
- res.status(404);
409
- if (isApiShapedPath(req.path)) {
410
- return res.json(apiNotFoundBody(req.path));
411
- }
412
- return res.type('text/plain').send('404 — not found');
415
+ // * a browser asking for a page gets the branded 404 (src/error-page.js,
416
+ // task 1005091), and any other client the plain-text 404 it always had.
417
+ // Then the trailing error net: a page route that throws answers the branded 500
418
+ // to a browser instead of Express's bare "Internal Server Error".
419
+ const { notFound, onError } = errorHandlers({
420
+ brandHtml: injectBrandingGlobal,
421
+ notFoundCopy: () => { try { return clientBranding().copy.notFound; } catch { return 'Page not found.'; } },
422
+ isApiShapedPath,
423
+ apiNotFoundBody,
424
+ log: (err, req) => logger.error({ err, method: req.method, url: req.originalUrl }, 'page request failed'),
413
425
  });
426
+ app.use(notFound);
427
+ app.use(onError);
414
428
 
415
429
  return app;
416
430
  }