@bongos/core 1.24.61 → 1.24.63

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 (40) hide show
  1. package/.bongos-core.json +54 -44
  2. package/docs/copy-inventory.md +152 -151
  3. package/docs/copy-registry.json +232 -203
  4. package/docs/module-api-changelog.md +4 -0
  5. package/docs/page-inventory.json +1 -0
  6. package/docs/page-readings.json +281 -249
  7. package/modules/hall-ui/public/approval-queue.css +5 -3
  8. package/modules/hall-ui/public/controls.css +10 -0
  9. package/modules/hall-ui/public/gate.js +11 -2
  10. package/modules/hall-ui/public/hall-dialog.js +50 -2
  11. package/modules/hall-ui/public/hall-kit.js +8 -3
  12. package/modules/hall-ui/public/roster.js +13 -5
  13. package/modules/hall-ui/public/settings-passkeys.js +4 -1
  14. package/modules/hall-ui/public/studio.css +19 -20
  15. package/modules/hall-ui/public/studio.html +1 -2
  16. package/modules/hall-ui/public/studio.js +6 -2
  17. package/modules/hall-ui/public/style.css +17 -5
  18. package/modules/hall-ui/public/thinking.css +18 -1
  19. package/modules/hall-ui/records/studio.md +2 -1
  20. package/modules/hall-ui/specimens/components.css +7 -0
  21. package/modules/npm-release/public/work.js +6 -1
  22. package/modules/public-landing/public/assets/cosmos.css +36 -13
  23. package/modules/public-landing/public/index.html +9 -1
  24. package/modules/public-landing/public/projects.html +11 -3
  25. package/modules/raise/page.js +3 -1
  26. package/modules/raise/public/edit.js +10 -3
  27. package/modules/raise/public/raise.css +31 -4
  28. package/modules/render-deploy/public/deploy.js +2 -2
  29. package/package-lock.json +2 -2
  30. package/package.json +1 -1
  31. package/release-notes.json +12 -0
  32. package/src/module-api.js +1 -1
  33. package/tests/hall_dialog.mjs +169 -17
  34. package/tests/hall_studio_home.mjs +8 -1
  35. package/tests/hall_topbar_320.mjs +145 -0
  36. package/tests/landing_shared_controls.mjs +157 -0
  37. package/tests/npm_release_release.mjs +1 -1
  38. package/tests/projects_hub.mjs +1 -1
  39. package/tests/projects_hub_pre_uat.mjs +3 -1
  40. package/tests/roster_government_columns.mjs +3 -1
@@ -131,7 +131,9 @@ async function renderPage({ editor }) {
131
131
  return R.fill(view('page.html'), {
132
132
  base: BASE,
133
133
  assetVersion: v,
134
- editorAssets: editor ? `<script src="${BASE}/assets/edit.js?v=${v}" defer></script>` : '',
134
+ // the editor asks its questions in the hall's one dialog: the shared escaper first,
135
+ // then the dialog, then the editor (task 1005136)
136
+ editorAssets: editor ? `<script src="/modules/hall-ui/public/dom-utils.js" defer></script><script src="/modules/hall-ui/public/hall-dialog.js" defer></script><script src="${BASE}/assets/edit.js?v=${v}" defer></script>` : '',
135
137
  draftBanner: c.draft ? '<div class="draft" role="note">DRAFT</div>' : '',
136
138
  editBar: editor ? editBar(editor, c) : '',
137
139
  entity: R.esc(c.entity),
@@ -120,10 +120,17 @@
120
120
 
121
121
  startBtn.addEventListener('click', function () { status.textContent = ''; setEditing(true); });
122
122
 
123
+ // Cancel with edits asks in the hall's own dialog (hall-dialog.js, loaded with this
124
+ // file for an editor; task 1005136), never the browser's box.
123
125
  cancelBtn.addEventListener('click', function () {
124
- if (changes().count && !confirm('Discard your unsaved changes?')) return;
125
- editing = false;
126
- location.reload();
126
+ var asked = changes().count
127
+ ? window.OTBDialog.confirm({ title: 'Discard your unsaved changes?', confirmLabel: 'Discard', trigger: cancelBtn })
128
+ : Promise.resolve(true);
129
+ asked.then(function (yes) {
130
+ if (!yes) return;
131
+ editing = false;
132
+ location.reload();
133
+ });
127
134
  });
128
135
 
129
136
  saveBtn.addEventListener('click', function () {
@@ -66,9 +66,9 @@ p { margin: 0 0 16px; max-width: 64ch; }
66
66
 
67
67
  .btn {
68
68
  display: inline-flex; align-items: center; justify-content: center; gap: 8px;
69
- font: 600 16px var(--font); color: var(--on-accent); background: var(--accent);
70
- border: 0; border-radius: 999px; padding: 13px 26px; cursor: pointer;
71
- transition: transform .12s ease, filter .12s ease;
69
+ font: 600 var(--ds-text-xl) var(--font); color: var(--on-accent); background: var(--accent);
70
+ border: 0; border-radius: var(--ds-radius-pill); padding: 13px 26px; cursor: pointer;
71
+ transition: transform var(--ds-dur-fast) var(--ds-ease), filter var(--ds-dur-fast) var(--ds-ease);
72
72
  }
73
73
  .btn:hover:not(:disabled) { filter: brightness(1.05); transform: translateY(-1px); }
74
74
  .btn:disabled { background: var(--deep); color: var(--ink-faint); cursor: not-allowed; }
@@ -198,7 +198,7 @@ section { padding: 72px 0; border-bottom: 1px solid var(--rule-soft); }
198
198
  .draft { letter-spacing: .2em; }
199
199
 
200
200
  /* ── team: Sign in with Bongos + in-place editing ───────── */
201
- .btn--ghost { background: transparent; color: var(--ink); border: 1px solid var(--rule); padding: 10px 20px; font-size: 15px; }
201
+ .btn--ghost { background: transparent; color: var(--ink); border: var(--ds-line) solid var(--rule); padding: 10px 20px; font-size: var(--ds-text-lg); }
202
202
  .btn--ghost:hover { border-color: var(--accent); }
203
203
  .gate__team { margin-top: 36px; padding-top: 24px; border-top: 1px solid var(--rule-soft); display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; font-size: 14px; color: var(--ink-soft); text-decoration: none; }
204
204
  .gate__team a { text-decoration: none; }
@@ -232,3 +232,30 @@ section { padding: 72px 0; border-bottom: 1px solid var(--rule-soft); }
232
232
  .tok { white-space: nowrap; }
233
233
  .is-editing .tok, .is-editing .cite { background: color-mix(in srgb, var(--sea) 18%, transparent); border-radius: 3px; padding: 0 2px; cursor: default; }
234
234
  .is-editing [data-kind="para"], .is-editing [data-kind="list"] { padding: 4px 6px; margin-left: -6px; margin-right: -6px; }
235
+
236
+ /* ── the dialog ─────────────────────────────────────────────
237
+ The editor's "discard your changes?" is the hall's one dialog (hall-dialog.js,
238
+ OTBDialog.confirm; task 1005136), drawn here on this page's tokens and the shared
239
+ scales: the card ground, one hairline, the panel corner, the one floating shadow,
240
+ the modal layer, over a black scrim (the one literal the Fifteen Rule allows). */
241
+ .hall-dialog {
242
+ z-index: var(--ds-z-modal); box-sizing: border-box;
243
+ width: min(480px, calc(100vw - 2 * var(--ds-space-sm)));
244
+ max-height: calc(100dvh - 2 * var(--ds-space-md)); overflow-y: auto;
245
+ margin: auto; padding: var(--ds-space-md);
246
+ color: var(--ink); background: var(--card);
247
+ border: var(--ds-line) solid var(--rule); border-radius: var(--ds-radius-panel);
248
+ box-shadow: var(--ds-shadow-pop);
249
+ }
250
+ .hall-dialog::backdrop { background: rgba(0, 0, 0, .55); }
251
+ .hall-dialog__head { display: flex; align-items: flex-start; gap: var(--ds-space-xs); margin: 0 0 var(--ds-space-xs); }
252
+ .hall-dialog__title { flex: 1; margin: 0; font-size: var(--ds-text-3xl); font-weight: 500; line-height: 1.25; }
253
+ .hall-dialog__x {
254
+ flex: none; min-width: 32px; min-height: 32px; padding: 0;
255
+ font: inherit; font-size: var(--ds-text-4xl); line-height: 1; color: var(--ink-soft);
256
+ background: transparent; border: 0; border-radius: var(--ds-radius-pill); cursor: pointer;
257
+ }
258
+ .hall-dialog__x:hover { color: var(--ink); }
259
+ .hall-dialog__body p { margin: 0 0 var(--ds-space-xs); font-size: var(--ds-text-md); line-height: 1.5; color: var(--ink-soft); }
260
+ .hall-dialog__actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--ds-space-xs); margin-top: var(--ds-space-md); }
261
+ .hall-dialog__actions .btn { font-size: var(--ds-text-lg); padding: 10px 20px; }
@@ -197,7 +197,7 @@
197
197
  });
198
198
 
199
199
  // Step two. Closes over `p`, the press that was shown, so what is sent is what was displayed.
200
- const confirm = () => {
200
+ const confirmAction = () => {
201
201
  const p = state.pending;
202
202
  if (!p) return;
203
203
  return run(async () => {
@@ -218,7 +218,7 @@
218
218
  const rb = t.closest('[data-rollback]');
219
219
  if (rb) { state.pending = { action: 'rollback', deployId: rb.getAttribute('data-rollback'), liveWas: state.hist.liveDeployId }; state.error = null; state.done = null; return paint(); }
220
220
  if (t.closest('#rd-cancel')) { state.pending = null; return paint(); }
221
- if (t.closest('#rd-confirm')) return confirm();
221
+ if (t.closest('#rd-confirm')) return confirmAction();
222
222
  const v = t.closest('[data-view]');
223
223
  if (v) { state.viewing = v.getAttribute('data-view'); return paint(); }
224
224
  if (t.closest('#rd-unview')) { state.viewing = null; return paint(); }
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.24.61",
3
+ "version": "1.24.63",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.24.61",
9
+ "version": "1.24.63",
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.61",
3
+ "version": "1.24.63",
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",
@@ -11341,5 +11341,17 @@
11341
11341
  "id": "1005139",
11342
11342
  "text": "The design docs now describe what is actually live, and the landing page shows a company's logo only when that company's settings provide one, so other projects no longer show the Verro Labs medallion."
11343
11343
  }
11344
+ ],
11345
+ "1.24.62": [
11346
+ {
11347
+ "id": "1005137",
11348
+ "text": "On a phone the hall's top bar now fits without cutting off buttons, the Studio stays a night room in dark mode, the sky page's dots no longer cover its labels, and the components reference page is readable in dark mode."
11349
+ }
11350
+ ],
11351
+ "1.24.63": [
11352
+ {
11353
+ "id": "1005136",
11354
+ "text": "The last browser pop-up boxes are gone: every question the site asks (rename, give a reason, are you sure) now opens in Cloud Bongos's own dialog, and on a phone the signed-in front page no longer squeezes the logo under the m"
11355
+ }
11344
11356
  ]
11345
11357
  }
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.61'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.24.63'; // 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');
@@ -10,6 +10,9 @@
10
10
  // branded confirm (what will happen, in words) and runs its write only when the answer is yes;
11
11
  // 3. the guards: no window.confirm anywhere in modules/hall-ui, and each of the six builds now
12
12
  // runs on OTBDialog with no scrim/backdrop rule of its own.
13
+ // Task 1005136 adds the prompt variant (OTBDialog.prompt, which replaced every window.prompt), its
14
+ // flows, and widens the guard repo-wide: no browser confirm, alert or prompt in any served page
15
+ // (every module's public/ and views/, the API docs, and the pages src/ renders).
13
16
  import assert from 'node:assert/strict';
14
17
  import { test, after } from 'node:test';
15
18
  import { createRequire } from 'node:module';
@@ -55,9 +58,13 @@ async function runFlow(file, name, args, { answer, globals = {} } = {}) {
55
58
  const writes = [];
56
59
  const record = (m, u, b) => { writes.push({ method: m, url: String(u), body: b }); return { ok: true, status: 200, data: { permission_count: 3 } }; };
57
60
  const window = {
58
- OTBDialog: { confirm: async (o) => { asked.push(o); return answer; } },
61
+ OTBDialog: {
62
+ confirm: async (o) => { asked.push(o); return answer; },
63
+ prompt: async (o) => { asked.push(o); return answer; },
64
+ },
59
65
  location: { assign() {} },
60
66
  confirm: () => { throw new Error('window.confirm was called'); },
67
+ prompt: () => { throw new Error('window.prompt was called'); },
61
68
  };
62
69
  const ctx = vm.createContext({
63
70
  window,
@@ -135,6 +142,37 @@ for (const f of FLOWS) {
135
142
  });
136
143
  }
137
144
 
145
+ // Every replaced window.prompt whose flow is a named function: the branded prompt is asked, a
146
+ // dismissal (null) sends nothing, and the typed text is what the write carries.
147
+ const PROMPT_FLOWS = [
148
+ { flow: 'name a passkey card', file: 'settings-passkeys.js', fn: 'rename',
149
+ args: () => ['cred-1', { querySelector: (q) => (q === 'strong' ? { textContent: 'Old card' } : {}) }],
150
+ write: /passkeys\/rename/, carries: (w, text) => w.body.nickname === text },
151
+ { flow: 'release a studio round', file: 'studio.js', fn: 'releasePage', args: () => ['page-1', 77, fakeBtn()],
152
+ globals: { lib: { PAGE_ID_RE: /^[a-z0-9-]+$/ }, WRITE_ROUTES: { release: (id) => `/tweak/${id}/release` }, loadPages: async () => {}, paintPanel() {}, byId: () => null },
153
+ write: /tweak\/page-1\/release/, carries: (w, text) => w.body.note === text && w.body.task_id === 77 },
154
+ { flow: 'rename a saved view', file: 'hall-kit.js', fn: 'renameView', args: () => [{ id: 5, name: 'Mine' }],
155
+ viewCall: true, globals: { act: (fn) => fn(), views: { page: 'work' } },
156
+ write: /^\/5$/, carries: (w, text) => w.body.name === text },
157
+ ];
158
+
159
+ for (const f of PROMPT_FLOWS) {
160
+ test(`${f.flow}: the branded prompt asks, a dismissal sends nothing, the typed text is sent`, async () => {
161
+ let sent = [];
162
+ const extra = { ...(f.globals || {}) };
163
+ if (f.viewCall) extra.call = (m, u, b) => { sent.push({ method: m, url: u, body: b }); };
164
+ const no = await runFlow(f.file, f.fn, f.args(), { answer: null, globals: extra });
165
+ assert.equal(no.asked.length, 1, 'the prompt is shown once');
166
+ assert.ok(no.asked[0].title && no.asked[0].confirmLabel, 'it has a title and names its act');
167
+ assert.equal([...no.writes, ...sent].filter((w) => f.write.test(w.url)).length, 0, 'a dismissal sends nothing');
168
+ sent = [];
169
+ const yes = await runFlow(f.file, f.fn, f.args(), { answer: 'A new line', globals: extra });
170
+ const hits = [...yes.writes, ...sent].filter((w) => f.write.test(w.url));
171
+ assert.equal(hits.length, 1, 'the answer sends the write, once');
172
+ assert.ok(f.carries(hits[0], 'A new line'), 'the write carries the typed text');
173
+ });
174
+ }
175
+
138
176
  test('a verify-kind confirm attests the verification; any other kind asks nothing', async () => {
139
177
  const sent = [];
140
178
  const runLifecycle = (b, u, body) => sent.push({ u, body });
@@ -182,35 +220,74 @@ test('the bulk release on the shared toolbar asks first, and releases only on ye
182
220
 
183
221
  // ── the guards ──────────────────────────────────────────────────────────────
184
222
 
185
- function hallFiles() {
223
+ // Every file a browser is served, repo-wide (task 1005136): each module's public/ and views/, the
224
+ // API docs, and src/ (whose server-rendered pages carry inline scripts). Vendored bundles excluded.
225
+ function servedFiles() {
186
226
  const out = [];
187
227
  const walk = (d) => {
228
+ if (!fs.existsSync(d)) return;
188
229
  for (const e of fs.readdirSync(d, { withFileTypes: true })) {
189
230
  const p = path.join(d, e.name);
190
- if (e.isDirectory()) walk(p);
191
- else if (/\.(js|html|mjs|cjs)$/.test(e.name)) out.push(p);
231
+ if (e.isDirectory()) { if (!/^(node_modules|vendor|fixtures)$/.test(e.name)) walk(p); }
232
+ else if (/\.(js|html|mjs|cjs)$/.test(e.name) && !/\.(standalone|min)\.js$/.test(e.name)) out.push(p);
192
233
  }
193
234
  };
194
- walk(path.join(ROOT, 'modules', 'hall-ui'));
235
+ for (const m of fs.readdirSync(path.join(ROOT, 'modules'))) {
236
+ walk(path.join(ROOT, 'modules', m, 'public'));
237
+ walk(path.join(ROOT, 'modules', m, 'views'));
238
+ }
239
+ walk(path.join(ROOT, 'public-docs'));
240
+ walk(path.join(ROOT, 'src'));
195
241
  return out;
196
242
  }
197
243
 
198
- test('no window.confirm anywhere in modules/hall-ui (comments stripped)', () => {
199
- const hits = [];
200
- for (const f of hallFiles()) {
201
- const src = stripComments(fs.readFileSync(f, 'utf8'));
202
- // a call to the browser's box: window.confirm, or a bare confirm( that is not a method or a definition
203
- if (/\bwindow\s*\.\s*confirm\b/.test(src) || /(^|[^.\w$])(?<!function\s)confirm\s*\(/m.test(src.replace(/function\s+confirm\s*\(/g, ''))) {
204
- hits.push(path.relative(ROOT, f));
244
+ // A call to one of the browser's three boxes: window.confirm/alert/prompt, or a bare confirm( /
245
+ // alert( / prompt( that is not a method (OTBDialog.confirm) or the definition of one. Read line
246
+ // by line, comments out: a whole-file comment stripper eats code across a `/*` inside a string
247
+ // ('/api/bongos/*'), which would let a real call hide behind it. A block comment is one that
248
+ // OPENS a line (how every file here writes them); a same-line /* */, <!-- --> and a trailing
249
+ // // comment are cut where they stand.
250
+ const BOX = /\bwindow\s*\.\s*(confirm|alert|prompt)\b|(^|[^.\w$])(confirm|alert|prompt)\s*\(/;
251
+ function browserBoxCall(rawSrc) {
252
+ let block = null;
253
+ for (const raw of String(rawSrc).split('\n')) {
254
+ let t = raw.trim();
255
+ if (block) {
256
+ const end = t.indexOf(block);
257
+ if (end === -1) continue;
258
+ t = t.slice(end + block.length);
259
+ block = null;
205
260
  }
261
+ if (t.startsWith('/*') && !t.includes('*/')) { block = '*/'; continue; }
262
+ if (t.startsWith('<!--') && !t.includes('-->')) { block = '-->'; continue; }
263
+ if (/^(\/\/|\*)/.test(t)) continue;
264
+ const code = t.replace(/\/\*.*?\*\//g, '').replace(/<!--.*?-->/g, '')
265
+ .replace(/(^|[^:'"`\\])\/\/.*$/, '$1').replace(/function\s+(confirm|alert|prompt)\s*\(/g, '');
266
+ if (BOX.test(code)) return true;
206
267
  }
207
- assert.deepEqual(hits, [], 'use OTBDialog.confirm (hall-dialog.js) instead of the browser confirm');
268
+ return false;
269
+ }
270
+
271
+ test('no browser confirm, alert or prompt in any served page (comments stripped)', () => {
272
+ const files = servedFiles();
273
+ assert.ok(files.length > 200 && files.some((f) => f.includes(`${path.sep}public-landing${path.sep}`)) && files.some((f) => f.includes(`${path.sep}raise${path.sep}`)),
274
+ 'the scan reaches beyond the hall');
275
+ const hits = files.filter((f) => browserBoxCall(fs.readFileSync(f, 'utf8'))).map((f) => path.relative(ROOT, f));
276
+ assert.deepEqual(hits, [], 'use OTBDialog.confirm / OTBDialog.prompt (hall-dialog.js) instead of the browser\'s box');
208
277
  });
209
278
 
210
- test('the guard catches a window.confirm when one comes back', () => {
211
- const src = stripComments("// window.confirm('old') is history\nif (!window.confirm('Delete?')) return;");
212
- assert.ok(/\bwindow\s*\.\s*confirm\b/.test(src));
213
- assert.ok(!/\bwindow\s*\.\s*confirm\b/.test(stripComments('/* window.confirm() was here */ OTBDialog.confirm({})')));
279
+ test('the guard catches a browser box when one comes back, and leaves the shared dialog alone', () => {
280
+ for (const bad of ["if (!window.confirm('Delete?')) return;", "const n = window.prompt('Name');", "alert('done')",
281
+ "if (x && !confirm('Discard?')) return;", "var r = prompt('Why?') || null;"]) {
282
+ assert.ok(browserBoxCall(bad), `caught: ${bad}`);
283
+ }
284
+ // a `/*` inside a string must not hide a real call on a later line
285
+ assert.ok(browserBoxCall("const p = '/api/bongos/*';\nif (!confirm('Go?')) return;\nconst q = 'a */ b';"), 'caught past a /* in a string');
286
+ for (const ok of ["// window.confirm('old') is history\nOTBDialog.confirm({})", '/* window.prompt() was here */ await window.OTBDialog.prompt({ title: "x" })',
287
+ '/* the browser box:\n window.confirm() calls whose box\n*/ OTBDialog.confirm({})', '<!-- an old\n confirm() lived here -->',
288
+ 'function confirm(opts) {}', 'const confirmAction = () => {}; confirmAction();']) {
289
+ assert.ok(!browserBoxCall(ok), `left alone: ${ok}`);
290
+ }
214
291
  });
215
292
 
216
293
  test('every hall page loads the shared dialog right after the shell, deferred alike', () => {
@@ -399,3 +476,78 @@ test('a confirm over a bound overlay: Escape closes the confirm only, then the o
399
476
  assert.equal(ov.modal, 'true');
400
477
  await ctx.close();
401
478
  });
479
+
480
+ // ── the prompt (task 1005136) ────────────────────────────────────────────────
481
+
482
+ test('the prompt: a labelled field in the shared dialog, focused with its text selected; Enter answers with what was typed', { skip }, async () => {
483
+ const { ctx, page } = await boot();
484
+ await page.focus('#go');
485
+ await page.evaluate(() => { window.__r = window.OTBDialog.prompt({ title: 'Name this card', label: 'Shown on your sign-in list', value: 'Old name', confirmLabel: 'Save' }); });
486
+ const s = await page.evaluate(() => {
487
+ const d = document.querySelector('dialog.hall-dialog');
488
+ const f = d.querySelector('input.hall-dialog__field');
489
+ const lab = document.querySelector(`label[for="${f.id}"]`);
490
+ return {
491
+ modal: d.matches(':modal'), title: document.getElementById(d.getAttribute('aria-labelledby')).textContent,
492
+ label: lab && lab.textContent, focused: document.activeElement === f,
493
+ selected: f.value.slice(f.selectionStart, f.selectionEnd), buttons: [...d.querySelectorAll('.hall-dialog__actions button')].map((b) => b.textContent),
494
+ width: Math.round(f.getBoundingClientRect().width), panel: Math.round(d.querySelector('.hall-dialog__body').getBoundingClientRect().width),
495
+ };
496
+ });
497
+ assert.equal(s.modal, true);
498
+ assert.equal(s.title, 'Name this card');
499
+ assert.equal(s.label, 'Shown on your sign-in list', 'the field is labelled');
500
+ assert.equal(s.focused, true, 'the field takes focus');
501
+ assert.equal(s.selected, 'Old name', 'its text is selected, so typing replaces it');
502
+ assert.deepEqual(s.buttons, ['Cancel', 'Save']);
503
+ assert.equal(s.width, s.panel, 'the field spans the dialog');
504
+ await page.keyboard.type('New name');
505
+ await page.keyboard.press('Enter');
506
+ assert.equal(await page.evaluate(() => window.__r), 'New name', 'Enter answers with the typed text');
507
+ assert.equal(await page.evaluate(() => document.querySelector('dialog.hall-dialog')), null, 'and the dialog is gone');
508
+ assert.equal(await page.evaluate(() => document.activeElement.id), 'go', 'focus is back on the trigger');
509
+ await ctx.close();
510
+ });
511
+
512
+ test('the prompt: Cancel, Escape and the scrim answer null; an empty answer is still an answer', { skip }, async () => {
513
+ const { ctx, page } = await boot();
514
+ const ask = (o) => page.evaluate((opts) => { window.__r = window.OTBDialog.prompt(opts); }, o);
515
+ await ask({ title: 'Why?' });
516
+ assert.equal(await page.evaluate(() => document.querySelector('input.hall-dialog__field').getAttribute('aria-label')), 'Why?', 'no label: the title names the field');
517
+ await page.click('.hall-dialog__actions .btn--ghost');
518
+ assert.equal(await page.evaluate(() => window.__r), null, 'Cancel is null');
519
+ await ask({ title: 'Why?' });
520
+ await page.keyboard.press('Escape');
521
+ assert.equal(await page.evaluate(() => window.__r), null, 'Escape is null');
522
+ await ask({ title: 'Why?' });
523
+ await page.mouse.click(5, 5);
524
+ assert.equal(await page.evaluate(() => window.__r), null, 'the scrim is null');
525
+ await ask({ title: 'Why?', confirmLabel: 'Go' });
526
+ await page.click('.hall-dialog__actions .btn:not(.btn--ghost)');
527
+ assert.equal(await page.evaluate(() => window.__r), '', 'confirming a blank field answers the empty string, not null');
528
+ await ctx.close();
529
+ });
530
+
531
+ test('the prompt: a required field keeps its confirm off until something is typed', { skip }, async () => {
532
+ const { ctx, page } = await boot();
533
+ await page.evaluate(() => { window.__r = window.OTBDialog.prompt({ title: 'New name for this view', required: true, confirmLabel: 'Rename' }); });
534
+ const ok = '.hall-dialog__actions .btn:not(.btn--ghost)';
535
+ assert.equal(await page.$eval(ok, (b) => b.disabled), true, 'blank: the confirm is off');
536
+ await page.keyboard.press('Enter');
537
+ assert.ok(await page.evaluate(() => !!document.querySelector('dialog.hall-dialog')), 'Enter on a blank required field does not answer');
538
+ await page.keyboard.type(' ');
539
+ assert.equal(await page.$eval(ok, (b) => b.disabled), true, 'spaces are still blank');
540
+ await page.keyboard.type('Mine');
541
+ assert.equal(await page.$eval(ok, (b) => b.disabled), false, 'typed: the confirm is on');
542
+ await page.click(ok);
543
+ assert.equal(await page.evaluate(() => window.__r), ' Mine', 'the text is handed back exactly as typed');
544
+ await ctx.close();
545
+ });
546
+
547
+ test('the prompt\'s title, body and label are text, never markup', { skip }, async () => {
548
+ const { ctx, page } = await boot();
549
+ await page.evaluate(() => { window.OTBDialog.prompt({ title: '<b>t</b>', body: '<img src=x onerror="window.__x=1">', label: '<i>l</i>' }); });
550
+ const s = await page.evaluate(() => ({ img: !!document.querySelector('.hall-dialog img'), i: !!document.querySelector('.hall-dialog i'), b: !!document.querySelector('.hall-dialog b'), x: window.__x }));
551
+ assert.deepEqual(s, { img: false, i: false, b: false, x: undefined });
552
+ await ctx.close();
553
+ });
@@ -639,7 +639,14 @@ try {
639
639
  assert.match(CSS, /\.st-light \{[^}]*background: var\(--st-pane\)/, 'the panes are lit by night and unlit by day through one token');
640
640
  assert.match(CSS, /body\[data-sky="day"\] \.st-cloud \{ opacity: \.9; \}/);
641
641
  assert.match(CSS, /body\[data-sky="day"\] \.st-sunpatch \{ opacity: \.7; \}/);
642
- assert.match(CSS, /body\[data-sky="day"\] \.st-star,\s*body\[data-sky="day"\] \.st-glow \{ opacity: 0; \}/, 'no stars, and the lamp is off');
642
+ assert.match(CSS, /body\[data-sky="day"\] \.st-star,\s*html:not\(\[data-theme="dark"\]\) body\[data-sky="day"\] \.st-glow \{ opacity: 0; \}/, 'no stars, and the lamp is off');
643
+ // task 1005137: the day room is a light-theme room. Under the hall's dark theme a cream room
644
+ // sat beside the black rail, so EVERY day rule is scoped off dark and dark keeps the night room.
645
+ const daySelectors = CSS.replace(/\/\*[\s\S]*?\*\//g, '').split(/[{}]/).filter((_, i) => i % 2 === 0)
646
+ .flatMap((sel) => sel.split(',')).map((x) => x.trim()).filter((x) => x.includes('[data-sky="day"]'));
647
+ assert.ok(daySelectors.length >= 10, `the day rules are found (${daySelectors.length})`);
648
+ for (const sel of daySelectors) assert.ok(sel.startsWith('html:not([data-theme="dark"]) '), `${sel} is scoped off the dark theme`);
649
+ assert.ok(!/st-eyebrow/.test(CSS + HTML), 'no mono eyebrow above the greeting (DESIGN.md bans a label over the h1)');
643
650
  assert.match(HTML, /class="st-cloud st-cloud--1"/);
644
651
  assert.match(HTML, /class="st-sunpatch"/);
645
652
  const anims = [...CSS.matchAll(/animation:\s*[^;]+;|transition:\s*[^;]+;/g)].map((m) => m.index);
@@ -0,0 +1,145 @@
1
+ // tests/hall_topbar_320.mjs — the hall's top bar fits a 320px phone, and the components
2
+ // specimen sits above the plate veil (task 1005137, goal 1000138).
3
+ //
4
+ // WHY. A visual check of the design system found every hall page 340px wide at 320: the menu, the
5
+ // Update pill, the search, the mode switch and the three toggles did not fit, so the theme and
6
+ // full-screen icons were cut off at the edge. And modules/hall-ui/specimens/components.html read as
7
+ // near-black in dark mode, because style.css's body::after veil (--plate-veil) covered the page: a
8
+ // hall page lifts its content over the plate (.app-body, z-index 1) and the specimen did not.
9
+ // Both are layout, so both are measured in a real browser on the REAL markup and the REAL sheets:
10
+ // the shell's own top bar on the Tasks page with the widest set of buttons a page can carry (the
11
+ // Update pill, the search, the full-screen switch), and the specimen as the kit renders it.
12
+ // Skips when the machine has no system Edge or Chrome, as welcome_boot_browser does.
13
+ import assert from 'node:assert/strict';
14
+ import { test, after } from 'node:test';
15
+ import { createRequire } from 'node:module';
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+
20
+ const require = createRequire(import.meta.url);
21
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
22
+ const HALL = path.join(ROOT, 'modules', 'hall-ui', 'public');
23
+ const SPEC = path.join(ROOT, 'modules', 'hall-ui', 'specimens');
24
+ const lib = require(path.join(ROOT, 'modules', 'ui-design', 'kit', 'lib.js'));
25
+
26
+ let browser = null;
27
+ try { browser = await lib.launchBrowser(); } catch (e) { if (!(e instanceof lib.NoBrowserError)) throw e; }
28
+ after(() => browser && browser.close());
29
+ const skip = !browser && 'no system Edge or Chrome';
30
+
31
+ const ORIGIN = 'http://hall.test';
32
+ const TYPES = { '.js': 'text/javascript', '.css': 'text/css', '.html': 'text/html' };
33
+
34
+ // The Tasks page's real markup, its scripts swapped for the shell's own, on a page that opts into
35
+ // the full-screen switch (Studio and Thinking do), so the bar carries every button it can.
36
+ const WORK = fs.readFileSync(path.join(HALL, 'work.html'), 'utf8')
37
+ .replace(/<script\b[^>]*>[\s\S]*?<\/script>/g, '')
38
+ .replace(/\{\{[^}]*\}\}/g, '')
39
+ .replace('<body data-page="work"', '<body data-page="work" data-fullscreen-switch');
40
+ const STUB = '<script>window.__BRANDING__ = { identity: { worldName: "Harbor" } }; window.BongosClient = { createClient: () => ({ request: async () => ({ ok: false, status: 404, data: null }) }) };</script>';
41
+ const SCRIPTS = ['dom-utils.js', 'shell-trail.js', 'craft-mode.js', 'shell.js'].map((f) => `<script src="/builders/${f}"></script>`).join('');
42
+ const PAGE = WORK.replace('</body>', STUB + SCRIPTS + '</body>');
43
+
44
+ async function context(width, scheme) {
45
+ const ctx = await browser.newContext({ viewport: { width, height: 700 }, colorScheme: scheme });
46
+ await ctx.route(ORIGIN + '/**', (route) => {
47
+ const p = new URL(route.request().url()).pathname;
48
+ if (p === '/work') return route.fulfill({ contentType: 'text/html', body: PAGE });
49
+ let f = null;
50
+ if (p.startsWith('/builders/')) f = path.join(HALL, p.slice('/builders/'.length));
51
+ else if (p.startsWith('/modules/hall-ui/public/')) f = path.join(HALL, p.slice('/modules/hall-ui/public/'.length));
52
+ else if (p.startsWith('/modules/ui-design/public/')) f = path.join(ROOT, 'modules', 'ui-design', 'public', p.slice('/modules/ui-design/public/'.length));
53
+ else if (p.startsWith('/specimens/')) f = path.join(SPEC, p.slice('/specimens/'.length));
54
+ if (f && TYPES[path.extname(f)] && fs.existsSync(f)) return route.fulfill({ contentType: TYPES[path.extname(f)], body: fs.readFileSync(f) });
55
+ return route.fulfill({ status: 404, body: '' });
56
+ });
57
+ return ctx;
58
+ }
59
+
60
+ // The bar as update-pill.js and palette.js leave it: the Update pill first in the side, the search
61
+ // shown. Then every button's box, and how wide the document is.
62
+ async function measureBar(width, scheme) {
63
+ const ctx = await context(width, scheme);
64
+ const page = await ctx.newPage();
65
+ await page.goto(ORIGIN + '/work');
66
+ await page.waitForSelector('#app-topbar .topbar-side #theme-toggle');
67
+ return page.evaluate(() => {
68
+ const side = document.querySelector('#app-topbar .topbar-side');
69
+ if (!document.getElementById('upd-pill')) {
70
+ const b = document.createElement('button');
71
+ b.type = 'button'; b.id = 'upd-pill'; b.className = 'upd-pill'; b.setAttribute('data-face', 'ready');
72
+ b.innerHTML = '<span class="upd-pill__dot" aria-hidden="true"></span><span class="upd-pill__label">Update</span>';
73
+ side.insertBefore(b, side.firstChild);
74
+ }
75
+ const jump = document.getElementById('topbar-jump'); if (jump) jump.hidden = false;
76
+ const fs = document.getElementById('fullscreen-toggle'); if (fs) fs.hidden = false;
77
+ const buttons = [...document.querySelectorAll('#app-topbar button')]
78
+ .filter((b) => getComputedStyle(b).display !== 'none' && b.getClientRects().length)
79
+ .map((b) => { const r = b.getBoundingClientRect(); return { id: b.id || b.className, left: r.left, right: r.right, w: r.width, h: r.height }; });
80
+ const bar = document.getElementById('app-topbar').getBoundingClientRect();
81
+ return { scrollWidth: document.documentElement.scrollWidth, bar: { left: bar.left, right: bar.right }, buttons };
82
+ }).finally(() => ctx.close());
83
+ }
84
+
85
+ for (const scheme of ['light', 'dark']) {
86
+ test(`at 320px the top bar fits, every button inside it and at least 24px (${scheme})`, { skip }, async () => {
87
+ const m = await measureBar(320, scheme);
88
+ const ids = m.buttons.map((b) => b.id);
89
+ for (const want of ['upd-pill', 'topbar-jump', 'zoom-toggle', 'theme-toggle', 'fullscreen-toggle']) {
90
+ assert.ok(ids.includes(want), `${want} is on the bar (got ${ids.join(', ')})`);
91
+ }
92
+ assert.ok(m.scrollWidth <= 320, `the page is ${m.scrollWidth}px wide at 320 (the top bar spills)`);
93
+ for (const b of m.buttons) {
94
+ assert.ok(b.left >= 0 && b.right <= 320, `${b.id} sits at ${Math.round(b.left)}..${Math.round(b.right)}, outside 0..320`);
95
+ assert.ok(b.w >= 24 && b.h >= 24, `${b.id} is ${Math.round(b.w)}x${Math.round(b.h)}, under the 24px floor`);
96
+ }
97
+ });
98
+ }
99
+
100
+ test('the top bar search and the mode switch are one shape: a pill with the same hairline', { skip }, async () => {
101
+ const ctx = await context(1440, 'dark');
102
+ const page = await ctx.newPage();
103
+ await page.goto(ORIGIN + '/work');
104
+ await page.waitForSelector('#app-topbar #topbar-jump', { state: 'attached' });
105
+ const s = await page.evaluate(() => {
106
+ const css = (el) => getComputedStyle(el);
107
+ const jump = document.getElementById('topbar-jump');
108
+ jump.hidden = false; // palette.js shows it; the shape is the sheet's either way
109
+ const sw = document.getElementById('craft-switch');
110
+ const root = css(document.documentElement);
111
+ return {
112
+ jumpRadius: css(jump).borderTopLeftRadius,
113
+ switchRadius: css(sw).borderTopLeftRadius,
114
+ jumpEdge: css(jump).borderTopColor,
115
+ switchShadow: css(sw).boxShadow,
116
+ pill: root.getPropertyValue('--ds-radius-pill').trim(),
117
+ };
118
+ }).finally(() => ctx.close());
119
+ assert.equal(s.jumpRadius, s.pill, 'the search is a full pill');
120
+ assert.equal(s.switchRadius, s.pill, 'the mode switch is a full pill');
121
+ assert.ok(s.switchShadow.startsWith(s.jumpEdge), `the two share one hairline colour (${s.jumpEdge} vs ${s.switchShadow})`);
122
+ });
123
+
124
+ test('the components specimen sits above the plate veil in dark (the hall layering)', { skip }, async () => {
125
+ const ctx = await context(1440, 'dark');
126
+ const page = await ctx.newPage();
127
+ await page.goto(ORIGIN + '/specimens/components.html');
128
+ await page.waitForSelector('.spec-main');
129
+ const r = await page.evaluate(() => {
130
+ const veil = getComputedStyle(document.body, '::after');
131
+ const blocks = [...document.body.children].filter((el) => !/^(SCRIPT|TEMPLATE)$/.test(el.tagName) && getComputedStyle(el).display !== 'none')
132
+ .map((el) => { const cs = getComputedStyle(el); return { el: el.tagName.toLowerCase() + '.' + el.className, position: cs.position, z: cs.zIndex }; });
133
+ return { veilImage: veil.backgroundImage, veilZ: veil.zIndex, veilPosition: veil.position, blocks };
134
+ }).finally(() => ctx.close());
135
+ // The veil is really there in dark — otherwise this test proves nothing.
136
+ assert.notEqual(r.veilImage, 'none', 'dark paints the plate veil');
137
+ assert.equal(r.veilPosition, 'fixed');
138
+ const veilZ = Number(r.veilZ) || 0;
139
+ assert.ok(r.blocks.length >= 2, 'the specimen has its head and its main');
140
+ for (const b of r.blocks) {
141
+ if (b.position === 'fixed' || b.position === 'absolute') continue;
142
+ assert.notEqual(b.position, 'static', `${b.el} is static, so the fixed veil paints over it`);
143
+ assert.ok(Number(b.z) > veilZ, `${b.el} has z-index ${b.z}, not above the veil's ${veilZ}`);
144
+ }
145
+ });