@bongos/core 1.19.1064 → 1.19.1066

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 (49) hide show
  1. package/.bongos-core.json +96 -46
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +6 -0
  4. package/clients/bongos-client/index.cjs +6 -0
  5. package/clients/bongos-client/index.d.ts +11 -1
  6. package/clients/bongos-client/index.mjs +6 -0
  7. package/docs/adr/0341-the-page-is-the-unit-of-tweak-mode.md +1 -0
  8. package/docs/api/openapi.json +237 -4
  9. package/docs/api-reference.md +5 -2
  10. package/docs/branding-contract.md +1 -1
  11. package/docs/copy-inventory.md +60 -41
  12. package/docs/copy-registry.json +228 -47
  13. package/docs/module-api-changelog.md +4 -0
  14. package/docs/page-inventory.json +11 -2
  15. package/docs/page-readings.json +45 -22
  16. package/modules/copy-desk/page-decisions.js +71 -0
  17. package/modules/copy-desk/page-status.js +100 -2
  18. package/modules/copy-desk/pages.js +30 -0
  19. package/modules/copy-desk/routes/copy-desk.js +156 -2
  20. package/modules/copy-desk/tests/copy_no_cms.mjs +40 -4
  21. package/modules/economy/credits.js +77 -0
  22. package/modules/economy/reward-policy.js +7 -1
  23. package/modules/economy/reward.js +4 -0
  24. package/modules/hall-ui/public/approval-queue-lib.js +156 -0
  25. package/modules/hall-ui/public/approval-queue.css +286 -0
  26. package/modules/hall-ui/public/approval-queue.js +338 -0
  27. package/modules/hall-ui/public/studio.css +33 -0
  28. package/modules/hall-ui/public/studio.html +18 -1
  29. package/modules/hall-ui/public/studio.js +30 -0
  30. package/modules/hall-ui/public/studio.states.json +198 -0
  31. package/modules/hall-ui/records/approval-queue.md +32 -0
  32. package/modules/lifecycle/db-grade.js +80 -58
  33. package/modules/lifecycle/db-ship.js +14 -5
  34. package/modules/lifecycle/github-pr-close.js +41 -0
  35. package/modules/lifecycle/github-push.js +5 -2
  36. package/modules/lifecycle/lifecycle.js +12 -0
  37. package/modules/lifecycle/page-tweak-approve.js +242 -0
  38. package/modules/lifecycle/routes/gate-approvals.js +17 -2
  39. package/package-lock.json +2 -2
  40. package/package.json +1 -1
  41. package/release-notes.json +12 -0
  42. package/scripts/hall-preview/server.js +18 -0
  43. package/src/module-api.js +1 -1
  44. package/tests/artist_page_credit.mjs +272 -0
  45. package/tests/copy_desk_page_approve.mjs +307 -0
  46. package/tests/fixtures/page-ledger.mjs +41 -1
  47. package/tests/gate_approvals.mjs +93 -0
  48. package/tests/hall_approval_queue.mjs +327 -0
  49. package/tests/hall_audit.mjs +4 -0
@@ -21,9 +21,9 @@
21
21
  // which no renderer reads — and the wording reaches a person only after somebody
22
22
  // claims that task and ships the diff. So the four things pinned here are:
23
23
  //
24
- // * the write surface is EXACTLY the verbs the module defines (eight since
25
- // TW09: the page draft, its Word upload and the submit write the round
26
- // TASK, never a row),
24
+ // * the write surface is EXACTLY the verbs the module defines (ten since
25
+ // TW12: the page draft, its Word upload, the submit and the Approval
26
+ // queue's two decisions write the round TASK, never a row),
27
27
  // * no column on copy_desk_flags could hold replacement text,
28
28
  // * nothing in this module writes a file, and
29
29
  // * the ONLY thing that writes a surface file is the applier, it lives outside
@@ -66,7 +66,8 @@ const stripComments = (src) => src
66
66
  // status, drift and tally) and page-data.js (the read side of the page
67
67
  // inventory and readings). They are held to the same two rules as the rest.
68
68
  // TW09 (task 1004320) added docx.js, the Word round trip over Buffers.
69
- const MODULE_FILES = ['registry.js', 'flags.js', 'proposals.js', 'pages.js', 'page-status.js', 'page-data.js', 'docx.js', 'routes/copy-desk.js'];
69
+ // TW12 (task 1004323) added page-decisions.js, the Approval queue's route helpers.
70
+ const MODULE_FILES = ['registry.js', 'flags.js', 'proposals.js', 'pages.js', 'page-status.js', 'page-data.js', 'docx.js', 'page-decisions.js', 'routes/copy-desk.js'];
70
71
 
71
72
  console.log('copy-desk: NO LIVE CMS');
72
73
 
@@ -78,6 +79,12 @@ await test('the write surface is exactly the verbs the module defines', () => {
78
79
  assert.deepEqual(found, [
79
80
  'POST /copy-desk/flags',
80
81
  'POST /copy-desk/flags/:id/close',
82
+ // TW12 (task 1004323, ADR 0341 D7, D11): the Approval queue's two
83
+ // decisions. Both move the page round TASK through the lifecycle port
84
+ // (approveHeldTweak, sendBackHeldTweak); approve lands the applied branch the
85
+ // ordinary way, and send-back appends a note block to the task. Neither
86
+ // writes a row here, a file, or anything a renderer reads.
87
+ 'POST /copy-desk/pages/:pageId/approve',
81
88
  // TW06 (task 1004317, ADR 0341 D5, D6, D11): the page claim opens or takes a
82
89
  // TASK and a web claim through the lifecycle port; the page ask writes one
83
90
  // flag row whose target is a page id. Neither stores wording.
@@ -87,6 +94,7 @@ await test('the write surface is exactly the verbs the module defines', () => {
87
94
  // round's DRAFT through the autosave's own port call and resolver, never a
88
95
  // row here, a file, or the site.
89
96
  'POST /copy-desk/pages/:pageId/draft.docx',
97
+ 'POST /copy-desk/pages/:pageId/send-back',
90
98
  // TW08 (task 1004319, ADR 0341 D4, D11): the draft autosave and the
91
99
  // submit. Both write the page round's TASK DESCRIPTION through the
92
100
  // lifecycle port, the place ADR 0233 lets wording rest; neither writes a
@@ -249,6 +257,34 @@ await test('the Word round trip is pure over Buffers, and its upload writes only
249
257
  'the upload writes the draft and nothing else: no row, and never the frozen batch');
250
258
  });
251
259
 
260
+ await test('the Approval queue decides only through the port, and a send-back writes a note, never wording', () => {
261
+ // TW12 (task 1004323, ADR 0341 D7): approve and send-back move the round
262
+ // TASK through the lifecycle port. Send-back's one piece of text is the
263
+ // artist's note to the next applier, written as a page-tweak-sent-back block
264
+ // by pages.appendSentBack; it must never touch the draft or the frozen batch.
265
+ const src = stripComments(read('routes/copy-desk.js'));
266
+ const handler = (p) => {
267
+ const start = src.indexOf(`router.post('${p}'`);
268
+ assert.ok(start >= 0, `${p} is mounted`);
269
+ const next = src.indexOf('router.', start + 1);
270
+ return src.slice(start, next === -1 ? undefined : next);
271
+ };
272
+ const approve = handler('/copy-desk/pages/:pageId/approve');
273
+ const sendBack = handler('/copy-desk/pages/:pageId/send-back');
274
+ assert.ok(/lifecycle\.approveHeldTweak\(/.test(approve), 'approve moves the task through the lifecycle port');
275
+ assert.ok(/lifecycle\.sendBackHeldTweak\(/.test(sendBack), 'send-back moves the task through the lifecycle port');
276
+ for (const [name, h] of [['approve', approve], ['send-back', sendBack]]) {
277
+ assert.equal(/insertFlag|closeFlag|INSERT INTO|UPDATE |createTask|composeBatchBlock|composeDraftBlock|saveDraftInto|replaceBlock/i.test(h), false,
278
+ `the ${name} route writes no row here and no draft or batch`);
279
+ assert.equal(/req\.body\.(before|after|section|placement|file|line|string_id|text|lines)\b/.test(h), false,
280
+ `the ${name} route takes no wording or target from the request body`);
281
+ }
282
+ assert.ok(/pages\.appendSentBack\(task\.description,/.test(sendBack), 'the note is appended by the one sent-back writer');
283
+ assert.ok(/pages\.normalizeSentBackNote\(req\.body\.note\)/.test(sendBack), 'the note floor is checked before anything moves');
284
+ const schemas = src.slice(src.indexOf('const APPROVE_BODY_SCHEMA'), src.indexOf('const PAGE_REFUSAL_STATUS'));
285
+ assert.equal(/\b(before|after|section|placement|file|string_id|lines)\s*:/.test(schemas), false, 'the decision schemas name no wording or target field');
286
+ });
287
+
252
288
  await test('the module manifest declares no seam another module could write copy through', () => {
253
289
  const manifest = JSON.parse(read('module.json'));
254
290
  assert.deepEqual(manifest.provides, [], 'a provided port here would be a copy write path for every other module');
@@ -1174,6 +1174,80 @@ async function sumSessionEarnings(executor, builderId, taskIds, sessionId) {
1174
1174
  return rows[0] ? rows[0].total : 0;
1175
1175
  }
1176
1176
 
1177
+ // =========================================================================
1178
+ // The ARTIST credit lane (task 1004324 / BV2.TW13, ADR 0341 D10).
1179
+ // =========================================================================
1180
+ // Tweak Mode replaced the per-ship artist review with an approved page, and the
1181
+ // owner's rule is the same payouts. A review paid its PRICED value,
1182
+ // credits_reward x kind multiplier, as the per-task estimate, which cost-plus-only
1183
+ // withholds, so paid literally a studio artist would earn nothing on this
1184
+ // instance. So an approved page pays that priced value (30 for a feature round
1185
+ // at 30) on its own lane, `artistCredit`, which cost-plus-only does not touch:
1186
+ // the ideator lane's precedent (ADR 0172). The lifecycle calls this at the land
1187
+ // INSTEAD of the shipper's per-task estimate, so the total is conserved in both
1188
+ // modes, and the applier keeps its cost-plus session reward, which is booked
1189
+ // elsewhere and is untouched here.
1190
+ //
1191
+ // The reason prefix is the one copy-desk's tally already sums
1192
+ // (modules/copy-desk/page-status.js TALLY_REASON_PREFIX, restated there because
1193
+ // a module cannot require another; tests/artist_page_credit.mjs holds the two
1194
+ // strings together).
1195
+ const ARTIST_PAGE_REASON_PREFIX = 'tweak.page_approved:';
1196
+ const PAGE_TWEAK_SOURCE = 'page-tweak';
1197
+
1198
+ // Everything is read from the ledger inside the caller's transaction, never
1199
+ // taken from the caller (ADR 0185): the task's own source, status and priced
1200
+ // value, and the payee, the builder of the round's LATEST WEB claim (both
1201
+ // worktree_name and creator_session_id null), the claim the submit released.
1202
+ // That is the TW05 reads' artist rule (modules/lifecycle/page-tweak-reads.js),
1203
+ // so the changelog's artist and the paid artist are the same person.
1204
+ //
1205
+ // NEVER PAID UNLESS SHIPPED. The row must read `shipped` in this transaction, so
1206
+ // a round that was sent back, abandoned or is still waiting pays nothing, even
1207
+ // if a caller reaches this early.
1208
+ //
1209
+ // ONE PAYMENT PER ROUND. The NOT EXISTS keys on (task_id, reason), served by
1210
+ // idx_credit_log_task. That is stricter than ADR 0341's "(builder, reason)": a
1211
+ // round has one payee, so keying on the round alone also refuses a second row
1212
+ // should the payee ever read differently on a re-sweep, and a self-applied round
1213
+ // (the artist also ran /tweak) is still one artist row, never two.
1214
+ //
1215
+ // Returns { credited, reason, artistId, delta } with a reason on every path:
1216
+ // 'paid' · 'already_paid' · 'artist_lane_off' · 'not_found' · 'not_a_page_tweak'
1217
+ // · 'not_shipped' · 'no_artist' · 'zero_value'.
1218
+ async function awardArtistPageCredit(client, { taskId, config = rewardConfig() } = {}) {
1219
+ if (config && config.artistCredit === false) return { credited: 0, reason: 'artist_lane_off' };
1220
+ const { rows } = await client.query(
1221
+ `SELECT t.source, t.status, t.credits_reward, t.kind,
1222
+ (SELECT c.builder_id FROM claims c
1223
+ WHERE c.task_id = t.id AND c.worktree_name IS NULL AND c.creator_session_id IS NULL
1224
+ ORDER BY c.claimed_at DESC, c.id DESC
1225
+ LIMIT 1) AS artist_id
1226
+ FROM tasks t
1227
+ WHERE t.id = $1`,
1228
+ [taskId]
1229
+ );
1230
+ const t = rows[0];
1231
+ if (!t) return { credited: 0, reason: 'not_found' };
1232
+ if (t.source !== PAGE_TWEAK_SOURCE) return { credited: 0, reason: 'not_a_page_tweak' };
1233
+ if (t.status !== 'shipped') return { credited: 0, reason: 'not_shipped' };
1234
+ if (t.artist_id == null) return { credited: 0, reason: 'no_artist' };
1235
+ // The review's priced value, by the one multiplier rule (rounded once, there).
1236
+ const priced = applyKindMultiplier(t.credits_reward, t.kind);
1237
+ if (priced.effective <= 0) return { credited: 0, reason: 'zero_value', artistId: t.artist_id };
1238
+ const reason = `${ARTIST_PAGE_REASON_PREFIX}${taskId}`;
1239
+ const { rowCount } = await client.query(
1240
+ `INSERT INTO credit_log (builder_id, task_id, delta, reason, description)
1241
+ SELECT $1, $2, $3, $4, $5
1242
+ WHERE NOT EXISTS (SELECT 1 FROM credit_log WHERE task_id = $2 AND reason = $4)`,
1243
+ [t.artist_id, taskId, priced.effective, reason,
1244
+ `Approved page tweak, task #${taskId}: ${priced.description} to its artist (artist lane, ADR 0341 D10)`]
1245
+ );
1246
+ return rowCount > 0
1247
+ ? { credited: priced.effective, reason: 'paid', artistId: t.artist_id, delta: priced.effective }
1248
+ : { credited: 0, reason: 'already_paid', artistId: t.artist_id, delta: priced.effective };
1249
+ }
1250
+
1177
1251
  // The three ideator streams (ADR 0172) each book under a reason key that EMBEDS
1178
1252
  // the idea (and, for stream 1, the task) — that string IS the idempotency
1179
1253
  // predicate, which is why it can't be a bare constant:
@@ -1294,4 +1368,7 @@ module.exports = {
1294
1368
  // the ledger read surface (task 1003484, blocker 1000127)
1295
1369
  creditLedger,
1296
1370
  IDEA_STREAM_REASON_PREFIXES,
1371
+ // the artist lane (task 1004324 / BV2.TW13, ADR 0341 D10)
1372
+ awardArtistPageCredit,
1373
+ ARTIST_PAGE_REASON_PREFIX,
1297
1374
  };
@@ -61,6 +61,12 @@ function rewardPolicy(rawReward) {
61
61
  const perTaskCredit = mode !== REWARD_MODE_COST_PLUS_ONLY;
62
62
  // Default-on, opt-out-only: only an explicit `false` withholds the ideator lane.
63
63
  const ideatorCredit = r.ideatorCredit !== false;
64
+ // The ARTIST lane (task 1004324 / BV2.TW13, ADR 0341 D10): an approved page
65
+ // pays its artist on its own lane, the ideator lane's precedent exactly. Not
66
+ // derived from `mode`, so cost-plus-only does not withhold it: a studio artist
67
+ // runs no session, so the cost-plus reward can never reach them. Default-on,
68
+ // opt-out-only, the same rule as ideatorCredit.
69
+ const artistCredit = r.artistCredit !== false;
64
70
  // Validated to [0, 1): see IDEA_DESCENT_DECAY above — a rate of 1 or more does
65
71
  // not converge, and a negative one would pay a descent backwards.
66
72
  // ABSENT IS THE DEFAULT, NOT ZERO — and Number(null) is 0, which is a legal
@@ -71,7 +77,7 @@ function rewardPolicy(rawReward) {
71
77
  const ideaDescentDecay = Number.isFinite(rawDecay) && rawDecay >= 0 && rawDecay < 1
72
78
  ? rawDecay
73
79
  : IDEA_DESCENT_DECAY;
74
- return { marginPct, usdPeg, mode, perTaskCredit, ideatorCredit, ideaDescentDecay };
80
+ return { marginPct, usdPeg, mode, perTaskCredit, ideatorCredit, artistCredit, ideaDescentDecay };
75
81
  }
76
82
 
77
83
  module.exports = {
@@ -40,6 +40,10 @@ module.exports = {
40
40
  // `client` (docs/specs/board-room-pass-event-contract.md) — the port is how
41
41
  // money crosses the wall; the `board.item.passed` event never carries a payment.
42
42
  awardIdeaGovernancePassCredit: (...a) => credits.awardIdeaGovernancePassCredit(...a),
43
+ // BV2.TW13 / ADR 0341 D10 — the artist lane: an approved page, at its land,
44
+ // pays its artist the review's priced value. The lifecycle's landing hook calls
45
+ // it INSTEAD of the per-task estimate on a page tweak; the payee is read here.
46
+ awardArtistPageCredit: (...a) => credits.awardArtistPageCredit(...a),
43
47
  awardNetNegativeBonus: (...a) => credits.awardNetNegativeBonus(...a),
44
48
  awardSessionTokenReward: (...a) => credits.awardSessionTokenReward(...a),
45
49
  // gamification hooks the lifecycle fires at claim/ship
@@ -0,0 +1,156 @@
1
+ // modules/hall-ui/public/approval-queue-lib.js — the Approval queue's pure rules
2
+ // (task 1004323 / BV2.TW12, goal 1000095; ADR 0341 D7). window.OTBApprovalQueueLib,
3
+ // node-require-safe (the tweak-editor-lib.js pattern), so tests/hall_approval_queue.mjs
4
+ // can pin every word the panel says without a browser.
5
+ //
6
+ // The words are the master design's (docs/design/mocks/tweak-mode/Main.dc.html,
7
+ // its Approval queue panel): "approval queue · 1 of 2", "Home · landing · 2 lines
8
+ // rewritten, applied", "highlighted: the words you changed", "Approved. It ships
9
+ // now, and Home reads text tweaked 3x. Next in the queue: Contact." and "Sent
10
+ // back with your note. It comes back to the queue once it is re-applied."
11
+
12
+ (() => {
13
+ 'use strict';
14
+
15
+ // The renders /tweak attaches (modules/lifecycle/task-visuals.js
16
+ // TWEAK_RENDER_SLOTS): <phase>-<device>-<mode>, eight in all.
17
+ const PHASES = Object.freeze(['before', 'after']);
18
+ const DEVICES = Object.freeze(['desktop', 'phone']);
19
+ const MODES = Object.freeze(['light', 'dark']);
20
+ const MIN_NOTE = 10; // pages.MIN_NOTE_CHARS, the send-back floor
21
+ const MAX_NOTE = 500;
22
+
23
+ function slotName(phase, device, mode) {
24
+ return `${phase}-${device}-${mode}`;
25
+ }
26
+
27
+ // The same server-minted shape hall-visual.js guards (task-visuals.js mints
28
+ // it); anything else is never put in a src.
29
+ const VISUAL_URL_RE = /^\/api\/(gds|bongos)\/task-visuals\/task-\d+-[0-9a-f]{16}\.(png|jpg|gif|webp)$/;
30
+
31
+ // slots: GET /tasks/:id/visuals's `slots` -> { '<slot>': { url, alt } }, only
32
+ // the eight render names and only server-minted URLs.
33
+ function renderIndex(slots) {
34
+ const known = new Set(PHASES.flatMap((p) => DEVICES.flatMap((d) => MODES.map((m) => slotName(p, d, m)))));
35
+ const out = {};
36
+ for (const s of slots || []) {
37
+ if (!s || !known.has(s.slot) || !VISUAL_URL_RE.test(String(s.visual_url || ''))) continue;
38
+ out[s.slot] = { url: s.visual_url, alt: s.visual_alt || '' };
39
+ }
40
+ return out;
41
+ }
42
+
43
+ // The render for a view, or the nearest one there is: the other colour mode
44
+ // first (a page that pins one mode is shot once per mode anyway), then the
45
+ // other device. `exact` says whether it is the view asked for.
46
+ function pickRender(index, { phase, device, mode }) {
47
+ const tries = [
48
+ [device, mode],
49
+ [device, mode === 'light' ? 'dark' : 'light'],
50
+ [device === 'desktop' ? 'phone' : 'desktop', mode],
51
+ [device === 'desktop' ? 'phone' : 'desktop', mode === 'light' ? 'dark' : 'light'],
52
+ ];
53
+ for (const [i, [d, m]] of tries.entries()) {
54
+ const hit = index && index[slotName(phase, d, m)];
55
+ if (hit) return { ...hit, slot: slotName(phase, d, m), device: d, mode: m, exact: i === 0 };
56
+ }
57
+ return null;
58
+ }
59
+
60
+ function hasAnyRender(index) {
61
+ return !!index && Object.keys(index).length > 0;
62
+ }
63
+
64
+ // An apex page's inventory title is its branding template ({{productName}}:
65
+ // {{tagline}}); the editor's own rule names it (tweak-editor-lib displayTitle),
66
+ // restated so this panel loads without the editor's lib.
67
+ function displayTitle(item) {
68
+ const t = String((item && item.title) || (item && item.page_id) || '');
69
+ if (!/\{\{/.test(t)) return t;
70
+ if (item.path === '/') return 'Home';
71
+ const rest = t.replace(/^\{\{[^}]*\}\}\s*(?:—|-|:)\s*/, '').replace(/\{\{[^}]*\}\}/g, '').trim();
72
+ if (!rest) return 'Home';
73
+ return rest.charAt(0).toUpperCase() + rest.slice(1);
74
+ }
75
+
76
+ const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
77
+
78
+ function eyebrow(item) {
79
+ return item ? `approval queue · ${item.position} of ${item.of}` : 'approval queue';
80
+ }
81
+
82
+ // "· landing · 2 lines rewritten, applied", and the refused lines named when
83
+ // there are any ("· 1 not applied").
84
+ function subLine(item) {
85
+ if (!item) return '';
86
+ const parts = [item.surface_word || item.surface || '', `${plural(item.applied_count, 'line', 'lines')} rewritten, applied`];
87
+ if (item.refused_count > 0) parts.push(`${item.refused_count} not applied`);
88
+ return `· ${parts.filter(Boolean).join(' · ')}`;
89
+ }
90
+
91
+ // What the studio's quick action says under "Approval queue".
92
+ function doorDetail(count) {
93
+ if (count == null) return 'the pages /tweak has applied';
94
+ if (count === 0) return 'nothing waiting for you';
95
+ return `${plural(count, 'page', 'pages')} applied, waiting for you`;
96
+ }
97
+
98
+ // Why a line was not applied, in words (copy-apply.js's refusals).
99
+ const REFUSED = Object.freeze({
100
+ target_gone: 'not applied: the page no longer shows it',
101
+ ambiguous_target: 'not applied: the page shows these words in more than one place',
102
+ no_exact_span: 'not applied: this text cannot be changed mechanically',
103
+ placeholder_mismatch: 'not applied: a {…} value was added or dropped',
104
+ path_outside_surface: 'not applied: it lives outside this page\'s files',
105
+ });
106
+ function refusedWords(code) {
107
+ return REFUSED[code] || 'not applied';
108
+ }
109
+
110
+ // One row of "the words you changed", for a flip. In AFTER the new words are
111
+ // the line and carry the mark; in BEFORE the old words are the line, unmarked,
112
+ // the mock's own two states.
113
+ function lineRow(line, phase) {
114
+ const after = phase !== 'before';
115
+ return {
116
+ key: line.key,
117
+ section: line.section || '',
118
+ text: after && !line.refused ? line.after : line.before,
119
+ marked: after && !line.refused,
120
+ aside: line.refused ? refusedWords(line.refused) : (after ? `was: ${line.before}` : `becomes: ${line.after}`),
121
+ refused: !!line.refused,
122
+ };
123
+ }
124
+
125
+ function noteProblem(note) {
126
+ const n = String(note == null ? '' : note).replace(/\s+/g, ' ').trim();
127
+ if (n.length < MIN_NOTE) return `Say what needs changing in at least ${MIN_NOTE} characters.`;
128
+ if (n.length > MAX_NOTE) return `Keep it to one line, at most ${MAX_NOTE} characters.`;
129
+ return null;
130
+ }
131
+
132
+ // "Approved. It ships now, and Home reads text tweaked 3x. Next in the queue:
133
+ // Contact." The status is the server's (status_after); the next page is the
134
+ // server's re-read of the queue.
135
+ function approvedLine(item, resp) {
136
+ const name = displayTitle(item);
137
+ const status = resp && resp.status_after ? resp.status_after.charAt(0).toLowerCase() + resp.status_after.slice(1) : null;
138
+ const next = resp && resp.next && resp.next.page ? displayTitle(resp.next.page) : null;
139
+ return [
140
+ status ? `Approved. It ships now, and ${name} reads ${status}.` : 'Approved. It ships now.',
141
+ next ? `Next in the queue: ${next}.` : 'Nothing else is waiting.',
142
+ ].join(' ');
143
+ }
144
+
145
+ function sentBackLine() {
146
+ return 'Sent back with your note. It comes back to the queue once it is re-applied.';
147
+ }
148
+
149
+ const api = {
150
+ PHASES, DEVICES, MODES, MIN_NOTE, MAX_NOTE,
151
+ slotName, renderIndex, pickRender, hasAnyRender, displayTitle,
152
+ eyebrow, subLine, doorDetail, refusedWords, lineRow, noteProblem, approvedLine, sentBackLine,
153
+ };
154
+ if (typeof window !== 'undefined') window.OTBApprovalQueueLib = api;
155
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
156
+ })();
@@ -0,0 +1,286 @@
1
+ /* modules/hall-ui/public/approval-queue.css — the Approval queue panel
2
+ (task 1004323 / BV2.TW12, goal 1000095; ADR 0341 D7).
3
+
4
+ THE PIXEL CONTRACT. docs/design/mocks/tweak-mode/Main.dc.html's wide glass
5
+ dialog is the master design the owner approved, and this sheet is its inline
6
+ styles, one rule per element: the scrim, the 1180 by 820 glass, the header
7
+ row (the mono eyebrow, the page name, the before/after pills, close), the
8
+ rendered page on its light frame, the accent "approve and ship", the ghost
9
+ "send it back" and its one-line note, the hint, and the two outcome lines.
10
+ The values are the artboard's own, so the sheet is its own token tier (the
11
+ tweak editor's precedent, tweak-editor.css; tests/hall_audit.mjs
12
+ LITERAL_EXEMPT names it). What a gate or a route forced to differ is in the
13
+ task's ship notes and modules/hall-ui/records/approval-queue.md.
14
+
15
+ ONE PANEL, BOTH MODES. The dialog is glass over the night studio whatever
16
+ the hall's theme, so its palette sits on the panel's own root (.aq-overlay),
17
+ never :root or body. The one thing that follows the theme is the scrim: the
18
+ artboard's .42 over the dark room, and .94 over the light hall, where .42
19
+ would leave the glass's grey words below the AA floor. That scrim is the
20
+ artboard's own tint (--aq-scrim), not the hall's --scrim: the glass is the
21
+ design of record, the tweak editor's --tw-scrim precedent. */
22
+
23
+ .aq-overlay {
24
+ color-scheme: dark;
25
+ --aq-scrim: rgba(4, 5, 9, .94);
26
+ --aq-glass: rgba(255, 255, 255, .075);
27
+ --aq-glass-edge: rgba(255, 255, 255, .18);
28
+ --aq-glass-rim: rgba(255, 255, 255, .22);
29
+ --aq-drop: rgba(0, 0, 0, .55);
30
+ --aq-ink: #ffffff;
31
+ --aq-ink-soft: #c0c0c0;
32
+ --aq-ink-faint: #a8a8a8;
33
+ --aq-ink-dim: #8a8a90;
34
+ --aq-well: rgba(0, 0, 0, .25);
35
+ --aq-pill-on: rgba(255, 255, 255, .16);
36
+ --aq-close: rgba(255, 255, 255, .08);
37
+ --aq-ghost-edge: rgba(255, 255, 255, .24);
38
+ --aq-accent: #fb9569;
39
+ --aq-on-accent: #000000;
40
+ --aq-link: #fcbfa4;
41
+ --aq-mark: rgba(251, 149, 105, .35);
42
+ --aq-paper: #edecee;
43
+ --aq-paper-ink: #17181a;
44
+ --aq-rule: rgba(255, 255, 255, .12);
45
+ --aq-face: var(--font-body, Manrope, "Segoe UI", system-ui, sans-serif);
46
+ --aq-mono: var(--font-mono, "JetBrains Mono", ui-monospace, Consolas, monospace);
47
+ position: fixed;
48
+ inset: 0;
49
+ z-index: var(--z-modal, 160);
50
+ display: flex;
51
+ align-items: center;
52
+ justify-content: center;
53
+ padding: 24px;
54
+ box-sizing: border-box;
55
+ background: var(--aq-scrim);
56
+ color: var(--aq-ink);
57
+ font-family: var(--aq-face);
58
+ }
59
+ html[data-theme="dark"] .aq-overlay { --aq-scrim: rgba(4, 5, 9, .42); }
60
+ @media (prefers-color-scheme: dark) {
61
+ html:not([data-theme]) .aq-overlay { --aq-scrim: rgba(4, 5, 9, .42); }
62
+ }
63
+ .aq-overlay[hidden] { display: none; }
64
+ html.aq-open { overflow: hidden; }
65
+
66
+ .aq-dialog {
67
+ width: min(1180px, 100%);
68
+ height: min(820px, 100%);
69
+ box-sizing: border-box;
70
+ padding: 26px 30px;
71
+ border-radius: 30px;
72
+ background: var(--aq-glass);
73
+ border: 1px solid var(--aq-glass-edge);
74
+ backdrop-filter: blur(30px) saturate(160%);
75
+ -webkit-backdrop-filter: blur(30px) saturate(160%);
76
+ box-shadow: inset 0 1px 0 var(--aq-glass-rim), 0 40px 90px var(--aq-drop);
77
+ display: flex;
78
+ flex-direction: column;
79
+ gap: 16px;
80
+
81
+ }
82
+ .aq-overlay button,
83
+ .aq-overlay input { font-family: inherit; }
84
+ .aq-overlay :focus-visible { outline: 2px solid var(--aq-accent); outline-offset: 2px; }
85
+
86
+ /* The header row. */
87
+ .aq-head {
88
+ display: flex;
89
+ align-items: center;
90
+ gap: 14px;
91
+ min-width: 0;
92
+ }
93
+ .aq-eyebrow {
94
+ font-family: var(--aq-mono);
95
+ font-size: 11px;
96
+ letter-spacing: .14em;
97
+ text-transform: uppercase;
98
+ color: var(--aq-ink-soft);
99
+ white-space: nowrap;
100
+ }
101
+ .aq-title {
102
+ margin: 0;
103
+ font-size: 20px;
104
+ font-weight: 300;
105
+ line-height: 1.3;
106
+ color: var(--aq-ink);
107
+ min-width: 0;
108
+ }
109
+ .aq-title__sub { font-size: 14px; color: var(--aq-ink-dim); }
110
+ .aq-head__gap { margin-left: auto; }
111
+ .aq-flips {
112
+ display: flex;
113
+ gap: 4px;
114
+ padding: 4px;
115
+ border-radius: 999px;
116
+ background: var(--aq-well);
117
+ flex: none;
118
+ }
119
+ .aq-flip {
120
+ height: 40px;
121
+ padding: 0 18px;
122
+ border-radius: 999px;
123
+ cursor: pointer;
124
+ border: 0;
125
+ background: transparent;
126
+ color: var(--aq-ink-faint);
127
+ font-size: 13.5px;
128
+ }
129
+ .aq-flip[aria-pressed="true"] { background: var(--aq-pill-on); color: var(--aq-ink); }
130
+ .aq-close {
131
+ height: 44px;
132
+ padding: 0 16px;
133
+ border-radius: 999px;
134
+ cursor: pointer;
135
+ border: 0;
136
+ background: var(--aq-close);
137
+ color: var(--aq-ink);
138
+ font-size: 13px;
139
+ flex: none;
140
+ }
141
+
142
+ /* The page, and the words beside it. */
143
+ .aq-body {
144
+ flex-grow: 1;
145
+ min-height: 0;
146
+ display: flex;
147
+ gap: 16px;
148
+ }
149
+ .aq-frame {
150
+ flex-grow: 1;
151
+ min-width: 0;
152
+ min-height: 0;
153
+ overflow-y: auto;
154
+ border-radius: 14px;
155
+ background: var(--aq-paper);
156
+ color: var(--aq-paper-ink);
157
+ }
158
+ .aq-render { display: block; width: 100%; height: auto; }
159
+ .aq-render--phone { width: min(390px, 100%); margin: 24px auto; border-radius: 10px; }
160
+ .aq-frame__note {
161
+ margin: 0;
162
+ padding: 70px 40px;
163
+ font-size: 15px;
164
+ line-height: 1.5;
165
+ color: var(--aq-paper-ink);
166
+ max-width: 620px;
167
+ }
168
+ .aq-frame__swap {
169
+ margin: 0;
170
+ padding: 10px 16px;
171
+ font-size: 13px;
172
+ color: var(--aq-paper-ink);
173
+ }
174
+ .aq-lines {
175
+ width: 300px;
176
+ flex: none;
177
+ min-height: 0;
178
+ overflow-y: auto;
179
+ display: flex;
180
+ flex-direction: column;
181
+ gap: 16px;
182
+ }
183
+ .aq-views { display: flex; flex-wrap: wrap; gap: 8px; }
184
+ .aq-lines__list {
185
+ list-style: none;
186
+ margin: 0;
187
+ padding: 0;
188
+ display: flex;
189
+ flex-direction: column;
190
+ gap: 14px;
191
+ }
192
+ .aq-line { display: flex; flex-direction: column; gap: 4px; padding-top: 14px; border-top: 1px solid var(--aq-rule); }
193
+ .aq-line__section {
194
+ font-family: var(--aq-mono);
195
+ font-size: 11px;
196
+ letter-spacing: .14em;
197
+ text-transform: uppercase;
198
+ color: var(--aq-ink-faint);
199
+ }
200
+ .aq-line__text { font-size: 15px; line-height: 1.5; color: var(--aq-ink); }
201
+ .aq-mark { background: var(--aq-mark); color: var(--aq-ink); border-radius: 4px; padding: 0 3px; }
202
+ .aq-line__aside { font-size: 13px; line-height: 1.45; color: var(--aq-ink-faint); }
203
+ .aq-line--refused .aq-line__aside { color: var(--aq-link); }
204
+ .aq-lines__none { margin: 0; font-size: 14px; color: var(--aq-ink-faint); }
205
+
206
+ /* The decision row. */
207
+ .aq-foot {
208
+ display: flex;
209
+ align-items: center;
210
+ gap: 12px;
211
+ min-height: 48px;
212
+ }
213
+ .aq-accent {
214
+ height: 48px;
215
+ padding: 0 26px;
216
+ border-radius: 999px;
217
+ cursor: pointer;
218
+ border: 0;
219
+ background: var(--aq-accent);
220
+ color: var(--aq-on-accent);
221
+ font-size: 15px;
222
+ flex: none;
223
+ }
224
+ .aq-ghost {
225
+ height: 48px;
226
+ padding: 0 24px;
227
+ border-radius: 999px;
228
+ cursor: pointer;
229
+ background: transparent;
230
+ border: 1px solid var(--aq-ghost-edge);
231
+ color: var(--aq-ink);
232
+ font-size: 15px;
233
+ flex: none;
234
+ }
235
+ .aq-ghost--send { padding: 0 20px; font-size: 14px; }
236
+ .aq-ghost--small { height: 44px; padding: 0 18px; font-size: 13.5px; }
237
+ .aq-accent:disabled,
238
+ .aq-ghost:disabled { opacity: .5; cursor: default; }
239
+ .aq-note { flex-grow: 1; display: flex; gap: 10px; align-items: center; min-width: 0; }
240
+ .aq-note__input {
241
+ flex-grow: 1;
242
+ min-width: 0;
243
+ box-sizing: border-box;
244
+ height: 48px;
245
+ padding: 0 16px;
246
+ border-radius: 999px;
247
+ font-size: 14.5px;
248
+ color: var(--aq-ink);
249
+ background: var(--aq-well);
250
+ border: 1px solid var(--aq-glass-edge);
251
+ }
252
+ .aq-note__input::placeholder { color: var(--aq-ink-faint); }
253
+ .aq-hint { margin-left: auto; font-size: 13px; color: var(--aq-ink-faint); white-space: nowrap; }
254
+ .aq-sealed { margin: 0; font-size: 14px; color: var(--aq-ink-soft); }
255
+ .aq-outcome { margin: 0; font-size: 15px; line-height: 1.5; }
256
+ .aq-outcome--approved { color: var(--aq-link); }
257
+ .aq-outcome--sent { color: var(--aq-ink-soft); }
258
+ .aq-foot--done { justify-content: space-between; }
259
+ .aq-error { margin: -6px 0 0; font-size: 13.5px; color: var(--aq-link); }
260
+ .aq-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
261
+
262
+ /* A phone: the dialog is the screen, and it scrolls as one; the words go under
263
+ the page, and the header and the decision row wrap. */
264
+ @media (max-width: 760px) {
265
+ /* The dialog is the whole screen here, so the room behind would read through
266
+ the glass under its words; the scrim darkens to .88 in both modes. */
267
+ .aq-overlay { padding: 8px; align-items: stretch; --aq-scrim: rgba(4, 5, 9, .88); }
268
+ html[data-theme] .aq-overlay,
269
+ html:not([data-theme]) .aq-overlay { --aq-scrim: rgba(4, 5, 9, .88); }
270
+ .aq-dialog { height: 100%; padding: 18px 16px; border-radius: 22px; overflow-y: auto; gap: 14px; }
271
+ .aq-dialog > * { flex-shrink: 0; }
272
+ .aq-head { flex-wrap: wrap; row-gap: 10px; }
273
+ .aq-eyebrow { order: 1; flex: 1 1 auto; }
274
+ .aq-close { order: 2; }
275
+ .aq-title { order: 3; flex: 1 1 100%; }
276
+ .aq-head__gap { display: none; }
277
+ .aq-head .aq-flips { order: 4; }
278
+ .aq-body { flex-direction: column; flex-grow: 0; }
279
+ .aq-frame { flex: none; max-height: 70vh; }
280
+ .aq-frame__note { padding: 40px 20px; }
281
+ .aq-lines { width: auto; overflow: visible; }
282
+ .aq-foot { flex-wrap: wrap; }
283
+ .aq-note { flex: 1 1 100%; }
284
+ .aq-hint { margin-left: 0; flex: 1 1 100%; white-space: normal; }
285
+ .aq-foot--done { flex-direction: column; align-items: flex-start; }
286
+ }