@bongos/core 1.23.13 → 1.23.14

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.
@@ -278,7 +278,7 @@
278
278
  },
279
279
  "approvals": {
280
280
  "auth": true,
281
- "_": "The Approval queue (task 1004323): the studio's quick action opens the glass panel, 1 of 2, on the AFTER render of the applied page with the changed lines beside it. Against the kit's own stub it opens on its could-not-read line; populated, run it against the hall-preview harness (fixtures copy-desk__approvals.json, tasks__visuals.json and the two CANNED_WRITES). HALL_PREVIEW_VISUALS=<dir> serves real renders by the fixture's names.",
281
+ "_": "The Approval queue (task 1004323): the studio's quick action opens the glass panel, 1 of 2. The card is the changed lines, what each was beside what it is now, with Approve and ship and Send it back under them. Against the kit's own stub it opens on its could-not-read line; populated, run it against the hall-preview harness (fixtures copy-desk__approvals.json, tasks__visuals.json and the two CANNED_WRITES).",
282
282
  "actions": [
283
283
  [
284
284
  "wait",
@@ -297,13 +297,14 @@
297
297
  "visible": [
298
298
  ".aq-dialog",
299
299
  ".aq-eyebrow",
300
- ".aq-render"
300
+ ".aq-line",
301
+ "[data-approve]"
301
302
  ]
302
303
  }
303
304
  },
304
305
  "approvals-before": {
305
306
  "auth": true,
306
- "_": "The before/after switch on before: the page as it was, nothing marked.",
307
+ "_": "See it on the page, switched to before: the page as it was.",
307
308
  "actions": [
308
309
  [
309
310
  "wait",
@@ -317,6 +318,14 @@
317
318
  "wait",
318
319
  1200
319
320
  ],
321
+ [
322
+ "click",
323
+ "[data-peek]"
324
+ ],
325
+ [
326
+ "wait",
327
+ 500
328
+ ],
320
329
  [
321
330
  "click",
322
331
  "[data-phase=\"before\"]"
@@ -336,7 +345,7 @@
336
345
  },
337
346
  "approvals-phone": {
338
347
  "auth": true,
339
- "_": "The phone render (the view switch beside the lines).",
348
+ "_": "See it on the page, on the phone render.",
340
349
  "actions": [
341
350
  [
342
351
  "wait",
@@ -350,6 +359,14 @@
350
359
  "wait",
351
360
  1200
352
361
  ],
362
+ [
363
+ "click",
364
+ "[data-peek]"
365
+ ],
366
+ [
367
+ "wait",
368
+ 500
369
+ ],
353
370
  [
354
371
  "click",
355
372
  "[data-device=\"phone\"]"
@@ -473,6 +490,39 @@
473
490
  ".aq-outcome--sent"
474
491
  ]
475
492
  }
493
+ },
494
+ "approvals-page": {
495
+ "auth": true,
496
+ "_": "See it on the page: the optional rendered page opens under the words, on the AFTER render.",
497
+ "actions": [
498
+ [
499
+ "wait",
500
+ 700
501
+ ],
502
+ [
503
+ "click",
504
+ "#studio-approvals"
505
+ ],
506
+ [
507
+ "wait",
508
+ 1200
509
+ ],
510
+ [
511
+ "click",
512
+ "[data-peek]"
513
+ ],
514
+ [
515
+ "wait",
516
+ 700
517
+ ]
518
+ ],
519
+ "expect": {
520
+ "visible": [
521
+ ".aq-dialog",
522
+ ".aq-line",
523
+ ".aq-render"
524
+ ]
525
+ }
476
526
  }
477
527
  },
478
528
  "reducedMotion": {
@@ -10,9 +10,11 @@
10
10
  // hall underneath.
11
11
  //
12
12
  // Rules:
13
- // - Once per builder per LOCAL day, remembered in localStorage. Storage that throws cannot
14
- // remember, so such a browser is never shown it (it would replay on every visit).
15
- // - Never for a visitor who is not signed in, and never over a deep link (#/task/N, #/idea/N).
13
+ // - A signed-in builder sees it once per LOCAL day, and again after every fresh sign-in (the
14
+ // sign-in click leaves a flag in localStorage that the return visit consumes). Storage that
15
+ // throws cannot remember, so such a browser is never shown it (it would replay on every visit).
16
+ // - A visitor who is not signed in sees the Welcome on Home, every visit, with a "Sign in with
17
+ // GitHub" button in place of the role choices. Only on Home, never over a deep link (#/task/N, #/idea/N).
16
18
  // - shell.js asks onAccess() BEFORE the craft picker; a load where the welcome showed marks the
17
19
  // picker asked (OTBCraftMode.markAsked), so the two never stack.
18
20
  // - Picking a role also sets the hall's craft mode. Governing is a seat (craft-mode.js), so the
@@ -26,6 +28,8 @@
26
28
  'use strict';
27
29
 
28
30
  var DAY_KEY = 'hall-welcome-day:';
31
+ var SIGNIN_KEY = 'hall-welcome-signin';
32
+ var SIGNIN_HREF = '/api/bongos/auth/web/start';
29
33
  var ART = { day: '/builders/welcome-day.webp', night: '/builders/welcome-night.webp' };
30
34
  var ART_WAIT = 1500; // the veil lifts once the painting is in, or after this long
31
35
  var RUN_MS = 3400; // the city powering up before the greeting
@@ -159,15 +163,25 @@
159
163
  '<span class="wb__version" id="wb-version" hidden></span></div>' +
160
164
  '<div class="wb__veil" aria-hidden="true"></div>' +
161
165
  '<button class="wb__skip" type="button" data-wb-skip>Skip</button>' +
162
- '<div class="wb__after">' +
163
- '<h2 class="wb__hi" id="wb-hi">' + esc(greetingFor(now) + ', ' + nameOf(builder)) + '</h2>' +
166
+ '<div class="wb__after">' + (builder ? signedInHtml(builder, now) : signedOutHtml()) + '</div>';
167
+ }
168
+ function signedInHtml(builder, now) {
169
+ return '<h2 class="wb__hi" id="wb-hi">' + esc(greetingFor(now) + ', ' + nameOf(builder)) + '</h2>' +
164
170
  '<p class="wb__lede">Where do you want to start the day?</p>' +
165
171
  '<div class="wb__choices">' +
166
172
  '<button class="wb__choice" type="button" data-wb-roles aria-expanded="false" aria-controls="wb-roles"><b>Start with your role</b><span>Pick today’s craft and go straight to its room.</span></button>' +
167
173
  '<button class="wb__choice" type="button" data-wb-home><b>Your home page</b><span>Your work, what needs you, and what shipped.</span></button>' +
168
174
  '</div>' +
169
- '<div class="wb__roles" id="wb-roles" hidden></div>' +
170
- '</div>';
175
+ '<div class="wb__roles" id="wb-roles" hidden></div>';
176
+ }
177
+ // A visitor who is not signed in: the same scene, one way in.
178
+ function signedOutHtml() {
179
+ return '<h2 class="wb__hi" id="wb-hi">Welcome to ' + esc(projectName(window.__BRANDING__)) + '</h2>' +
180
+ '<p class="wb__lede">Sign in to see your work, what needs you, and what shipped.</p>' +
181
+ '<div class="wb__choices">' +
182
+ '<button class="wb__choice" type="button" data-wb-signin><b>Sign in with GitHub</b><span>Pick up where you left off.</span></button>' +
183
+ '<button class="wb__choice" type="button" data-wb-home><b>Look around first</b><span>See the public side of the project.</span></button>' +
184
+ '</div>';
171
185
  }
172
186
  function rolesHtml() {
173
187
  return rolesFor(craft()).map(function (r) {
@@ -250,6 +264,7 @@
250
264
  if (!btn || !root || leaving) return;
251
265
  if (btn.hasAttribute('data-wb-skip')) { begin(); finish(); return; }
252
266
  if (btn.hasAttribute('data-wb-home')) { leave(); return; }
267
+ if (btn.hasAttribute('data-wb-signin')) { markSigningIn(); go(SIGNIN_HREF); return; }
253
268
  if (btn.hasAttribute('data-wb-roles')) {
254
269
  var box = root.querySelector('#wb-roles');
255
270
  var open = box.hidden;
@@ -299,19 +314,54 @@
299
314
  at(ART_WAIT, begin);
300
315
  var skip = root.querySelector('[data-wb-skip]');
301
316
  if (skip && skip.focus) skip.focus();
302
- loadVersion();
317
+ if (builder) loadVersion(); // an anonymous visit costs no request
303
318
  }
304
319
 
305
320
  // A link straight to an overlay (#/task/N, #/idea/N, #/blocker/N) is not greeted over.
306
321
  function deepLink() { return /^#\//.test((window.location && window.location.hash) || ''); }
307
322
 
323
+ // Home is the bare hall URL with no routed view; a signed-out visitor is only greeted there.
324
+ function onHome() {
325
+ var p = String((window.location && window.location.pathname) || '/').replace(/\/+$/, '');
326
+ var h = (window.location && window.location.hash) || '';
327
+ return (p === '' || p === '/builders') && (h === '' || h === '#' || h === '#home');
328
+ }
329
+
330
+ // A sign-in in flight is remembered, so the welcome greets the builder when they come back signed in.
331
+ function markSigningIn() { try { window.localStorage.setItem(SIGNIN_KEY, '1'); } catch (_) { /* cannot remember */ } }
332
+ function takeSignedIn() {
333
+ try {
334
+ var was = window.localStorage.getItem(SIGNIN_KEY) === '1';
335
+ if (was) window.localStorage.removeItem(SIGNIN_KEY);
336
+ return was;
337
+ } catch (_) { return false; }
338
+ }
339
+ // Any sign-in link on the page (the shell's own, in the top bar) counts the same as the welcome's button.
340
+ function watchSignInLinks() {
341
+ if (!document.addEventListener) return;
342
+ document.addEventListener('click', function (e) {
343
+ var a = e.target && e.target.closest && e.target.closest('a[href]');
344
+ // Same-origin only: the href must START with the sign-in path, not merely mention it.
345
+ if (a && String(a.getAttribute('href')).indexOf(SIGNIN_HREF) === 0) markSigningIn();
346
+ }, true);
347
+ }
348
+
308
349
  // shell.js's call, before the craft picker's. True when the welcome is (or was) up on this load.
309
350
  function onAccess(access, builder) {
310
351
  if (shown) return true;
311
- if (!access || access.authed !== true || !builder || builder.id == null || deepLink()) return false;
352
+ if (deepLink()) return false;
353
+ if (access && access.authed === false) {
354
+ if (!onHome()) return false;
355
+ shown = true;
356
+ show(null);
357
+ return true;
358
+ }
359
+ if (!access || access.authed !== true || !builder || builder.id == null) return false;
312
360
  var key = DAY_KEY + builder.id, now = new Date();
313
361
  try {
314
- if (!firstVisitToday(window.localStorage.getItem(key), now)) return false;
362
+ // Every fresh sign-in is greeted; otherwise only the day's first visit.
363
+ var fresh = takeSignedIn();
364
+ if (!fresh && !firstVisitToday(window.localStorage.getItem(key), now)) return false;
315
365
  window.localStorage.setItem(key, localDay(now));
316
366
  } catch (_) { return false; }
317
367
  shown = true;
@@ -322,11 +372,11 @@
322
372
  }
323
373
 
324
374
  var api = {
325
- DAY_KEY: DAY_KEY, ROLES: ROLES, ART: ART, TOWERS: TOWERS, BEACONS: BEACONS, LANES: LANES, AW: AW, AH: AH,
375
+ DAY_KEY: DAY_KEY, SIGNIN_KEY: SIGNIN_KEY, ROLES: ROLES, ART: ART, TOWERS: TOWERS, BEACONS: BEACONS, LANES: LANES, AW: AW, AH: AH,
326
376
  localDay: localDay, firstVisitToday: firstVisitToday, greetingFor: greetingFor, nameOf: nameOf, timeOfDay: timeOfDay,
327
377
  artFor: artFor, projectName: projectName, bannerText: bannerText, rolesFor: rolesFor, pulseRuns: pulseRuns,
328
378
  onAccess: onAccess, finish: finish, close: close,
329
379
  };
330
- if (typeof window !== 'undefined') window.OTBWelcome = api;
380
+ if (typeof window !== 'undefined') { window.OTBWelcome = api; watchSignInLinks(); }
331
381
  if (typeof module !== 'undefined' && module.exports) module.exports = api;
332
382
  })();
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.23.13",
3
+ "version": "1.23.14",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.23.13",
9
+ "version": "1.23.14",
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.23.13",
3
+ "version": "1.23.14",
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",
@@ -10460,7 +10460,15 @@
10460
10460
  "text": "Project owners can now press Recruit next to any builder on the community leaderboard or search, which opens their project's invite page with that builder's name already filled in."
10461
10461
  }
10462
10462
  ],
10463
- "1.23.13": [
10463
+ "1.23.14": [
10464
+ {
10465
+ "id": "1004839",
10466
+ "text": "The studio is simpler for artists. Its four doors now read in plain words: Resume, Work through tweaks, Approve and confirm tweaks, and Needs to be reviewed. The approve door only shows for artists at metic or above, and an em"
10467
+ },
10468
+ {
10469
+ "id": "1004851",
10470
+ "text": "The Welcome page now greets people every time they sign in, not only on their first visit of the day. Visitors who are not signed in now land on the Welcome with a Sign in with GitHub button."
10471
+ },
10464
10472
  {
10465
10473
  "id": "1004644",
10466
10474
  "text": "The tweak editor's split view now shows the landing and status pages under the words, instead of opening a second window."
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.23.13'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.23.14'; // 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');
@@ -188,14 +188,19 @@ await test('open: the queue\'s head, the applied page\'s render, and the words b
188
188
  assert.ok(b.classes.has('aq-open'), 'the page behind stops scrolling');
189
189
  assert.equal(text(b.q('.aq-eyebrow')), 'approval queue · 1 of 2');
190
190
  assert.equal(text(b.q('.aq-title')), 'Home · landing · 1 line rewritten, applied · 1 not applied');
191
+ assert.equal(b.q('img'), null, 'the words lead: no picture until the artist asks for it');
192
+ assert.equal(b.requests.filter((r) => r.url.endsWith('/visuals')).length, 0, 'and none is fetched until then');
193
+ await b.click(b.q('[data-peek]'));
191
194
  const img = b.q('img');
192
195
  assert.equal(img.attrs.src, url(41, 'after-desktop-light'), 'the AFTER render, desktop, in the hall\'s own mode');
193
196
  assert.equal(img.attrs.alt, 'Home, after, desktop, light');
194
197
  assert.deepEqual(b.qa('[data-phase]').map((x) => [text(x), x.attrs['aria-pressed']]), [['before', 'false'], ['after', 'true']]);
198
+ await b.click(b.q('[data-peek]'));
199
+ assert.equal(b.q('img'), null, 'and it folds away again');
195
200
  assert.equal(b.qa('mark').length, 1, 'one applied line carries the mark');
196
201
  assert.equal(text(b.q('mark')), 'Agents write the code.');
197
202
  assert.match(text(b.q('.aq-line--refused')), /not applied: the page no longer shows it/);
198
- assert.equal(b.body.querySelectorAll('img').length, 1, 'a hostile line is text, never markup');
203
+ assert.equal(b.body.querySelectorAll('img').length, 0, 'a hostile line is text, never markup');
199
204
  assert.equal(b.win.pwned, undefined);
200
205
  assert.equal(text(b.q('.aq-hint')), 'highlighted: the words you changed');
201
206
  assert.deepEqual(b.counts, [2], 'the door hears how many wait');
@@ -205,9 +210,10 @@ await test('open: the queue\'s head, the applied page\'s render, and the words b
205
210
  await test('the switches: before and after, desktop and phone, light and dark, each a render of the applied branch', async () => {
206
211
  const b = bootPanel({ answers: { [APPROVALS]: queueFor(), [vis(41)]: slotsFor(41) } });
207
212
  await b.panel.open(); await settle(); await settle();
213
+ await b.click(b.q('[data-peek]'));
208
214
  await b.click(b.q('[data-phase="before"]'));
209
215
  assert.equal(b.q('img').attrs.src, url(41, 'before-desktop-light'));
210
- assert.equal(b.qa('mark').length, 0, 'before is the page as it was: nothing marked');
216
+ assert.equal(b.qa('mark').length, 1, 'the words beside the picture do not change with it');
211
217
  await b.click(b.q('[data-device="phone"]'));
212
218
  assert.equal(b.q('img').attrs.src, url(41, 'before-phone-light'));
213
219
  assert.match(b.q('img').attrs.class, /aq-render--phone/);
@@ -216,17 +222,19 @@ await test('the switches: before and after, desktop and phone, light and dark, e
216
222
  assert.equal(b.requests.filter((r) => r.url.endsWith('/visuals')).length, 1, 'the renders are read once per round');
217
223
  });
218
224
 
219
- await test('a missing render says what it shows instead; no renders at all means nothing to approve from', async () => {
225
+ await test('a missing render says what it shows instead; no renders at all never blocks the decision', async () => {
220
226
  const b = bootPanel({ answers: { [APPROVALS]: queueFor(), [vis(41)]: slotsFor(41, ['after-desktop-dark']) } });
221
227
  await b.panel.open(); await settle(); await settle();
228
+ await b.click(b.q('[data-peek]'));
222
229
  assert.equal(b.q('img').attrs.src, url(41, 'after-desktop-dark'));
223
230
  assert.equal(text(b.q('.aq-frame__swap')), 'No desktop light render; showing desktop dark.');
224
231
  const none = bootPanel({ answers: { [APPROVALS]: queueFor(), [vis(41)]: slotsFor(41, []) } });
225
232
  await none.panel.open(); await settle(); await settle();
233
+ assert.ok(!none.q('[data-approve]').disabled, 'the words are what is approved: no picture is needed');
234
+ await none.click(none.q('[data-peek]'));
226
235
  assert.equal(none.q('img'), null);
227
236
  assert.match(text(none.q('.aq-frame__note')), /No render is attached/);
228
- assert.ok(none.q('[data-approve]').disabled, 'approve waits for a picture of the page');
229
- assert.ok(!none.q('[data-open-note]').disabled, 'sending it back does not');
237
+ assert.ok(!none.q('[data-open-note]').disabled, 'sending it back is open too');
230
238
  });
231
239
 
232
240
  await test('approve and ship: one POST through the host\'s write, then the artboard\'s line and the next page', async () => {
@@ -297,6 +305,24 @@ await test('a builder who may not decide reads the queue with a sentence, not tw
297
305
  assert.match(text(b.q('.aq-sealed')), /Only the artist who wrote this page, or another artist/);
298
306
  });
299
307
 
308
+ await test('open(pageId) lands on that page card, says so when it is not in the queue, and can skip the second read', async () => {
309
+ const b = bootPanel({ answers: { [APPROVALS]: queueFor(), [vis(42)]: slotsFor(42) } });
310
+ await b.panel.open('landing:projects');
311
+ await settle(); await settle();
312
+ assert.equal(text(b.q('.aq-eyebrow')), 'approval queue · 2 of 2', 'the Projects card, not the head');
313
+ const miss = bootPanel({ answers: { [APPROVALS]: queueFor() } });
314
+ await miss.panel.open('landing:nowhere');
315
+ await settle(); await settle();
316
+ assert.equal(text(miss.q('.aq-eyebrow')), 'approval queue · 1 of 2');
317
+ assert.equal(text(miss.q('.aq-error')), 'That page is not waiting for your sign-off right now.');
318
+ const once = bootPanel({ answers: { [APPROVALS]: queueFor() } });
319
+ await once.panel.refresh();
320
+ await once.panel.open(undefined, { reload: false });
321
+ await settle(); await settle();
322
+ assert.equal(once.requests.filter((r) => r.url.endsWith('/copy-desk/approvals')).length, 1, 'refreshed once, opened without re-reading');
323
+ assert.equal(once.panel.notAppliedCount(), 0);
324
+ });
325
+
300
326
  await test('empty, closed with Escape, and focus goes back where it was', async () => {
301
327
  const b = bootPanel({ answers: { [APPROVALS]: queueFor({ tasks: [] }) } });
302
328
  const opener = { focus() { boot.focused = opener; } };
@@ -391,9 +417,6 @@ await test('a line holding {{productName}} reads as the product name in a chip,
391
417
  assert.ok(chips.every((c) => c.attrs['data-hole'] === 'productName' && text(c) === 'Cloud Bongos'));
392
418
  assert.ok(b.q('mark').querySelector('.aq-hole'), 'the chip sits inside the changed words\' mark');
393
419
  assert.ok(!/\{\{/.test(b.scrim.innerHTML || '') && !/\{\{/.test(text(b.scrim)), 'no raw {{token}} anywhere in the panel');
394
- b.scrim.fire('click', { target: b.q('[data-phase="before"]') }); await settle();
395
- assert.equal(lineWords(), 'Both are how Cloud Bongos itself works.');
396
- assert.equal(asideWords(), 'becomes: This project, Cloud Bongos, has two doors.');
397
420
  });
398
421
 
399
422
  await test('the dialog fits the viewport at every width: capped by the screen, the page shrinks first, rows wrap', () => {
@@ -411,7 +434,8 @@ await test('the dialog fits the viewport at every width: capped by the screen, t
411
434
  assert.match(rule('.aq-foot'), /flex-wrap: wrap/);
412
435
  assert.match(rule('.aq-body'), /min-width: 0/);
413
436
  assert.match(rule('.aq-frame'), /flex-grow: 1;[\s\S]*min-width: 0/, 'the rendered page is what shrinks first');
414
- assert.match(rule('.aq-lines'), /width: clamp\(240px, 32%, 300px\)/, 'the words keep the artboard\'s 300 until the page has shrunk');
437
+ assert.match(rule('.aq-lines'), /min-width: 0/, 'the words take the full width and can shrink');
438
+ assert.match(rule('.aq-line'), /grid-template-columns: minmax\(0, 1fr\) minmax\(0, 1fr\)/, 'was and now sit side by side');
415
439
  assert.match(rule('.aq-line__text'), /overflow-wrap: anywhere/, 'a long word breaks inside its column');
416
440
  });
417
441
 
@@ -129,7 +129,7 @@ function heldClock(h, m) {
129
129
  return Held;
130
130
  }
131
131
 
132
- function bootStudio({ answers = {}, signedIn = true, at = null, body = BODY } = {}) {
132
+ function bootStudio({ answers = {}, signedIn = true, at = null, body = BODY, rank = 'metic', queue = null } = {}) {
133
133
  const root = augment(makeEl('body', {}));
134
134
  root.innerHTML = body;
135
135
  const byId = (id) => walk(root, (c) => (c.id === id ? c : null));
@@ -156,7 +156,7 @@ function bootStudio({ answers = {}, signedIn = true, at = null, body = BODY } =
156
156
  const body = opts ? opts.body : undefined;
157
157
  requests.push({ method, url, body });
158
158
  const key = `${method} ${url.replace(/^\/api\/bongos/, '')}`;
159
- if (key === 'GET /me') return signedIn ? ok({ builder: { id: ME, display_name: 'Artist A', rank: 'metic' } }) : { ok: false, status: 401, data: {} };
159
+ if (key === 'GET /me') return signedIn ? ok({ builder: { id: ME, display_name: 'Artist A', rank } }) : { ok: false, status: 401, data: {} };
160
160
  const a = answers[key];
161
161
  if (typeof a === 'function') return a(body);
162
162
  if (a) return a;
@@ -170,6 +170,7 @@ function bootStudio({ answers = {}, signedIn = true, at = null, body = BODY } =
170
170
  localStorage: { getItem: () => null, setItem() {} },
171
171
  addEventListener() {},
172
172
  scrollTo() {},
173
+ ...(queue ? { OTBApprovalQueue: queue.api, OTBApprovalQueueLib: require('../modules/hall-ui/public/approval-queue-lib.js') } : {}),
173
174
  };
174
175
  // setInterval is held, never real (a real one would keep the run alive): the
175
176
  // test fires each registered tick by hand, as a minute passing.
@@ -243,8 +244,8 @@ try {
243
244
  ]);
244
245
  assert.equal(lib.landingHost('https://cloudbongos.com/', 'builders.x.test'), 'cloudbongos.com', 'the stamped origin wins');
245
246
  assert.equal(lib.landingHost('{{publicOrigin}}', 'builders.x.test'), 'x.test', 'an unstamped token is no address');
246
- assert.equal(lib.askedLine(NEXT.asked_pages), '1 page has been asked for. It comes up first in the Tweak/CopyWrite Flow.');
247
- assert.equal(lib.askedLine(4), '4 pages have been asked for. They come up first in the Tweak/CopyWrite Flow.');
247
+ assert.equal(lib.askedLine(NEXT.asked_pages), '1 page has been asked for. It comes up first in Work through tweaks.');
248
+ assert.equal(lib.askedLine(4), '4 pages have been asked for. They come up first in Work through tweaks.');
248
249
  assert.equal(lib.askedLine(0), '');
249
250
  });
250
251
 
@@ -260,7 +261,7 @@ try {
260
261
  assert.deepEqual(rows.map((r) => [r.title, r.state, r.age, r.stuck]), [
261
262
  ['Contact', 'Approved, going live', 'for 17 days', true],
262
263
  ['Tasks', 'Submitted, waiting to be applied', 'for 1 day', false],
263
- ['Status', 'Needs a look', 'since today', true],
264
+ ['Status', 'Needs to be reviewed', 'since today', true],
264
265
  ]);
265
266
  assert.deepEqual(lib.inFlightRows(null, NOW), []);
266
267
  });
@@ -309,13 +310,79 @@ try {
309
310
  assert.equal(s.requests.filter((r) => /copy-desk\/(tally|next|pages)/.test(r.url)).length, 0);
310
311
  });
311
312
 
313
+ // A stand-in for the Approval queue panel: what the studio asks of it is the point here.
314
+ const stubQueue = ({ count = 2, pending = 0 } = {}) => {
315
+ const calls = { open: [], refresh: 0 };
316
+ const api = { mount: (o) => ({
317
+ open: async (...a) => { calls.open.push(a); },
318
+ refresh: async () => { calls.refresh += 1; o.onCount(count); return count; },
319
+ count: () => count,
320
+ notAppliedCount: () => pending,
321
+ }) };
322
+ return { calls, api };
323
+ };
324
+
325
+ await test('the approve door shows for metic and up, and never mounts the queue below that', async () => {
326
+ const q = stubQueue();
327
+ const metic = bootStudio({ answers: HOME, queue: q });
328
+ await metic.settle();
329
+ assert.equal(metic.el('studio-approvals').hidden, false);
330
+ assert.equal(q.calls.refresh, 1, 'the door hears how many wait');
331
+ const low = stubQueue();
332
+ const thetes = bootStudio({ answers: HOME, queue: low, rank: 'thetes' });
333
+ await thetes.settle();
334
+ assert.equal(thetes.el('studio-approvals').hidden, true, 'below metic the door stays hidden');
335
+ assert.equal(low.calls.refresh, 0, 'and its queue is never read');
336
+ const gone = bootStudio({ answers: HOME, rank: 'archon' });
337
+ await gone.settle();
338
+ assert.equal(gone.el('studio-approvals').hidden, true, 'no panel loaded: no door to nowhere');
339
+ });
340
+
341
+ await test('an empty approval queue says so in a toast; a submitted-not-applied round still opens the dialog', async () => {
342
+ const empty = stubQueue({ count: 0 });
343
+ const a = bootStudio({ answers: HOME, queue: empty });
344
+ await a.settle();
345
+ await a.click('studio-approvals');
346
+ assert.equal(empty.calls.open.length, 0, 'no blank dialog');
347
+ assert.match(a.el('toast').textContent, /Nothing is waiting for your sign-off/);
348
+ const pending = stubQueue({ count: 0, pending: 1 });
349
+ const b = bootStudio({ answers: HOME, queue: pending });
350
+ await b.settle();
351
+ await b.click('studio-approvals');
352
+ assert.equal(pending.calls.open.length, 1, 'the not-applied list is still reachable');
353
+ const full = stubQueue({ count: 2 });
354
+ const c = bootStudio({ answers: HOME, queue: full });
355
+ await c.settle();
356
+ await c.click('studio-approvals');
357
+ assert.equal(full.calls.open.length, 1);
358
+ assert.equal(full.calls.open[0][1].reload, false, 'one read, then open without re-reading');
359
+ assert.equal(full.calls.refresh, 2, 'the page load and the click, nothing more');
360
+ });
361
+
362
+ await test('a page that Needs to be reviewed opens its own approval card from the status panel', async () => {
363
+ const q = stubQueue();
364
+ const waiting = { ...PAGES, in_flight: [{ page_id: 'landing:contact', title: 'Contact', surface: 'landing', state: 'waiting_for_artist', state_word: 'Waiting for your review', since: day(1), stuck: false }] };
365
+ const s = bootStudio({ answers: { ...HOME, 'GET /copy-desk/pages': ok(waiting) }, queue: q });
366
+ await s.settle();
367
+ await s.click('st-status');
368
+ const overlay = s.el('st-panel');
369
+ overlay.fire('click', { target: overlay.querySelector('[data-review-page]'), preventDefault() {} });
370
+ await s.settle();
371
+ assert.equal(s.el('st-panel').hidden, true, 'the panel gives way to the card');
372
+ assert.deepEqual(JSON.parse(JSON.stringify(q.calls.open)).map((c) => c[0]), ['landing:contact']);
373
+ const low = bootStudio({ answers: { ...HOME, 'GET /copy-desk/pages': ok(waiting) }, queue: stubQueue(), rank: 'thetes' });
374
+ await low.settle();
375
+ await low.click('st-status');
376
+ assert.equal(low.el('st-panel-body').querySelector('[data-review-page]'), null, 'below metic the row opens the editor instead');
377
+ });
378
+
312
379
  await test('the Tweak/CopyWrite Flow: the recommendation, pick another swaps it, take it claims THAT page and opens it', async () => {
313
380
  const s = bootStudio({ answers: { ...HOME, 'POST /copy-desk/pages/builders:studio/claim': ok({ ok: true, outcome: 'created' }) } });
314
381
  await s.settle();
315
382
  await s.click('st-flow');
316
383
  assert.equal(s.el('st-panel').hidden, false);
317
384
  assert.ok(s.html.classList.contains('st-open'));
318
- assert.equal(s.el('st-panel-title').textContent, 'Tweak/CopyWrite Flow');
385
+ assert.equal(s.el('st-panel-title').textContent, 'Work through tweaks');
319
386
  const body = () => s.el('st-panel-body').innerHTML;
320
387
  assert.match(body(), /st-next__page">Projects</);
321
388
  assert.match(body(), /landing · 2 people asked for it/);
@@ -346,7 +413,7 @@ try {
346
413
  await s.settle();
347
414
  await s.click('st-status');
348
415
  const body = s.el('st-panel-body').innerHTML;
349
- assert.equal(s.el('st-panel-title').textContent, 'Artist Review Status');
416
+ assert.equal(s.el('st-panel-title').textContent, 'Needs to be reviewed');
350
417
  assert.deepEqual([...body.matchAll(/st-surface__name">([^<]*)</g)].map((m) => m[1]), ['Landing', 'Builders hall', 'Status']);
351
418
  assert.match(body, /st-surface__where">cloudbongos\.com</);
352
419
  assert.match(body, /1 of 2 tweaked/);
@@ -43,6 +43,7 @@ async function hall({ art = true, reducedMotion = 'no-preference', time = null }
43
43
  await ctx.route(ORIGIN + '/**', (route) => {
44
44
  const p = new URL(route.request().url()).pathname;
45
45
  if (p === '/' || p === '/index.html') return route.fulfill({ contentType: 'text/html', body: PAGE });
46
+ if (p === '/api/bongos/auth/web/start') return route.fulfill({ contentType: 'text/html', body: '<!doctype html><title>github</title><p id="auth">sign in</p>' });
46
47
  if (p === '/thinking' || p === '/city') return route.fulfill({ contentType: 'text/html', body: '<!doctype html><title>room</title><p id="room">' + p + '</p>' });
47
48
  const ext = path.extname(p);
48
49
  if (ext === '.webp' && !art) return route.fulfill({ status: 404, body: '' });
@@ -123,12 +124,53 @@ test('once a day: the next load the same day gets no welcome (and the picker is
123
124
  await ctx.close();
124
125
  });
125
126
 
126
- test('a visitor who is not signed in is never greeted, and nothing is remembered', { skip }, async () => {
127
+ test('a visitor who is not signed in gets the Welcome on Home with a sign-in button, and no day is remembered', { skip }, async () => {
127
128
  const ctx = await hall();
128
129
  const { page } = await open(ctx);
129
130
  await page.evaluate(() => window.OTBShell.applyAccess({ authed: false }));
130
- const s = await page.evaluate(() => ({ welcome: !!document.getElementById('welcome-boot'), keys: Object.keys(localStorage).filter((k) => k.startsWith('hall-welcome-day')) }));
131
- assert.deepEqual(s, { welcome: false, keys: [] });
131
+ await page.waitForSelector('#welcome-boot');
132
+ const s = await page.evaluate(() => ({
133
+ hi: document.getElementById('wb-hi').textContent,
134
+ signin: !!document.querySelector('[data-wb-signin]'),
135
+ roles: !!document.querySelector('[data-wb-roles]'),
136
+ keys: Object.keys(localStorage).filter((k) => k.startsWith('hall-welcome-day') || k === 'hall-welcome-signin'),
137
+ picker: !!document.getElementById('craft-picker'),
138
+ }));
139
+ assert.match(s.hi, /^Welcome to /);
140
+ assert.deepEqual([s.signin, s.roles, s.keys, s.picker], [true, false, [], false]);
141
+ await ctx.close();
142
+ });
143
+
144
+ test('every fresh sign-in is welcomed again the same day, whichever sign-in link was clicked', { skip }, async () => {
145
+ for (const link of ['[data-wb-signin]', '#shell-signin']) {
146
+ const ctx = await hall();
147
+ const first = await open(ctx);
148
+ await signIn(first.page);
149
+ await first.page.waitForSelector('#welcome-boot');
150
+ await first.page.close(); // the day is now seen
151
+ const out = await open(ctx);
152
+ await out.page.evaluate(() => window.OTBShell.applyAccess({ authed: false }));
153
+ await out.page.waitForSelector('#welcome-boot');
154
+ // The welcome's own button, or the shell's top-bar link, leaves the flag and heads for GitHub.
155
+ await out.page.evaluate((sel) => document.querySelector(sel).click(), link);
156
+ await out.page.waitForSelector('#auth');
157
+ const { page } = await open(ctx);
158
+ await signIn(page);
159
+ await page.waitForSelector('#welcome-boot');
160
+ assert.equal(await page.evaluate(() => localStorage.getItem('hall-welcome-signin')), null, link + ': the flag is spent');
161
+ await ctx.close();
162
+ }
163
+ });
164
+
165
+ test('a link that only mentions the sign-in path on another site sets no flag', { skip }, async () => {
166
+ const ctx = await hall();
167
+ const { page } = await open(ctx);
168
+ await page.evaluate(() => {
169
+ const a = document.createElement('a');
170
+ a.id = 'far'; a.href = 'https://elsewhere.test/auth/web/start'; a.addEventListener('click', (e) => e.preventDefault());
171
+ document.body.appendChild(a); a.click();
172
+ });
173
+ assert.equal(await page.evaluate(() => localStorage.getItem('hall-welcome-signin')), null);
132
174
  await ctx.close();
133
175
  });
134
176