@bongos/core 1.23.13 → 1.23.15

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 (56) hide show
  1. package/.bongos-core.json +90 -55
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +2 -0
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/api/openapi.json +22 -1
  8. package/docs/api-reference.md +3 -2
  9. package/docs/copy-inventory.md +1000 -946
  10. package/docs/copy-registry.json +2009 -1465
  11. package/docs/module-api-changelog.md +4 -2
  12. package/docs/page-inventory.json +10 -4
  13. package/docs/page-readings.json +593 -573
  14. package/modules/copy-desk/page-status.js +2 -2
  15. package/modules/copy-desk/tests/copy_page_status.mjs +1 -1
  16. package/modules/hall-ui/public/approval-queue-lib.js +1 -1
  17. package/modules/hall-ui/public/approval-queue.css +26 -12
  18. package/modules/hall-ui/public/approval-queue.js +40 -16
  19. package/modules/hall-ui/public/studio-lib.js +5 -3
  20. package/modules/hall-ui/public/studio.html +8 -8
  21. package/modules/hall-ui/public/studio.js +25 -5
  22. package/modules/hall-ui/public/studio.states.json +54 -4
  23. package/modules/hall-ui/public/welcome-boot.js +62 -12
  24. package/modules/platform-identity/builder-of-the-week.js +180 -0
  25. package/modules/platform-identity/migrations/platform_identity_032_weekly_features.sql +53 -0
  26. package/modules/platform-identity/routes/community-leaderboard.js +25 -1
  27. package/modules/public-landing/public/account.html +2 -1
  28. package/modules/public-landing/public/community-week-none.states.json +16 -0
  29. package/modules/public-landing/public/community.html +142 -13
  30. package/modules/public-landing/public/community.states.json +6 -4
  31. package/modules/public-landing/public/connections.html +2 -1
  32. package/modules/public-landing/public/contact.html +2 -1
  33. package/modules/public-landing/public/guild.html +2 -1
  34. package/modules/public-landing/public/index.html +2 -1
  35. package/modules/public-landing/public/invitations.html +2 -1
  36. package/modules/public-landing/public/privacy.html +2 -1
  37. package/modules/public-landing/public/projects.html +2 -1
  38. package/modules/public-landing/public/terms.html +2 -1
  39. package/modules/ui-design/kit/fixtures/community-week-none.json +6 -0
  40. package/modules/ui-design/kit/fixtures/community-week.json +11 -0
  41. package/modules/ui-design/kit/serve.js +8 -0
  42. package/package-lock.json +2 -2
  43. package/package.json +1 -1
  44. package/release-notes.json +15 -1
  45. package/scripts/bongos/run-unit-tests.js +4 -0
  46. package/src/module-api.js +1 -1
  47. package/src/platform-server.js +5 -5
  48. package/tests/builder_of_the_week.mjs +266 -0
  49. package/tests/builder_of_the_week_db.mjs +166 -0
  50. package/tests/hall_approval_queue.mjs +33 -9
  51. package/tests/hall_studio_home.mjs +74 -7
  52. package/tests/hub_account_page.mjs +2 -2
  53. package/tests/hub_community_page.mjs +136 -25
  54. package/tests/landing_legal_pages.mjs +2 -2
  55. package/tests/privacy_matrix.mjs +53 -8
  56. package/tests/welcome_boot_browser.mjs +45 -3
@@ -306,7 +306,7 @@ function orderedSurfaces(inventory) {
306
306
  // last update), and whether it has sat there too long. Waiting for review is
307
307
  // the artist's own move and a draft is theirs to finish; a draft is never stuck,
308
308
  // and a review is stuck only after 3 days (task 1004777, so its artist is told);
309
- // a state the reads cannot name always needs a look.
309
+ // a state the reads cannot name always needs to be reviewed.
310
310
  // ---------------------------------------------------------------------------
311
311
 
312
312
  const DAY_MS = 24 * 60 * 60 * 1000;
@@ -318,7 +318,7 @@ const IN_FLIGHT = Object.freeze({
318
318
  // task 1004777: a review left 3 days is stuck too, so its artist is told on Home.
319
319
  waiting_for_artist: { word: 'Waiting for your review', stuck_after: 3 * DAY_MS },
320
320
  landing: { word: 'Approved, going live', stuck_after: DAY_MS },
321
- unknown: { word: 'Needs a look', stuck_after: 0 },
321
+ unknown: { word: 'Needs to be reviewed', stuck_after: 0 },
322
322
  });
323
323
  const IN_FLIGHT_ORDER = ['unknown', 'landing', 'applying', 'submitted', 'waiting_for_artist', 'writing', 'draft'];
324
324
 
@@ -259,7 +259,7 @@ await test('in_flight: every open round, its plain state, how long, and stuck wh
259
259
  ];
260
260
  const out = status.composePages({ inventory, readings: null, tasks, now: NOW });
261
261
  assert.deepEqual(out.in_flight.map((f) => [f.page_id, f.state, f.state_word, f.stuck]), [
262
- ['builders:f', 'unknown', 'Needs a look', true],
262
+ ['builders:f', 'unknown', 'Needs to be reviewed', true],
263
263
  ['landing:c', 'landing', 'Approved, going live', true],
264
264
  ['status:h', 'applying', 'Being applied', true],
265
265
  ['landing:a', 'submitted', 'Submitted, waiting to be applied', true],
@@ -90,7 +90,7 @@
90
90
 
91
91
  // What the studio's quick action says under "Approval queue".
92
92
  function doorDetail(count) {
93
- if (count == null) return 'the pages /tweak has applied';
93
+ if (count == null) return 'tweaks waiting for your sign-off';
94
94
  if (count === 0) return 'nothing waiting for you';
95
95
  return `${plural(count, 'page', 'pages')} applied, waiting for you`;
96
96
  }
@@ -150,12 +150,16 @@ html.aq-open { overflow: hidden; }
150
150
  }
151
151
 
152
152
  /* The page, and the words beside it. */
153
+ /* The words changed lead and the page is a look-if-you-want beneath them, so the
154
+ body is one column that scrolls as a whole. */
153
155
  .aq-body {
154
156
  flex-grow: 1;
155
157
  min-height: 0;
156
158
  min-width: 0;
157
159
  display: flex;
158
- gap: 16px;
160
+ flex-direction: column;
161
+ gap: 18px;
162
+ overflow-y: auto;
159
163
  }
160
164
  .aq-frame {
161
165
  flex-grow: 1;
@@ -183,16 +187,15 @@ html.aq-open { overflow: hidden; }
183
187
  color: var(--aq-paper-ink);
184
188
  }
185
189
  .aq-lines {
186
- /* 300px, the artboard's, until the page beside it has shrunk; then down to 240. */
187
- width: clamp(240px, 32%, 300px);
188
190
  flex: none;
189
191
  min-width: 0;
190
- min-height: 0;
191
- overflow-y: auto;
192
192
  display: flex;
193
193
  flex-direction: column;
194
194
  gap: 16px;
195
195
  }
196
+ .aq-peek { display: flex; flex-direction: column; gap: 12px; min-width: 0; flex: none; }
197
+ .aq-peek__bar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
198
+ .aq-peek .aq-frame { flex-grow: 0; max-height: min(520px, 60vh); }
196
199
  .aq-views { display: flex; flex-wrap: wrap; gap: 8px; }
197
200
  .aq-lines__list {
198
201
  list-style: none;
@@ -202,7 +205,16 @@ html.aq-open { overflow: hidden; }
202
205
  flex-direction: column;
203
206
  gap: 14px;
204
207
  }
205
- .aq-line { display: flex; flex-direction: column; gap: 4px; padding-top: 14px; border-top: 1px solid var(--aq-rule); }
208
+ /* One changed line: what it was on the left, what it is now on the right. */
209
+ .aq-line {
210
+ display: grid;
211
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
212
+ gap: 6px 24px;
213
+ padding-top: 14px;
214
+ border-top: 1px solid var(--aq-rule);
215
+ }
216
+ .aq-line__section, .aq-line__pinned { grid-column: 1 / -1; }
217
+ .aq-line__aside { grid-column: 1; grid-row: 2; }
206
218
  .aq-line__section {
207
219
  font-family: var(--aq-mono);
208
220
  font-size: 11px;
@@ -210,7 +222,7 @@ html.aq-open { overflow: hidden; }
210
222
  text-transform: uppercase;
211
223
  color: var(--aq-ink-faint);
212
224
  }
213
- .aq-line__text { font-size: 15px; line-height: 1.5; color: var(--aq-ink); overflow-wrap: anywhere; }
225
+ .aq-line__text { grid-column: 2; grid-row: 2; font-size: 17px; line-height: 1.5; color: var(--aq-ink); overflow-wrap: anywhere; }
214
226
  .aq-mark { background: var(--aq-mark); color: var(--aq-ink); border-radius: 4px; padding: 0 3px; }
215
227
  /* A brand hole ({{productName}}) shown as the name it stands for: a fixed name
216
228
  the artist did not write and cannot rewrite, so a quiet chip (task 1004381). */
@@ -224,7 +236,7 @@ html.aq-open { overflow: hidden; }
224
236
  line-height: 1.35;
225
237
  white-space: nowrap;
226
238
  }
227
- .aq-line__aside { font-size: 13px; line-height: 1.45; color: var(--aq-ink-faint); overflow-wrap: anywhere; }
239
+ .aq-line__aside { font-size: 15px; line-height: 1.5; color: var(--aq-ink-faint); overflow-wrap: anywhere; }
228
240
  .aq-line--refused .aq-line__aside { color: var(--aq-link); }
229
241
  /* task 1004775: a test that still checks the old words. */
230
242
  .aq-line__pinned { font-size: 13px; line-height: 1.45; color: var(--aq-link); overflow-wrap: anywhere; }
@@ -248,8 +260,9 @@ html.aq-open { overflow: hidden; }
248
260
  min-height: 48px;
249
261
  }
250
262
  .aq-accent {
251
- height: 48px;
252
- padding: 0 26px;
263
+ height: 52px;
264
+ padding: 0 34px;
265
+ font-weight: 600;
253
266
  border-radius: 999px;
254
267
  cursor: pointer;
255
268
  border: 0;
@@ -312,10 +325,11 @@ html.aq-open { overflow: hidden; }
312
325
  .aq-title { order: 3; flex: 1 1 100%; }
313
326
  .aq-head__gap { display: none; }
314
327
  .aq-head .aq-flips { order: 4; }
315
- .aq-body { flex-direction: column; flex-grow: 0; }
328
+ .aq-body { flex-grow: 0; overflow: visible; }
329
+ .aq-line { grid-template-columns: minmax(0, 1fr); }
330
+ .aq-line__aside, .aq-line__text { grid-column: 1; grid-row: auto; }
316
331
  .aq-frame { flex: none; max-height: 70vh; }
317
332
  .aq-frame__note { padding: 40px 20px; }
318
- .aq-lines { width: auto; overflow: visible; }
319
333
  .aq-foot { flex-wrap: wrap; }
320
334
  .aq-note { flex: 1 1 100%; }
321
335
  .aq-hint { margin-left: 0; flex: 1 1 100%; white-space: normal; }
@@ -72,6 +72,7 @@
72
72
  renders: new Map(), // task id -> render index, or 'loading' / 'failed'
73
73
  outcome: new Map(), // task id -> { kind: 'approved'|'sent', line }
74
74
  noteOpen: false,
75
+ peek: false, // the rendered page is a look-if-you-want, never the card
75
76
  busy: false,
76
77
  error: null,
77
78
  opener: null,
@@ -133,7 +134,7 @@
133
134
  if (!pick) {
134
135
  return L.hasAnyRender(r)
135
136
  ? `<p class="aq-frame__note">No ${esc(state.view.phase)} render is attached to this page.</p>`
136
- : '<p class="aq-frame__note">No render is attached to this page yet. /tweak attaches them when it applies a page; until then there is nothing to approve from, so send it back or wait.</p>';
137
+ : '<p class="aq-frame__note">No render is attached to this page yet. The words above are what changed.</p>';
137
138
  }
138
139
  const name = L.displayTitle(item);
139
140
  const alt = `${name}, ${state.view.phase}, ${pick.device}, ${pick.mode}`;
@@ -145,8 +146,9 @@
145
146
  return H ? H.holesHtml(text, { cls: 'aq-hole' }) : esc(text);
146
147
  }
147
148
 
149
+ // The card IS the words: each changed line, what it was beside what it is now.
148
150
  function linesHtml(item) {
149
- const rows = (item.lines || []).map((l) => L.lineRow(l, state.view.phase));
151
+ const rows = (item.lines || []).map((l) => L.lineRow(l, 'after'));
150
152
  if (!rows.length) return '<p class="aq-lines__none">This round changed no lines.</p>';
151
153
  return `<ol class="aq-lines__list">${rows.map((r) => `
152
154
  <li class="aq-line${r.refused ? ' aq-line--refused' : ''}">
@@ -170,20 +172,35 @@
170
172
  if (!item.can_decide) {
171
173
  return '<div class="aq-foot"><p class="aq-sealed">Only the artist who wrote this page, or another artist, can approve it or send it back.</p><span class="aq-hint">highlighted: the words you changed</span></div>';
172
174
  }
173
- const r = state.renders.get(item.task_id);
174
- const noPicture = r && r !== 'loading' && r !== 'failed' && !L.hasAnyRender(r);
175
- const approve = `<button type="button" class="aq-accent" data-approve${state.busy || noPicture ? ' disabled' : ''}>approve and ship</button>`;
175
+ const approve = `<button type="button" class="aq-accent" data-approve${state.busy ? ' disabled' : ''}>Approve and ship</button>`;
176
176
  const note = state.noteOpen
177
177
  ? `<div class="aq-note">
178
178
  <label for="aq-note" class="aq-sr">What needs changing</label>
179
179
  <input id="aq-note" class="aq-note__input" type="text" maxlength="${L.MAX_NOTE}" placeholder="The hero line is too long on a phone." autocomplete="off">
180
180
  <button type="button" class="aq-ghost aq-ghost--send" data-send${state.busy ? ' disabled' : ''}>send</button>
181
181
  </div>`
182
- : `<button type="button" class="aq-ghost" data-open-note${state.busy ? ' disabled' : ''}>send it back</button>`;
182
+ : `<button type="button" class="aq-ghost" data-open-note${state.busy ? ' disabled' : ''}>Send it back</button>`;
183
183
  const err = state.error ? `<p class="aq-error" role="alert">${esc(state.error)}</p>` : '';
184
184
  return `<div class="aq-foot">${approve}${note}<span class="aq-hint">highlighted: the words you changed</span></div>${err}`;
185
185
  }
186
186
 
187
+ // The page as the artist would see it: closed until asked for, because a
188
+ // picture that may not exist is no way to open a decision.
189
+ function peekHtml(item) {
190
+ if (!state.peek) {
191
+ return '<div class="aq-peek"><button type="button" class="aq-ghost aq-ghost--small" data-peek aria-expanded="false">See it on the page</button></div>';
192
+ }
193
+ return `<div class="aq-peek aq-peek--open">
194
+ <div class="aq-peek__bar">
195
+ <button type="button" class="aq-ghost aq-ghost--small" data-peek aria-expanded="true">Hide the page</button>
196
+ ${pills('phase', L.PHASES, state.view.phase, 'Before or after')}
197
+ ${pills('device', L.DEVICES, state.view.device, 'Desktop or phone')}
198
+ ${pills('mode', L.MODES, state.view.mode, 'Light or dark')}
199
+ </div>
200
+ <div class="aq-frame" tabindex="0" aria-label="The page, rendered">${frameHtml(item)}</div>
201
+ </div>`;
202
+ }
203
+
187
204
  // task 1004765: submitted rounds nobody has applied, below the reviewable
188
205
  // pages. The server lists them apart; the panel only words them, and gives
189
206
  // them no approve or send-back verb.
@@ -204,28 +221,26 @@
204
221
  const item = current();
205
222
  const close = '<button type="button" class="aq-close" data-close>close</button>';
206
223
  if (!item) {
207
- const words = state.loadError || (state.loaded ? 'Nothing is waiting for you. A page appears here once /tweak has applied it and its grade has passed.' : 'Opening the Approval queue…');
224
+ const words = state.loadError || (state.loaded ? 'Nothing is waiting for you. A page shows up here once its tweaks have been applied and checked.' : 'Opening the Approval queue…');
208
225
  dialog.innerHTML = `
209
226
  <div class="aq-head"><span class="aq-eyebrow">approval queue</span><h2 class="aq-title" id="aq-title">${state.loaded ? 'Nothing waiting' : 'Approval queue'}</h2><span class="aq-head__gap"></span>${close}</div>
210
227
  <div class="aq-body aq-body--empty"><div class="aq-frame"><p class="aq-frame__note">${esc(words)}</p></div></div>
211
228
  ${notAppliedHtml()}`;
212
229
  return;
213
230
  }
214
- loadRenders(item);
231
+ if (state.peek) loadRenders(item);
215
232
  dialog.innerHTML = `
216
233
  <div class="aq-head">
217
234
  <span class="aq-eyebrow">${esc(L.eyebrow(item))}</span>
218
235
  <h2 class="aq-title" id="aq-title">${esc(L.displayTitle(item))} <span class="aq-title__sub">${esc(L.subLine(item))}</span></h2>
219
236
  <span class="aq-head__gap"></span>
220
- ${pills('phase', L.PHASES, state.view.phase, 'Before or after')}
221
237
  ${close}
222
238
  </div>
223
239
  <div class="aq-body">
224
- <div class="aq-frame" tabindex="0" aria-label="The page, rendered">${frameHtml(item)}</div>
225
- <aside class="aq-lines" aria-label="The words you changed">
226
- <div class="aq-views">${pills('device', L.DEVICES, state.view.device, 'Desktop or phone')}${pills('mode', L.MODES, state.view.mode, 'Light or dark')}</div>
240
+ <section class="aq-lines" aria-label="The words you changed">
227
241
  ${linesHtml(item)}
228
- </aside>
242
+ </section>
243
+ ${peekHtml(item)}
229
244
  </div>
230
245
  ${footHtml(item)}
231
246
  ${notAppliedHtml()}`;
@@ -276,7 +291,9 @@
276
291
 
277
292
  // ---- open / close ----------------------------------------------------------
278
293
 
279
- async function open() {
294
+ // `pageId` (optional) lands on that page's card when it is in the queue, so a
295
+ // row elsewhere that says "needs to be reviewed" opens its own card.
296
+ async function open(pageId, { reload = true } = {}) {
280
297
  state.opener = document.activeElement;
281
298
  state.open = true;
282
299
  state.error = null;
@@ -285,9 +302,14 @@
285
302
  document.documentElement.classList.add('aq-open');
286
303
  paint();
287
304
  dialog.focus();
288
- await loadQueue();
305
+ if (reload || !state.loaded) await loadQueue();
306
+ if (typeof pageId === 'string') {
307
+ const at = state.queue.findIndex((q) => q.page_id === pageId);
308
+ if (at !== -1) state.idx = at;
309
+ else if (state.queue.length) state.error = 'That page is not waiting for your sign-off right now.';
310
+ }
289
311
  paint();
290
- const first = dialog.querySelector('[data-phase][aria-pressed="true"]') || dialog.querySelector('[data-close]');
312
+ const first = dialog.querySelector('[data-approve]:not([disabled])') || dialog.querySelector('[data-close]');
291
313
  if (first) first.focus();
292
314
  }
293
315
 
@@ -322,6 +344,7 @@
322
344
  if (t.dataset.device) return flip('device', t.dataset.device);
323
345
  if (t.dataset.mode) return flip('mode', t.dataset.mode);
324
346
  if (('approve' in t.dataset)) return decide('approved');
347
+ if (('peek' in t.dataset)) { state.peek = !state.peek; paint(); return undefined; }
325
348
  if (('openNote' in t.dataset)) { state.noteOpen = true; paint(); return focusNote(); }
326
349
  if (('send' in t.dataset)) return decide('sent');
327
350
  if (t.dataset.next != null) { state.idx = Number(t.dataset.next); state.noteOpen = false; state.error = null; paint(); return undefined; }
@@ -354,6 +377,7 @@
354
377
  close,
355
378
  async refresh() { await loadQueue(); paint(); return countNow(); },
356
379
  count: countNow,
380
+ notAppliedCount: () => (state.notApplied || []).length,
357
381
  };
358
382
  }
359
383
 
@@ -164,9 +164,11 @@
164
164
  page_id: p.page_id,
165
165
  title: titleOf(p),
166
166
  surface: surfaceWord(p.surface),
167
- state: p.state_word || 'Needs a look',
167
+ state: p.state_word || 'Needs to be reviewed',
168
168
  age,
169
169
  stuck: !!p.stuck,
170
+ // A page waiting on a sign-off opens its approval card.
171
+ review: p.state === 'waiting_for_artist',
170
172
  // A round the reads cannot name has no action of its own: the row offers
171
173
  // a release (task 1004837). task_id pins the round the viewer looked at.
172
174
  release: p.state === 'unknown',
@@ -191,8 +193,8 @@
191
193
  const v = num(n);
192
194
  if (v <= 0) return '';
193
195
  return v === 1
194
- ? '1 page has been asked for. It comes up first in the Tweak/CopyWrite Flow.'
195
- : `${v} pages have been asked for. They come up first in the Tweak/CopyWrite Flow.`;
196
+ ? '1 page has been asked for. It comes up first in Work through tweaks.'
197
+ : `${v} pages have been asked for. They come up first in Work through tweaks.`;
196
198
  }
197
199
 
198
200
  // A "pick another" chip names the page and its surface (the artboard's label).
@@ -99,7 +99,7 @@
99
99
  shell's top bar is the page's one <header>. -->
100
100
  <div class="st-words" id="st-words">
101
101
  <h1 class="st-greet" id="studio-greet" data-words>Good evening.</h1>
102
- <p class="st-sub" id="studio-sub" data-words>Nothing is on your desk yet. The Tweak/CopyWrite Flow has a page ready for you.</p>
102
+ <p class="st-sub" id="studio-sub" data-words>Nothing is on your desk yet. Work through tweaks has a page ready for you.</p>
103
103
  <button type="button" class="st-hint" id="st-hint" aria-controls="st-offer">click any words here to rewrite them</button>
104
104
  <p class="st-editing" id="st-editing" aria-live="polite" hidden></p>
105
105
  <div class="st-offer" id="st-offer" hidden>
@@ -120,16 +120,16 @@
120
120
  <span class="st-resume__detail" id="st-resume-detail"></span>
121
121
  </a>
122
122
  <button type="button" class="st-action" id="st-flow" aria-haspopup="dialog">
123
- <span class="st-action__label">Tweak/CopyWrite Flow</span>
123
+ <span class="st-action__label">Work through tweaks</span>
124
124
  <span class="st-action__detail" id="st-flow-detail">the next page to tweak</span>
125
125
  </button>
126
- <button type="button" class="st-action" id="studio-approvals" data-approvals aria-haspopup="dialog">
127
- <span class="st-action__label">Copy-write Approval queue</span>
128
- <span class="st-action__detail" id="studio-approvals-detail">the pages /tweak has applied</span>
126
+ <button type="button" class="st-action" id="studio-approvals" data-approvals aria-haspopup="dialog" hidden>
127
+ <span class="st-action__label">Approve and confirm tweaks</span>
128
+ <span class="st-action__detail" id="studio-approvals-detail">tweaks waiting for your sign-off</span>
129
129
  </button>
130
130
  <button type="button" class="st-action" id="st-status" aria-haspopup="dialog">
131
- <span class="st-action__label">Artist Review Status</span>
132
- <span class="st-action__detail" id="st-status-detail">how far each surface has come</span>
131
+ <span class="st-action__label">Needs to be reviewed</span>
132
+ <span class="st-action__detail" id="st-status-detail">pages waiting on you, and how far each surface has come</span>
133
133
  </button>
134
134
  <!-- Task 1004499: the reviews builders wrote on the public site with
135
135
  its Artist review button. Each opens here, in the palette's panel. -->
@@ -150,7 +150,7 @@
150
150
  <div class="st-overlay" id="st-panel" hidden>
151
151
  <div class="st-dialog" role="dialog" aria-modal="true" aria-labelledby="st-panel-title">
152
152
  <div class="st-dialog__head">
153
- <h2 class="st-dialog__title" id="st-panel-title" tabindex="-1">Tweak/CopyWrite Flow</h2>
153
+ <h2 class="st-dialog__title" id="st-panel-title" tabindex="-1">Work through tweaks</h2>
154
154
  <button type="button" class="st-close" data-close-panel>close</button>
155
155
  </div>
156
156
  <div class="st-dialog__body" id="st-panel-body"></div>
@@ -128,6 +128,8 @@
128
128
  const RANK_ORDER = { archon: 4, metic: 3, thetes: 2, xenos: 1 };
129
129
  const MAX_WORDS = 2000; // the abandon/merge `reason` cap the routes apply anyway
130
130
 
131
+ // Also the floor for the approve door: a studio viewer who cannot pass a tweak
132
+ // is not shown the door (cosmetic; the approve and send-back routes decide).
131
133
  function canVerdict() {
132
134
  const rank = String((viewer && viewer.rank) || '').toLowerCase();
133
135
  return (RANK_ORDER[rank] ?? 0) >= RANK_ORDER.metic;
@@ -570,15 +572,31 @@
570
572
  const door = $('#studio-approvals');
571
573
  const Q = window.OTBApprovalQueue;
572
574
  const lib = window.OTBApprovalQueueLib;
573
- if (!door || !Q || !lib) return;
575
+ if (!door || !Q || !lib) return false;
574
576
  approvals = Q.mount({
575
577
  api,
576
578
  apiBase: API,
577
579
  writes: { approve: WRITE_ROUTES.approve, sendBack: WRITE_ROUTES.sendBack },
578
580
  onCount: (n) => { const d = $('#studio-approvals-detail'); if (d) d.textContent = lib.doorDetail(n); },
579
581
  });
580
- door.addEventListener('click', () => approvals.open());
582
+ // Nothing to approve means no dialog: the door's own detail line already
583
+ // says so, and a click on it just confirms it.
584
+ door.addEventListener('click', async () => {
585
+ const n = await approvals.refresh();
586
+ // Submitted-but-not-applied rounds still show in the dialog, so they count as something to see.
587
+ if (n === 0 && !approvals.notAppliedCount()) { toast('Nothing is waiting for your sign-off right now.'); return; }
588
+ approvals.open(undefined, { reload: false });
589
+ });
581
590
  approvals.refresh();
591
+ return true;
592
+ }
593
+
594
+ // The approve door is for artists who can pass a tweak (metic and up). Below
595
+ // that rank it never shows; the routes still decide who may (ADR 0016).
596
+ function showApprovals() {
597
+ const door = $('#studio-approvals');
598
+ if (!door || !canVerdict()) return;
599
+ if (mountApprovals()) door.hidden = false;
582
600
  }
583
601
 
584
602
  // ===========================================================================
@@ -682,7 +700,7 @@
682
700
 
683
701
  // ---- the small glass panels -----------------------------------------------
684
702
 
685
- const TITLES = Object.freeze({ flow: 'Tweak/CopyWrite Flow', status: 'Artist Review Status', site: 'From the live site' });
703
+ const TITLES = Object.freeze({ flow: 'Work through tweaks', status: 'Needs to be reviewed', site: 'From the live site' });
686
704
 
687
705
  function flowHtml() {
688
706
  if (!home.next) {
@@ -725,7 +743,7 @@
725
743
  const asked = lib.askedLine(home.next && home.next.asked_pages);
726
744
  const flight = lib.inFlightRows(home.pages, new Date()).map((f) => `
727
745
  <li class="st-flight${f.stuck ? ' st-flight--stuck' : ''}">
728
- <a class="st-flight__page" href="${escapeHtml(editorHref(f.page_id))}">${escapeHtml(f.title)}</a>
746
+ ${f.review && approvals ? `<a class="st-flight__page" href="#review" data-review-page="${escapeHtml(f.page_id)}">${escapeHtml(f.title)}</a>` : `<a class="st-flight__page" href="${escapeHtml(editorHref(f.page_id))}">${escapeHtml(f.title)}</a>`}
729
747
  <span class="st-flight__where">${escapeHtml(f.surface)}</span>
730
748
  <span class="st-flight__state">${escapeHtml(f.state)}${f.age ? ` · ${escapeHtml(f.age)}` : ''}${f.stuck ? ' · stuck' : ''}</span>
731
749
  ${f.release && canVerdict() ? `<button type="button" class="st-release" data-release-page="${escapeHtml(f.page_id)}" data-release-task="${escapeHtml(f.task_id || '')}">release</button>` : ''}
@@ -1021,6 +1039,8 @@
1021
1039
  if (overlay) {
1022
1040
  overlay.addEventListener('click', (ev) => {
1023
1041
  if (ev.target === overlay || ev.target.closest('[data-close-panel]')) { closePanel(); return; }
1042
+ const review = ev.target.closest('[data-review-page]');
1043
+ if (review && approvals) { ev.preventDefault(); const id = review.getAttribute('data-review-page'); closePanel(); approvals.open(id); return; }
1024
1044
  const rel = ev.target.closest('[data-release-page]');
1025
1045
  if (rel) { releasePage(rel.getAttribute('data-release-page'), rel.getAttribute('data-release-task'), rel); return; }
1026
1046
  const take = ev.target.closest('[data-take]');
@@ -1082,9 +1102,9 @@
1082
1102
 
1083
1103
  (async () => {
1084
1104
  startSky();
1085
- mountApprovals();
1086
1105
  const me = await api.request('GET', `${API}/me`);
1087
1106
  if (me.ok) viewer = me.data.builder || me.data;
1107
+ showApprovals();
1088
1108
  bootHome(me.ok);
1089
1109
  // Reviews first (the richer craft), then flags — each newest-first within.
1090
1110
  const [reviews, flags] = await Promise.all([loadReviews(), loadFlags()]);
@@ -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": {