@bongos/core 1.19.492 → 1.19.494

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 (110) hide show
  1. package/.bongos-core.json +449 -34
  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 +4 -0
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0233-a-copy-proposal-is-a-task-carrying-a-patch.md +151 -0
  8. package/docs/adr/README.md +2 -0
  9. package/docs/api/openapi.json +106 -3
  10. package/docs/api-reference.md +3 -2
  11. package/docs/copy-inventory.md +26 -14
  12. package/docs/copy-registry.json +123 -15
  13. package/docs/design/reviews/hall-v2/README.md +124 -0
  14. package/docs/design/reviews/hall-v2/atlas--archon.webp +0 -0
  15. package/docs/design/reviews/hall-v2/atlas--metic.webp +0 -0
  16. package/docs/design/reviews/hall-v2/atlas--xenos.webp +0 -0
  17. package/docs/design/reviews/hall-v2/collab--archon.webp +0 -0
  18. package/docs/design/reviews/hall-v2/collab--metic.webp +0 -0
  19. package/docs/design/reviews/hall-v2/collab--xenos.webp +0 -0
  20. package/docs/design/reviews/hall-v2/copy-desk--archon.webp +0 -0
  21. package/docs/design/reviews/hall-v2/copy-desk--metic.webp +0 -0
  22. package/docs/design/reviews/hall-v2/copy-desk--xenos.webp +0 -0
  23. package/docs/design/reviews/hall-v2/diagrams--archon.webp +0 -0
  24. package/docs/design/reviews/hall-v2/diagrams--metic.webp +0 -0
  25. package/docs/design/reviews/hall-v2/diagrams--xenos.webp +0 -0
  26. package/docs/design/reviews/hall-v2/drachmae--archon.webp +0 -0
  27. package/docs/design/reviews/hall-v2/drachmae--metic.webp +0 -0
  28. package/docs/design/reviews/hall-v2/drachmae--xenos.webp +0 -0
  29. package/docs/design/reviews/hall-v2/gate--archon.webp +0 -0
  30. package/docs/design/reviews/hall-v2/gate--metic.webp +0 -0
  31. package/docs/design/reviews/hall-v2/gate--xenos.webp +0 -0
  32. package/docs/design/reviews/hall-v2/goals--archon.webp +0 -0
  33. package/docs/design/reviews/hall-v2/goals--metic.webp +0 -0
  34. package/docs/design/reviews/hall-v2/goals--xenos.webp +0 -0
  35. package/docs/design/reviews/hall-v2/government--archon.webp +0 -0
  36. package/docs/design/reviews/hall-v2/government--metic.webp +0 -0
  37. package/docs/design/reviews/hall-v2/government--xenos.webp +0 -0
  38. package/docs/design/reviews/hall-v2/harbor--archon.webp +0 -0
  39. package/docs/design/reviews/hall-v2/harbor--metic.webp +0 -0
  40. package/docs/design/reviews/hall-v2/harbor--xenos.webp +0 -0
  41. package/docs/design/reviews/hall-v2/idea--archon.webp +0 -0
  42. package/docs/design/reviews/hall-v2/idea--metic.webp +0 -0
  43. package/docs/design/reviews/hall-v2/idea--xenos.webp +0 -0
  44. package/docs/design/reviews/hall-v2/ideas--archon.webp +0 -0
  45. package/docs/design/reviews/hall-v2/ideas--metic.webp +0 -0
  46. package/docs/design/reviews/hall-v2/ideas--xenos.webp +0 -0
  47. package/docs/design/reviews/hall-v2/index--archon.webp +0 -0
  48. package/docs/design/reviews/hall-v2/index--metic.webp +0 -0
  49. package/docs/design/reviews/hall-v2/index--xenos.webp +0 -0
  50. package/docs/design/reviews/hall-v2/modules--archon.webp +0 -0
  51. package/docs/design/reviews/hall-v2/modules--metic.webp +0 -0
  52. package/docs/design/reviews/hall-v2/modules--xenos.webp +0 -0
  53. package/docs/design/reviews/hall-v2/not-ready--archon.webp +0 -0
  54. package/docs/design/reviews/hall-v2/not-ready--metic.webp +0 -0
  55. package/docs/design/reviews/hall-v2/not-ready--xenos.webp +0 -0
  56. package/docs/design/reviews/hall-v2/pair--archon.webp +0 -0
  57. package/docs/design/reviews/hall-v2/pair--metic.webp +0 -0
  58. package/docs/design/reviews/hall-v2/pair--xenos.webp +0 -0
  59. package/docs/design/reviews/hall-v2/people--archon.webp +0 -0
  60. package/docs/design/reviews/hall-v2/people--metic.webp +0 -0
  61. package/docs/design/reviews/hall-v2/people--xenos.webp +0 -0
  62. package/docs/design/reviews/hall-v2/primer--archon.webp +0 -0
  63. package/docs/design/reviews/hall-v2/primer--metic.webp +0 -0
  64. package/docs/design/reviews/hall-v2/primer--xenos.webp +0 -0
  65. package/docs/design/reviews/hall-v2/profile--archon.webp +0 -0
  66. package/docs/design/reviews/hall-v2/profile--metic.webp +0 -0
  67. package/docs/design/reviews/hall-v2/profile--xenos.webp +0 -0
  68. package/docs/design/reviews/hall-v2/ranks--archon.webp +0 -0
  69. package/docs/design/reviews/hall-v2/ranks--metic.webp +0 -0
  70. package/docs/design/reviews/hall-v2/ranks--xenos.webp +0 -0
  71. package/docs/design/reviews/hall-v2/roadmap--archon.webp +0 -0
  72. package/docs/design/reviews/hall-v2/roadmap--metic.webp +0 -0
  73. package/docs/design/reviews/hall-v2/roadmap--xenos.webp +0 -0
  74. package/docs/design/reviews/hall-v2/roster--archon.webp +0 -0
  75. package/docs/design/reviews/hall-v2/roster--metic.webp +0 -0
  76. package/docs/design/reviews/hall-v2/roster--xenos.webp +0 -0
  77. package/docs/design/reviews/hall-v2/sessions--archon.webp +0 -0
  78. package/docs/design/reviews/hall-v2/sessions--metic.webp +0 -0
  79. package/docs/design/reviews/hall-v2/sessions--xenos.webp +0 -0
  80. package/docs/design/reviews/hall-v2/settings--archon.webp +0 -0
  81. package/docs/design/reviews/hall-v2/settings--metic.webp +0 -0
  82. package/docs/design/reviews/hall-v2/settings--xenos.webp +0 -0
  83. package/docs/design/reviews/hall-v2/task--archon.webp +0 -0
  84. package/docs/design/reviews/hall-v2/task--metic.webp +0 -0
  85. package/docs/design/reviews/hall-v2/task--xenos.webp +0 -0
  86. package/docs/design/reviews/hall-v2/watch--archon.webp +0 -0
  87. package/docs/design/reviews/hall-v2/watch--metic.webp +0 -0
  88. package/docs/design/reviews/hall-v2/watch--xenos.webp +0 -0
  89. package/docs/design/reviews/hall-v2/work--archon.webp +0 -0
  90. package/docs/design/reviews/hall-v2/work--metic.webp +0 -0
  91. package/docs/design/reviews/hall-v2/work--xenos.webp +0 -0
  92. package/docs/file-map.md +2 -1
  93. package/docs/module-api-changelog.md +4 -0
  94. package/modules/copy-desk/module.json +2 -2
  95. package/modules/copy-desk/proposals.js +278 -0
  96. package/modules/copy-desk/routes/copy-desk.js +173 -9
  97. package/modules/copy-desk/tests/copy_no_cms.mjs +90 -12
  98. package/modules/copy-desk/tests/copy_proposals.mjs +279 -0
  99. package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
  100. package/modules/hall-ui/public/copy-desk.css +9 -1
  101. package/modules/hall-ui/public/copy-desk.html +33 -0
  102. package/modules/hall-ui/public/copy-desk.js +134 -3
  103. package/modules/hall-ui/public/copy-desk.states.json +18 -1
  104. package/modules/lifecycle/lifecycle.js +6 -0
  105. package/package-lock.json +2 -2
  106. package/package.json +1 -1
  107. package/scripts/gds/copy-apply.js +338 -0
  108. package/scripts/gds/copy-inventory.js +128 -30
  109. package/src/module-api.js +1 -1
  110. package/tests/hall_collab_world.mjs +24 -4
@@ -132,6 +132,28 @@ function looksLikeCopy(raw) {
132
132
  return true;
133
133
  }
134
134
 
135
+ // THE SPAN: where a row's text physically SITS in the file, as [start, end)
136
+ // character offsets. Every extractor below records one alongside the text.
137
+ //
138
+ // It is deliberately NOT part of the committed registry — buildRegistry drops it
139
+ // (see the note there). A committed offset would go stale on the next edit to an
140
+ // unrelated line of the same file, and the registry's whole point is an id that
141
+ // survives movement. The span is a LIVE fact, recomputed from the tree by whoever
142
+ // needs it: today that is scripts/gds/copy-apply.js (R03 of goal 1000074), which
143
+ // re-runs this extractor against the working tree and uses the span to replace
144
+ // exactly the characters a string occupies and nothing else. Deriving it here,
145
+ // beside the regex that captured the text, is what keeps the applier from owning
146
+ // a second, drifting copy of these matching rules.
147
+ //
148
+ // The span covers the TRIMMED text — a row's `text` is whitespace-collapsed and
149
+ // trimmed, so the indentation around a multi-line literal belongs to the file's
150
+ // formatting, not to the copy, and an applier must leave it alone.
151
+ function trimmedSpan(group, groupStart) {
152
+ const lead = group.length - group.replace(/^\s+/, '').length;
153
+ const trail = group.length - group.replace(/\s+$/, '').length;
154
+ return { start: groupStart + lead, end: groupStart + group.length - trail };
155
+ }
156
+
135
157
  // Strip <script>/<style> bodies so their contents are not read as page text.
136
158
  // Replaced with spaces of equal length so every line number stays truthful.
137
159
  function stripEmbedded(html) {
@@ -151,7 +173,8 @@ function fromHtml({ source, rel }) {
151
173
  const textRe = />([^<>]+)</g;
152
174
  while ((m = textRe.exec(cleaned)) !== null) {
153
175
  if (!looksLikeCopy(m[1])) continue;
154
- out.push({ text: m[1].replace(/\s+/g, ' ').trim(), file: rel, line: lineAt(starts, m.index), origin: 'html-text', confidence: 'certain' });
176
+ // The captured group opens one character past the '>' that anchored the match.
177
+ out.push({ text: m[1].replace(/\s+/g, ' ').trim(), file: rel, line: lineAt(starts, m.index), origin: 'html-text', confidence: 'certain', ...trimmedSpan(m[1], m.index + 1) });
155
178
  }
156
179
 
157
180
  // 2. copy-bearing attributes.
@@ -159,14 +182,19 @@ function fromHtml({ source, rel }) {
159
182
  re.lastIndex = 0;
160
183
  while ((m = re.exec(cleaned)) !== null) {
161
184
  if (!looksLikeCopy(m[1])) continue;
162
- out.push({ text: m[1].replace(/\s+/g, ' ').trim(), file: rel, line: lineAt(starts, m.index), origin: `html-attr:${attr}`, confidence: 'certain' });
185
+ // Every COPY_ATTR_RE ends with the closing quote, so the group's last
186
+ // character sits exactly one before the match's end. DERIVED, never
187
+ // searched for — indexOf would land on an earlier copy of the same text.
188
+ const attrStart = m.index + m[0].length - 1 - m[1].length;
189
+ out.push({ text: m[1].replace(/\s+/g, ' ').trim(), file: rel, line: lineAt(starts, m.index), origin: `html-attr:${attr}`, confidence: 'certain', ...trimmedSpan(m[1], attrStart) });
163
190
  }
164
191
  }
165
192
 
166
193
  // 3. <title> is the page's own name — always copy.
167
194
  const titleRe = /<title>([^<]{2,200})<\/title>/gi;
168
195
  while ((m = titleRe.exec(source)) !== null) {
169
- out.push({ text: m[1].replace(/\s+/g, ' ').trim(), file: rel, line: lineAt(starts, m.index), origin: 'html-title', confidence: 'certain' });
196
+ const titleStart = m.index + '<title>'.length;
197
+ out.push({ text: m[1].replace(/\s+/g, ' ').trim(), file: rel, line: lineAt(starts, m.index), origin: 'html-title', confidence: 'certain', ...trimmedSpan(m[1], titleStart) });
170
198
  }
171
199
  return out;
172
200
  }
@@ -198,24 +226,75 @@ function unescapeLiteral(raw) {
198
226
  return String(raw).replace(/\\(['"`\\])/g, '$1');
199
227
  }
200
228
 
201
- // A literal that carries markup: extract what a person reads out of it.
229
+ // Can offsets into this match's BODY be read as offsets into the file?
230
+ //
231
+ // A template literal (group 1) is used raw, so yes. A quoted literal is
232
+ // `unescapeLiteral`d first, and that SHIFTS every character after a backslash —
233
+ // so yes only when the unescape changed nothing. The alternative, an
234
+ // escape-aware index map, would buy spans for a handful of quoted-markup rows
235
+ // and add a second place for the escaping rules to be wrong. A row with no span
236
+ // is refused BY NAME in copy-apply.js instead, which is the honest trade.
237
+ function literalIsUnshifted(m) {
238
+ if (m[1] !== undefined) return true;
239
+ const raw = m[2] ?? m[3];
240
+ return raw === undefined ? false : unescapeLiteral(raw) === raw;
241
+ }
242
+
243
+ // Interpolations are replaced by a fixed-width PLACEHOLDER before matching, so a
244
+ // position in the holed string is NOT a position in the source. This records the
245
+ // literal runs between the holes so one can be converted back to the other —
246
+ // which is what lets a markup row carry a source span (see `trimmedSpan`).
247
+ //
248
+ // The conversion is only ever asked for at a capture BOUNDARY, and every boundary
249
+ // sits on a real character: a text node's group opens just past a '>' and closes
250
+ // just before a '<', and neither can appear inside `{…}`. A boundary that lands
251
+ // where a hole meets the next run resolves to the character after the closing
252
+ // '}' of the interpolation, which is the right place to cut.
253
+ function holeMap(body) {
254
+ const re = /\$\{[^{}]*\}/g;
255
+ const runs = [];
256
+ let withHoles = '';
257
+ let last = 0;
258
+ let m;
259
+ while ((m = re.exec(body)) !== null) {
260
+ const literal = body.slice(last, m.index);
261
+ runs.push({ holed: withHoles.length, raw: last, length: literal.length });
262
+ withHoles += literal + PLACEHOLDER;
263
+ last = m.index + m[0].length;
264
+ }
265
+ const tail = body.slice(last);
266
+ runs.push({ holed: withHoles.length, raw: last, length: tail.length });
267
+ return { withHoles: withHoles + tail, runs };
268
+ }
269
+
270
+ function holedToRaw(runs, at) {
271
+ for (const run of runs) {
272
+ if (at >= run.holed && at <= run.holed + run.length) return run.raw + (at - run.holed);
273
+ }
274
+ return null; // only reachable for a position strictly inside a placeholder
275
+ }
276
+
277
+ // A literal that carries markup: extract what a person reads out of it, each row
278
+ // carrying [start, end) offsets into the RAW body it was found in.
202
279
  function copyFromMarkupLiteral(body) {
203
- const withHoles = body.replace(/\$\{[^{}]*\}/g, PLACEHOLDER);
280
+ const { withHoles, runs } = holeMap(body);
204
281
  const out = [];
282
+ const emit = (group, groupStart) => {
283
+ const t = group.replace(/\s+/g, ' ').trim();
284
+ if (t === PLACEHOLDER || !looksLikeCopy(t.replace(/\{…\}/g, 'x'))) return;
285
+ const span = trimmedSpan(group, groupStart);
286
+ const start = holedToRaw(runs, span.start);
287
+ const end = holedToRaw(runs, span.end);
288
+ // A span that cannot be mapped back is reported as text with no span rather
289
+ // than as a guess; the applier refuses such a row by name.
290
+ out.push(start === null || end === null ? { text: t } : { text: t, start, end });
291
+ };
205
292
  let m;
206
293
  const textRe = />([^<>]+)</g;
207
- while ((m = textRe.exec(withHoles)) !== null) {
208
- const t = m[1].replace(/\s+/g, ' ').trim();
209
- if (t === PLACEHOLDER || !looksLikeCopy(t.replace(/\{…\}/g, 'x'))) continue;
210
- out.push(t);
211
- }
294
+ while ((m = textRe.exec(withHoles)) !== null) emit(m[1], m.index + 1);
212
295
  for (const { re } of COPY_ATTR_RES) {
213
296
  re.lastIndex = 0;
214
- while ((m = re.exec(withHoles)) !== null) {
215
- const t = m[1].replace(/\s+/g, ' ').trim();
216
- if (!looksLikeCopy(t.replace(/\{…\}/g, 'x'))) continue;
217
- out.push(t);
218
- }
297
+ while ((m = re.exec(withHoles)) !== null) emit(m[1], m.index + m[0].length - 1 - m[1].length);
219
298
  }
220
299
  return out;
221
300
  }
@@ -224,12 +303,12 @@ function fromJs({ source, rel }) {
224
303
  const out = [];
225
304
  const starts = lineIndex(source);
226
305
  const seen = new Set(); // (text, line) — one row per string per site
227
- const push = (text, index, origin) => {
306
+ const push = (text, index, origin, span = null) => {
228
307
  const line = lineAt(starts, index);
229
308
  const key = `${text}\u0000${line}\u0000${origin}`;
230
309
  if (seen.has(key)) return;
231
310
  seen.add(key);
232
- out.push({ text, file: rel, line, origin, confidence: 'certain' });
311
+ out.push({ text, file: rel, line, origin, confidence: 'certain', ...(span || {}) });
233
312
  };
234
313
 
235
314
  let m;
@@ -238,19 +317,31 @@ function fromJs({ source, rel }) {
238
317
  while ((m = JS_LITERALS.exec(source)) !== null) {
239
318
  const body = m[1] ?? unescapeLiteral(m[2] ?? m[3] ?? '');
240
319
  if (!body || !/<[a-zA-Z/]/.test(body) || !/>/.test(body)) continue;
241
- for (const t of copyFromMarkupLiteral(body)) push(t, m.index, 'js-markup');
242
- }
243
- // 2. strings shown directly to a person.
244
- UI_CALL.lastIndex = 0;
245
- while ((m = UI_CALL.exec(source)) !== null) {
246
- const body = (m[1] ?? unescapeLiteral(m[2] ?? m[3] ?? '')).replace(/\$\{[^{}]*\}/g, PLACEHOLDER).replace(/\s+/g, ' ').trim();
247
- if (body && looksLikeCopy(body.replace(/\{…\}/g, 'x'))) push(body, m.index, 'js-notice');
248
- }
249
- TEXT_ASSIGN.lastIndex = 0;
250
- while ((m = TEXT_ASSIGN.exec(source)) !== null) {
251
- const body = (m[1] ?? unescapeLiteral(m[2] ?? m[3] ?? '')).replace(/\$\{[^{}]*\}/g, PLACEHOLDER).replace(/\s+/g, ' ').trim();
252
- if (body && looksLikeCopy(body.replace(/\{…\}/g, 'x'))) push(body, m.index, 'js-text-assign');
320
+ // Each alternative opens with exactly one delimiter, so the body starts one
321
+ // character past the match.
322
+ const mapped = literalIsUnshifted(m);
323
+ for (const f of copyFromMarkupLiteral(body)) {
324
+ const span = mapped && f.start !== undefined
325
+ ? { start: m.index + 1 + f.start, end: m.index + 1 + f.end }
326
+ : null;
327
+ push(f.text, m.index, 'js-markup', span);
328
+ }
253
329
  }
330
+ // 2. strings shown directly to a person. Both of these normalize the WHOLE
331
+ // literal into the row's text, so the span is the whole body: the delimiter
332
+ // closes the match, so the body ends one character before the match does.
333
+ const wholeLiteral = (re, origin) => {
334
+ re.lastIndex = 0;
335
+ while ((m = re.exec(source)) !== null) {
336
+ const rawBody = m[1] ?? m[2] ?? m[3] ?? '';
337
+ const body = (m[1] ?? unescapeLiteral(m[2] ?? m[3] ?? '')).replace(/\$\{[^{}]*\}/g, PLACEHOLDER).replace(/\s+/g, ' ').trim();
338
+ if (!body || !looksLikeCopy(body.replace(/\{…\}/g, 'x'))) continue;
339
+ const bodyStart = m.index + m[0].length - 1 - rawBody.length;
340
+ push(body, m.index, origin, literalIsUnshifted(m) ? trimmedSpan(rawBody, bodyStart) : null);
341
+ }
342
+ };
343
+ wholeLiteral(UI_CALL, 'js-notice');
344
+ wholeLiteral(TEXT_ASSIGN, 'js-text-assign');
254
345
  return out;
255
346
  }
256
347
 
@@ -276,7 +367,14 @@ function buildRegistry() {
276
367
  ? fromHtml({ source, rel })
277
368
  : fromJs({ source, rel });
278
369
  for (const f of found) {
279
- entries.push({ id: stableId(surface.id, f.text), surface: surface.id, ...f });
370
+ // THE SPAN IS DROPPED HERE, DELIBERATELY. Every extractor records where a
371
+ // string physically sits (see `trimmedSpan`), and none of it is committed:
372
+ // an offset goes stale the moment anything above it in the file changes,
373
+ // so a registry carrying one would be wrong far more often than the id —
374
+ // which is designed to survive exactly that movement — ever is. The span
375
+ // is recomputed from the tree by the applier, at the moment it edits.
376
+ const { start, end, ...row } = f;
377
+ entries.push({ id: stableId(surface.id, f.text), surface: surface.id, ...row });
280
378
  count += 1;
281
379
  }
282
380
  }
package/src/module-api.js CHANGED
@@ -45,7 +45,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
45
45
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
46
46
  // the entry to that file. Look for a version's history there, not here.
47
47
  // ---------------------------------------------------------------------------
48
- const CORE_VERSION = '1.19.492'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
48
+ const CORE_VERSION = '1.19.494'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
49
49
 
50
50
  // A namespaced logger so a module's log lines are attributable + consistent.
51
51
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -158,13 +158,32 @@ test('the filters and the search are the kit\'s bar; the duplicates are paged th
158
158
  assert.match(read('copy-desk.html'), /src="\/builders\/hall-kit\.js/, 'the page loads the kit');
159
159
  });
160
160
 
161
- test('the two dialogs are the family modal on a native <dialog>, with the number input in a field', () => {
161
+ test('the three dialogs are the family modal on a native <dialog>, with the number input in a field', () => {
162
162
  const html = read('copy-desk.html');
163
- assert.equal((html.match(/<dialog id="cd-(close-)?dialog" class="ov-modal cd-dialog">/g) || []).length, 2);
163
+ // Three since task 1003114 (R03): flag, close, and the proposal composer.
164
+ assert.equal((html.match(/<dialog id="cd-(close-dialog|dialog|propose-dialog)" class="ov-modal cd-dialog">/g) || []).length, 3);
164
165
  assert.match(html, /<div class="ov-field ov-field--wide">\s*<label class="ov-field__l" for="cd-close-task">[^<]*<\/label>\s*<input type="number" id="cd-close-task"/, 'the number input sits in the family field, which restates the 30px pill');
165
166
  assert.ok(!/<label[^>]*>[^<]*<span class="cd-dialog__hint"/.test(html), 'no hint lives inside a label (the label voice would uppercase it)');
166
- assert.equal((html.match(/class="ov-form__why"/g) || []).length, 3, 'the three hints are the form\'s own explainer line');
167
+ assert.equal((html.match(/class="ov-form__why"/g) || []).length, 4, 'the four hints are the form\'s own explainer line');
167
168
  assert.match(html, /class="btn-accent" id="cd-submit"/, 'the accent door files the flag');
169
+ assert.match(html, /class="btn-accent" id="cd-propose-submit"/, 'and the proposal composer has its own');
170
+ });
171
+
172
+ test('the proposal composer is prefilled, and says what filing it does and does not do', () => {
173
+ const html = read('copy-desk.html');
174
+ const js = read('copy-desk.js');
175
+ // The composer opens with the CURRENT wording in the box — an artist edits a
176
+ // sentence rather than retyping it out of the quote above, which is how a
177
+ // proposal accidentally changes a word nobody meant to change.
178
+ assert.match(js, /\$\('#cd-propose-text'\)\.value = t\.text;/, 'the textarea opens prefilled with the current wording');
179
+ // The placeholder rule is shown only when the sentence has holes, because for
180
+ // most strings it is noise and for a few it is the reason the change refuses.
181
+ assert.match(html, /id="cd-propose-holes" hidden/, 'the placeholder rule starts hidden');
182
+ assert.match(js, /hint\.hidden = n === 0;/, 'and is shown only for a sentence with holes');
183
+ // NO LIVE CMS, said to the person in the dialog rather than only in an ADR.
184
+ assert.match(html, /Nothing changes yet\. This files a task carrying your wording/,
185
+ 'the composer tells the artist their words do not reach a reader until a task ships');
186
+ assert.match(js, /copy-desk\/proposals/, 'and it posts to the proposal route');
168
187
  });
169
188
 
170
189
  test('the copy desk renders MORE facts per row than any other page, and lost none', () => {
@@ -205,10 +224,11 @@ test('the harness fixtures exist and carry the shapes the pages read', () => {
205
224
 
206
225
  test('the states files walk the new states', () => {
207
226
  const cd = JSON.parse(read('copy-desk.states.json'));
208
- assert.deepEqual(Object.keys(cd.states), ['queue', 'out', 'search', 'composer', 'closer']);
227
+ assert.deepEqual(Object.keys(cd.states), ['queue', 'out', 'search', 'composer', 'closer', 'proposer']);
209
228
  assert.deepEqual(cd.states.search.actions[1], ['fill', '#cd-search', 'save']);
210
229
  assert.deepEqual(cd.states.composer.actions[1], ['clickIfVisible', '.cd-flag-btn'], 'clickIfVisible: against the kit\'s stub the button does not exist');
211
230
  assert.deepEqual(cd.states.closer.actions[1], ['clickIfVisible', '.cd-close-btn']);
231
+ assert.deepEqual(cd.states.proposer.actions[1], ['clickIfVisible', '.cd-propose-btn'], 'task 1003114: the proposal composer is a measured state, not one looked at once');
212
232
  const cb = JSON.parse(read('collab.states.json'));
213
233
  assert.deepEqual(cb.states.board.expect.visible, ['#cb-for-me']);
214
234
  });