@bongos/core 1.19.642 → 1.19.643
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 +114 -114
- package/docs/copy-registry.json +127 -127
- package/docs/module-api-changelog.md +2 -0
- package/modules/builder-settings/builder-needs.js +14 -0
- package/modules/hall-ui/public/hall-render.js +5 -0
- package/modules/hall-ui/public/shell.js +95 -5
- package/modules/hall-ui/public/style.css +38 -0
- package/modules/lifecycle/rot.js +51 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/claude-materialize.js +83 -5
- package/src/module-api.js +1 -1
- package/tests/claude_materialize.mjs +95 -0
- package/tests/government_board_summons.mjs +19 -0
- package/tests/hall_nav.mjs +150 -1
- package/tests/rot_inbox.mjs +79 -3
package/tests/hall_nav.mjs
CHANGED
|
@@ -81,6 +81,31 @@ function boot({ page = 'settings', hash = '', pathname = '/settings' } = {}) {
|
|
|
81
81
|
return out;
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
// task 1003737: the badge nodes applyCounts() settles. They are THE SAME
|
|
85
|
+
// element objects gatedNodes() hands back — keyed by the same id — because
|
|
86
|
+
// applyCounts reads `el.hidden`, which the gate loop is what sets. A separate
|
|
87
|
+
// stub object here would read a hidden that nothing ever wrote, and the
|
|
88
|
+
// "a badge never out-runs its gate" assertion would pass vacuously.
|
|
89
|
+
function countNodes() {
|
|
90
|
+
const html = els.has('app-sidebar') ? String(els.get('app-sidebar').innerHTML) : '';
|
|
91
|
+
const out = [];
|
|
92
|
+
for (const tag of html.split('<')) {
|
|
93
|
+
const count = attr(tag, 'data-count');
|
|
94
|
+
if (count === undefined) continue;
|
|
95
|
+
const key = attr(tag, 'id');
|
|
96
|
+
if (!gatedEls.has(key)) gatedEls.set(key, { key, dataset: {}, hidden: true });
|
|
97
|
+
const el = gatedEls.get(key);
|
|
98
|
+
el.dataset.count = count;
|
|
99
|
+
if (!el._badge) {
|
|
100
|
+
el._badge = { hidden: true, textContent: '' };
|
|
101
|
+
el._label = { textContent: '' };
|
|
102
|
+
el.querySelector = (sel) => (sel === '[data-badge]' ? el._badge : sel === '[data-badge-label]' ? el._label : null);
|
|
103
|
+
}
|
|
104
|
+
out.push(el);
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
84
109
|
const documentObj = {
|
|
85
110
|
body: { dataset: { page } },
|
|
86
111
|
documentElement: { dataset: {}, style: {}, classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } } },
|
|
@@ -88,7 +113,11 @@ function boot({ page = 'settings', hash = '', pathname = '/settings' } = {}) {
|
|
|
88
113
|
if (!els.has(id)) els.set(id, makeEl());
|
|
89
114
|
return els.get(id);
|
|
90
115
|
},
|
|
91
|
-
querySelectorAll(sel) {
|
|
116
|
+
querySelectorAll(sel) {
|
|
117
|
+
if (sel === '#app-sidebar [data-gate]') return gatedNodes();
|
|
118
|
+
if (sel === '#app-sidebar .nav-item[data-count]') return countNodes();
|
|
119
|
+
return [];
|
|
120
|
+
},
|
|
92
121
|
createElement() { return makeEl(); },
|
|
93
122
|
addEventListener() {},
|
|
94
123
|
dispatchEvent() {},
|
|
@@ -130,6 +159,14 @@ function boot({ page = 'settings', hash = '', pathname = '/settings' } = {}) {
|
|
|
130
159
|
assert.ok(el, `nav item ${id} rendered`);
|
|
131
160
|
return el.hidden;
|
|
132
161
|
},
|
|
162
|
+
// task 1003737: what the badge on that item says right now — null when it is
|
|
163
|
+
// not showing, which is the state ZERO IS SILENT is about.
|
|
164
|
+
navBadge: (id) => {
|
|
165
|
+
const el = gatedEls.get('nav-' + id);
|
|
166
|
+
assert.ok(el, `nav item ${id} rendered`);
|
|
167
|
+
assert.ok(el._badge, `nav item ${id} declares a count`);
|
|
168
|
+
return el._badge.hidden ? null : { text: el._badge.textContent, label: el._label.textContent };
|
|
169
|
+
},
|
|
133
170
|
};
|
|
134
171
|
}
|
|
135
172
|
|
|
@@ -446,3 +483,115 @@ test('collapsible groups carry a stable storage key (collapse persistence)', ()
|
|
|
446
483
|
assert.ok(!/nav-group--collapsible[^>]*>(?!<button)/.test(html.split('<div class="nav-group')[1] || ''),
|
|
447
484
|
'the unlabeled project group stays always-expanded');
|
|
448
485
|
});
|
|
486
|
+
|
|
487
|
+
// ── a nav item can carry a COUNT badge (task 1003737) ───────────────────────
|
|
488
|
+
//
|
|
489
|
+
// The owner's ask: show that sittings wait on your vote WITHOUT going inside.
|
|
490
|
+
// The count already rode /me (boardVotesNeed, pre-filtered to ballots this viewer
|
|
491
|
+
// could actually cast); what the hall had was no badge/bell/dot mechanism of ANY
|
|
492
|
+
// kind. These pin the MECHANISM, not the one consumer — the rules below are what
|
|
493
|
+
// a second adopter (help requests, the idea inbox) inherits for free.
|
|
494
|
+
|
|
495
|
+
const boardNeeds = (count, extra = {}) => ({
|
|
496
|
+
items: [{
|
|
497
|
+
id: 'board_votes', state: 'action_needed', count,
|
|
498
|
+
title: `${count} ideas are waiting on your vote`, ...extra,
|
|
499
|
+
}],
|
|
500
|
+
});
|
|
501
|
+
const seeingBoard = { authed: true, rank: 'xenos', permissions: { 'board.vote.cast': true } };
|
|
502
|
+
|
|
503
|
+
test('THE ASK: a waiting sitting puts a count on the Board Room item', () => {
|
|
504
|
+
const b = boot();
|
|
505
|
+
b.shell.applyAccess({ ...seeingBoard, needs: boardNeeds(3) });
|
|
506
|
+
assert.deepEqual(b.navBadge('board-room'), { text: '3', label: '3 ideas are waiting on your vote' });
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
test('ZERO IS SILENT — and so is every other kind of nothing', () => {
|
|
510
|
+
// Five different nothings, one answer. A reader must not be able to tell an
|
|
511
|
+
// empty board from a /me that has not landed: both mean "nothing to act on".
|
|
512
|
+
const cases = {
|
|
513
|
+
'no needs key at all': undefined,
|
|
514
|
+
'needs is null (not yet told)': null,
|
|
515
|
+
'a roster with no board need': { items: [{ id: 'art_key', state: 'action_needed' }] },
|
|
516
|
+
'the need exists but counts zero': boardNeeds(0),
|
|
517
|
+
'a need carrying no count at all': { items: [{ id: 'board_votes', state: 'action_needed' }] },
|
|
518
|
+
};
|
|
519
|
+
for (const [name, needs] of Object.entries(cases)) {
|
|
520
|
+
const b = boot();
|
|
521
|
+
b.shell.applyAccess({ ...seeingBoard, needs });
|
|
522
|
+
assert.equal(b.navBadge('board-room'), null, `${name} → no badge`);
|
|
523
|
+
}
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
test('the founding grace silences the badge, because a badge is a demand', () => {
|
|
527
|
+
// computeNeeds rewrites every action_needed row to 'covered' during a new
|
|
528
|
+
// instance's first days, precisely so it does not greet its owner with demands.
|
|
529
|
+
// The count SURVIVES that rewrite (the grace spreads the row), so a badge keyed
|
|
530
|
+
// on the number alone would shout straight through the grace.
|
|
531
|
+
const b = boot();
|
|
532
|
+
b.shell.applyAccess({ ...seeingBoard, needs: boardNeeds(4, { state: 'covered' }) });
|
|
533
|
+
assert.equal(b.navBadge('board-room'), null, 'covered is not action_needed');
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
test('A BADGE NEVER OUT-RUNS ITS GATE — no count on a room you cannot see', () => {
|
|
537
|
+
// The disclosure rule. The count is real and the viewer holds no atom, so the
|
|
538
|
+
// item is hidden; the badge must not be written anyway. If it were, the DOM —
|
|
539
|
+
// and a screen reader walking it — would carry board activity to someone with
|
|
540
|
+
// no access to the room.
|
|
541
|
+
const b = boot();
|
|
542
|
+
b.shell.applyAccess({
|
|
543
|
+
authed: true, rank: 'archon', permissions: { 'board.vote.cast': false }, needs: boardNeeds(7),
|
|
544
|
+
});
|
|
545
|
+
assert.equal(b.navHidden('board-room'), true, 'precondition: the gate hides it');
|
|
546
|
+
assert.equal(b.navBadge('board-room'), null, 'a hidden item carries no count');
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
test('a badge already shown is CLEARED when access goes away', () => {
|
|
550
|
+
// The stale-count case: skipping hidden items rather than clearing them would
|
|
551
|
+
// leave the previous state's number sitting in the DOM after a sign-out.
|
|
552
|
+
const b = boot();
|
|
553
|
+
b.shell.applyAccess({ ...seeingBoard, needs: boardNeeds(2) });
|
|
554
|
+
assert.ok(b.navBadge('board-room'), 'precondition: it showed');
|
|
555
|
+
b.shell.applyAccess({ authed: false });
|
|
556
|
+
assert.equal(b.navBadge('board-room'), null, 'signing out takes the count with it');
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
test('a big count is capped rather than allowed to stretch the rail', () => {
|
|
560
|
+
const b = boot();
|
|
561
|
+
b.shell.applyAccess({ ...seeingBoard, needs: boardNeeds(140) });
|
|
562
|
+
assert.equal(b.navBadge('board-room').text, '99+');
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
test('the count reaches a screen reader as WORDS, not a floating numeral', () => {
|
|
566
|
+
// The numeral is aria-hidden decoration; the need's own sentence rides beside
|
|
567
|
+
// it in an sr-only span. "3" alone inside a link named Board Room says nothing.
|
|
568
|
+
const html = boot().sidebarHtml;
|
|
569
|
+
const item = html.split('<a class="nav-item"').find((t) => t.includes('id="nav-board-room"'));
|
|
570
|
+
assert.ok(item, 'the Board Room item renders');
|
|
571
|
+
assert.match(item, /class="nav-item__badge" data-badge aria-hidden="true" hidden/, 'the numeral is decoration');
|
|
572
|
+
assert.match(item, /class="sr-only" data-badge-label/, 'the words are what a reader gets');
|
|
573
|
+
// An item that declared no count grows neither node — the badge is opt-in.
|
|
574
|
+
const home = html.split('<a class="nav-item"').find((t) => t.includes('id="nav-home"'));
|
|
575
|
+
assert.ok(!/data-badge/.test(home), 'no count declared, no badge markup');
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
test('the mechanism is GENERIC — it keys on a declared need id, not on the board', () => {
|
|
579
|
+
// What makes a second consumer cheap: shell.js names a need id in `count:` and
|
|
580
|
+
// reads that row's number. Nothing in the badge path knows what a board IS, so
|
|
581
|
+
// adopting it is one field here plus one field on the need.
|
|
582
|
+
const fn = SRC.slice(SRC.indexOf('function applyCounts'), SRC.indexOf('// applyAccess({'));
|
|
583
|
+
assert.ok(fn.length > 0, 'applyCounts exists');
|
|
584
|
+
assert.ok(!/board/i.test(fn), 'the badge resolver names no specific need');
|
|
585
|
+
assert.match(fn, /dataset\.count/, 'it keys on the item’s own declared need id');
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
test('the badge is styled for BOTH shapes of the rail', () => {
|
|
589
|
+
// The rail is a 64px icon strip until hover/focus opens it, and the drawer is
|
|
590
|
+
// it permanently open with no hover to trigger anything. A badge positioned for
|
|
591
|
+
// one of the three reads wrong in the others, and no unit test would catch it.
|
|
592
|
+
const css = fs.readFileSync(path.join(HALL, 'style.css'), 'utf8');
|
|
593
|
+
assert.match(css, /\.nav-item__badge \{/, 'the collapsed (icon-only) position');
|
|
594
|
+
assert.match(css, /:focus-within \.nav-item__badge/, 'the open-rail position');
|
|
595
|
+
const drawer = css.slice(css.indexOf('html.drawer-open .app-sidebar'));
|
|
596
|
+
assert.match(drawer, /\.nav-item__badge \{ top: 50%/, 'the mobile drawer position');
|
|
597
|
+
});
|
package/tests/rot_inbox.mjs
CHANGED
|
@@ -309,13 +309,89 @@ test('an unattributed thing is visible ONLY to canSeeAll — it has no creator t
|
|
|
309
309
|
'a NULL creator must not be OR-ed into every builder\'s inbox');
|
|
310
310
|
});
|
|
311
311
|
|
|
312
|
-
test('each row carries the verbs for ITS tier, so the client never hardcodes the mapping', async () => {
|
|
313
|
-
expect({ rows: [rotRow(), rotRow({ tier: 'goal', id: '9' })] });
|
|
312
|
+
test('each row carries the verbs for ITS tier AND ITS status, so the client never hardcodes the mapping', async () => {
|
|
313
|
+
expect({ rows: [rotRow(), rotRow({ tier: 'goal', id: '9', status: 'open' })] });
|
|
314
314
|
const got = await rot.rottingFor({ builderId: 77, rotDays: 30 });
|
|
315
|
-
|
|
315
|
+
// rotRow() is a BACKLOG task: promote accepts it, demote does not.
|
|
316
|
+
assert.deepEqual(got.rows[0].verbs.map((v) => v.verb), ['prioritise', 'kill', 'water']);
|
|
316
317
|
assert.deepEqual(got.rows[1].verbs.map((v) => v.verb), ['kill', 'water']);
|
|
317
318
|
});
|
|
318
319
|
|
|
320
|
+
// ---- 4b. the offer must be the row's, not the tier's (task 1003751) ---------
|
|
321
|
+
//
|
|
322
|
+
// The regression this replaces: verbs were attached as `tier === 'task' ? TASK_VERBS
|
|
323
|
+
// : GOAL_VERBS`, by tier only. Since TASK_ROTTABLE_STATUSES is exactly
|
|
324
|
+
// ['backlog','ready'] and promote/demote accept disjoint sets, EVERY task line in
|
|
325
|
+
// the feed shipped one button whose route answers 409 — a backlog row offered
|
|
326
|
+
// Prune, a ready row offered Prioritise. The card's own header promises the
|
|
327
|
+
// opposite ("the client renders what the server will actually accept").
|
|
328
|
+
|
|
329
|
+
test('a backlog row is not offered Prune, and a ready row is not offered Prioritise', async () => {
|
|
330
|
+
expect({ rows: [rotRow({ status: 'backlog' }), rotRow({ id: '6', status: 'ready' })] });
|
|
331
|
+
const got = await rot.rottingFor({ builderId: 77, rotDays: 30 });
|
|
332
|
+
const backlog = got.rows[0].verbs.map((v) => v.verb);
|
|
333
|
+
const ready = got.rows[1].verbs.map((v) => v.verb);
|
|
334
|
+
assert.ok(!backlog.includes('prune'), "a backlog task is already at the bottom — /demote answers 409 cannot_demote");
|
|
335
|
+
assert.ok(backlog.includes('prioritise'), 'a backlog task is exactly what /promote is for');
|
|
336
|
+
assert.ok(!ready.includes('prioritise'), "a ready task is already claimable — /promote answers 409 cannot_promote");
|
|
337
|
+
assert.ok(ready.includes('prune'), 'ready is the only status /demote accepts');
|
|
338
|
+
// kill and water are unconditional across the rottable set, so no row loses them.
|
|
339
|
+
for (const offered of [backlog, ready]) {
|
|
340
|
+
assert.ok(offered.includes('kill') && offered.includes('water'),
|
|
341
|
+
'kill and water apply to every rottable row — narrowing must not drop them');
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test('no verb is offered for a status its route would refuse, across the whole rottable set', () => {
|
|
346
|
+
// The end-to-end claim of this feature, checked against the route guards rather
|
|
347
|
+
// than against a second hardcoded list. Every status that can reach the feed,
|
|
348
|
+
// every verb attached to it, must name a route that accepts that status.
|
|
349
|
+
for (const status of rot.TASK_ROTTABLE_STATUSES) {
|
|
350
|
+
const offered = rot.verbsFor('task', status).map((v) => v.verb);
|
|
351
|
+
assert.ok(offered.length > 0, `status '${status}' was offered no verbs at all`);
|
|
352
|
+
for (const verb of offered) {
|
|
353
|
+
const accepts = rot.TASK_VERB_ACCEPTS[verb];
|
|
354
|
+
assert.ok(!accepts || accepts.includes(status),
|
|
355
|
+
`verb '${verb}' is offered on a '${status}' row but its route accepts only [${accepts}] — that button 409s`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test('the accepted-status map is the one the ROUTES actually enforce', () => {
|
|
361
|
+
// The unavoidable-copy pairing guard, in the same spirit as the rot-clock check
|
|
362
|
+
// against migration core_231: rot.js must mirror routes/tasks.js, so read the
|
|
363
|
+
// guards back out of the route source and fail if they have drifted apart.
|
|
364
|
+
const TASKS_SRC = readFileSync(join(ROOT, 'modules/lifecycle/routes/tasks.js'), 'utf8');
|
|
365
|
+
|
|
366
|
+
const promoteGuard = /if \(!\[([^\]]*)\]\.includes\(task\.status\)\)\s*\{\s*return res\.fail\('cannot_promote'/.exec(TASKS_SRC);
|
|
367
|
+
assert.ok(promoteGuard, "could not find /promote's status guard in routes/tasks.js — if it was reshaped, re-derive TASK_VERB_ACCEPTS.prioritise from the new shape");
|
|
368
|
+
const promoteAccepts = promoteGuard[1].split(',').map((x) => x.trim().replace(/^'|'$/g, '')).filter(Boolean);
|
|
369
|
+
assert.deepEqual(
|
|
370
|
+
[...rot.TASK_VERB_ACCEPTS.prioritise].sort(), promoteAccepts.sort(),
|
|
371
|
+
'TASK_VERB_ACCEPTS.prioritise no longer matches what POST /tasks/:id/promote accepts — the rot card would offer (or withhold) a Prioritise button wrongly'
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
const demoteGuard = /if \(task\.status !== '([^']+)'\)\s*\{\s*return res\.fail\('cannot_demote'/.exec(TASKS_SRC);
|
|
375
|
+
assert.ok(demoteGuard, "could not find /demote's status guard in routes/tasks.js — if it was reshaped, re-derive TASK_VERB_ACCEPTS.prune from the new shape");
|
|
376
|
+
assert.deepEqual(
|
|
377
|
+
[...rot.TASK_VERB_ACCEPTS.prune], [demoteGuard[1]],
|
|
378
|
+
'TASK_VERB_ACCEPTS.prune no longer matches what POST /tasks/:id/demote accepts'
|
|
379
|
+
);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test('every key in the accepted-status map is a real task verb', () => {
|
|
383
|
+
// A typo'd key would silently make its verb UNCONDITIONAL (absent = no filter),
|
|
384
|
+
// which is the exact bug this map exists to fix, reintroduced quietly.
|
|
385
|
+
const known = new Set(rot.TASK_VERBS.map((v) => v.verb));
|
|
386
|
+
for (const verb of Object.keys(rot.TASK_VERB_ACCEPTS)) {
|
|
387
|
+
assert.ok(known.has(verb), `'${verb}' is not a declared task verb — a stale key filters nothing`);
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test('a goal row keeps both its verbs — neither goal route is status-conditional here', () => {
|
|
392
|
+
assert.deepEqual(rot.verbsFor('goal', 'open').map((v) => v.verb), ['kill', 'water']);
|
|
393
|
+
});
|
|
394
|
+
|
|
319
395
|
// ---- 5. the timer resolver: precedence + the reporting fail posture ---------
|
|
320
396
|
|
|
321
397
|
test('no row anywhere = the documented default, and it says so', async () => {
|