@bongos/core 1.21.43 → 1.21.45
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 +80 -70
- package/docs/copy-inventory.md +34 -22
- package/docs/copy-registry.json +144 -26
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +5 -2
- package/docs/page-readings.json +573 -583
- package/modules/hall-ui/public/atlas.html +3 -3
- package/modules/hall-ui/public/blockers.html +3 -3
- package/modules/hall-ui/public/board-room.html +3 -3
- package/modules/hall-ui/public/city-draw.js +51 -13
- package/modules/hall-ui/public/city.css +9 -2
- package/modules/hall-ui/public/city.html +6 -6
- package/modules/hall-ui/public/city.js +23 -12
- package/modules/hall-ui/public/city.states.json +146 -12
- package/modules/hall-ui/public/collab.html +3 -3
- package/modules/hall-ui/public/commands.html +3 -3
- package/modules/hall-ui/public/copy-desk.html +3 -3
- package/modules/hall-ui/public/craft-mode.js +8 -1
- package/modules/hall-ui/public/deploy.html +3 -3
- package/modules/hall-ui/public/diagrams.html +3 -3
- package/modules/hall-ui/public/drachmae.html +3 -3
- package/modules/hall-ui/public/fleet.html +3 -3
- package/modules/hall-ui/public/gate.html +3 -3
- package/modules/hall-ui/public/goal-map.html +3 -3
- package/modules/hall-ui/public/goals.html +3 -3
- package/modules/hall-ui/public/government.html +3 -3
- package/modules/hall-ui/public/idea.html +3 -3
- package/modules/hall-ui/public/ideas.html +3 -3
- package/modules/hall-ui/public/index.html +3 -3
- package/modules/hall-ui/public/modules.html +3 -3
- package/modules/hall-ui/public/paintbrush.js +398 -0
- package/modules/hall-ui/public/primer.html +3 -3
- package/modules/hall-ui/public/profile.html +3 -3
- package/modules/hall-ui/public/project-settings.html +3 -3
- package/modules/hall-ui/public/ranks.html +3 -3
- package/modules/hall-ui/public/roadmap.html +3 -3
- package/modules/hall-ui/public/roster.html +3 -3
- package/modules/hall-ui/public/sessions.html +3 -3
- package/modules/hall-ui/public/settings.html +3 -3
- package/modules/hall-ui/public/street-card.css +6 -0
- package/modules/hall-ui/public/street-card.js +31 -3
- package/modules/hall-ui/public/street-draw.js +96 -19
- package/modules/hall-ui/public/street.css +23 -7
- package/modules/hall-ui/public/street.html +11 -10
- package/modules/hall-ui/public/street.js +45 -13
- package/modules/hall-ui/public/street.states.json +68 -0
- package/modules/hall-ui/public/studio.html +3 -3
- package/modules/hall-ui/public/style.css +78 -0
- package/modules/hall-ui/public/task.html +3 -3
- package/modules/hall-ui/public/thinking.html +3 -3
- package/modules/hall-ui/public/tweak-editor.html +3 -3
- package/modules/hall-ui/public/watch.html +3 -3
- package/modules/hall-ui/public/work.html +3 -3
- package/modules/hall-ui/records/craft-mode.md +9 -0
- package/modules/hall-ui/records/governor-street.md +1 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/hall-preview/server.js +5 -2
- package/src/module-api.js +1 -1
- package/tests/hall_city.mjs +42 -11
- package/tests/hall_matter_card.mjs +17 -0
- package/tests/hall_street.mjs +185 -12
- package/tests/hall_tweak_editor.mjs +1 -1
- package/tests/page_notes_panel.mjs +4 -3
- package/tests/paintbrush.mjs +351 -0
package/tests/hall_street.mjs
CHANGED
|
@@ -32,6 +32,7 @@ const PUB = path.join(ROOT, 'modules/hall-ui/public');
|
|
|
32
32
|
const require = createRequire(import.meta.url);
|
|
33
33
|
const City = require(path.join(PUB, 'city-draw.js'));
|
|
34
34
|
const S = require(path.join(PUB, 'street-draw.js'));
|
|
35
|
+
require(path.join(PUB, 'street-desk.js')); // hangs GovDesk (the line rule's fit) on the global the street reads
|
|
35
36
|
const read = (f) => fs.readFileSync(path.join(PUB, f), 'utf8');
|
|
36
37
|
const NOW = Date.parse('2026-10-02T12:00:00.000Z'); // a Friday
|
|
37
38
|
|
|
@@ -185,17 +186,118 @@ test('every civic door is the prototype\'s door, and every door opens where its
|
|
|
185
186
|
|
|
186
187
|
// ── what the street says comes from the docket, and only from it ───────────
|
|
187
188
|
|
|
188
|
-
|
|
189
|
-
|
|
189
|
+
// task 1004569 (owner, 2026-10-04): the pins stacked on top of each other and
|
|
190
|
+
// their text overlapped. Deliberate change from the prototype's drawing: ONE
|
|
191
|
+
// pin per building, in the tier of its most important matter.
|
|
192
|
+
test('one pin per building, never more: it carries the count of the building\'s open matters and every matter, most important first', () => {
|
|
193
|
+
const ms = [
|
|
194
|
+
matter({ id: 'blockers:0', state: 'moving' }), matter({ id: 'blockers:1', state: 'needs' }),
|
|
195
|
+
matter({ id: 'blockers:2', state: 'settled' }), matter({ id: 'blockers:3', state: 'needs', deadline: '2026-10-03T06:00:00.000Z' }),
|
|
196
|
+
matter({ id: 'board:9', building: 'charter', state: 'moving' }),
|
|
197
|
+
];
|
|
190
198
|
const m = model({ docket: docket(ms) });
|
|
191
199
|
const here = m.pins.filter((p) => p.building === 'planning');
|
|
192
|
-
assert.equal(here.length,
|
|
193
|
-
assert.
|
|
194
|
-
assert.equal(
|
|
195
|
-
assert.
|
|
200
|
+
assert.equal(here.length, 1, 'one pin, not one per matter');
|
|
201
|
+
assert.equal(m.pins.length, 2, 'one per building that has a matter');
|
|
202
|
+
assert.equal(here[0].count, '3', 'three open (the settled one is not open)');
|
|
203
|
+
assert.deepEqual(here[0].matters, ['blockers:3', 'blockers:1', 'blockers:0', 'blockers:2'], 'urgent, needs, moving, settled: the pager\'s order');
|
|
204
|
+
assert.equal(here[0].key, 'blockers:3', 'the pin opens on its most important matter');
|
|
205
|
+
assert.equal(here[0].state, 'urgent');
|
|
206
|
+
assert.match(here[0].aria, /^3 open at planning, the first needs you within a day/);
|
|
196
207
|
assert.equal(m.cols.find((c) => c.key === 'planning').state, '0 goals open', 'Planning says how many goals are open');
|
|
197
208
|
});
|
|
198
209
|
|
|
210
|
+
test('a building\'s pin takes the tier of its most important matter: urgent is needs-you due within 24 hours', () => {
|
|
211
|
+
const tierOf = (list) => model({ docket: docket(list) }).pins.find((p) => p.building === 'planning').state;
|
|
212
|
+
const at = (h) => new Date(NOW + h * 3600000).toISOString();
|
|
213
|
+
assert.equal(tierOf([matter({ id: 'a:1', state: 'needs', deadline: at(23) })]), 'urgent', 'due inside a day');
|
|
214
|
+
assert.equal(tierOf([matter({ id: 'a:1', state: 'needs', deadline: at(-5) })]), 'urgent', 'overdue');
|
|
215
|
+
assert.equal(tierOf([matter({ id: 'a:1', state: 'needs', deadline: at(25) })]), 'needs', 'due after a day');
|
|
216
|
+
assert.equal(tierOf([matter({ id: 'a:1', state: 'moving', deadline: at(1) })]), 'moving', 'a deadline never makes moving work urgent');
|
|
217
|
+
assert.equal(tierOf([matter({ id: 'a:1', state: 'moving' }), matter({ id: 'a:2', state: 'needs' })]), 'needs', 'needs beats moving, wherever it stands in the docket');
|
|
218
|
+
assert.equal(tierOf([matter({ id: 'a:1', state: 'settled' }), matter({ id: 'a:2', state: 'moving' })]), 'moving');
|
|
219
|
+
assert.equal(tierOf([matter({ id: 'a:1', state: 'settled' })]), 'settled');
|
|
220
|
+
assert.equal(tierOf([matter({ id: 'a:1', state: 'weird' })]), 'moving', 'an unknown state is never needs');
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test('labels: urgent and needs-you show one short label cut by the line rule; moving waits for a hover; settled is a dot with no label and no count', () => {
|
|
224
|
+
const Desk = require(path.join(PUB, 'street-desk.js'));
|
|
225
|
+
const ms = [
|
|
226
|
+
matter({ id: 'board:1', building: 'charter', state: 'needs', short: 'a very long matter name that runs on', deadline: '2026-10-06T12:00:00.000Z' }),
|
|
227
|
+
matter({ id: 'blockers:2', building: 'automation', state: 'moving', short: 'moving work' }),
|
|
228
|
+
matter({ id: 'removal_proposals:3', building: 'treasury', state: 'settled', short: 'settled thing' }),
|
|
229
|
+
];
|
|
230
|
+
const m = model({ docket: docket(ms), goals: [1, 2, 3, 4].map((i) => goal(i)) });
|
|
231
|
+
const by = Object.fromEntries(m.pins.map((p) => [p.building, p]));
|
|
232
|
+
assert.equal(by.charter.label, Desk.fit('a very long matter name that runs on', S.PIN_LABEL - 6) + ' · tue', 'fit() cuts the name, the deadline survives');
|
|
233
|
+
assert.ok(by.charter.label.length <= S.PIN_LABEL);
|
|
234
|
+
assert.equal(by.charter.shown, true);
|
|
235
|
+
assert.deepEqual([by.automation.label, by.automation.shown], ['moving work', false], 'moving: the label is there for hover and focus, not shown');
|
|
236
|
+
assert.deepEqual([by.treasury.label, by.treasury.count, by.treasury.shown], ['', '', false], 'settled: no label, no count');
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// A dense docket: every civic building holds several matters in every tier,
|
|
240
|
+
// with long names and deadlines, and work waits at Review.
|
|
241
|
+
function denseDocket() {
|
|
242
|
+
const keys = ['charter', 'rules', 'disputes', 'people', 'planning', 'automation', 'release', 'treasury'];
|
|
243
|
+
const ms = [];
|
|
244
|
+
keys.forEach((b, i) => {
|
|
245
|
+
const day = (h) => new Date(NOW + h * 3600000).toISOString();
|
|
246
|
+
ms.push(matter({ id: `dense:${b}:1`, building: b, state: i % 3 === 2 ? 'moving' : 'needs', short: `${b} needs a long careful decision`, deadline: i % 2 ? day(6) : day(30 + i * 24) }));
|
|
247
|
+
ms.push(matter({ id: `dense:${b}:2`, building: b, state: 'needs', short: `second ${b} matter` }));
|
|
248
|
+
ms.push(matter({ id: `dense:${b}:3`, building: b, state: 'moving', short: `moving at ${b}` }));
|
|
249
|
+
ms.push(matter({ id: `dense:${b}:4`, building: b, state: 'settled', short: `settled at ${b}` }));
|
|
250
|
+
});
|
|
251
|
+
[1, 2, 3, 4].forEach((i) => ms.push(matter({ id: `criteria_uat:${i}`, source: 'criteria_uat', building: 'review', state: 'needs', title: `Criterion ${i}` })));
|
|
252
|
+
return docket(ms);
|
|
253
|
+
}
|
|
254
|
+
const meets = (a, b) => a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1;
|
|
255
|
+
function overlaps(boxes) {
|
|
256
|
+
const hits = [];
|
|
257
|
+
boxes.forEach((a, i) => boxes.slice(i + 1).forEach((b) => { if (meets(a, b)) hits.push([a.key, b.key]); }));
|
|
258
|
+
return hits;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
test('no overlap, ever: on a dense docket the collision pass lifts or quiets labels until no two shown labels meet, both modes', () => {
|
|
262
|
+
for (const mode of ['dark', 'light']) {
|
|
263
|
+
const m = model({ mode, docket: denseDocket(), goals: [1, 2, 3, 4].map((i) => goal(i)) });
|
|
264
|
+
const shown = S.shownLabels(m.pins);
|
|
265
|
+
assert.deepEqual(overlaps(shown), [], `${mode}: no two shown labels meet`);
|
|
266
|
+
assert.ok(shown.length >= 7, 'the labels that need you show');
|
|
267
|
+
assert.equal(new Set(m.pins.map((p) => p.building)).size, m.pins.length, 'one pin per building');
|
|
268
|
+
// The street's roofs stagger the pins' heights, so its own row rarely
|
|
269
|
+
// collides. The worst case is every pin at one height (a future roof, a
|
|
270
|
+
// new building): the pass must still keep every shown label clear, lifting
|
|
271
|
+
// some and quieting the lowest tier, never a higher one over a lower one.
|
|
272
|
+
const flat = m.pins.map((p) => ({ ...p, y: 300, h: 78, shown: false, lifted: false }));
|
|
273
|
+
const naive = flat.filter((p) => ['urgent', 'needs', 'queue'].includes(p.rank)).map((p) => ({ key: p.key, ...S.labelBox(p) }));
|
|
274
|
+
assert.ok(overlaps(naive).length > 0, 'at one height, without the pass, labels would meet');
|
|
275
|
+
S.placeLabels(flat);
|
|
276
|
+
assert.deepEqual(overlaps(S.shownLabels(flat)), [], `${mode}: worst case, still no two shown labels meet`);
|
|
277
|
+
assert.ok(flat.some((p) => p.lifted), 'a label stepped up to the alternate height');
|
|
278
|
+
const T = ['urgent', 'needs', 'queue'];
|
|
279
|
+
flat.filter((p) => T.includes(p.rank) && !p.shown).forEach((q) => {
|
|
280
|
+
const at = (y) => S.labelBox({ x: q.x, y, label: q.label });
|
|
281
|
+
const blockers = flat.filter((p) => p.shown && (meets(S.labelBox(p), at(300)) || meets(S.labelBox(p), at(300 - S.PIN_STACK))));
|
|
282
|
+
assert.ok(blockers.length && blockers.every((b) => T.indexOf(b.rank) <= T.indexOf(q.rank)), `${q.key} gave way only to an equal or higher tier`);
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test('the collision pass places in tier order: when only one of two can show, the lower tier waits for hover and keeps its count and colour', () => {
|
|
288
|
+
const pins = [
|
|
289
|
+
{ key: 'lo', rank: 'needs', state: 'needs', x: 100, y: 400, h: 78, label: 'aaaaaaaaaaaaaaaa', count: '2' },
|
|
290
|
+
{ key: 'hi', rank: 'urgent', state: 'urgent', x: 120, y: 400, h: 78, label: 'bbbbbbbbbbbbbbbb', count: '1' },
|
|
291
|
+
{ key: 'mid', rank: 'needs', state: 'needs', x: 110, y: 400, h: 78, label: 'cccccccccccccccc', count: '3' },
|
|
292
|
+
];
|
|
293
|
+
S.placeLabels(pins);
|
|
294
|
+
const by = Object.fromEntries(pins.map((p) => [p.key, p]));
|
|
295
|
+
assert.deepEqual([by.hi.shown, by.hi.lifted], [true, false], 'the urgent label keeps the pin\'s own height');
|
|
296
|
+
assert.deepEqual([by.lo.shown, by.lo.lifted, by.lo.y, by.lo.h], [true, true, 400 - S.PIN_STACK, 78 + S.PIN_STACK], 'the next steps up to the alternate height');
|
|
297
|
+
assert.equal(by.mid.shown, false, 'the third has nowhere to go: its label waits for hover');
|
|
298
|
+
assert.deepEqual([by.mid.state, by.mid.count], ['needs', '3'], 'its count and colour stay');
|
|
299
|
+
});
|
|
300
|
+
|
|
199
301
|
test('no docket: no pins, the readings say so, and nothing is invented', () => {
|
|
200
302
|
const m = S.streetModel({ docket: null, goals: [goal(1)], rollups: [], mode: 'dark', now: NOW });
|
|
201
303
|
assert.equal(m.pins.length, 0);
|
|
@@ -443,8 +545,9 @@ function fakeDom() {
|
|
|
443
545
|
return { document, byId, html, focused };
|
|
444
546
|
}
|
|
445
547
|
|
|
446
|
-
async function bootPage({ feeds = {}, stored = null, osDark = true, hash = '', shell = undefined } = {}) {
|
|
548
|
+
async function bootPage({ feeds = {}, stored = null, osDark = true, hash = '', shell = undefined, width = 1440 } = {}) {
|
|
447
549
|
const { document, byId, html, focused } = fakeDom();
|
|
550
|
+
byId.get('streetWindow').clientWidth = width;
|
|
448
551
|
const store = new Map(stored ? [['hall-theme', stored]] : []);
|
|
449
552
|
const calls = [];
|
|
450
553
|
const listeners = {};
|
|
@@ -468,6 +571,8 @@ async function bootPage({ feeds = {}, stored = null, osDark = true, hash = '', s
|
|
|
468
571
|
vm.runInContext(read('city-draw.js'), ctx);
|
|
469
572
|
vm.runInContext(read('street-draw.js'), ctx);
|
|
470
573
|
vm.runInContext(read('street-card.js'), ctx);
|
|
574
|
+
vm.runInContext(read('street-inside.js'), ctx);
|
|
575
|
+
vm.runInContext(read('street-desk.js'), ctx);
|
|
471
576
|
vm.runInContext(read('street.js'), ctx);
|
|
472
577
|
await window.GovStreetPage.ready;
|
|
473
578
|
const key = (t, e) => (listeners[t] || []).forEach((fn) => fn(e));
|
|
@@ -482,16 +587,23 @@ const FEEDS = (ms, goals = [goal(1)], rollups = [rollup(1, { done: 2, active: 1
|
|
|
482
587
|
});
|
|
483
588
|
|
|
484
589
|
test('the page reads the four feeds and draws the docket\'s pins, the readings and the header', async () => {
|
|
485
|
-
const
|
|
590
|
+
const soon = new Date(Date.now() + 3 * 3600000).toISOString();
|
|
591
|
+
const ms = [matter({ id: 'board:1', building: 'charter', state: 'needs', deadline: soon }), matter({ id: 'blockers:2', state: 'moving' }), matter({ id: 'blockers:3', state: 'moving' }), matter({ id: 'access_requests:4', building: 'people', state: 'needs' }), matter({ id: 'override_requests:5', building: 'release', state: 'settled' })];
|
|
486
592
|
const p = await bootPage({ feeds: FEEDS(ms) });
|
|
487
593
|
assert.deepEqual([...p.calls].sort(), ['/api/bongos/builders/95/earnings', '/api/bongos/goals', '/api/bongos/goals/rollups', '/api/bongos/government/docket']);
|
|
488
594
|
const pins = p.byId.get('streetPins').children;
|
|
489
|
-
assert.deepEqual(pins.map((s) => s.className).sort(), ['street-pin street-pin--moving', 'street-pin street-pin--needs']);
|
|
490
|
-
|
|
595
|
+
assert.deepEqual(pins.map((s) => s.className).sort(), ['street-pin street-pin--moving', 'street-pin street-pin--needs street-pin--shown', 'street-pin street-pin--settled', 'street-pin street-pin--urgent street-pin--shown']);
|
|
596
|
+
const pin = (k) => pins.find((s) => s.attrs['data-matter'] === k);
|
|
597
|
+
assert.equal(pin('board:1').children[1].children[0].className, 'street-pin__ring', 'only the urgent pin pulses');
|
|
598
|
+
assert.ok(!pin('access_requests:4').children[1].children.some((c) => c.className === 'street-pin__ring'), 'a needs-you pin is solid orange, no ring');
|
|
599
|
+
assert.equal(pin('blockers:2').attrs['data-matters'], 'blockers:2 blockers:3');
|
|
600
|
+
assert.deepEqual(pin('blockers:2').children[1].children.map((c) => c.className), ['', 'street-pin__badge'], 'the glyph, then the count of open matters');
|
|
601
|
+
assert.equal(pin('blockers:2').children[1].children[1].textContent, '2');
|
|
602
|
+
assert.equal(pin('override_requests:5').children[1].children.length, 0, 'a settled pin is a plain dot');
|
|
491
603
|
assert.equal(p.byId.get('streetSigns').children.length, 6);
|
|
492
604
|
assert.ok(p.byId.get('streetSigns').children.every((a) => /^\/builders\/street#building=/.test(a.attrs.href)));
|
|
493
|
-
assert.equal(p.byId.get('hudNeeds').textContent, '
|
|
494
|
-
assert.equal(p.byId.get('hudMoving').textContent, '
|
|
605
|
+
assert.equal(p.byId.get('hudNeeds').textContent, '2');
|
|
606
|
+
assert.equal(p.byId.get('hudMoving').textContent, '2');
|
|
495
607
|
assert.equal(p.byId.get('hudShare').textContent, '$24');
|
|
496
608
|
assert.equal(p.byId.get('hudDot').className, 'street-hud__dot street-hud__dot--hot');
|
|
497
609
|
assert.equal(p.byId.get('streetDoors').children.length, 10, 'nine buildings and one district, each a door');
|
|
@@ -515,6 +627,67 @@ test('a pin opens its short card, Escape closes it and focus goes back to the pi
|
|
|
515
627
|
assert.equal(p.focused.el.attrs['data-matter'], 'board:1');
|
|
516
628
|
});
|
|
517
629
|
|
|
630
|
+
test('the card pager: a pin with several matters opens the most important with "1 of N", and previous/next reach every matter there', async () => {
|
|
631
|
+
const ms = [matter({ id: 'blockers:1', state: 'moving', title: 'Third' }), matter({ id: 'blockers:2', state: 'needs', title: 'First' }), matter({ id: 'blockers:3', state: 'needs', title: 'Second' }), matter({ id: 'blockers:4', state: 'settled', title: 'Fourth' })];
|
|
632
|
+
const p = await bootPage({ feeds: FEEDS(ms) });
|
|
633
|
+
const pin = p.byId.get('streetPins').children.find((s) => s.attrs['data-building'] === 'planning');
|
|
634
|
+
pin.fire('click');
|
|
635
|
+
const body = p.byId.get('streetCardBody');
|
|
636
|
+
const pager = () => body.children[0];
|
|
637
|
+
const btn = (dir) => pager().children.find((c) => c.attrs['data-act'] === 'page-' + dir);
|
|
638
|
+
assert.equal(p.byId.get('streetCardTitle').textContent, 'First', 'the most important matter first');
|
|
639
|
+
assert.equal(pager().className, 'mcard-pager');
|
|
640
|
+
assert.equal(pager().children[1].textContent, '1 of 4');
|
|
641
|
+
const seen = [p.byId.get('streetCardTitle').textContent];
|
|
642
|
+
for (let i = 0; i < 3; i++) { btn('next').fire('click'); seen.push(p.byId.get('streetCardTitle').textContent); }
|
|
643
|
+
assert.deepEqual(seen, ['First', 'Second', 'Third', 'Fourth'], 'next reaches every matter at the building');
|
|
644
|
+
assert.equal(pager().children[1].textContent, '4 of 4');
|
|
645
|
+
assert.equal(p.focused.el && p.focused.el.attrs['data-act'], 'page-next', 'focus stays on the arrow');
|
|
646
|
+
btn('next').fire('click');
|
|
647
|
+
assert.equal(p.byId.get('streetCardTitle').textContent, 'First', 'and wraps');
|
|
648
|
+
btn('prev').fire('click');
|
|
649
|
+
assert.equal(p.byId.get('streetCardTitle').textContent, 'Fourth', 'previous goes back, wrapping too');
|
|
650
|
+
assert.equal(p.byId.get('streetCard').hidden, false, 'all without leaving the street');
|
|
651
|
+
assert.equal(p.byId.get('streetPins').children.find((s) => s.attrs['data-building'] === 'planning').attrs['aria-expanded'], 'true', 'the pin still says its card is open');
|
|
652
|
+
p.key('keydown', { key: 'Escape', preventDefault() {} });
|
|
653
|
+
assert.equal(p.focused.el.attrs['data-building'], 'planning', 'Escape returns focus to the pin');
|
|
654
|
+
// one matter: no pager
|
|
655
|
+
const q = await bootPage({ feeds: FEEDS([matter({ id: 'board:1', building: 'charter' })]) });
|
|
656
|
+
q.byId.get('streetPins').children[0].fire('click');
|
|
657
|
+
assert.ok(!q.byId.get('streetCardBody').children.some((c) => c.className === 'mcard-pager'), 'one matter, no pager');
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
test('no two shown labels meet on a dense docket at 1440, 1024, 390 and 320 wide, as the page draws them', async () => {
|
|
661
|
+
for (const width of [1440, 1024, 390, 320]) {
|
|
662
|
+
const p = await bootPage({ feeds: { ...FEEDS([]), '/api/bongos/government/docket': denseDocket(), '/api/bongos/goals': { goals: [1, 2, 3, 4].map((i) => goal(i)) } }, width });
|
|
663
|
+
const s = Number(p.byId.get('streetBoard').style['--street-scale']);
|
|
664
|
+
assert.ok(s > 0, `${width}: the board is scaled`);
|
|
665
|
+
const pins = p.byId.get('streetPins').children;
|
|
666
|
+
assert.equal(pins.length, 9, `${width}: one pin per civic building`);
|
|
667
|
+
const shown = pins.filter((el) => /street-pin--shown/.test(el.className)).map((el) => {
|
|
668
|
+
const b = S.labelBox({ x: parseFloat(el.style.left), y: parseFloat(el.style.top), label: el.children[0].textContent });
|
|
669
|
+
return { key: el.attrs['data-matter'], x0: b.x0 * s, x1: b.x1 * s, y0: b.y0 * s, y1: b.y1 * s };
|
|
670
|
+
});
|
|
671
|
+
assert.ok(shown.length >= 5, `${width}: labels show`);
|
|
672
|
+
assert.deepEqual(overlaps(shown), [], `${width}: no two shown labels meet on screen`);
|
|
673
|
+
}
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
test('the header counts wear the pins\' colours, so they double as the legend', () => {
|
|
677
|
+
const html = read('street.html');
|
|
678
|
+
assert.match(html, /<span class="street-hud__dot" id="hudDot" aria-hidden="true"><\/span>\s*<span class="street-label">needs you<\/span>/);
|
|
679
|
+
assert.match(html, /<span class="street-hud__dot street-hud__dot--moving" aria-hidden="true"><\/span>\s*<span class="street-label">moving<\/span>/);
|
|
680
|
+
const css = read('street.css').replace(/\/\*[\s\S]*?\*\//g, '');
|
|
681
|
+
assert.match(css, /\.street-hud__dot \{[^}]*border: 1\.5px solid var\(--accent\)/, 'needs you is orange, a ring while nothing waits');
|
|
682
|
+
assert.match(css, /\.street-hud__dot--hot \{ background: var\(--accent\); \}/);
|
|
683
|
+
assert.match(css, /\.street-hud__dot--moving \{[^}]*background: var\(--ink\)/, 'moving is the moving pin\'s ink');
|
|
684
|
+
assert.match(css, /\.street-pin--moving \.street-pin__head \{[^}]*background: var\(--ink\)/);
|
|
685
|
+
assert.match(css, /\.street-pin--urgent \.street-pin__head, \.street-pin--needs \.street-pin__head \{ background: var\(--accent\)/);
|
|
686
|
+
// size carries the tier too, so it reads without colour
|
|
687
|
+
const size = (t) => Number((css.match(new RegExp(`\\.street-pin--${t} \\.street-pin__head \\{ width: (\\d+)px`)) || [])[1] || 28);
|
|
688
|
+
assert.ok(size('urgent') > size('needs') && size('needs') > size('moving') && size('moving') > size('settled'), 'urgent > needs > moving > settled');
|
|
689
|
+
});
|
|
690
|
+
|
|
518
691
|
test('the switch reads and writes the hall\'s own theme preference and redraws the street', async () => {
|
|
519
692
|
const p = await bootPage({ feeds: FEEDS([]), stored: 'light', osDark: true });
|
|
520
693
|
assert.equal(p.html.attrs['data-theme'], 'light', 'the stored hall choice beats the OS');
|
|
@@ -635,7 +635,7 @@ try {
|
|
|
635
635
|
assert.match(HTML, /<body data-page="tweak-editor"[^>]*data-fullscreen-switch/);
|
|
636
636
|
assert.equal((HTML.match(/<h1\b/g) || []).length, 1);
|
|
637
637
|
assert.ok(HTML.indexOf('tweak-editor-lib.js') < HTML.indexOf('tweak-editor.js?'), 'the lib loads first');
|
|
638
|
-
assert.match(HTML, /shell\.js\?v=2026-10-04-
|
|
638
|
+
assert.match(HTML, /shell\.js\?v=2026-10-04-paintbrush/);
|
|
639
639
|
assert.equal(/fonts\.googleapis\.com\/css/.test(HTML), false);
|
|
640
640
|
for (const id of ['tw-bar', 'tw-counter', 'tw-open', 'tw-open-more', 'tw-divider', 'tw-openwrap', 'tw-open-menu', 'tw-open-tab', 'tw-open-split', 'tw-frame', 'tw-frame-view', 'tw-split-close', 'tw-download', 'tw-upload', 'tw-file', 'tw-submit', 'tw-paper', 'tw-doc', 'tw-notice', 'tw-done', 'tw-take', 'tw-pick', 'tw-picks', 'tw-refused', 'tw-refused-title', 'tw-refused-list', 'tw-refused-next', 'tw-refused-download', 'tw-refused-close']) {
|
|
641
641
|
assert.ok(new RegExp(`id="${id}"`).test(HTML), `#${id} is in the markup`);
|
|
@@ -76,8 +76,9 @@ const flush = () => new Promise((r) => setTimeout(r, 0));
|
|
|
76
76
|
|
|
77
77
|
test('THE PALETTE: in the top bar in Artistry alone, right before the switch, and gone when the mode changes', () => {
|
|
78
78
|
const p = boot({ session: { 'hall-craft': 'artistry' } });
|
|
79
|
-
assert.deepEqual(p.tools().map((t) => t.id), ['craft-tool-palette']);
|
|
80
|
-
assert.equal(p.tools()[0].nextSibling, p.
|
|
79
|
+
assert.deepEqual(p.tools().map((t) => t.id), ['craft-tool-palette', 'craft-tool-paintbrush'], 'the palette, then the paintbrush (task 1004497)');
|
|
80
|
+
assert.equal(p.tools()[0].nextSibling, p.tools()[1], 'the palette comes first');
|
|
81
|
+
assert.equal(p.tools()[1].nextSibling, p.sw, 'and the pair sits right before the mode switch');
|
|
81
82
|
assert.equal(p.tools()[0].getAttribute('aria-label'), 'Write feedback on this page');
|
|
82
83
|
for (const m of ['building', 'ideating', 'governing']) {
|
|
83
84
|
p.craft.choose(m);
|
|
@@ -85,7 +86,7 @@ test('THE PALETTE: in the top bar in Artistry alone, right before the switch, an
|
|
|
85
86
|
}
|
|
86
87
|
p.craft.choose('artistry');
|
|
87
88
|
p.craft.choose('artistry');
|
|
88
|
-
assert.equal(p.tools().length,
|
|
89
|
+
assert.equal(p.tools().length, 2, 'choosing Artistry twice draws one palette and one paintbrush');
|
|
89
90
|
assert.equal(boot().tools().length, 0, 'and none with no mode at all');
|
|
90
91
|
});
|
|
91
92
|
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
// tests/paintbrush.mjs — the Artistry paintbrush (task 1004497): a page's own
|
|
2
|
+
// words, edited where they stand, as a proposed wording change.
|
|
3
|
+
//
|
|
4
|
+
// Runs the REAL paintbrush.js, with the REAL tweak-editor-lib.js and
|
|
5
|
+
// studio-lib.js it shares with the editor and the studio, over a small page
|
|
6
|
+
// (there is no jsdom here; the fake-DOM idiom of tests/page_notes_panel.mjs).
|
|
7
|
+
// The task's done-when, as cases: an artist toggles editing, changes the words
|
|
8
|
+
// a page shows, reviews the changes and sends them as one proposal; nobody
|
|
9
|
+
// else's view changes — the page's words come back when editing ends, and the
|
|
10
|
+
// only writes are the copy desk's own page-round routes (owner, 2026-10-04: the
|
|
11
|
+
// edits are real wording changes, kept apart from the palette's feedback docs).
|
|
12
|
+
import assert from 'node:assert/strict';
|
|
13
|
+
import { test } from 'node:test';
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import vm from 'node:vm';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
|
|
19
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
20
|
+
const HALL = path.join(ROOT, 'modules/hall-ui/public');
|
|
21
|
+
const read = (f) => fs.readFileSync(path.join(HALL, f), 'utf8');
|
|
22
|
+
const BRUSH = read('paintbrush.js');
|
|
23
|
+
const EDITOR_LIB = read('tweak-editor-lib.js');
|
|
24
|
+
const STUDIO_LIB = read('studio-lib.js');
|
|
25
|
+
const CRAFT = read('craft-mode.js');
|
|
26
|
+
const CSS = read('style.css');
|
|
27
|
+
|
|
28
|
+
// ---------- a small DOM: enough for the selectors paintbrush.js asks ----------
|
|
29
|
+
function matchesOne(el, sel) {
|
|
30
|
+
sel = sel.trim();
|
|
31
|
+
const m = /^([a-z]+)?(?:#([\w-]+))?(?:\.([\w-]+))?(?:\[([\w-]+)(?:="([^"]*)")?\])?$/i.exec(sel);
|
|
32
|
+
if (!m) throw new Error('fake DOM cannot match ' + sel);
|
|
33
|
+
const [, tag, id, cls, attr, val] = m;
|
|
34
|
+
if (tag && el.tagName !== tag.toUpperCase()) return false;
|
|
35
|
+
if (id && el.id !== id) return false;
|
|
36
|
+
if (cls && !el.classList.contains(cls)) return false;
|
|
37
|
+
if (attr) {
|
|
38
|
+
if (attr === 'hidden') return !!el.hidden;
|
|
39
|
+
if (!el.hasAttribute(attr)) return false;
|
|
40
|
+
if (val !== undefined && el.getAttribute(attr) !== val) return false;
|
|
41
|
+
}
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
const matches = (el, sel) => sel.split(',').some((s) => matchesOne(el, s));
|
|
45
|
+
|
|
46
|
+
class El {
|
|
47
|
+
constructor(tag, text) {
|
|
48
|
+
this.tagName = tag.toUpperCase();
|
|
49
|
+
this.attrs = {};
|
|
50
|
+
this.children = [];
|
|
51
|
+
this.parentNode = null;
|
|
52
|
+
this.id = '';
|
|
53
|
+
this.hidden = false;
|
|
54
|
+
this.dataset = {};
|
|
55
|
+
this.listeners = {};
|
|
56
|
+
this._text = text == null ? '' : String(text);
|
|
57
|
+
this._html = '';
|
|
58
|
+
const cls = new Set();
|
|
59
|
+
this.classList = {
|
|
60
|
+
add: (...c) => c.forEach((x) => cls.add(x)), remove: (...c) => c.forEach((x) => cls.delete(x)),
|
|
61
|
+
contains: (c) => cls.has(c), toggle: (c, on) => { if (on === undefined ? !cls.has(c) : on) cls.add(c); else cls.delete(c); },
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
add(child) { child.parentNode = this; this.children.push(child); return child; }
|
|
65
|
+
appendChild(c) { return this.add(c); }
|
|
66
|
+
removeChild(c) { this.children.splice(this.children.indexOf(c), 1); c.parentNode = null; return c; }
|
|
67
|
+
get childElementCount() { return this.children.length; }
|
|
68
|
+
get textContent() { return this.children.length ? this.children.map((c) => c.textContent).join('') : this._text; }
|
|
69
|
+
set textContent(v) { this.children = []; this._text = String(v); }
|
|
70
|
+
get innerHTML() { return this._html; }
|
|
71
|
+
set innerHTML(v) { this._html = String(v); }
|
|
72
|
+
set className(v) { String(v).split(/\s+/).filter(Boolean).forEach((c) => this.classList.add(c)); }
|
|
73
|
+
setAttribute(k, v) { this.attrs[k] = String(v); if (k === 'contenteditable') this.contentEditable = String(v); }
|
|
74
|
+
getAttribute(k) { return k in this.attrs ? this.attrs[k] : null; }
|
|
75
|
+
hasAttribute(k) { return k in this.attrs; }
|
|
76
|
+
removeAttribute(k) { delete this.attrs[k]; if (k === 'contenteditable') this.contentEditable = 'inherit'; }
|
|
77
|
+
closest(sel) { for (let n = this; n; n = n.parentNode) if (n.tagName && matches(n, sel)) return n; return null; }
|
|
78
|
+
querySelectorAll(sel) {
|
|
79
|
+
const out = [];
|
|
80
|
+
const walk = (n) => n.children.forEach((c) => { if (sel === '*' || matches(c, sel)) out.push(c); walk(c); });
|
|
81
|
+
walk(this);
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
querySelector(sel) { return this.querySelectorAll(sel)[0] || null; }
|
|
85
|
+
addEventListener(t, fn) { (this.listeners[t] ||= []).push(fn); }
|
|
86
|
+
focus() { this.ownerDocument.activeElement = this; }
|
|
87
|
+
blur() {}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// A page with words the reading lists, and words it must leave alone.
|
|
91
|
+
function page() {
|
|
92
|
+
const body = new El('body');
|
|
93
|
+
body.dataset.page = 'roadmap';
|
|
94
|
+
body.dataset.pageTitle = 'Roadmap';
|
|
95
|
+
const main = body.add(new El('main'));
|
|
96
|
+
const h1 = main.add(new El('h1', 'Where the build is going'));
|
|
97
|
+
const lede = main.add(new El('p', 'Every version, from first idea to shipped.'));
|
|
98
|
+
const link = main.add(new El('a'));
|
|
99
|
+
link.setAttribute('href', '/goals');
|
|
100
|
+
const linkWords = link.add(new El('span', 'See every goal'));
|
|
101
|
+
const count = main.add(new El('p', '12 versions shipped')); // a {…} hole line: the editor's
|
|
102
|
+
const btn = main.add(new El('button', 'Star this version')); // a control: never editable
|
|
103
|
+
const stray = main.add(new El('p', 'Words the reading has never seen'));
|
|
104
|
+
const hiddenBox = main.add(new El('div'));
|
|
105
|
+
hiddenBox.hidden = true;
|
|
106
|
+
const hiddenWords = hiddenBox.add(new El('p', 'Nothing in flight right now.'));
|
|
107
|
+
const footer = body.add(new El('footer', 'Skip to content')); // outside main
|
|
108
|
+
return { body, main, h1, lede, link, linkWords, count, btn, stray, hiddenWords, footer };
|
|
109
|
+
}
|
|
110
|
+
const READING = {
|
|
111
|
+
reading_hash: 'abc123',
|
|
112
|
+
read: true,
|
|
113
|
+
lines: [
|
|
114
|
+
{ key: 'L0001', section: 'skip link', text: 'Skip to content', placement: 'shared' },
|
|
115
|
+
{ key: 'L0002', section: 'page top', text: 'Where the build is going', placement: 'placed' },
|
|
116
|
+
{ key: 'L0003', section: 'page top', text: 'Every version, from first idea to shipped.', placement: 'placed' },
|
|
117
|
+
{ key: 'L0004', section: 'versions', text: 'See every goal', placement: 'placed' },
|
|
118
|
+
{ key: 'L0005', section: 'versions', text: '{…} versions shipped', placement: 'placed' },
|
|
119
|
+
{ key: 'L0006', section: 'versions', text: 'Star this version', placement: 'placed' },
|
|
120
|
+
{ key: 'L0007', section: 'versions', text: 'Nothing in flight right now.', placement: 'unplaced' },
|
|
121
|
+
],
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
function boot({ round = null, draft = null, claim = { status: 200, body: { ok: true } }, submit = { status: 200, body: { ok: true, outcome: 'submitted' } } } = {}) {
|
|
125
|
+
const p = page();
|
|
126
|
+
const head = new El('head');
|
|
127
|
+
const calls = [];
|
|
128
|
+
const docListeners = {};
|
|
129
|
+
const document = {
|
|
130
|
+
body: p.body, head, documentElement: new El('html'), activeElement: null,
|
|
131
|
+
currentScript: { src: 'https://h/builders/paintbrush.js?v=2026-10-04-test' },
|
|
132
|
+
querySelector: (sel) => p.body.querySelector(sel),
|
|
133
|
+
getElementById: (id) => p.body.querySelectorAll('*').find((n) => n.id === id) || null,
|
|
134
|
+
createElement: (t) => { const e = new El(t); e.ownerDocument = document; return e; },
|
|
135
|
+
addEventListener: (t, fn) => { (docListeners[t] ||= []).push(fn); },
|
|
136
|
+
removeEventListener: (t, fn) => { docListeners[t] = (docListeners[t] || []).filter((f) => f !== fn); },
|
|
137
|
+
execCommand() { return true; },
|
|
138
|
+
title: 'Roadmap — Builders hall',
|
|
139
|
+
};
|
|
140
|
+
for (const n of [p.body, ...p.body.querySelectorAll('*')]) n.ownerDocument = document;
|
|
141
|
+
let state = { round, draft };
|
|
142
|
+
const respond = (status, body) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(body) });
|
|
143
|
+
const fetch = (url, opts = {}) => {
|
|
144
|
+
const method = opts.method || 'GET';
|
|
145
|
+
calls.push({ method, url, body: opts.body ? JSON.parse(opts.body) : undefined, keepalive: !!opts.keepalive });
|
|
146
|
+
if (!url.startsWith('/api/bongos/copy-desk/pages/builders%3Aroadmap/')) return respond(404, {});
|
|
147
|
+
const rest = url.slice('/api/bongos/copy-desk/pages/builders%3Aroadmap/'.length);
|
|
148
|
+
if (method === 'GET' && rest === 'draft') return respond(200, { ok: true, page: { id: 'builders:roadmap', title: 'Roadmap' }, reading: READING, round: state.round, draft: state.draft });
|
|
149
|
+
if (method === 'POST' && rest === 'claim') {
|
|
150
|
+
if (claim.status < 400) state = { round: { task_id: '9', state: 'writing', held_by_you: true, holder: { login: 'me' } }, draft: null };
|
|
151
|
+
return respond(claim.status, claim.body);
|
|
152
|
+
}
|
|
153
|
+
if (method === 'PUT' && rest === 'draft') return respond(200, { ok: true, outcome: 'saved' });
|
|
154
|
+
if (method === 'POST' && rest === 'submit') return respond(submit.status, submit.body);
|
|
155
|
+
return respond(404, {});
|
|
156
|
+
};
|
|
157
|
+
const winListeners = {};
|
|
158
|
+
const window = { location: { pathname: '/roadmap' }, addEventListener: (t, fn) => { (winListeners[t] ||= []).push(fn); }, removeEventListener: (t, fn) => { winListeners[t] = (winListeners[t] || []).filter((f) => f !== fn); } };
|
|
159
|
+
const sandbox = { window, document, fetch, Promise, setTimeout, clearTimeout, JSON, Math, String, Array, Object };
|
|
160
|
+
window.window = window;
|
|
161
|
+
vm.createContext(sandbox);
|
|
162
|
+
// The two shared libs, as the page would have them once the tool loads them.
|
|
163
|
+
vm.runInContext(EDITOR_LIB, sandbox, { filename: 'tweak-editor-lib.js' });
|
|
164
|
+
vm.runInContext(STUDIO_LIB, sandbox, { filename: 'studio-lib.js' });
|
|
165
|
+
vm.runInContext(BRUSH, sandbox, { filename: 'paintbrush.js' });
|
|
166
|
+
const bar = () => p.body.querySelector('#paintbrush');
|
|
167
|
+
const fire = (type, ev) => (docListeners[type] || []).slice().forEach((fn) => fn(ev));
|
|
168
|
+
// Press a bar button by its data attribute.
|
|
169
|
+
const press = (attr) => {
|
|
170
|
+
const html = bar().innerHTML;
|
|
171
|
+
assert.match(html, new RegExp(attr), `the bar offers [${attr}]`);
|
|
172
|
+
const btn = new El('button');
|
|
173
|
+
btn.setAttribute(attr, '');
|
|
174
|
+
btn.parentNode = bar();
|
|
175
|
+
bar().listeners.click.forEach((fn) => fn({ target: btn }));
|
|
176
|
+
};
|
|
177
|
+
const type = (el, text) => { el.textContent = text; fire('input', { target: el }); };
|
|
178
|
+
const editable = () => p.body.querySelectorAll('*').filter((n) => n.hasAttribute('contenteditable'));
|
|
179
|
+
return { ...p, leave: () => (winListeners.pagehide || []).forEach((fn) => fn()), brush: window.OTBPaintbrush, calls, bar, press, type, fire, editable, html: document.documentElement, writes: () => calls.filter((c) => c.method !== 'GET') };
|
|
180
|
+
}
|
|
181
|
+
const settle = () => new Promise((r) => setTimeout(r, 5));
|
|
182
|
+
|
|
183
|
+
test('craft-mode.js puts the paintbrush beside the palette in Artistry, loading its own script', () => {
|
|
184
|
+
assert.match(CRAFT, /\{ id: 'paintbrush', icon: 'brush', short: 'Edit words', label: 'Edit this page\\'s words', script: 'paintbrush.js', api: 'OTBPaintbrush' \}/);
|
|
185
|
+
const tools = /var TOOLS = \{([\s\S]*?)\n \};/.exec(CRAFT)[1];
|
|
186
|
+
assert.match(tools, /^\s*artistry: \[/m, 'it is an Artistry tool');
|
|
187
|
+
assert.doesNotMatch(tools, /building:|ideating:|governing:/, 'and no other mode has one');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('pressing it on a page nobody holds OFFERS the page — nothing is editable and nothing is written until the artist takes it', async () => {
|
|
191
|
+
const p = boot();
|
|
192
|
+
p.brush.open();
|
|
193
|
+
await settle();
|
|
194
|
+
assert.match(p.bar().innerHTML, /Take this page/);
|
|
195
|
+
assert.match(p.bar().innerHTML, /Nobody else sees them until then/);
|
|
196
|
+
assert.equal(p.editable().length, 0);
|
|
197
|
+
assert.deepEqual(p.writes(), [], 'reading the page writes nothing');
|
|
198
|
+
assert.equal(p.calls[0].url, '/api/bongos/copy-desk/pages/builders%3Aroadmap/draft', 'it reads this page by its reading id');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('taking the page makes the words the reading lists editable where they stand — not shared chrome, holes, controls, hidden or unknown words', async () => {
|
|
202
|
+
const p = boot();
|
|
203
|
+
p.brush.open();
|
|
204
|
+
await settle();
|
|
205
|
+
p.press('data-pb-take');
|
|
206
|
+
await settle();
|
|
207
|
+
assert.deepEqual(p.writes().map((c) => c.method + ' ' + c.url.split('/').pop()), ['POST claim'], 'one claim, through the copy desk');
|
|
208
|
+
assert.deepEqual(p.editable().map((n) => n.textContent), ['Where the build is going', 'Every version, from first idea to shipped.', 'See every goal']);
|
|
209
|
+
for (const n of [p.count, p.btn, p.stray, p.hiddenWords, p.footer]) assert.ok(!n.hasAttribute('contenteditable'), `"${n.textContent}" stays as it is`);
|
|
210
|
+
assert.equal(p.h1.getAttribute('data-pb-key'), 'L0002');
|
|
211
|
+
assert.ok(p.html.classList.contains('pb-editing'));
|
|
212
|
+
assert.match(p.bar().innerHTML, /0 changes/);
|
|
213
|
+
assert.match(p.bar().innerHTML, /tweak editor/, 'the lines it cannot reach point to the editor');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('a refused claim says why, in the artist\'s words, and leaves the page alone', async () => {
|
|
217
|
+
const p = boot({ claim: { status: 403, body: { ok: false, error: { code: 'not_an_artist', message: 'no' } } } });
|
|
218
|
+
p.brush.open();
|
|
219
|
+
await settle();
|
|
220
|
+
p.press('data-pb-take');
|
|
221
|
+
await settle();
|
|
222
|
+
assert.match(p.bar().innerHTML, /Only an artist can take a page/);
|
|
223
|
+
assert.equal(p.editable().length, 0);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test('a page another artist holds is not offered, and one already on its way is not either', async () => {
|
|
227
|
+
const held = boot({ round: { task_id: '9', state: 'writing', held_by_you: false, holder: { login: 'mira' } } });
|
|
228
|
+
held.brush.open();
|
|
229
|
+
await settle();
|
|
230
|
+
assert.match(held.bar().innerHTML, /mira is tweaking this page right now/);
|
|
231
|
+
assert.doesNotMatch(held.bar().innerHTML, /Take this page/);
|
|
232
|
+
const flight = boot({ round: { task_id: '9', state: 'submitted', held_by_you: false, holder: null } });
|
|
233
|
+
flight.brush.open();
|
|
234
|
+
await settle();
|
|
235
|
+
assert.match(flight.bar().innerHTML, /on its way to the site/);
|
|
236
|
+
assert.deepEqual([...held.writes(), ...flight.writes()], []);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test('typing saves the WHOLE draft to the page round, and a click on words inside a link does not follow it', async () => {
|
|
240
|
+
const p = boot({ round: { task_id: '9', state: 'writing', held_by_you: true, holder: { login: 'me' } } });
|
|
241
|
+
p.brush.open();
|
|
242
|
+
await settle();
|
|
243
|
+
assert.equal(p.editable().length, 3, 'a page you already hold opens straight into editing');
|
|
244
|
+
p.type(p.lede, 'Every version, from the first spark to shipped.');
|
|
245
|
+
p.fire('focusout', { target: p.lede });
|
|
246
|
+
await settle();
|
|
247
|
+
const put = p.writes().find((c) => c.method === 'PUT');
|
|
248
|
+
assert.ok(put, 'a save went out');
|
|
249
|
+
assert.match(put.url, /builders%3Aroadmap\/draft$/);
|
|
250
|
+
assert.deepEqual(JSON.parse(JSON.stringify(put.body)), { reading_hash: 'abc123', lines: [{ key: 'L0003', after: 'Every version, from the first spark to shipped.' }] });
|
|
251
|
+
assert.ok(p.lede.classList.contains('pb-word--changed'));
|
|
252
|
+
let prevented = false;
|
|
253
|
+
p.fire('click', { target: p.linkWords, preventDefault() { prevented = true; } });
|
|
254
|
+
assert.ok(prevented, 'the link around the words does not navigate while editing');
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test('REVIEW lists every change as was/now, and SEND submits the page once and puts its own words back', async () => {
|
|
258
|
+
const p = boot({ round: { task_id: '9', state: 'writing', held_by_you: true, holder: { login: 'me' } } });
|
|
259
|
+
p.brush.open();
|
|
260
|
+
await settle();
|
|
261
|
+
p.type(p.h1, 'Where we are going');
|
|
262
|
+
p.type(p.linkWords, 'Every goal <b>here</b>');
|
|
263
|
+
p.press('data-pb-review');
|
|
264
|
+
await settle();
|
|
265
|
+
const html = p.bar().innerHTML;
|
|
266
|
+
assert.match(html, /Was<\/span> Where the build is going/);
|
|
267
|
+
assert.match(html, /Now<\/span> Where we are going/);
|
|
268
|
+
assert.match(html, /Every goal <b>here<\/b>/, 'what the artist typed is escaped in the review');
|
|
269
|
+
p.press('data-pb-send');
|
|
270
|
+
await settle();
|
|
271
|
+
const submits = p.writes().filter((c) => c.method === 'POST' && c.url.endsWith('/submit'));
|
|
272
|
+
assert.equal(submits.length, 1, 'one proposal for the page');
|
|
273
|
+
const lastPut = p.writes().filter((c) => c.method === 'PUT').pop();
|
|
274
|
+
assert.deepEqual(JSON.parse(JSON.stringify(lastPut.body.lines)), [{ key: 'L0002', after: 'Where we are going' }, { key: 'L0004', after: 'Every goal <b>here</b>' }], 'the saved draft held both changes before the submit');
|
|
275
|
+
assert.match(p.bar().innerHTML, /Sent for approval: 2 changes/);
|
|
276
|
+
assert.equal(p.h1.textContent, 'Where the build is going', 'the page shows its own words again');
|
|
277
|
+
assert.equal(p.linkWords.textContent, 'See every goal');
|
|
278
|
+
assert.equal(p.editable().length, 0);
|
|
279
|
+
assert.ok(!p.html.classList.contains('pb-editing'));
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test('STOP EDITING keeps the draft saved and puts the page\'s words back', async () => {
|
|
283
|
+
const p = boot({ round: { task_id: '9', state: 'writing', held_by_you: true, holder: { login: 'me' } } });
|
|
284
|
+
p.brush.open();
|
|
285
|
+
await settle();
|
|
286
|
+
p.type(p.h1, 'A new heading');
|
|
287
|
+
p.press('data-pb-stop');
|
|
288
|
+
await settle();
|
|
289
|
+
assert.ok(p.writes().some((c) => c.method === 'PUT'), 'the change was saved first');
|
|
290
|
+
assert.ok(!p.writes().some((c) => c.url.endsWith('/submit')), 'stopping sends nothing');
|
|
291
|
+
assert.equal(p.h1.textContent, 'Where the build is going');
|
|
292
|
+
assert.equal(p.editable().length, 0);
|
|
293
|
+
assert.equal(p.bar(), null, 'the bar is gone');
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test('a draft saved earlier comes back: the words show the artist\'s rewrite when editing resumes', async () => {
|
|
297
|
+
const p = boot({
|
|
298
|
+
round: { task_id: '9', state: 'writing', held_by_you: true, holder: { login: 'me' } },
|
|
299
|
+
draft: { reading_hash: 'abc123', lines: [{ key: 'L0002', after: 'Where it all goes' }] },
|
|
300
|
+
});
|
|
301
|
+
p.brush.open();
|
|
302
|
+
await settle();
|
|
303
|
+
assert.equal(p.h1.textContent, 'Where it all goes');
|
|
304
|
+
assert.ok(p.h1.classList.contains('pb-word--changed'));
|
|
305
|
+
assert.match(p.bar().innerHTML, /1 change/);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test('the only routes it writes are the copy desk\'s page round — never page-notes, never anything else', () => {
|
|
309
|
+
const routes = [...BRUSH.matchAll(/request\('(POST|PUT|PATCH|DELETE)', [^,]*?'\/([a-z-]+)'/g)].map((m) => m[1] + ' ' + m[2]);
|
|
310
|
+
assert.deepEqual(routes.sort(), ['POST claim', 'POST submit', 'PUT draft']);
|
|
311
|
+
assert.match(BRUSH, /var API = '\/api\/bongos\/copy-desk\/pages\/';/);
|
|
312
|
+
assert.doesNotMatch(BRUSH, /page-notes/);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test('its styles live in style.css, on the token tier', () => {
|
|
316
|
+
for (const sel of ['.pb {', '.pb__btn--go', '.pb-word {', '.pb-word--changed', '.pb__changes']) assert.ok(CSS.includes(sel), `style.css styles ${sel}`);
|
|
317
|
+
const block = CSS.slice(CSS.indexOf('/* THE PAINTBRUSH'), CSS.indexOf('/* THE BACK LINK'));
|
|
318
|
+
assert.doesNotMatch(block, /#[0-9a-f]{3,8}\b|rgba?\(/i, 'no colour literals: tokens only');
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test('leaving the page mid-edit sends the last save with keepalive, so closing the tab does not lose it', async () => {
|
|
322
|
+
const p = boot({ round: { task_id: '9', state: 'writing', held_by_you: true, holder: { login: 'me' } } });
|
|
323
|
+
p.brush.open();
|
|
324
|
+
await settle();
|
|
325
|
+
p.type(p.h1, 'Typed, then the tab closed');
|
|
326
|
+
p.leave();
|
|
327
|
+
await settle();
|
|
328
|
+
const put = p.writes().find((c) => c.method === 'PUT');
|
|
329
|
+
assert.ok(put && put.keepalive, 'the save on the way out is a keepalive request');
|
|
330
|
+
assert.equal(put.body.lines[0].after, 'Typed, then the tab closed');
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test('words only: bold, italic and underline keys do nothing in the words; elsewhere they are left alone', async () => {
|
|
334
|
+
const p = boot({ round: { task_id: '9', state: 'writing', held_by_you: true, holder: { login: 'me' } } });
|
|
335
|
+
p.brush.open();
|
|
336
|
+
await settle();
|
|
337
|
+
let prevented = 0;
|
|
338
|
+
for (const key of ['b', 'i', 'u']) p.fire('keydown', { key, ctrlKey: true, target: p.h1, preventDefault() { prevented++; } });
|
|
339
|
+
assert.equal(prevented, 3);
|
|
340
|
+
p.fire('keydown', { key: 'b', ctrlKey: true, target: p.stray, preventDefault() { prevented++; } });
|
|
341
|
+
assert.equal(prevented, 3, 'a key outside the edited words is not touched');
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test('a word on the page matches its line however its spaces wrap, and one table serves every element', async () => {
|
|
345
|
+
const p = boot({ round: { task_id: '9', state: 'writing', held_by_you: true, holder: { login: 'me' } } });
|
|
346
|
+
p.lede.textContent = ' Every version,\n from first idea to shipped. ';
|
|
347
|
+
p.brush.open();
|
|
348
|
+
await settle();
|
|
349
|
+
assert.equal(p.lede.getAttribute('data-pb-key'), 'L0003');
|
|
350
|
+
assert.doesNotMatch(BRUSH, /matchLine\(/, 'no per-element pattern matching: the hole-free lines are looked up by their words');
|
|
351
|
+
});
|