@bongos/core 1.24.52 → 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.
- package/.bongos-core.json +45 -25
- package/docs/copy-inventory.md +27 -28
- package/docs/copy-registry.json +51 -60
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-readings.json +66 -69
- package/modules/discord/webhook.js +28 -6
- package/modules/public-landing/public/index.html +11 -8
- package/modules/ui-design/public/brand/og-card.png +0 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-docs/index.html +107 -12
- package/release-notes.json +6 -0
- package/scripts/bongos/gen-brand.js +40 -6
- package/scripts/discord/lib.js +17 -3
- package/scripts/status-mirror-sync.js +11 -7
- package/src/bongos/serve-internal.js +3 -2
- package/src/error-page.js +120 -0
- package/src/link-preview.js +71 -0
- package/src/module-api.js +1 -1
- package/src/platform-server.js +22 -8
- package/tests/brand_surfaces.mjs +213 -0
- package/tests/discord_board_broadcast.mjs +23 -10
- package/tests/docs_openapi_route.mjs +2 -1
|
@@ -71,16 +71,36 @@ function capContent(text) {
|
|
|
71
71
|
return String(text).slice(0, 2000);
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
// THE BRAND ON A POST (task 1005091). A broadcast goes out as an EMBED, not as bare
|
|
75
|
+
// message text: the same words as the embed's description, with the pack's accent as
|
|
76
|
+
// the embed's colour bar, so every post in a channel reads as the product's. The
|
|
77
|
+
// description cap is 4096 (an embed's own limit). Mentions inside an embed never ring,
|
|
78
|
+
// which is #871's rule anyway; allowed_mentions stays parse:[] belt-and-braces.
|
|
79
|
+
// The pack's accent as Discord's integer colour, or null (no bar) when it is not a hex.
|
|
80
|
+
function brandColor(brand) {
|
|
81
|
+
let accent = '';
|
|
82
|
+
try { accent = ((brand || api.clientBranding()).theme || {}).colors.accent || ''; } catch { accent = ''; }
|
|
83
|
+
const m = /^#([0-9a-f]{6})$/i.exec(String(accent).trim());
|
|
84
|
+
return m ? parseInt(m[1], 16) : null;
|
|
85
|
+
}
|
|
86
|
+
// The body of a broadcast post: one embed carrying the text, coloured with the accent.
|
|
87
|
+
function embedBody(text, brand) {
|
|
88
|
+
const embed = { description: String(text).slice(0, 4096) };
|
|
89
|
+
const color = brandColor(brand);
|
|
90
|
+
if (color !== null) embed.color = color;
|
|
91
|
+
return { embeds: [embed], allowed_mentions: { parse: [] } };
|
|
92
|
+
}
|
|
93
|
+
|
|
74
94
|
// R22 (the board mirror) needs the id of the message it just posted so the close
|
|
75
95
|
// can be edited onto it, so the POST asks for the created message back
|
|
76
96
|
// (`?wait=true` — Discord then answers 200 + the message JSON instead of 204 with
|
|
77
97
|
// no body). The extra field is additive: every existing caller ignores the return.
|
|
78
|
-
async function postCore(channel,
|
|
98
|
+
async function postCore(channel, body) {
|
|
79
99
|
const url = resolveWebhookUrl(channel);
|
|
80
100
|
const res = await fetch(`${url}${url.includes('?') ? '&' : '?'}wait=true`, {
|
|
81
101
|
method: 'POST',
|
|
82
102
|
headers: { 'Content-Type': 'application/json' },
|
|
83
|
-
body: JSON.stringify(
|
|
103
|
+
body: JSON.stringify(body),
|
|
84
104
|
});
|
|
85
105
|
if (!res.ok) {
|
|
86
106
|
const body = await res.text().catch(() => '');
|
|
@@ -96,7 +116,7 @@ async function postCore(channel, text, allowedMentions) {
|
|
|
96
116
|
// attacker-influenceable; allowed_mentions:{parse:[]} renders text verbatim but
|
|
97
117
|
// fires zero notifications. (Discord defaults to parsing mentions when omitted.)
|
|
98
118
|
async function postWebhook(channel, text) {
|
|
99
|
-
return postCore(channel, text
|
|
119
|
+
return postCore(channel, embedBody(text));
|
|
100
120
|
}
|
|
101
121
|
|
|
102
122
|
// postWebhookMentions — the ONE sanctioned exception to #871's blanket mention
|
|
@@ -104,10 +124,12 @@ async function postWebhook(channel, text) {
|
|
|
104
124
|
// notice"). Still parse:[] — nothing typed INTO the content can ever ping — but
|
|
105
125
|
// an explicit `users` allowlist lets exactly the named ids ring. The ids must
|
|
106
126
|
// come from the board-membership resolve (server data), NEVER from message
|
|
107
|
-
// text; the cap of 20 is a sanity bound far above any real board.
|
|
127
|
+
// text; the cap of 20 is a sanity bound far above any real board. It stays
|
|
128
|
+
// MESSAGE TEXT, not an embed: a mention inside an embed never rings, and ringing
|
|
129
|
+
// is this post's whole job (R22's edit rewrites the same text in place).
|
|
108
130
|
async function postWebhookMentions(channel, text, userIds = []) {
|
|
109
131
|
const users = [...new Set(userIds.filter((u) => /^\d+$/.test(String(u))))].slice(0, 20).map(String);
|
|
110
|
-
return postCore(channel, text, { parse: [], users });
|
|
132
|
+
return postCore(channel, { content: capContent(text), allowed_mentions: { parse: [], users } });
|
|
111
133
|
}
|
|
112
134
|
|
|
113
135
|
// editWebhook — rewrite a message this webhook already posted, in place (R22 of
|
|
@@ -149,5 +171,5 @@ function hasWebhook(channel) {
|
|
|
149
171
|
|
|
150
172
|
module.exports = {
|
|
151
173
|
CHANNELS, WEBHOOKS_PATH, loadWebhooks, resolveWebhookUrl, hasWebhook,
|
|
152
|
-
postWebhook, postWebhookMentions, editWebhook,
|
|
174
|
+
postWebhook, postWebhookMentions, editWebhook, embedBody,
|
|
153
175
|
};
|
|
@@ -320,10 +320,10 @@ h2{font-family:var(--font-display);font-weight:300;font-size:clamp(26px,3.1vw,42
|
|
|
320
320
|
.figline:empty ~ .meter,.figline:empty ~ .sub{display:none}
|
|
321
321
|
.rows:empty{display:none}
|
|
322
322
|
|
|
323
|
-
/*
|
|
323
|
+
/* the maker's band (the pack's company): medallion, wordmark, one paragraph, no frame */
|
|
324
324
|
.verro{display:grid;grid-template-columns:auto 1fr;gap:clamp(26px,4vw,58px);align-items:start}
|
|
325
325
|
.verro img{width:clamp(92px,9vw,124px);height:auto}
|
|
326
|
-
.wordmark{font-family:var(--font-mono);font-size:13px;letter-spacing:.3em;color:var(--ink)}
|
|
326
|
+
.wordmark{font-family:var(--font-mono);font-size:13px;letter-spacing:.3em;text-transform:uppercase;color:var(--ink)}
|
|
327
327
|
.verro p.body{margin-top:18px;font-size:17.5px;line-height:1.55;color:var(--ink-soft);max-width:62ch}
|
|
328
328
|
|
|
329
329
|
/* the basics: two columns, never three */
|
|
@@ -781,7 +781,7 @@ composition A; landing composition v3, picked by the owner 2026-08-27. No roll,
|
|
|
781
781
|
<section class="band">
|
|
782
782
|
<div class="wrap">
|
|
783
783
|
<h2>Built on Bongos.</h2>
|
|
784
|
-
|
|
784
|
+
<!--brand-if:companyName--><p class="sub">At present, all Bongos projects are held by {{companyName}} or its pilot clients.</p><!--/brand-if:companyName-->
|
|
785
785
|
<ul class="orbs" data-orbs>
|
|
786
786
|
<li class="orb-item o1">
|
|
787
787
|
<span class="orb-bead" aria-hidden="true"></span>
|
|
@@ -885,16 +885,19 @@ composition A; landing composition v3, picked by the owner 2026-08-27. No roll,
|
|
|
885
885
|
</section>
|
|
886
886
|
|
|
887
887
|
<!--apex-only:start-->
|
|
888
|
-
|
|
888
|
+
<!-- The maker's band (task 1005091): the company is the pack's (identity.company), never
|
|
889
|
+
a name written into the portable landing, and the band drops whole when the pack
|
|
890
|
+
names none. The medallion is the hub's own asset. -->
|
|
891
|
+
<!--brand-if:companyName--><section class="band">
|
|
889
892
|
<div class="wrap verro">
|
|
890
893
|
<img src="/assets/verro-medallion.png" width="384" height="384" loading="lazy" decoding="async"
|
|
891
|
-
alt="The
|
|
894
|
+
alt="The {{companyName}} mark">
|
|
892
895
|
<div>
|
|
893
|
-
<p class="wordmark">
|
|
894
|
-
<p class="body">Built by
|
|
896
|
+
<p class="wordmark">{{companyName}}</p>
|
|
897
|
+
<p class="body">Built by {{companyName}}, {{productName}} is our way of making AI co-building usable by entire project groups, whether that's a 5,000-person company, a 100-person department, a college research project, or a solo dev.</p>
|
|
895
898
|
</div>
|
|
896
899
|
</div>
|
|
897
|
-
</section
|
|
900
|
+
</section><!--/brand-if:companyName-->
|
|
898
901
|
<!--apex-only:end-->
|
|
899
902
|
|
|
900
903
|
<!--apex-only:start-->
|
|
Binary file
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.24.
|
|
3
|
+
"version": "1.24.53",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.24.
|
|
9
|
+
"version": "1.24.53",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.24.
|
|
3
|
+
"version": "1.24.53",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/public-docs/index.html
CHANGED
|
@@ -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
|
-
/*
|
|
10
|
-
html
|
|
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:
|
|
13
|
-
|
|
14
|
-
border-bottom:
|
|
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
|
|
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
|
|
32
|
-
<
|
|
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>
|
package/release-notes.json
CHANGED
|
@@ -11283,5 +11283,11 @@
|
|
|
11283
11283
|
"id": "1005085",
|
|
11284
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
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
|
+
}
|
|
11286
11292
|
]
|
|
11287
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
|
-
|
|
43
|
-
|
|
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 };
|
package/scripts/discord/lib.js
CHANGED
|
@@ -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
|
-
|
|
62
|
-
const
|
|
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({
|
|
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
|
-
//
|
|
150
|
-
//
|
|
151
|
-
|
|
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
|
-
|
|
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 };
|