@bongos/core 1.24.54 → 1.24.56

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 (35) hide show
  1. package/.bongos-core.json +65 -35
  2. package/docs/copy-inventory.md +16 -16
  3. package/docs/copy-registry.json +17 -17
  4. package/docs/module-api-changelog.md +4 -0
  5. package/docs/page-readings.json +18 -18
  6. package/modules/hall-ui/public/approval-queue.css +9 -9
  7. package/modules/hall-ui/public/atlas.css +5 -1
  8. package/modules/hall-ui/public/atlas.js +14 -11
  9. package/modules/hall-ui/public/city-draw.js +14 -14
  10. package/modules/hall-ui/public/sky-draw.js +24 -6
  11. package/modules/hall-ui/public/sky-interaction.js +1 -1
  12. package/modules/hall-ui/public/sky-panel.js +1 -1
  13. package/modules/hall-ui/public/studio.css +22 -22
  14. package/modules/hall-ui/public/thinking.css +46 -48
  15. package/modules/hall-ui/public/tweak-editor.css +18 -18
  16. package/modules/hall-ui/public/welcome-boot.css +5 -8
  17. package/modules/hall-ui/public/welcome-boot.js +11 -15
  18. package/modules/ui-design/public/brand.css +168 -0
  19. package/modules/ui-design/public/brand.html +162 -0
  20. package/modules/ui-design/public/brand.states.json +35 -0
  21. package/modules/ui-design/scripts/gen-design-reference.js +354 -0
  22. package/package-lock.json +2 -2
  23. package/package.json +1 -1
  24. package/release-notes.json +12 -0
  25. package/src/module-api.js +1 -1
  26. package/src/platform-server.js +18 -0
  27. package/tests/design_reference.mjs +175 -0
  28. package/tests/hall_catalog_world.mjs +7 -2
  29. package/tests/hall_city.mjs +5 -4
  30. package/tests/hall_private_palettes.mjs +148 -0
  31. package/tests/hall_sky.mjs +29 -32
  32. package/tests/hall_street.mjs +3 -2
  33. package/tests/helpers.mjs +25 -1
  34. package/tests/welcome_boot.mjs +1 -1
  35. package/tests/welcome_boot_browser.mjs +1 -1
@@ -0,0 +1,354 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // modules/ui-design/scripts/gen-design-reference.js — build the public design-system
4
+ // reference page, modules/ui-design/public/brand.html, served at /brand on the hub
5
+ // (task 1005083, goal 1000138; DESIGN.md "Naming" → the reference is public).
6
+ //
7
+ // WHY GENERATED. A hand-written reference drifts from the sheets the day after it is
8
+ // written. Nothing on the page is typed by hand except its own layout and the short
9
+ // headings: every value is read from the file that owns it, every component is drawn by
10
+ // the shared rule that every hall page uses.
11
+ //
12
+ // the thirteen colours + the two faces ← config/branding.neutral.json theme.ui
13
+ // their dark values, the derived tier, ← modules/hall-ui/public/style.css (the hall's
14
+ // the status vocabulary, the mono face :root and its html[data-theme="dark"] twin)
15
+ // type, spacing, line, corners, shadow, ← modules/ui-design/public/tokens.css (--ds-*,
16
+ // motion, layers and its dark block for the shadow)
17
+ // buttons, field, pill, dialog, toast… ← drawn live by modules/hall-ui/public/style.css
18
+ // + controls.css; each class is checked to exist
19
+ // task statuses, toast durations ← modules/hall-ui/public/dom-utils.js (the one map)
20
+ // the logo files ← modules/ui-design/public/brand/ + the file list
21
+ // in scripts/bongos/gen-brand.js
22
+ // the naming rule ← DESIGN.md "## Naming"
23
+ //
24
+ // node modules/ui-design/scripts/gen-design-reference.js write the page
25
+ // node modules/ui-design/scripts/gen-design-reference.js --check exit 1 if it is stale
26
+ //
27
+ // Guarded by tests/design_reference.mjs. The kit renders it with
28
+ // modules/ui-design/public/brand.states.json.
29
+
30
+ const fs = require('node:fs');
31
+ const path = require('node:path');
32
+
33
+ const ROOT = path.resolve(__dirname, '..', '..', '..');
34
+ const P = (...a) => path.join(ROOT, ...a);
35
+ const SRC = Object.freeze({
36
+ pack: P('config', 'branding.neutral.json'),
37
+ sheet: P('modules', 'ui-design', 'public', 'tokens.css'),
38
+ hall: P('modules', 'hall-ui', 'public', 'style.css'),
39
+ controls: P('modules', 'hall-ui', 'public', 'controls.css'),
40
+ domUtils: P('modules', 'hall-ui', 'public', 'dom-utils.js'),
41
+ brandDir: P('modules', 'ui-design', 'public', 'brand'),
42
+ genBrand: P('scripts', 'bongos', 'gen-brand.js'),
43
+ design: P('DESIGN.md'),
44
+ });
45
+ const OUT = P('modules', 'ui-design', 'public', 'brand.html');
46
+ const BRAND_URL = '/modules/ui-design/public/brand/';
47
+
48
+ // the hall's one escaper (dom-utils.js also loads under require(); fitness: one escaper per side)
49
+ const esc = (s) => require(SRC.domUtils).escapeHtml(String(s));
50
+ const kebab = (k) => '--' + k.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase());
51
+ const stripComments = (css) => css.replace(/\/\*[\s\S]*?\*\//g, '');
52
+
53
+ // The declarations of the first block whose selector matches `selectorRe`, in source
54
+ // order, as [[prop, value], …]. Values are whitespace-collapsed.
55
+ function blockVars(css, selectorRe, what) {
56
+ const m = stripComments(css).match(new RegExp(selectorRe.source + '\\s*\\{([^}]*)\\}'));
57
+ if (!m) throw new Error(`${what}: no block matching ${selectorRe}`);
58
+ const out = [];
59
+ for (const decl of m[1].split(';')) {
60
+ const d = decl.match(/^\s*(--[a-z0-9-]+)\s*:\s*([\s\S]+?)\s*$/i);
61
+ if (d) out.push([d[1], d[2].replace(/\s+/g, ' ')]);
62
+ }
63
+ return out;
64
+ }
65
+
66
+ // Turn a DESIGN.md line's inline markdown (**bold**, `code`) into escaped HTML.
67
+ function inline(md) {
68
+ return esc(md).replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>').replace(/`([^`]+)`/g, '<code>$1</code>');
69
+ }
70
+
71
+ // DESIGN.md "## Naming", up to its first sub-heading: the lead paragraph and the bullets.
72
+ function readNaming(design) {
73
+ const m = design.match(/\n## Naming\n([\s\S]*?)(?=\n### |\n## |$)/);
74
+ if (!m) throw new Error('DESIGN.md: no "## Naming" chapter');
75
+ const lines = m[1].split('\n').map((l) => l.trim()).filter(Boolean);
76
+ const lead = lines.filter((l) => !l.startsWith('- ')).map(inline);
77
+ const rules = lines.filter((l) => l.startsWith('- ')).map((l) => inline(l.slice(2)));
78
+ if (!rules.length) throw new Error('DESIGN.md: the Naming chapter has no rules');
79
+ return { lead, rules };
80
+ }
81
+
82
+ // The logo files and what each is for, from gen-brand.js's own header (the one list
83
+ // of what it writes), kept only where the file is actually on disk.
84
+ function readBrandFiles(genBrand, dir) {
85
+ const files = [];
86
+ for (const line of genBrand.split('\n')) {
87
+ const m = line.match(/^\/\/\s{2,}([a-z-]+\.(?:svg|png))\s{2,}(.+?)\s*$/);
88
+ if (!m || !fs.existsSync(path.join(dir, m[1]))) continue;
89
+ // the first column of the description only; drop a build note "(--png …)" and an
90
+ // aside the header continues on the next line (an unclosed "(")
91
+ const what = m[2].split(/\s{2,}/)[0].replace(/\s*\(--png[^)]*\)/, '').replace(/\s*\([^)]*$/, '')
92
+ .replace(/\s*\(task \d+\)/, '').replace(/[:;,]\s*$/, '');
93
+ files.push({ file: m[1], what });
94
+ }
95
+ if (!files.length) throw new Error('gen-brand.js: no file list found in its header');
96
+ return [{ file: 'mark.svg', what: 'the one source: drawn inline by reference, coloured by the page' }, ...files];
97
+ }
98
+
99
+ // The classes the components section draws with: each must exist in the hall's sheets,
100
+ // so a renamed rule fails the generator instead of shipping an unstyled specimen.
101
+ const NEEDS = ['.btn', '.btn--ghost', '.btn--danger', '.btn--sm', '.btn--text', '.status-pill', '.status-pill--sm',
102
+ '.scroll', '.scroll--quiet', '.scroll__h', '.scroll__lede', '.empty-state', '.empty-state__action', '.hall-loading',
103
+ '.hall-loading--inline', '.toast', '.kit-tabs', '.setup__tab', '.count-pill', '.viewtoggle', '.viewtoggle__btn',
104
+ '.hall-dialog', '.hall-dialog__head', '.hall-dialog__title', '.hall-dialog__body', '.hall-dialog__actions'];
105
+
106
+ // Which hall :root props count as the derived COLOUR tier: anything whose value is a
107
+ // colour expression (not a length, a face, a gradient veil or a plate).
108
+ const COLOURISH = /^(#|rgba?\(|color-mix\()/;
109
+ const STATUS = ['--ok', '--ok-weak', '--warn', '--warn-weak', '--danger', '--danger-weak', '--info', '--info-weak'];
110
+
111
+ function read() {
112
+ return {
113
+ pack: JSON.parse(fs.readFileSync(SRC.pack, 'utf8')),
114
+ sheet: fs.readFileSync(SRC.sheet, 'utf8'),
115
+ hall: fs.readFileSync(SRC.hall, 'utf8'),
116
+ controls: fs.readFileSync(SRC.controls, 'utf8'),
117
+ design: fs.readFileSync(SRC.design, 'utf8'),
118
+ genBrand: fs.readFileSync(SRC.genBrand, 'utf8'),
119
+ otb: require(SRC.domUtils),
120
+ };
121
+ }
122
+
123
+ // ---- the page's parts ---------------------------------------------------------------
124
+
125
+ const row = (cells, cls = '') => `<li class="ref-row${cls ? ' ' + cls : ''}">${cells.join('')}</li>`;
126
+ const name = (prop) => `<code class="ref-name">${esc(prop)}</code>`;
127
+ const val = (label, v) => `<span class="ref-val"><span class="ref-val__k">${label}</span> <code>${esc(v)}</code></span>`;
128
+ const swatch = (prop) => `<span class="ref-swatch" style="background: var(${prop})" aria-hidden="true"></span>`;
129
+ const list = (rows, cls = '') => `<ul class="ref-list${cls ? ' ' + cls : ''}" role="list">${rows.join('')}</ul>`;
130
+ function section(id, title, lede, body) {
131
+ return `<section class="scroll ref-sec" id="${id}" aria-labelledby="${id}-h">
132
+ <h2 class="scroll__h" id="${id}-h">${esc(title)}</h2>
133
+ ${lede ? `<p class="scroll__lede">${lede}</p>` : ''}
134
+ ${body}
135
+ </section>`;
136
+ }
137
+
138
+ function colourRows(entries) {
139
+ return entries.map(({ prop, light, dark }) => row([
140
+ swatch(prop), name(prop), val('light', light), val('dark', dark),
141
+ ], 'ref-row--colour'));
142
+ }
143
+
144
+ function build(src = read()) {
145
+ const ui = src.pack.theme && src.pack.theme.ui;
146
+ if (!ui || !ui.colors || !ui.fonts) throw new Error('branding.neutral.json: theme.ui has no colours or fonts');
147
+ const hallRoot = new Map(blockVars(src.hall, /(?:^|\n|\})\s*:root/, 'style.css'));
148
+ const hallDark = new Map(blockVars(src.hall, /html\[data-theme="dark"\]/, 'style.css'));
149
+ const ds = blockVars(src.sheet, /(?:^|\n|\})\s*:root/, 'tokens.css');
150
+ const dsDark = new Map(blockVars(src.sheet, /html\[data-theme="dark"\], :root\[data-mode="dark"\]/, 'tokens.css'));
151
+ const allCss = src.hall + '\n' + src.controls;
152
+ for (const cls of NEEDS) {
153
+ if (!new RegExp(cls.replace(/[.[\]-]/g, '\\$&') + '(?![a-z0-9_-])').test(allCss)) throw new Error(`the hall's sheets no longer define ${cls}`);
154
+ }
155
+ const product = (src.pack.identity && src.pack.identity.productName) || '';
156
+ const naming = readNaming(src.design);
157
+ const files = readBrandFiles(src.genBrand, SRC.brandDir);
158
+ const OTB = src.otb;
159
+
160
+ // 1. the pack's thirteen
161
+ const packKeys = Object.keys(ui.colors);
162
+ const pack = packKeys.map((k) => {
163
+ const prop = kebab(k);
164
+ if (!hallRoot.has(prop)) throw new Error(`style.css :root does not declare ${prop} (pack colour ${k})`);
165
+ return { prop, light: ui.colors[k], dark: hallDark.get(prop) || 'same as light' };
166
+ });
167
+ const packProps = new Set(pack.map((c) => c.prop));
168
+ // 2. the status vocabulary
169
+ const status = STATUS.map((prop) => {
170
+ if (!hallRoot.has(prop)) throw new Error(`style.css :root does not declare ${prop}`);
171
+ return { prop, light: hallRoot.get(prop), dark: hallDark.get(prop) || hallRoot.get(prop) };
172
+ });
173
+ // 3. the rest of the derived colour tier
174
+ const derived = [...hallRoot].filter(([prop, v]) => !packProps.has(prop) && !STATUS.includes(prop) && COLOURISH.test(v))
175
+ .map(([prop, light]) => ({ prop, light, dark: hallDark.get(prop) || light }));
176
+
177
+ // the scales, grouped by their prefix in tokens.css
178
+ const group = (re) => ds.filter(([p]) => re.test(p));
179
+ const text = group(/^--ds-text-/);
180
+ const space = group(/^--ds-space-/);
181
+ const line = group(/^--ds-line$/);
182
+ const radius = group(/^--ds-radius-/);
183
+ const shadow = group(/^--ds-shadow-/);
184
+ const motion = group(/^--ds-(dur|stagger|ease)/);
185
+ const layers = group(/^--ds-z-/);
186
+ const covered = new Set([...text, ...space, ...line, ...radius, ...shadow, ...motion, ...layers].map(([p]) => p));
187
+ const loose = ds.filter(([p]) => !covered.has(p));
188
+ if (loose.length) throw new Error(`tokens.css declares ${loose.map(([p]) => p).join(', ')} in no group the reference shows: add its group here`);
189
+
190
+ const faces = [
191
+ { prop: '--font-display', v: ui.fonts.display, role: 'display' },
192
+ { prop: '--font-body', v: ui.fonts.body, role: 'body' },
193
+ { prop: '--font-mono', v: hallRoot.get('--font-mono'), role: 'mono' },
194
+ ];
195
+
196
+ const sample = 'Claim the next task';
197
+ const parts = [];
198
+
199
+ parts.push(section('ref-logo', 'Logo and name', 'The mark is one file every page draws from. The ring and the planet take the ink; the moon takes the accent.', `
200
+ <div class="ref-lockup">
201
+ <svg class="ref-lockup__mark" viewBox="0 0 200 200" aria-hidden="true"><use href="${BRAND_URL}mark.svg#mark"/></svg>
202
+ <span class="ref-lockup__name">${esc(product.toLowerCase())}</span>
203
+ </div>
204
+ <div class="ref-naming">
205
+ ${naming.lead.map((p) => `<p>${p}</p>`).join('\n ')}
206
+ <ul>
207
+ ${naming.rules.map((r) => `<li>${r}</li>`).join('\n ')}
208
+ </ul>
209
+ </div>
210
+ <h3 class="ref-sub">The files</h3>
211
+ ${list(files.map((f) => row([`<a class="ref-file" href="${BRAND_URL}${esc(f.file)}"><code>${esc(f.file)}</code></a>`, `<span class="ref-what">${esc(f.what)}</span>`], 'ref-row--file')))}`));
212
+
213
+ parts.push(section('ref-colour', 'Colour', 'The thirteen colours of the branding pack. An instance replaces them in its own pack; in dark mode only the neutrals flip, and the accent, sea and terracotta stay the pack’s.', `
214
+ ${list(colourRows(pack))}
215
+ <h3 class="ref-sub">The status vocabulary</h3>
216
+ <p class="ref-note">Fixed, not taken from the pack, and the one exception to the fifteen tokens: a status is always a word as well as a colour.</p>
217
+ ${list(colourRows(status))}
218
+ <h3 class="ref-sub">Derived colours</h3>
219
+ <p class="ref-note">Mixed from the thirteen, never typed as a new colour. Black and white with alpha appear only as a scrim, a shadow or glass.</p>
220
+ ${list(colourRows(derived))}`));
221
+
222
+ parts.push(section('ref-type', 'Type', 'Two faces from the pack and one mono face for what the machine says: task ids, labels, figures.', `
223
+ ${list(faces.map((f) => row([name(f.prop), `<span class="ref-face" style="font-family: var(${f.prop})">${esc(sample)}</span>`, val(f.role, f.v)], 'ref-row--face')))}
224
+ <h3 class="ref-sub">The type scale</h3>
225
+ ${list(text.map(([p, v]) => row([name(p), val('size', v), `<span class="ref-sample" style="font-size: var(${p})">${esc(sample)}</span>`], 'ref-row--type')))}`));
226
+
227
+ parts.push(section('ref-space', 'Spacing', 'The rhythm every gap and padding is taken from.', `
228
+ ${list(space.map(([p, v]) => row([name(p), val('size', v), `<span class="ref-bar" style="width: var(${p})" aria-hidden="true"></span>`], 'ref-row--space')))}
229
+ <h3 class="ref-sub">The hairline</h3>
230
+ ${list(line.map(([p, v]) => row([name(p), val('width', v), '<span class="ref-hair" aria-hidden="true"></span>'], 'ref-row--space')))}`));
231
+
232
+ parts.push(section('ref-shape', 'Corners and shadow', 'A control is a pill or square; a panel and a tile take their own corner. The one shadow is spent only on what floats.', `
233
+ ${list(radius.map(([p, v]) => row([`<span class="ref-corner" style="border-radius: var(${p})" aria-hidden="true"></span>`, name(p), val('radius', v)], 'ref-row--shape')))}
234
+ ${list(shadow.map(([p, v]) => row([`<span class="ref-float" style="box-shadow: var(${p})" aria-hidden="true"></span>`, name(p), val('light', v), val('dark', dsDark.get(p) || v)], 'ref-row--shape')))}`));
235
+
236
+ parts.push(section('ref-motion', 'Motion and layers', 'State changes take the one easing; an entrance takes the slow ease-out. With reduced motion asked for, every duration is zero.', `
237
+ ${list(motion.map(([p, v]) => row([name(p), val('value', v)], 'ref-row--plain')))}
238
+ <h3 class="ref-sub">Stacking layers, low to high</h3>
239
+ ${list(layers.map(([p, v]) => row([name(p), val('z', v)], 'ref-row--plain')))}`));
240
+
241
+ const pills = OTB.STATUS_TONES.map((t) => OTB.statusPill(t, { tone: t })).join(' ');
242
+ const pillsSm = OTB.STATUS_TONES.map((t) => OTB.statusPill(t, { tone: t, small: true })).join(' ');
243
+ const taskPills = Object.keys(OTB.STATUS_TONE).map((s) => OTB.statusPill(s)).join(' ');
244
+ const toasts = [['info', 'Copied the prompt.'], ['ok', 'Task claimed.'], ['warn', 'The board is paused while the owner reviews it.'], ['error', 'Could not reach the server: try again in a moment.']]
245
+ .map(([k, msg]) => `<p class="toast ref-toast" data-visible="1" data-kind="${k}">${esc(OTB.plainMessage(msg))} <span class="ref-toast__k">(${k}, ${OTB.TOAST_MS[k] / 1000}s)</span></p>`).join('\n ');
246
+
247
+ parts.push(section('ref-components', 'Components', 'Drawn by the same shared rules every page of the hall loads, in each of their states.', `
248
+ <h3 class="ref-sub">Buttons</h3>
249
+ <div class="ref-specimen">
250
+ <button type="button" class="btn">claim it</button>
251
+ <button type="button" class="btn btn--ghost">later</button>
252
+ <button type="button" class="btn btn--danger">release</button>
253
+ <button type="button" class="btn" disabled>claimed</button>
254
+ </div>
255
+ <div class="ref-specimen">
256
+ <button type="button" class="btn btn--sm">claim</button>
257
+ <button type="button" class="btn btn--ghost btn--sm">release</button>
258
+ <button type="button" class="btn btn--text">read the brief</button>
259
+ </div>
260
+ <h3 class="ref-sub">Fields</h3>
261
+ <div class="ref-fields">
262
+ <label class="ref-field">A field <input type="text" value="A sentence someone typed"></label>
263
+ <label class="ref-field">A choice <select><option>ready</option><option>active</option></select></label>
264
+ <label class="ref-field">A longer answer <textarea rows="2">Two lines of what changed.</textarea></label>
265
+ <label class="ref-field">Not open yet <input type="text" value="Read only" disabled></label>
266
+ </div>
267
+ <h3 class="ref-sub">Status pills</h3>
268
+ <div class="ref-specimen" data-ref="tones">${pills}</div>
269
+ <div class="ref-specimen" data-ref="tones-sm">${pillsSm}</div>
270
+ <p class="ref-note">A task’s status, from the one map:</p>
271
+ <div class="ref-specimen" data-ref="task-statuses">${taskPills}</div>
272
+ <h3 class="ref-sub">Tabs and the view toggle</h3>
273
+ <div class="kit-tabs" role="tablist" aria-label="Example tabs">
274
+ <button type="button" class="setup__tab is-active" role="tab" aria-selected="true">ready <span class="count-pill">4</span></button>
275
+ <button type="button" class="setup__tab" role="tab" aria-selected="false">active</button>
276
+ <button type="button" class="setup__tab" role="tab" aria-selected="false">done</button>
277
+ </div>
278
+ <div class="viewtoggle" role="group" aria-label="View">
279
+ <button type="button" class="viewtoggle__btn" aria-pressed="true">rows</button>
280
+ <button type="button" class="viewtoggle__btn" aria-pressed="false">cards</button>
281
+ </div>
282
+ <h3 class="ref-sub">Panel, tile and the empty state</h3>
283
+ <div class="scroll scroll--quiet ref-tile"><p class="ref-note">This section is a panel; a quiet tile inside it recedes by tone.</p></div>
284
+ <div class="empty-state"><p>Nothing is waiting for you.</p><a class="empty-state__action" href="#ref-components">See the whole queue</a></div>
285
+ <h3 class="ref-sub">Loading</h3>
286
+ <p class="hall-loading" role="status">Reading the queue…</p>
287
+ <p class="ref-specimen"><span class="hall-loading hall-loading--inline" role="status">Saving…</span></p>
288
+ <h3 class="ref-sub">Toasts</h3>
289
+ <div class="ref-toasts">
290
+ ${toasts}
291
+ </div>
292
+ <h3 class="ref-sub">Dialog</h3>
293
+ <div class="ref-dialog">
294
+ <div class="hall-dialog" role="dialog" aria-modal="false" aria-labelledby="ref-dialog-t">
295
+ <div class="hall-dialog__head"><h4 class="hall-dialog__title" id="ref-dialog-t">Release this task?</h4></div>
296
+ <div class="hall-dialog__body"><p>It returns to the queue and no credits are earned.</p></div>
297
+ <div class="hall-dialog__actions"><button type="button" class="btn btn--ghost">cancel</button><button type="button" class="btn btn--danger">release</button></div>
298
+ </div>
299
+ </div>`));
300
+
301
+ const sources = Object.values(SRC).map((f) => `<li><code>${esc(path.relative(ROOT, f).split(path.sep).join('/'))}</code></li>`).join('\n ');
302
+
303
+ return `<!doctype html>
304
+ <html lang="en">
305
+ <head>
306
+ <meta charset="utf-8">
307
+ <meta name="viewport" content="width=device-width, initial-scale=1">
308
+ <title>{{productName}} — Design system</title>
309
+ <meta name="description" content="The colours, type, spacing, corners, motion and shared components every {{productName}} page is drawn from.">
310
+ <!-- GENERATED by node modules/ui-design/scripts/gen-design-reference.js (task 1005083); do not edit by hand.
311
+ Every value is read from its source file; every component is drawn by the hall's shared rules.
312
+ kit-ignore: tell: tiny type (the type scale shows its smallest step at its own size) -->
313
+ <link rel="stylesheet" href="/modules/hall-ui/public/style.css">
314
+ <link rel="stylesheet" href="/modules/ui-design/public/brand.css">
315
+ </head>
316
+ <body data-page="brand">
317
+ <header class="ref-head">
318
+ <a class="ref-head__brand" href="/"><svg class="ref-head__mark" viewBox="0 0 200 200" aria-hidden="true"><use href="${BRAND_URL}mark.svg#mark"/></svg><span>${esc(product.toLowerCase())}</span></a>
319
+ </header>
320
+ <main class="ref-main">
321
+ <h1 class="ref-title">Design system</h1>
322
+ <p class="ref-lede">The colours, type, spacing, corners, motion and shared components every {{productName}} page is drawn from. This page is generated from those same files, so it cannot drift from them. It follows your device’s light or dark setting; each value is shown for both.</p>
323
+ <nav class="ref-toc" aria-label="On this page">
324
+ <a href="#ref-logo">Logo</a><a href="#ref-colour">Colour</a><a href="#ref-type">Type</a><a href="#ref-space">Spacing</a><a href="#ref-shape">Corners</a><a href="#ref-motion">Motion</a><a href="#ref-components">Components</a>
325
+ </nav>
326
+ ${parts.join('\n ')}
327
+ <section class="ref-sources" aria-labelledby="ref-sources-h">
328
+ <h2 class="ref-sources__h" id="ref-sources-h">Generated from</h2>
329
+ <ul>
330
+ ${sources}
331
+ </ul>
332
+ </section>
333
+ </main>
334
+ </body>
335
+ </html>
336
+ `;
337
+ }
338
+
339
+ module.exports = { build, blockVars };
340
+
341
+ if (require.main === module) {
342
+ const next = build();
343
+ const current = fs.existsSync(OUT) ? fs.readFileSync(OUT, 'utf8') : '';
344
+ if (process.argv.includes('--check')) {
345
+ if (current === next) process.stdout.write('brand.html: current\n');
346
+ else {
347
+ process.stderr.write('modules/ui-design/public/brand.html is STALE: run node modules/ui-design/scripts/gen-design-reference.js\n');
348
+ process.exitCode = 1;
349
+ }
350
+ } else {
351
+ if (current !== next) fs.writeFileSync(OUT, next);
352
+ process.stdout.write((current === next ? 'brand.html: unchanged' : 'brand.html: written') + '\n');
353
+ }
354
+ }
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.24.54",
3
+ "version": "1.24.56",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.24.54",
9
+ "version": "1.24.56",
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.54",
3
+ "version": "1.24.56",
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",
@@ -11295,5 +11295,17 @@
11295
11295
  "id": "1005086",
11296
11296
  "text": "A task's status now looks the same on every hall page: one pill with six colours, chosen in one place. Error pop-up messages stay longer and never show a server's address, and a new specimen page shows every shared piece in ev"
11297
11297
  }
11298
+ ],
11299
+ "1.24.55": [
11300
+ {
11301
+ "id": "1005083",
11302
+ "text": "There is now a public design-system page at /brand on cloudbongos.com showing every colour, size, corner and shared building block, with light and dark values. It is built automatically from the same files the site uses, so it"
11303
+ }
11304
+ ],
11305
+ "1.24.56": [
11306
+ {
11307
+ "id": "1005087",
11308
+ "text": "The Studio, the thinking sky, the tweak editor, the city, approvals, the atlas and the welcome screen now take their colours and fonts from the project's own branding, so a re-branded project sees them in its own colours; the"
11309
+ }
11298
11310
  ]
11299
11311
  }
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.54'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.24.56'; // 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');
@@ -165,6 +165,7 @@ function landingAssetCache(urlPath, v) {
165
165
  return null;
166
166
  }
167
167
 
168
+ let _brandHtml; // the /brand reference, branded once (task 1005083); '' if the file is absent
168
169
  const _landingPages = new Map(); // memoized per file; '' if absent (instance ships no landing)
169
170
  function landingPage(file) {
170
171
  if (!_landingPages.has(file)) {
@@ -317,6 +318,23 @@ function buildPlatformApp() {
317
318
  res.type('html').send(html);
318
319
  });
319
320
  }
321
+ // THE DESIGN-SYSTEM REFERENCE (task 1005083; DESIGN.md "Naming": the reference is
322
+ // public). A generated page — modules/ui-design/scripts/gen-design-reference.js writes
323
+ // modules/ui-design/public/brand.html from the token sheets and the shared components —
324
+ // served signed-out at /brand, branding-stamped like the footer doors. It describes the
325
+ // platform's own world, so it is the hub's alone (platform-identity IS the hub flag, as
326
+ // for /account below) and needs ui-design on, the module that ships it.
327
+ app.get('/brand', (req, res, next) => {
328
+ if (!isApexHost(req) || !isModuleEnabled('platform-identity') || !isModuleEnabled('ui-design')) return next();
329
+ if (_brandHtml === undefined) {
330
+ try {
331
+ _brandHtml = injectBrandingGlobal(fs.readFileSync(path.join(__dirname, '..', 'modules', 'ui-design', 'public', 'brand.html'), 'utf8'));
332
+ } catch { _brandHtml = ''; }
333
+ }
334
+ if (!_brandHtml) return next();
335
+ res.set('Cache-Control', 'public, max-age=300');
336
+ res.type('html').send(_brandHtml);
337
+ });
320
338
  // THE RAISE PAGE (task 1004847, ADR 0384): the instance's investor page at
321
339
  // /raise, served by the raise module through its `raise.web` port — looked up
322
340
  // per request, because modules mount inside mountInternalSurfaces above. No
@@ -0,0 +1,175 @@
1
+ // tests/design_reference.mjs — the public design-system reference (task 1005083, goal
2
+ // 1000138): modules/ui-design/public/brand.html is GENERATED from the files that own
3
+ // each value, and the hub serves it signed-out at /brand.
4
+ //
5
+ // Pins:
6
+ // 1. the committed page is exactly what the generator writes (a hand edit, or a
7
+ // token change nobody regenerated, fails here; task 1005093 adds the ship gate);
8
+ // 2. every --ds-* token, every pack colour with its light AND dark value, and the
9
+ // status vocabulary are on the page — read from the sheets, not a copy;
10
+ // 3. a source change reaches the page, and a source losing what the page needs is
11
+ // an error rather than a silent gap;
12
+ // 4. the page keeps the platform floors: no page :root, no colour literal in its
13
+ // layout sheet, a states file the kit can render in both modes;
14
+ // 5. the route: 200 signed-out on the hub's apex, stamped; never on a child
15
+ // instance, the builders host or the status host.
16
+ //
17
+ // DB-free. Run: node --test tests/design_reference.mjs
18
+
19
+ import assert from 'node:assert/strict';
20
+ import { test, before, after } from 'node:test';
21
+ import http from 'node:http';
22
+ import fs from 'node:fs';
23
+ import path from 'node:path';
24
+ import { fileURLToPath } from 'node:url';
25
+ import { createRequire } from 'node:module';
26
+
27
+ const require = createRequire(import.meta.url);
28
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
29
+ const gen = require(path.join(ROOT, 'modules', 'ui-design', 'scripts', 'gen-design-reference.js'));
30
+ const PUB = path.join(ROOT, 'modules', 'ui-design', 'public');
31
+ const PAGE = fs.readFileSync(path.join(PUB, 'brand.html'), 'utf8');
32
+ const SHEET = fs.readFileSync(path.join(PUB, 'tokens.css'), 'utf8');
33
+ const HALL = fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', 'style.css'), 'utf8');
34
+ const PACK = JSON.parse(fs.readFileSync(path.join(ROOT, 'config', 'branding.neutral.json'), 'utf8'));
35
+
36
+ function sources() {
37
+ return {
38
+ pack: JSON.parse(JSON.stringify(PACK)),
39
+ sheet: SHEET,
40
+ hall: HALL,
41
+ controls: fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', 'controls.css'), 'utf8'),
42
+ design: fs.readFileSync(path.join(ROOT, 'DESIGN.md'), 'utf8'),
43
+ genBrand: fs.readFileSync(path.join(ROOT, 'scripts', 'bongos', 'gen-brand.js'), 'utf8'),
44
+ otb: require(path.join(ROOT, 'modules', 'hall-ui', 'public', 'dom-utils.js')),
45
+ };
46
+ }
47
+
48
+ // ── 1. generated ─────────────────────────────────────────────────────────────
49
+
50
+ test('the committed page is exactly what the generator writes', () => {
51
+ assert.equal(PAGE, gen.build(),
52
+ 'modules/ui-design/public/brand.html is stale or hand-edited: run node modules/ui-design/scripts/gen-design-reference.js');
53
+ });
54
+
55
+ // ── 2. every value is on the page ────────────────────────────────────────────
56
+
57
+ // the text of the row a token's name opens (one <li> per token)
58
+ function rowOf(html, prop) {
59
+ const m = html.match(new RegExp(`<li class="ref-row[^"]*">(?:(?!</li>).)*?<code class="ref-name">${prop}</code>(?:(?!</li>).)*</li>`));
60
+ assert.ok(m, `the page has a row for ${prop}`);
61
+ return m[0];
62
+ }
63
+
64
+ test('every --ds-* token of the shared sheet is on the page with its value', () => {
65
+ const vars = gen.blockVars(SHEET, /(?:^|\n|\})\s*:root/, 'tokens.css');
66
+ assert.ok(vars.length > 40, 'the sheet was read');
67
+ for (const [prop, v] of vars) assert.ok(rowOf(PAGE, prop).includes(`<code>${v}</code>`), `${prop} shows ${v}`);
68
+ // the one mode-dependent scale shows its dark twin too
69
+ assert.match(rowOf(PAGE, '--ds-shadow-pop'), /dark<\/span> <code>0 18px 40px rgba\(0, 0, 0, \.62\)<\/code>/);
70
+ });
71
+
72
+ test('every pack colour shows its light value and its dark value; the status vocabulary both modes', () => {
73
+ for (const [key, light] of Object.entries(PACK.theme.ui.colors)) {
74
+ const prop = '--' + key.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase());
75
+ const r = rowOf(PAGE, prop);
76
+ assert.ok(r.includes(`light</span> <code>${light}</code>`), `${prop} light ${light}`);
77
+ assert.match(r, /dark<\/span> <code>[^<]+<\/code>/, `${prop} has a dark value`);
78
+ }
79
+ assert.ok(rowOf(PAGE, '--bg').includes('dark</span> <code>#000000</code>'), 'the dark ground is read from the hall\'s dark tier');
80
+ assert.ok(rowOf(PAGE, '--ok').includes('light</span> <code>#187444</code>') && rowOf(PAGE, '--ok').includes('dark</span> <code>#4ade80</code>'));
81
+ for (const s of ['--warn', '--danger', '--info', '--ok-weak', '--warn-weak', '--danger-weak', '--info-weak']) rowOf(PAGE, s);
82
+ });
83
+
84
+ test('the shared components, the logo and the naming rule are on the page', () => {
85
+ for (const sel of ['class="btn"', 'class="btn btn--ghost"', 'class="btn btn--danger"', 'class="btn btn--text"', 'class="hall-dialog"',
86
+ 'data-kind="error"', 'class="viewtoggle"', 'class="hall-loading"', '<select>', '<textarea']) {
87
+ assert.ok(PAGE.includes(sel), `the page draws ${sel}`);
88
+ }
89
+ assert.equal((PAGE.match(/data-ref="tones"[^>]*>(.*?)<\/div>/) || [])[1].match(/status-pill--/g).length, 6, 'six tones');
90
+ assert.match(PAGE, /<use href="\/modules\/ui-design\/public\/brand\/mark\.svg#mark"\/>/, 'the mark is drawn from its one source');
91
+ assert.match(PAGE, /<span class="ref-lockup__name">cloud bongos<\/span>/, 'the wordmark is lowercase (DESIGN.md Naming)');
92
+ assert.match(PAGE, /Never &quot;CloudBongos&quot;/, 'the naming rule is read from DESIGN.md');
93
+ for (const f of ['mark-light.svg', 'mark-dark.svg', 'mark-mono.svg', 'favicon.svg']) assert.ok(PAGE.includes(`brand/${f}"`), `lists ${f}`);
94
+ });
95
+
96
+ // ── 3. the sources drive it ──────────────────────────────────────────────────
97
+
98
+ test('a source change reaches the page (the generator reads the sources, not a copy)', () => {
99
+ const src = sources();
100
+ src.sheet = src.sheet.replace('--ds-space-hair: 6px;', '--ds-space-hair: 7px;');
101
+ src.pack.theme.ui.colors.accent = '#ff8855';
102
+ src.design = src.design.replace('- The domain is written', '- A new naming rule.\n- The domain is written');
103
+ const html = gen.build(src);
104
+ assert.ok(rowOf(html, '--ds-space-hair').includes('<code>7px</code>'));
105
+ assert.ok(rowOf(html, '--accent').includes('<code>#ff8855</code>'));
106
+ assert.match(html, /<li>A new naming rule\.<\/li>/);
107
+ });
108
+
109
+ test('a source losing what the page needs is an error, not a silent gap', () => {
110
+ const lostRule = sources();
111
+ lostRule.controls = lostRule.controls.split('.btn--danger').join('.btn--gone');
112
+ assert.throws(() => gen.build(lostRule), /no longer define \.btn--danger/);
113
+ const lostNaming = sources();
114
+ lostNaming.design = lostNaming.design.replace('\n## Naming\n', '\n## Names\n');
115
+ assert.throws(() => gen.build(lostNaming), /Naming/);
116
+ const newGroup = sources();
117
+ newGroup.sheet = newGroup.sheet.replace('--ds-line: 1px;', '--ds-line: 1px;\n --ds-blur-glass: 18px;');
118
+ assert.throws(() => gen.build(newGroup), /--ds-blur-glass in no group/);
119
+ });
120
+
121
+ // ── 4. the floors ────────────────────────────────────────────────────────────
122
+
123
+ test('no page :root, no colour literal in the layout sheet, and a kit states file for both modes', () => {
124
+ const css = fs.readFileSync(path.join(PUB, 'brand.css'), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '');
125
+ assert.doesNotMatch(css, /:root/);
126
+ assert.doesNotMatch(css, /#[0-9a-f]{3,8}\b|rgba?\(/i, 'every colour is a token');
127
+ assert.doesNotMatch(PAGE, /<style>/, 'the page carries no style block of its own');
128
+ assert.doesNotMatch(PAGE, /cloudbongos\.com"/, 'no baked apex link');
129
+ const lib = require(path.join(ROOT, 'modules', 'ui-design', 'kit', 'lib.js'));
130
+ const states = JSON.parse(fs.readFileSync(path.join(PUB, 'brand.states.json'), 'utf8'));
131
+ assert.deepEqual(lib.validateStatesFile(states).errors || [], []);
132
+ assert.equal(states.page, '/brand');
133
+ assert.equal(states.modes, undefined, 'both modes: the page follows the device');
134
+ });
135
+
136
+ // ── 5. the route ─────────────────────────────────────────────────────────────
137
+
138
+ const { buildPlatformApp } = require('../src/platform-server.js');
139
+ const modules = require('../src/modules.js');
140
+ let server;
141
+ let port;
142
+ before(async () => {
143
+ server = http.createServer(buildPlatformApp());
144
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
145
+ port = server.address().port;
146
+ });
147
+ after(async () => {
148
+ modules.setModuleOverrides({});
149
+ await new Promise((resolve) => server.close(resolve));
150
+ });
151
+ function get(pathname, host) {
152
+ return new Promise((resolve, reject) => {
153
+ http.get({ host: '127.0.0.1', port, path: pathname, headers: { Host: host } }, (res) => {
154
+ let body = '';
155
+ res.on('data', (c) => (body += c));
156
+ res.on('end', () => resolve({ status: res.statusCode, body }));
157
+ }).on('error', reject);
158
+ });
159
+ }
160
+
161
+ test('GET /brand: 200 signed-out on the hub\'s apex, branded; never on a child, the builders or status host', async () => {
162
+ modules.setModuleOverrides({ 'platform-identity': false });
163
+ assert.equal((await get('/brand', 'game.test.local')).status, 404, 'a child instance serves no platform reference');
164
+ modules.setModuleOverrides({ 'platform-identity': true, 'ui-design': true });
165
+ const hub = await get('/brand', 'game.test.local');
166
+ assert.equal(hub.status, 200, 'reachable with no session cookie');
167
+ assert.match(hub.body, /— Design system<\/title>/);
168
+ assert.match(hub.body, /window\.__BRANDING__=/, 'the pack is spliced, so the swatches take the instance colours');
169
+ assert.doesNotMatch(hub.body, /\{\{[a-zA-Z]+\}\}/, 'every token stamps');
170
+ for (const host of ['builders.test.local', 'status.test.local']) {
171
+ assert.doesNotMatch((await get('/brand', host)).body, /— Design system<\/title>/, `${host} must not serve /brand`);
172
+ }
173
+ const css = await get('/modules/ui-design/public/brand.css', 'game.test.local');
174
+ assert.equal(css.status, 200, 'the layout sheet is served at its module path');
175
+ });
@@ -168,10 +168,15 @@ test('the shell no longer aliases .mod-chip onto the pill', () => {
168
168
 
169
169
  test('atlas: the nine semantic colours stay fixed literals, and nothing else in the sheet is one', () => {
170
170
  const css = read('atlas.css');
171
- for (const t of ['--ab-sun', '--ab-sky', '--ab-coral', '--ab-paper', '--ab-good', '--ab-core', '--ab-db', '--ab-ghost', '--ab-danger']) {
171
+ for (const t of ['--ab-sun', '--ab-sky', '--ab-coral', '--ab-paper', '--ab-ghost']) {
172
172
  assert.match(css, new RegExp(`${t}:#[0-9a-f]{6}`), `${t} is a fixed literal (ADR 0094)`);
173
173
  }
174
- const rest = decomment(css).replace(/--ab-(sun|sky|coral|paper|good|core|db|ghost|danger):#[0-9a-f]{6}/g, '');
174
+ // task 1005087: the four STATE colours are the hall's status vocabulary — fixed per instance
175
+ // (never the pack's), so ADR 0094's no-scramble invariant still holds, and no longer Tailwind's.
176
+ for (const [t, tok] of [['--ab-good', '--ok'], ['--ab-core', '--warn'], ['--ab-db', '--danger'], ['--ab-danger', '--danger']]) {
177
+ assert.match(css, new RegExp(`${t}:var\\(${tok}\\)`), `${t} is the hall's ${tok}`);
178
+ }
179
+ const rest = decomment(css).replace(/--ab-(sun|sky|coral|paper|ghost):#[0-9a-f]{6}/g, '');
175
180
  assert.deepEqual(rest.match(/#[0-9a-f]{3,8}\b/gi) || [], [], 'no other literal colour');
176
181
  noAlias(css, 'atlas.css');
177
182
  tokensDefined(css, 'atlas.css');