@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.
- package/.bongos-core.json +21 -21
- package/docs/copy-inventory.md +46 -44
- package/docs/copy-registry.json +79 -60
- package/docs/module-api-changelog.md +4 -0
- package/modules/hall-ui/public/collab-lib.js +71 -0
- package/modules/hall-ui/public/collab.css +27 -0
- package/modules/hall-ui/public/collab.html +3 -0
- package/modules/hall-ui/public/collab.js +32 -10
- package/modules/hall-ui/public/ideas.css +13 -0
- package/modules/hall-ui/public/ideas.html +11 -0
- package/modules/hall-ui/public/ideas.js +91 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/collab_page.mjs +92 -0
- package/tests/hall_collab_world.mjs +11 -3
- package/tests/idea_spark_hall.mjs +115 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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');
|
package/tests/collab_page.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
266
|
-
|
|
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);
|