@bongos/core 1.19.906 → 1.19.908

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.
@@ -53,9 +53,17 @@
53
53
  // exact timestamp stays on the title, where a reader who wants the date can
54
54
  // still reach it — which is also why the separate date line that used to sit
55
55
  // under this pill is gone: the same fact twice is one fact too many.
56
- function ageChip(req, nowMs) {
56
+ //
57
+ // `baseline` is the block's own room condition (L.roomBaseline), and it is the
58
+ // second half of the same verdict: where the label stops fourteen old rows
59
+ // reading as fourteen copies of one alarm, the baseline stops them WEARING
60
+ // fourteen copies of one alarm. A row at or below its room's baseline is the
61
+ // rule, not the exception, so it drops the ring and the danger ink and keeps
62
+ // only its words — the head says the room's condition once instead.
63
+ function ageChip(req, nowMs, baseline) {
57
64
  const band = L.ageBand(req.created_at, nowMs);
58
- return `<span class="fact-pill cb-age cb-age--${band.id}" title="Asked ${escapeHtml(String(req.created_at || ''))}">${escapeHtml(L.ageLabel(req.created_at, nowMs))}</span>`;
65
+ const quiet = L.isRoomQuiet(band, baseline) ? ' cb-age--quiet' : '';
66
+ return `<span class="fact-pill cb-age cb-age--${band.id}${quiet}" title="Asked ${escapeHtml(String(req.created_at || ''))}">${escapeHtml(L.ageLabel(req.created_at, nowMs))}</span>`;
59
67
  }
60
68
 
61
69
  // A DOM id per row that is stable, unique and safe to put in an attribute. The
@@ -175,14 +183,14 @@
175
183
  // chat mirror). What the page owes a helper is the request, its age, its
176
184
  // context, and who to answer — and their face. A by-name ask is the personal
177
185
  // call on this reader, so its row is the LIT one (.cb-row--live).
178
- function forMeRow(req, nowMs, extra, key) {
186
+ function forMeRow(req, nowMs, extra, key, baseline) {
179
187
  const live = req.needs_builder_id != null ? ' cb-row--live' : '';
180
188
  return `<li class="ov-row ov-row--lead cb-row${live}" data-id="${escapeHtml(String(req.id))}"${extra ? ' data-extra hidden' : ''}>
181
189
  ${faceHtml(L.faceOf(req, 'in'))}
182
190
  <div class="ov-row__main">${askHtml(req, key)}
183
191
  <div class="ov-row__facts">${directionFact(req, 'in')}${contextChip(req)}</div>
184
192
  </div>
185
- <div class="ov-row__right">${ageChip(req, nowMs)}</div>
193
+ <div class="ov-row__right">${ageChip(req, nowMs, baseline)}</div>
186
194
  </li>`;
187
195
  }
188
196
 
@@ -198,12 +206,12 @@
198
206
  // which is precisely the distinction a reader could no longer make. Moving the
199
207
  // pill down to the facts line puts the warning with the other facts about the
200
208
  // ask and leaves the right column as what it is here: the place you act.
201
- function mineRow(req, nowMs, extra, key) {
209
+ function mineRow(req, nowMs, extra, key, baseline) {
202
210
  const id = escapeHtml(String(req.id));
203
211
  return `<li class="ov-row ov-row--lead cb-row" data-id="${id}"${extra ? ' data-extra hidden' : ''}>
204
212
  ${faceHtml(L.faceOf(req, 'out'))}
205
213
  <div class="ov-row__main">${askHtml(req, key)}
206
- <div class="ov-row__facts">${directionFact(req, 'out')}${contextChip(req)}${ageChip(req, nowMs)}</div>
214
+ <div class="ov-row__facts">${directionFact(req, 'out')}${contextChip(req)}${ageChip(req, nowMs, baseline)}</div>
207
215
  </div>
208
216
  <div class="ov-row__right">
209
217
  <div class="ov-row__actions">
@@ -216,13 +224,13 @@
216
224
 
217
225
  // A row on the board of everyone's asks: the asker's face, the third-person
218
226
  // line, no actions — settling is the asker's, in their own block.
219
- function allRow(req, nowMs, extra, key) {
227
+ function allRow(req, nowMs, extra, key, baseline) {
220
228
  return `<li class="ov-row ov-row--lead cb-row" data-id="${escapeHtml(String(req.id))}"${extra ? ' data-extra hidden' : ''}>
221
229
  ${faceHtml(L.faceOf(req, 'in'))}
222
230
  <div class="ov-row__main">${askHtml(req, key)}
223
231
  <div class="ov-row__facts">${boardFact(req)}${contextChip(req)}</div>
224
232
  </div>
225
- <div class="ov-row__right">${ageChip(req, nowMs)}</div>
233
+ <div class="ov-row__right">${ageChip(req, nowMs, baseline)}</div>
226
234
  </li>`;
227
235
  }
228
236
 
@@ -235,13 +243,27 @@
235
243
  function paintBlock(key, rows, rowFn, nowMs, faces) {
236
244
  const list = rows.slice().sort(L.byNewestFirst);
237
245
  const { shown, hidden } = L.splitShown(list, SHOWN);
246
+ // Over the WHOLE list, not the shown three: the door hides rows, it does not
247
+ // change what the room is, and a baseline that flipped when someone pressed
248
+ // "Show all" would re-alarm the rows it had just quieted.
249
+ const baseline = L.roomBaseline(list, nowMs);
238
250
  const host = $(`#cb-${key}`);
239
251
  if (host) {
240
- host.innerHTML = shown.map((r) => rowFn(r, nowMs, false, key)).join('')
241
- + hidden.map((r) => rowFn(r, nowMs, true, key)).join('');
252
+ host.innerHTML = shown.map((r) => rowFn(r, nowMs, false, key, baseline)).join('')
253
+ + hidden.map((r) => rowFn(r, nowMs, true, key, baseline)).join('');
242
254
  }
243
255
  const n = $(`#cb-${key}-n`);
244
256
  if (n) n.textContent = list.length ? String(list.length) : '';
257
+ // The room's condition, said once, beside the count that scopes it.
258
+ const age = $(`#cb-${key}-age`);
259
+ if (age) {
260
+ const line = L.roomAgeLine(baseline);
261
+ age.textContent = line;
262
+ age.hidden = !line;
263
+ age.className = line
264
+ ? `cb-block__age cb-block__age--${baseline.band.id}`
265
+ : 'cb-block__age';
266
+ }
245
267
  const empty = $(`#cb-${key}-empty`);
246
268
  if (empty) empty.hidden = list.length > 0;
247
269
  const facesEl = $(`#cb-${key}-faces`);
@@ -79,6 +79,19 @@
79
79
  .fi-result { margin: var(--sp-sm) 0 0; max-width: var(--measure); font-size: 14px; line-height: 1.5; color: var(--ink-soft); }
80
80
  .fi-result a { display: inline-block; min-height: 24px; padding: 3px 0; line-height: 18px; box-sizing: border-box; color: inherit; }
81
81
 
82
+ /* The durable receipts list (task 1004082 / spec R01). Deliberately quieter than
83
+ .fi-result: that line is the answer to "did it land?", this is the answer to
84
+ "what happened to the last few?" — reference, not confirmation. Same 24px row
85
+ recipe on its links as every other list in the world. */
86
+ .fi-receipts { margin: var(--sp-md) 0 0; max-width: var(--measure); }
87
+ .fi-receipts__h { margin: 0; font-size: 12.5px; font-weight: 600; color: var(--ink-faint); }
88
+ .fi-receipts__list { margin: 6px 0 0; padding: 0; list-style: none; }
89
+ .fi-receipts__item { font-size: 13.5px; line-height: 1.5; color: var(--ink-soft); }
90
+ .fi-receipts__item + .fi-receipts__item { margin-top: 4px; }
91
+ .fi-receipts__item a { display: inline-block; min-height: 24px; padding: 3px 0; line-height: 18px; box-sizing: border-box; color: inherit; }
92
+ .fi-receipts__what { color: var(--ink-faint); }
93
+ .fi-receipts__note { margin: 6px 0 0; font-size: 12.5px; color: var(--ink-faint); }
94
+
82
95
  .fi-actions { display: flex; flex-wrap: wrap; gap: 10px 12px; align-items: center; margin-top: var(--sp-md); }
83
96
  .fi-draft { font-size: 12.5px; color: var(--ink-faint); }
84
97
 
@@ -92,6 +92,17 @@
92
92
  <p class="fi-err" id="fq-err" role="alert"></p>
93
93
  <p class="fi-result" id="fq-result" role="status" aria-live="polite"></p>
94
94
 
95
+ <!-- The receipt that outlives the confirmation above (task 1004082 /
96
+ spec R01). #fq-result is the live announcement for the filing
97
+ just made and dies with the page; this list is the durable
98
+ record of the last few, read back from localStorage on load so
99
+ a thought filed before a reload is still traceable here. -->
100
+ <section class="fi-receipts" id="fq-receipts" aria-labelledby="fq-receipts-h" hidden>
101
+ <h3 class="fi-receipts__h" id="fq-receipts-h">Recent filings</h3>
102
+ <ol class="fi-receipts__list" id="fq-receipts-list"></ol>
103
+ <p class="fi-receipts__note" id="fq-receipts-note" hidden></p>
104
+ </section>
105
+
95
106
  <div class="fi-actions">
96
107
  <button type="submit" class="btn" id="fq-submit">File it</button>
97
108
  </div>
@@ -127,6 +127,91 @@
127
127
  try { window.localStorage.removeItem(DRAFT_KEY); } catch (_) { /* nothing to clear */ }
128
128
  }
129
129
 
130
+ // ---------------------------------------------------------------------------
131
+ // filing receipts (task 1004082 / spec R01 — "the receipt persists")
132
+ // ---------------------------------------------------------------------------
133
+
134
+ // WHAT WAS ACTUALLY BROKEN. Not the keystroke clearing the line — nothing does
135
+ // that, and the task text was amended at build time to say so. The gaps were
136
+ // that only ONE outcome ever existed (the next filing overwrote it), and that
137
+ // it was DOM-only, so a reload lost it. So this is not a guard added to
138
+ // #fq-result; it is the record #fq-result never was.
139
+ //
140
+ // WHY LOCAL, and why that is honest here. A receipt is "what I was told when I
141
+ // filed", which is per-person and per-browser by nature — and the durable
142
+ // record of the ideas themselves already exists server-side on the profile
143
+ // trail. Re-reading the trail here would be a second copy of a read this page
144
+ // does not own (the same reasoning that keeps the scorer out of this file).
145
+ // Guarded exactly like the draft above: storage that throws must never take
146
+ // the composer down, and must never be reported as having saved.
147
+ const RECEIPTS_KEY = 'hall-filing-receipts';
148
+ const RECEIPTS_MAX = 5;
149
+ let receiptsStorageRefused = false;
150
+
151
+ function readReceipts() {
152
+ try {
153
+ const raw = window.localStorage.getItem(RECEIPTS_KEY);
154
+ if (!raw) return [];
155
+ const list = JSON.parse(raw);
156
+ if (!Array.isArray(list)) return [];
157
+ // Shape-check every row rather than trusting the key: a stale or
158
+ // hand-edited value must degrade to "no receipts", never to a thrown
159
+ // render that takes the filing form with it.
160
+ return list.filter((r) => r && typeof r === 'object' && typeof r.title === 'string')
161
+ .slice(0, RECEIPTS_MAX);
162
+ } catch (_) { return []; }
163
+ }
164
+
165
+ function pushReceipt(receipt) {
166
+ const next = [receipt].concat(readReceipts()).slice(0, RECEIPTS_MAX);
167
+ try {
168
+ window.localStorage.setItem(RECEIPTS_KEY, JSON.stringify(next));
169
+ receiptsStorageRefused = false;
170
+ } catch (_) {
171
+ // Same discipline as writeDraft: say it is not being kept rather than
172
+ // letting someone believe a receipt will be there after a reload.
173
+ receiptsStorageRefused = true;
174
+ }
175
+ return next;
176
+ }
177
+
178
+ function receiptLine(r) {
179
+ const when = r.at ? `<time datetime="${escapeHtml(String(r.at))}">${escapeHtml(formatReceiptTime(r.at))}</time> · ` : '';
180
+ const what = `<span class="fi-receipts__what">${escapeHtml(r.title)}</span>`;
181
+ if (r.task_id && r.href) {
182
+ const goal = r.goal_name ? ` under ${escapeHtml(r.goal_name)}` : '';
183
+ return `${when}${what} — filed as <a href="${escapeHtml(r.href)}">task ${escapeHtml(String(r.task_id))}</a>${goal}.`;
184
+ }
185
+ return `${when}${what} — filed to the inbox.`;
186
+ }
187
+
188
+ function formatReceiptTime(iso) {
189
+ const d = new Date(iso);
190
+ if (Number.isNaN(d.getTime())) return '';
191
+ try {
192
+ return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
193
+ } catch (_) { return ''; }
194
+ }
195
+
196
+ function renderReceipts(list) {
197
+ const wrap = $('#fq-receipts');
198
+ const ol = $('#fq-receipts-list');
199
+ const noteEl = $('#fq-receipts-note');
200
+ if (!wrap || !ol) return;
201
+ const receipts = Array.isArray(list) ? list : readReceipts();
202
+ if (!receipts.length) { wrap.hidden = true; ol.innerHTML = ''; return; }
203
+ wrap.hidden = false;
204
+ ol.innerHTML = receipts
205
+ .map((r) => `<li class="fi-receipts__item">${receiptLine(r)}</li>`)
206
+ .join('');
207
+ if (noteEl) {
208
+ noteEl.hidden = !receiptsStorageRefused;
209
+ noteEl.textContent = receiptsStorageRefused
210
+ ? 'This browser is not keeping receipts — they will go when the tab does.'
211
+ : '';
212
+ }
213
+ }
214
+
130
215
  function note(msg) {
131
216
  const el = $('#fi-draft');
132
217
  if (el) el.textContent = msg;
@@ -474,8 +559,10 @@
474
559
  const goal = allGoalsById.get(String(routing.goal_id));
475
560
  const goalName = goal ? goal.title : `goal ${routing.goal_id}`;
476
561
  $('#fq-result').innerHTML = `Filed as <a href="${href}">task ${escapeHtml(String(routedTask.id))}</a> under ${escapeHtml(goalName)}.`;
562
+ renderReceipts(pushReceipt({ title, task_id: routedTask.id, goal_name: goalName, href, at: new Date().toISOString() }));
477
563
  } else {
478
564
  $('#fq-result').textContent = 'Filed to the inbox.';
565
+ renderReceipts(pushReceipt({ title, task_id: null, goal_name: null, href: null, at: new Date().toISOString() }));
479
566
  }
480
567
 
481
568
  fqTitleEl().value = '';
@@ -493,6 +580,10 @@
493
580
  fqGoalEl().addEventListener('change', () => { goalTouchedByUser = true; });
494
581
  $('#fq-form').addEventListener('submit', fileQuick);
495
582
 
583
+ // Before any network call: the receipts are local, so a builder who reloads
584
+ // sees what became of earlier thoughts even if every read below fails.
585
+ renderReceipts();
586
+
496
587
  try {
497
588
  const [versionsRes, goalsRes] = await Promise.all([
498
589
  api.versions.getVersions({}),
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.906",
3
+ "version": "1.19.908",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.906",
9
+ "version": "1.19.908",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.906",
3
+ "version": "1.19.908",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.906'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.908'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -136,6 +136,98 @@ test('the two calm bands are not chips, and the date is not said twice', () => {
136
136
  assert.match(js, /title="Asked \$\{escapeHtml\(String\(req\.created_at/, 'the exact date is still reachable');
137
137
  });
138
138
 
139
+ // ---- THE ROOM'S OWN CONDITION (task 1004067, from idea 1001065) -----------
140
+ //
141
+ // The report: fourteen of fourteen rows wore the "waiting over a week" pill, so
142
+ // the loudest thing on the page carried no information. Task 1004041 made the
143
+ // WORDS differ, which is why these tests are about the TREATMENT — fourteen
144
+ // differently-worded rows in identical danger ink and identical rings still read
145
+ // as a wall. Each case below is run through the lib, not grepped, so a rule that
146
+ // is wrong fails here rather than looking plausible in a diff.
147
+ const room = (...ages) => ages.map((d) => ({ created_at: daysAgo(d) }));
148
+
149
+ test('a uniformly old room states its condition once instead of on every row', () => {
150
+ const L = lib();
151
+ // The reported board, reproduced: fourteen rows, every one past a week.
152
+ const rows = room(8, 9, 11, 13, 15, 16, 18, 21, 24, 27, 30, 34, 38, 41);
153
+ const baseline = L.roomBaseline(rows, NOW);
154
+ assert.ok(baseline, 'fourteen of fourteen is a room condition, not fourteen warnings');
155
+ assert.equal(baseline.band.id, 'stale');
156
+ assert.equal(L.roomAgeLine(baseline), 'all 14 waiting over a week');
157
+ const loud = rows.filter((r) => !L.isRoomQuiet(L.ageBand(r.created_at, NOW), baseline));
158
+ assert.equal(loud.length, 0, 'no row is worse than a room where every row is the same');
159
+ // And nothing is silently dead: each row still says its own real age.
160
+ const words = new Set(rows.map((r) => L.ageLabel(r.created_at, NOW)));
161
+ assert.ok(words.size > 1, 'the per-row words still carry the differing ages');
162
+ });
163
+
164
+ test('the pill is kept for the row that is worse than its room', () => {
165
+ const L = lib();
166
+ // Six of eight sitting a few days, two genuinely old. The two are the whole
167
+ // point of the column, and before this they were indistinguishable from the six.
168
+ const rows = room(4, 4, 5, 5, 5, 6, 21, 30);
169
+ const baseline = L.roomBaseline(rows, NOW);
170
+ assert.equal(baseline.band.id, 'aging');
171
+ assert.equal(L.roomAgeLine(baseline), '6 of 8 waiting a few days');
172
+ const loud = rows
173
+ .filter((r) => !L.isRoomQuiet(L.ageBand(r.created_at, NOW), baseline))
174
+ .map((r) => L.ageLabel(r.created_at, NOW));
175
+ assert.deepEqual(loud, ['waiting 3 weeks', 'waiting 4 weeks'], 'only the exceptions keep a chip');
176
+ });
177
+
178
+ test('a mixed room is left exactly as it was', () => {
179
+ const L = lib();
180
+ // No band is dominant here, so nothing is quieted and nothing is announced —
181
+ // which is what keeps this inert on the board as it usually looks.
182
+ const rows = room(0.2, 0.5, 1, 2, 4, 5, 9, 21);
183
+ assert.equal(L.roomBaseline(rows, NOW), null);
184
+ assert.equal(L.roomAgeLine(null), '');
185
+ for (const r of rows) {
186
+ assert.equal(L.isRoomQuiet(L.ageBand(r.created_at, NOW), null), false, 'no baseline quiets nothing');
187
+ }
188
+ });
189
+
190
+ test('a short list keeps every pill, and a calm room announces nothing', () => {
191
+ const L = lib();
192
+ // Three chips can be read one at a time; a wall needs a crowd.
193
+ assert.equal(L.roomBaseline(room(30, 30, 30), NOW), null, 'under the minimum, every row keeps its own');
194
+ assert.equal(L.BASELINE_MIN_ROWS, 4);
195
+ // A room that is uniformly calm has no alarm to move to the head. Announcing
196
+ // one there would invent the noise this removes.
197
+ assert.equal(L.roomBaseline(room(0.1, 0.2, 0.3, 0.4, 0.5), NOW), null, 'all fresh says nothing');
198
+ assert.equal(L.roomBaseline(room(1, 1, 2, 2, 2), NOW), null, 'all waiting-a-day says nothing either');
199
+ });
200
+
201
+ test('the room line and the rows cannot describe the threshold two ways', () => {
202
+ const L = lib();
203
+ // The head's words come from the band's own label, so there is one vocabulary.
204
+ for (const b of L.BANDS) {
205
+ const line = L.roomAgeLine({ band: b, count: 9, total: 12 });
206
+ assert.equal(line, `9 of 12 ${b.label}`, `${b.id} reads in its own words`);
207
+ }
208
+ });
209
+
210
+ test('the quiet treatment is wired through the render, not just defined', () => {
211
+ const js = read('modules', 'hall-ui', 'public', 'collab.js');
212
+ assert.match(js, /function ageChip\(req, nowMs, baseline\)/, 'the chip is told about its room');
213
+ assert.match(js, /L\.isRoomQuiet\(band, baseline\)/, 'through the lib, so the rule is the tested one');
214
+ assert.match(js, /cb-age--quiet/, 'and the class reaches the markup');
215
+ // Over the WHOLE list: a baseline computed on the shown three would flip when
216
+ // a reader pressed "Show all" and re-alarm the rows it had just quieted.
217
+ assert.match(js, /const baseline = L\.roomBaseline\(list, nowMs\)/, 'the room is the whole block');
218
+ const html = read('modules', 'hall-ui', 'public', 'collab.html');
219
+ for (const key of ['for-me', 'mine', 'all']) {
220
+ assert.ok(html.includes(`id="cb-${key}-age"`), `the ${key} head has somewhere to say it`);
221
+ }
222
+ const css = read('modules', 'hall-ui', 'public', 'collab.css');
223
+ assert.match(
224
+ css,
225
+ /\.cb-age\.cb-age--quiet\s*\{[^}]*border-color: transparent/,
226
+ 'the quieted row drops its ring',
227
+ );
228
+ assert.match(css, /\.cb-age\.cb-age--quiet\s*\{[^}]*color: var\(--ink-soft\)/, 'and the danger ink with it');
229
+ });
230
+
139
231
  test('the alarm does not sit beside the accent control', () => {
140
232
  // DESIGN.md spends the accent only on what is pressable or live. On "Your
141
233
  // asks" the age pill (danger ink) used to share the right column with the
@@ -230,7 +230,11 @@ test('the door and the copy control are on the row, and the copy control is reac
230
230
  // One request can appear in three blocks at once, so the id is block-scoped.
231
231
  assert.match(js, /function askId\(key, id\)/);
232
232
  assert.match(js, /cb-ask-\$\{key\}-/);
233
- assert.match(js, /rowFn\(r, nowMs, false, key\)/, 'and paintBlock hands the key down');
233
+ // A bracket class, not a closing paren: paintBlock also hands the row its
234
+ // block's age baseline (task 1004067), and pinning the exact arity would red
235
+ // this on an argument the invariant — that the KEY reaches the row — does not
236
+ // care about.
237
+ assert.match(js, /rowFn\(r, nowMs, false, key[,)]/, 'and paintBlock hands the key down');
234
238
 
235
239
  const css = read('collab.css');
236
240
  for (const cls of ['.cb-ask ', '.cb-ask__door', '.cb-ask__copy', '.cb-ask__full']) {
@@ -262,8 +266,12 @@ test('the age is a state pill in the row\'s right column, and every band has its
262
266
  assert.match(css, /\.cb-age--stale\s*\{[^}]*var\(--danger\)/, 'stale is the danger ink');
263
267
  assert.match(css, /\.cb-age--aging\s*\{[^}]*var\(--warn\)/, 'aging is the warn ink');
264
268
  const js = read('collab.js');
265
- assert.match(js, /class="fact-pill cb-age cb-age--\$\{band\.id\}"/, 'the pill is the shell\'s fact-pill');
266
- assert.match(js, /<div class="ov-row__right">\$\{ageChip\(req, nowMs\)\}/, 'in the right column, so a glance down the list reads the ages');
269
+ // Both pins stop short of the closing character, because the band's class is
270
+ // now followed by the room's quiet modifier and ageChip by the baseline that
271
+ // decides it (task 1004067). What they assert is unchanged: the shell's pill,
272
+ // in the right column.
273
+ assert.match(js, /class="fact-pill cb-age cb-age--\$\{band\.id\}/, 'the pill is the shell\'s fact-pill');
274
+ assert.match(js, /<div class="ov-row__right">\$\{ageChip\(req, nowMs[,)]/, 'in the right column, so a glance down the list reads the ages');
267
275
  });
268
276
 
269
277
  // ---- the copy desk: quieter, and no fact lost ----------------------------------
@@ -400,5 +400,120 @@ await test('the kind control travels with the filing', async () => {
400
400
  assert.equal(call[1].body.kind, 'refactor');
401
401
  });
402
402
 
403
+ // ---------------------------------------------------------------------------
404
+ // task 1004082 / spec R01 — the receipt persists
405
+ //
406
+ // The task as written blamed a keystroke for clearing #fq-result. It does not;
407
+ // the description was amended at build time. What was actually missing: only
408
+ // ONE outcome ever existed (the next filing overwrote it) and it was DOM-only,
409
+ // so a reload lost it. These cases pin the record, not the confirmation line.
410
+ //
411
+ // READ THIS BEFORE TRUSTING THE ONES BELOW: this file's DOM stub MANUFACTURES an
412
+ // element for any id it is handed (getElementById sets on miss), so a test that
413
+ // merely asserts on #fq-receipts-list would stay green if ideas.html never
414
+ // declared it. The markup pin is therefore the load-bearing case here.
415
+ // ---------------------------------------------------------------------------
416
+
417
+ await test('ideas.html actually declares the receipts markup (the stub would fake it otherwise)', async () => {
418
+ const html = fs.readFileSync(path.join(HALL, 'ideas.html'), 'utf8');
419
+ assert.match(html, /id="fq-receipts"/, 'the receipts container is in the real markup');
420
+ assert.match(html, /id="fq-receipts-list"/, 'and the list the items render into');
421
+ assert.match(html, /id="fq-result"/, 'the live confirmation line is untouched beside it');
422
+ });
423
+
424
+ await test('a routed filing writes a receipt that outlives the confirmation line', async () => {
425
+ const r = boot();
426
+ await settle(); await settle();
427
+ r.el('fq-title').value = 'the widget is broken';
428
+ r.el('fq-goal').value = '77';
429
+ r.el('fq-goal')._handlers.change();
430
+ await r.submitQuick();
431
+ await settle();
432
+ const stored = JSON.parse(r.store['hall-filing-receipts']);
433
+ assert.equal(stored.length, 1);
434
+ assert.equal(stored[0].task_id, '5555');
435
+ assert.equal(stored[0].title, 'the widget is broken');
436
+ assert.equal(stored[0].goal_name, 'Goal Seventy Seven');
437
+ const rendered = r.el('fq-receipts-list').innerHTML;
438
+ assert.match(rendered, /task 5555/);
439
+ assert.match(rendered, /Goal Seventy Seven/);
440
+ assert.equal(r.el('fq-receipts').hidden, false, 'and the section is shown');
441
+ });
442
+
443
+ await test('a SECOND filing does not overwrite the first — the bug this task exists for', async () => {
444
+ const r = boot();
445
+ await settle(); await settle();
446
+ r.el('fq-title').value = 'the first thought';
447
+ await r.submitQuick();
448
+ await settle();
449
+ r.el('fq-title').value = 'the second thought';
450
+ await r.submitQuick();
451
+ await settle();
452
+ const stored = JSON.parse(r.store['hall-filing-receipts']);
453
+ assert.equal(stored.length, 2, 'both are kept');
454
+ assert.equal(stored[0].title, 'the second thought', 'newest first');
455
+ assert.equal(stored[1].title, 'the first thought', 'and the earlier one survived');
456
+ const rendered = r.el('fq-receipts-list').innerHTML;
457
+ assert.match(rendered, /the first thought/, 'the earlier filing is still reachable on the surface');
458
+ });
459
+
460
+ await test('receipts are read back on load — a filing survives a reload', async () => {
461
+ const earlier = [{ title: 'filed before the reload', task_id: '4242', goal_name: 'Goal Seventy Seven', href: '/task/4242', at: '2026-09-20T09:00:00Z' }];
462
+ const r = boot({ store: { 'hall-filing-receipts': JSON.stringify(earlier) } });
463
+ await settle(); await settle();
464
+ const rendered = r.el('fq-receipts-list').innerHTML;
465
+ assert.match(rendered, /filed before the reload/, 'rendered from storage with no filing this session');
466
+ assert.match(rendered, /task 4242/);
467
+ assert.equal(r.el('fq-result').textContent, '', 'and the live line is correctly empty — it is not the record');
468
+ });
469
+
470
+ await test('an unrouted filing is receipted too — "filed to the inbox" is an outcome', async () => {
471
+ const r = boot();
472
+ await settle(); await settle();
473
+ r.el('fq-title').value = 'a homeless thought';
474
+ await r.submitQuick();
475
+ await settle();
476
+ const stored = JSON.parse(r.store['hall-filing-receipts']);
477
+ assert.equal(stored[0].task_id, null);
478
+ assert.match(r.el('fq-receipts-list').innerHTML, /filed to the inbox/);
479
+ });
480
+
481
+ await test('the list is capped at five, keeping the newest', async () => {
482
+ const old = Array.from({ length: 5 }, (_, i) => ({ title: `old ${i}`, task_id: null, at: '2026-09-20T09:00:00Z' }));
483
+ const r = boot({ store: { 'hall-filing-receipts': JSON.stringify(old) } });
484
+ await settle(); await settle();
485
+ r.el('fq-title').value = 'the newest thought';
486
+ await r.submitQuick();
487
+ await settle();
488
+ const stored = JSON.parse(r.store['hall-filing-receipts']);
489
+ assert.equal(stored.length, 5, 'still five');
490
+ assert.equal(stored[0].title, 'the newest thought');
491
+ assert.ok(!stored.some((x) => x.title === 'old 4'), 'the oldest fell off');
492
+ });
493
+
494
+ await test('storage that refuses never takes the composer down, and says it is not keeping them', async () => {
495
+ // The failure a private window / blocked site data actually produces: the
496
+ // write throws. The filing itself must still complete, and the page must not
497
+ // claim a receipt it did not keep.
498
+ const blocked = boot({ store: new Proxy({}, { set() { throw new Error('blocked'); } }) });
499
+ await settle(); await settle();
500
+ blocked.el('fq-title').value = 'a thought in a private window';
501
+ await blocked.submitQuick();
502
+ await settle();
503
+ assert.equal(blocked.el('fq-result').textContent, 'Filed to the inbox.', 'the filing still completed');
504
+ assert.match(blocked.el('fq-receipts-note').textContent, /not keeping receipts/, 'and it says so rather than pretending');
505
+ });
506
+
507
+ // Every guard was reverted and each redded its named case (run, not assumed):
508
+ // drop pushReceipt from the routed branch -> 1 red (the routed case)
509
+ // write instead of prepend (overwrite the list) -> 2 red (second-filing + the cap)
510
+ // drop renderReceipts() from bootQuickComposer -> 1 red (the reload case)
511
+ // rename the id in ideas.html -> 1 red (the markup pin ONLY)
512
+ //
513
+ // That last line is the one worth reading twice: every DOM-asserting case above
514
+ // stayed GREEN while the real markup was broken, because the stub manufactured
515
+ // the element. Without the markup pin this file would have certified a page
516
+ // that renders nothing.
517
+
403
518
  console.log(`\nidea_spark_hall: ${passed} passed, ${failed} failed`);
404
519
  if (failed > 0) process.exit(1);