@bongos/core 1.23.12 → 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": {
@@ -223,12 +223,13 @@
223
223
  //
224
224
  // The SIBLINGS (task 1004393): once the proxy answers the apex and status hosts
225
225
  // with `frame-ancestors 'self' https://builders.<apex>` instead of SAMEORIGIN,
226
- // those two pages may split in place too. Until then framing them shows the
227
- // browser's refusal page, so FRAME_SIBLINGS stays false and flipping it is the
228
- // step after the proxy change (the owner's steps are on task 1004393). Only the
229
- // apex and status.<apex> are siblings, never another subdomain: the proxy keeps
230
- // every other host unframeable, sign-in and settings included.
231
- const FRAME_SIBLINGS = false;
226
+ // those two pages may split in place too. The proxy change is live (checked on
227
+ // the apex and status hosts in task 1004644), so FRAME_SIBLINGS is on. Set it
228
+ // back to false if the proxy ever stops sending frame-ancestors: framing them
229
+ // would show the browser's refusal page. Only the apex and status.<apex> are
230
+ // siblings, never another subdomain: the proxy keeps every other host
231
+ // unframeable, sign-in and settings included.
232
+ const FRAME_SIBLINGS = true;
232
233
  function siblingOrigins(loc) {
233
234
  const here = new URL(loc.href || loc.origin);
234
235
  if (!/^builders\./.test(here.hostname)) return [];
@@ -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
  })();
@@ -27,7 +27,7 @@ The paper is paper and the room is the room whatever the hall's theme: the stage
27
27
  - **Boxes grow to their words** rather than the artboard's rows 1 or 2.
28
28
  - **A refused upload opens a panel** (task 1004354, `#tw-refused`): the "is in" panel's glass and scrim over the whole window, a headline per refusal (`lib.uploadRefusal`), the server's words, the lines it names, "what to do", and "download this page again" or close (Escape too). An artist uploads from the bar, often far down the page, where the paper's notice was off screen. The notice stays on the paper after the panel closes. The kit has no file-upload action, so the panel is proven in `tests/hall_tweak_editor.mjs` and by shots of a real browser at 1440 and 390, not by a states.json state.
29
29
  - **The check, the restore and Shift + Enter (task 1004385, owner-asked).** Beside each line a round restore button (only on a changed line: it puts the page's words back and takes the check off) and a round green check. A checked line takes a light green wash; the owner picked the check over a tint alone. **Shift + Enter** in a line checks it and lands in the next, and a green hint above the words says so (with "N of M checked"), because it is the quickest way through a page. Enter alone is unchanged. The checks are the artist's place-keeper and stay in the browser (`localStorage`, keyed by page and reading hash, so a page that moved never ticks the wrong words); the server has no such field and `WRITES` is unchanged. A page the caller cannot write has none of it. Rules in `tweak-editor-lib.js` (`nextLineKey`, `confirmedCount`, `parseConfirmed`, `confirmStoreKey`, `hintText`).
30
- - **No page title or sub text at the top of the paper, and "open the page" is a menu (task 1004386, owner-asked).** The bar already names the page, so the paper opens on its first section; the h1 stays, visually hidden, for the one-h1 floor. "open the page" is a **split button** (owner-amended mid-build): the main half repeats the artist's last choice ("open the page ↗" for a new tab, "open in split view"), and the ▾ beside it opens **open in new tab** and **open in split view**. Click, keyboard and touch; hover alone opens nothing, because a hover menu closes when the pointer wanders and does not exist on touch. Split view has a **draggable divider** (mouse, touch, or the arrow keys and Home/End on the focused divider; the live page takes 20% to 80% of the window, `lib.clampSplit`), and the frame stops taking the pointer while it is dragged. The choice, the size and whether split view was open are remembered (`hall-tweak-open-as`, `hall-tweak-split-size`, `hall-tweak-split`), so the next page opens the same way; a split that would be a second window never reopens on load, because a browser blocks a popup nobody clicked. At phone width the live page takes at most half the screen, whatever the saved size, because the two-row bar is tall there. Split view pins the live page under the words (words above, page below, both scroll; "close split view" unloads it; the frame loads only when opened). **The live site answers `X-Frame-Options: SAMEORIGIN`** (set at the proxy in front of the app, not in this repo), so only a page on the editor's own host can be framed. A landing or status page is on another host, so its split view opens the page in a second window docked to the bottom half of the screen (`lib.canFrame`, `lib.splitWindowFeatures`). **Letting them split in place (task 1004393)** is two steps, in this order. First the proxy answers the apex and `status.<apex>` with `<redacted>: frame-ancestors 'self' https://builders.<apex>` in place of `X-Frame-Options: SAMEORIGIN`, and every other host keeps SAMEORIGIN (sign-in and settings stay unframeable). Then `FRAME_SIBLINGS` in `tweak-editor-lib.js` flips to `true`. `lib.canFrame` then also admits exactly those two origins, same scheme and port, and only from the builders host; never another subdomain or a lookalike. Flipping it before the proxy changes would put the browser's refusal page in the split, which is why it ships off.
30
+ - **No page title or sub text at the top of the paper, and "open the page" is a menu (task 1004386, owner-asked).** The bar already names the page, so the paper opens on its first section; the h1 stays, visually hidden, for the one-h1 floor. "open the page" is a **split button** (owner-amended mid-build): the main half repeats the artist's last choice ("open the page ↗" for a new tab, "open in split view"), and the ▾ beside it opens **open in new tab** and **open in split view**. Click, keyboard and touch; hover alone opens nothing, because a hover menu closes when the pointer wanders and does not exist on touch. Split view has a **draggable divider** (mouse, touch, or the arrow keys and Home/End on the focused divider; the live page takes 20% to 80% of the window, `lib.clampSplit`), and the frame stops taking the pointer while it is dragged. The choice, the size and whether split view was open are remembered (`hall-tweak-open-as`, `hall-tweak-split-size`, `hall-tweak-split`), so the next page opens the same way; a split that would be a second window never reopens on load, because a browser blocks a popup nobody clicked. At phone width the live page takes at most half the screen, whatever the saved size, because the two-row bar is tall there. Split view pins the live page under the words (words above, page below, both scroll; "close split view" unloads it; the frame loads only when opened). **The live site answers `X-Frame-Options: SAMEORIGIN`** (set at the proxy in front of the app, not in this repo), so only a page on the editor's own host can be framed. A landing or status page is on another host, so its split view opens the page in a second window docked to the bottom half of the screen (`lib.canFrame`, `lib.splitWindowFeatures`). **Letting them split in place (task 1004393)** is two steps, in this order. First the proxy answers the apex and `status.<apex>` with `<redacted>: frame-ancestors 'self' https://builders.<apex>` in place of `X-Frame-Options: SAMEORIGIN`, and every other host keeps SAMEORIGIN (sign-in and settings stay unframeable). Then `FRAME_SIBLINGS` in `tweak-editor-lib.js` flips to `true` (done in task 1004644, after checking both hosts answer frame-ancestors and no X-Frame-Options). `lib.canFrame` then also admits exactly those two origins, same scheme and port, and only from the builders host; never another subdomain or a lookalike. Flipping it before the proxy changes would put the browser's refusal page in the split, which is why it shipped off until the proxy was live; if the proxy ever stops sending frame-ancestors, flip it back.
31
31
  - **Not on the artboard at all:** the phone layout (the bar becomes a two-row glass card, notes drop under their lines), the shell's full-screen button top right, the read-only and refusal notices on the paper, the chrome view (the switch off), and light mode.
32
32
 
33
33
  ## The proof
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.23.12",
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.12",
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.12",
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",
@@ -10459,5 +10459,19 @@
10459
10459
  "id": "1002292",
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
+ ],
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
+ },
10472
+ {
10473
+ "id": "1004644",
10474
+ "text": "The tweak editor's split view now shows the landing and status pages under the words, instead of opening a second window."
10475
+ }
10462
10476
  ]
10463
10477
  }
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.12'; // 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/);
@@ -675,11 +675,13 @@ try {
675
675
  await test('split view can frame only its own host; the live site refuses to be framed from another', () => {
676
676
  const here = { origin: 'https://builders.cloudbongos.com', href: 'https://builders.cloudbongos.com/builders/tweak-editor' };
677
677
  assert.equal(lib.canFrame('https://builders.cloudbongos.com/studio', here), true);
678
- assert.equal(lib.canFrame('https://cloudbongos.com/', here), false, 'the apex is another origin');
678
+ assert.equal(lib.canFrame('https://cloudbongos.com/', here), true, 'the apex is a sibling the proxy lets the builders host frame');
679
+ assert.equal(lib.canFrame('https://cloudbongos.com/', here, false), false, 'the apex is another origin when siblings are off');
679
680
  assert.equal(lib.canFrame('not a url', { origin: '', href: '' }), false);
680
681
  // task 1004393: the apex and status pages split in place only once the proxy allows it.
681
- assert.equal(lib.FRAME_SIBLINGS, false, 'off until the proxy sends frame-ancestors for the builders host');
682
- assert.equal(lib.canFrame('https://status.cloudbongos.com/', here), false, 'the default follows the switch');
682
+ assert.equal(lib.FRAME_SIBLINGS, true, 'on: the proxy sends frame-ancestors for the builders host (task 1004644)');
683
+ assert.equal(lib.canFrame('https://status.cloudbongos.com/', here), true, 'the default follows the switch');
684
+ assert.equal(lib.canFrame('https://status.cloudbongos.com/', here, false), false, 'switched off, only the editor host frames');
683
685
  assert.equal(lib.canFrame('https://cloudbongos.com/pricing', here, true), true, 'the apex, switched on');
684
686
  assert.equal(lib.canFrame('https://status.cloudbongos.com/', here, true), true, 'the status host, switched on');
685
687
  assert.equal(lib.canFrame('https://box.cloudbongos.com/', here, true), false, 'never another subdomain');
@@ -791,19 +793,14 @@ try {
791
793
  } finally { PAGE.url = saved; }
792
794
  });
793
795
 
794
- await test('a page on the apex splits into its own window docked to the bottom half, and never on load', async () => {
796
+ await test('a page on the apex splits in place under the words, with no second window (task 1004644)', async () => {
795
797
  const b = bootEditor({
796
798
  answers: { 'GET /copy-desk/pages/landing:index/draft': ok(editorRead()) },
797
799
  stored: { 'hall-tweak-open-as': 'split', 'hall-tweak-split': 'on' },
798
800
  });
799
801
  await b.settle();
800
- assert.equal(b.opened.length, 0, 'no popup on load: a browser would block it');
801
- assert.equal(b.el('tw-frame').hidden, true, 'nothing framed: the site would refuse');
802
- await b.click('tw-open');
803
- assert.equal(b.opened.length, 1);
804
- assert.deepEqual(b.opened[0].slice(0, 2), ['https://cloudbongos.com/', 'tw-live-page']);
805
- assert.equal(b.opened[0][2], 'popup=yes,width=1600,height=500,left=0,top=500');
806
- assert.equal(b.el('tw-frame').hidden, true);
802
+ assert.equal(b.opened.length, 0, 'no popup: the page is framed in place');
803
+ assert.equal(b.el('tw-frame').hidden, false, 'the apex page shows under the words');
807
804
  });
808
805
 
809
806
  // task 1004776: a guarded line shows what it must still say under it, and the