@bongos/core 1.19.1063 → 1.19.1065

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 (65) hide show
  1. package/.bongos-core.json +136 -56
  2. package/.claude/skills/grade-recover/SKILL.md +1 -0
  3. package/.claude/skills/tweak/SKILL.md +70 -0
  4. package/clients/bongos-client/README.md +1 -1
  5. package/clients/bongos-client/bongos-client.global.js +12 -0
  6. package/clients/bongos-client/index.cjs +12 -0
  7. package/clients/bongos-client/index.d.ts +21 -1
  8. package/clients/bongos-client/index.mjs +12 -0
  9. package/docs/api/openapi.json +492 -4
  10. package/docs/api-reference.md +9 -3
  11. package/docs/copy-inventory.md +60 -41
  12. package/docs/copy-registry.json +228 -47
  13. package/docs/file-map.md +1 -0
  14. package/docs/module-api-changelog.md +4 -0
  15. package/docs/page-inventory.json +11 -2
  16. package/docs/page-readings.json +45 -22
  17. package/modules/copy-desk/page-decisions.js +71 -0
  18. package/modules/copy-desk/page-status.js +96 -0
  19. package/modules/copy-desk/pages.js +30 -0
  20. package/modules/copy-desk/routes/copy-desk.js +153 -0
  21. package/modules/copy-desk/tests/copy_no_cms.mjs +40 -4
  22. package/modules/hall-ui/public/approval-queue-lib.js +156 -0
  23. package/modules/hall-ui/public/approval-queue.css +286 -0
  24. package/modules/hall-ui/public/approval-queue.js +338 -0
  25. package/modules/hall-ui/public/studio.css +33 -0
  26. package/modules/hall-ui/public/studio.html +18 -1
  27. package/modules/hall-ui/public/studio.js +30 -0
  28. package/modules/hall-ui/public/studio.states.json +198 -0
  29. package/modules/hall-ui/records/approval-queue.md +32 -0
  30. package/modules/lifecycle/db-grade.js +96 -58
  31. package/modules/lifecycle/github-pr-close.js +41 -0
  32. package/modules/lifecycle/github-push.js +5 -2
  33. package/modules/lifecycle/lifecycle.js +12 -0
  34. package/modules/lifecycle/migrations/lifecycle_013_task_visual_slots.sql +52 -0
  35. package/modules/lifecycle/page-tweak-approve.js +242 -0
  36. package/modules/lifecycle/page-tweak-hold.js +74 -0
  37. package/modules/lifecycle/page-tweak-reads.js +33 -1
  38. package/modules/lifecycle/routes/gate-approvals.js +17 -2
  39. package/modules/lifecycle/routes/tasks.js +4 -2
  40. package/modules/lifecycle/routes/visuals.js +82 -0
  41. package/modules/lifecycle/task-visual-db.js +40 -1
  42. package/modules/lifecycle/task-visuals.js +24 -0
  43. package/package-lock.json +2 -2
  44. package/package.json +1 -1
  45. package/release-notes.json +12 -0
  46. package/scripts/gds/copy-apply.js +277 -1
  47. package/scripts/gds/page-reader.js +2 -0
  48. package/scripts/gds/ship-finish.js +27 -2
  49. package/scripts/gds/ship-flow.js +17 -1
  50. package/scripts/gds/ship-land.js +30 -7
  51. package/scripts/gds/ship-merge.js +43 -15
  52. package/scripts/gds/strand-watch.js +41 -1
  53. package/scripts/gds/task.js +23 -5
  54. package/scripts/gds/tweak-renders.js +200 -0
  55. package/scripts/hall-preview/server.js +18 -0
  56. package/src/module-api.js +1 -1
  57. package/tests/copy_desk_page_approve.mjs +307 -0
  58. package/tests/fixtures/page-ledger.mjs +41 -1
  59. package/tests/gate_approvals.mjs +93 -0
  60. package/tests/hall_approval_queue.mjs +327 -0
  61. package/tests/hall_audit.mjs +4 -0
  62. package/tests/page_tweak_hold.mjs +265 -0
  63. package/tests/publish_status_branch.mjs +23 -0
  64. package/tests/task_visual_slots.mjs +161 -0
  65. package/tests/tweak_batch_apply.mjs +265 -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');
@@ -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
+ }