@bongos/core 1.19.475 → 1.19.477
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 +30 -25
- package/.claude/skills/session-handoff/SKILL.md +3 -3
- package/docs/adr/0229-the-title-is-the-door-and-the-record-exits-to-where-it-was-opened-from.md +38 -0
- package/docs/adr/README.md +1 -0
- package/docs/copy-inventory.md +136 -141
- package/docs/copy-registry.json +194 -260
- package/docs/module-api-changelog.md +4 -0
- package/modules/hall-ui/public/goals-page.js +4 -3
- package/modules/hall-ui/public/goals-render.js +5 -3
- package/modules/hall-ui/public/style.css +20 -18
- package/modules/hall-ui/public/task-detail.js +16 -4
- package/modules/hall-ui/public/task.html +5 -3
- package/modules/hall-ui/public/task.js +58 -4
- package/modules/hall-ui/public/task.states.json +1 -0
- package/modules/hall-ui/public/work.js +10 -11
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/hall_board_world.mjs +69 -0
- package/tests/hall_record_world.mjs +83 -4
|
@@ -1387,5 +1387,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1387
1387
|
landed since 1.19.473 with no explicit bump. run 33650008858. (task 1002620)
|
|
1388
1388
|
1.19.475 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1389
1389
|
landed since 1.19.474 with no explicit bump. run 33653632532. (task 1002620)
|
|
1390
|
+
1.19.476 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1391
|
+
landed since 1.19.475 with no explicit bump. run 33655706909. (task 1002620)
|
|
1392
|
+
1.19.477 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1393
|
+
landed since 1.19.476 with no explicit bump. run 33662059275. (task 1002620)
|
|
1390
1394
|
---------------------------------------------------------------------------
|
|
1391
1395
|
```
|
|
@@ -1067,10 +1067,11 @@
|
|
|
1067
1067
|
if (copyBtn) { copyPromptForTask(copyBtn.dataset.copyTask, copyBtn); return; }
|
|
1068
1068
|
const releaseBtn = e.target.closest('[data-release-claim]');
|
|
1069
1069
|
if (releaseBtn) { e.stopPropagation(); doGoalRelease(releaseBtn.dataset.releaseClaim, releaseBtn.dataset.releaseTask, releaseBtn); return; }
|
|
1070
|
-
|
|
1071
|
-
|
|
1070
|
+
// task 1003468: the title is the way into the record — a real link the
|
|
1071
|
+
// browser follows (it carries where it was opened from); let it be.
|
|
1072
|
+
if (e.target.closest('.task__link')) return;
|
|
1072
1073
|
// Click elsewhere: a ready card toggles its selection; a read-only (active /
|
|
1073
|
-
// backlog) card opens the
|
|
1074
|
+
// backlog) card opens the record (the board's affordance, work.js).
|
|
1074
1075
|
const card = e.target.closest('.task');
|
|
1075
1076
|
if (!card || !card.dataset.taskId) return;
|
|
1076
1077
|
if (card.classList.contains('task--readonly')) {
|
|
@@ -47,6 +47,9 @@
|
|
|
47
47
|
};
|
|
48
48
|
const taskSettled = (t) => window.OTBBranding?.taskSettled?.(t) ?? (!!t && (t.status === 'confirmed' || t.status === 'shipped'));
|
|
49
49
|
const creditVal = (t) => Number(window.OTBBranding?.taskCreditValue?.(t) ?? t?.credits_reward) || 0;
|
|
50
|
+
// task 1003468 — the record link a card's title carries; it names where it
|
|
51
|
+
// was opened from so the record can offer the way back (task-detail.js).
|
|
52
|
+
const taskHref = (id) => window.OTBTaskDetail.href(id);
|
|
50
53
|
function statusBadge(status) {
|
|
51
54
|
const cls = status === 'achieved' ? 'goal-badge--achieved'
|
|
52
55
|
: (status === 'archived' ? 'goal-badge--archived' : 'goal-badge--open');
|
|
@@ -327,7 +330,7 @@
|
|
|
327
330
|
? `<input type="checkbox" class="task__select" data-task-id="${escapeHtml(id)}" aria-label="Select task ${escapeHtml(id)} for bulk claim"${selected ? ' checked' : ''}>`
|
|
328
331
|
: `<span class="task__tag" title="Task status">${escapeHtml(t.status || '')}</span>`;
|
|
329
332
|
// Actions: ready → Claim + copy; mine-active → Release; other's active →
|
|
330
|
-
// "claimed · @who"; anything else → nothing (open via the
|
|
333
|
+
// "claimed · @who"; anything else → nothing (open via the title / card).
|
|
331
334
|
let actions = '';
|
|
332
335
|
if (isReady) {
|
|
333
336
|
actions = `<button class="task__claim" type="button" data-claim-task="${escapeHtml(id)}" aria-label="Claim task ${escapeHtml(id)}">Claim</button>`
|
|
@@ -343,10 +346,9 @@
|
|
|
343
346
|
<div class="task__id">
|
|
344
347
|
${lead}
|
|
345
348
|
<span>#${escapeHtml(id)}</span>
|
|
346
|
-
<button class="task__open" type="button" data-task-id="${escapeHtml(id)}" title="Open task details" aria-label="Open details for task ${escapeHtml(id)}">⤢</button>
|
|
347
349
|
</div>
|
|
348
350
|
<div class="task__body">
|
|
349
|
-
<p class="task__title">${escapeHtml(t.title || '')}</p>
|
|
351
|
+
<p class="task__title"><a class="task__link" href="${escapeHtml(taskHref(id))}">${escapeHtml(t.title || '')}</a></p>
|
|
350
352
|
<div class="task__meta">
|
|
351
353
|
<span class="task__tag">${escapeHtml(t.version_id || '?')}</span>
|
|
352
354
|
<span class="task__tag">${escapeHtml(t.kind || 'unclassified')}</span>
|
|
@@ -2352,26 +2352,28 @@ main [id] { scroll-margin-top: calc(var(--topbar-h) + 16px); }
|
|
|
2352
2352
|
light). The checked box is already an accent fill on every selected row;
|
|
2353
2353
|
a second one per row is what made it shout. */
|
|
2354
2354
|
.task--selected { border-color: var(--accent); }
|
|
2355
|
-
/* task 1869: the explicit open-detail hitbox — clicking the card body toggles
|
|
2356
|
-
selection, so this is the only way from a claimable card into the overlay. */
|
|
2357
|
-
.task__open {
|
|
2358
|
-
flex: none;
|
|
2359
|
-
width: 26px;
|
|
2360
|
-
height: 26px;
|
|
2361
|
-
display: inline-flex;
|
|
2362
|
-
align-items: center;
|
|
2363
|
-
justify-content: center;
|
|
2364
|
-
background: none;
|
|
2365
|
-
border: none;
|
|
2366
|
-
color: var(--ink-faint);
|
|
2367
|
-
font-size: 12px;
|
|
2368
|
-
line-height: 1;
|
|
2369
|
-
cursor: pointer;
|
|
2370
|
-
border-radius: var(--radius-sm);
|
|
2371
|
-
}
|
|
2372
|
-
.task__open:hover { color: var(--ink); background: var(--glass); }
|
|
2373
2355
|
.task__body { min-width: 0; padding-top: 2px; }
|
|
2374
2356
|
.task__title { font-weight: 400; font-size: 14.5px; line-height: 1.4; margin: 0 0 7px; color: var(--ink); }
|
|
2357
|
+
/* task 1003468 — the title is the way into the record. The card body toggles
|
|
2358
|
+
selection (bulk claim has been the primary card interaction since 1759), so
|
|
2359
|
+
the door has to be a control of its own — and task 1869's 26px ⤢ glyph in
|
|
2360
|
+
--ink-faint was one the owner never found. A title is the door everywhere
|
|
2361
|
+
else in the hall (.gr-title, the ledger rows), so this needs no teaching.
|
|
2362
|
+
Once a control it owes the 24px floor on both axes: the padding buys the
|
|
2363
|
+
height, the equal negative margin hands the room back so the card's line
|
|
2364
|
+
does not re-space (the .scroll__lede a recipe above). Ink at rest, not the
|
|
2365
|
+
link colour — sixty accent titles would take the board over. */
|
|
2366
|
+
.task__link {
|
|
2367
|
+
display: inline-block;
|
|
2368
|
+
color: inherit;
|
|
2369
|
+
text-decoration: none;
|
|
2370
|
+
min-width: 24px;
|
|
2371
|
+
min-height: 24px;
|
|
2372
|
+
padding: 2px 4px;
|
|
2373
|
+
margin: -2px -4px;
|
|
2374
|
+
overflow-wrap: anywhere;
|
|
2375
|
+
}
|
|
2376
|
+
.task__link:hover { color: var(--accent-ink); text-decoration: underline; text-underline-offset: 3px; }
|
|
2375
2377
|
.task__meta { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; font-size: 12px; color: var(--ink-faint); margin-top: 0; }
|
|
2376
2378
|
/* The label voice, outlined — a tag is a fact about the task, never a fill. */
|
|
2377
2379
|
.task__tag {
|
|
@@ -655,11 +655,23 @@
|
|
|
655
655
|
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
|
|
656
656
|
else boot();
|
|
657
657
|
|
|
658
|
+
// task 1003468 — the record's way back. A list that opens a task writes WHERE
|
|
659
|
+
// it was opened from onto the link (path + query + hash, so a goal's #/goal/N
|
|
660
|
+
// survives the trip) and task.js offers that as the exit. A query param, not
|
|
661
|
+
// memory: it has to outlive a reload and a copied link.
|
|
662
|
+
function taskHref(id) {
|
|
663
|
+
var from = location.pathname + location.search + location.hash;
|
|
664
|
+
return hallBase() + '/task/' + encodeURIComponent(id) + '?from=' + encodeURIComponent(from);
|
|
665
|
+
}
|
|
666
|
+
|
|
658
667
|
window.OTBTaskDetail = {
|
|
659
668
|
resolve: resolve,
|
|
660
|
-
//
|
|
661
|
-
//
|
|
662
|
-
|
|
663
|
-
|
|
669
|
+
// The link a list writes on a task title (the Work Board's cards, the goal
|
|
670
|
+
// pane's cards) — the same target open() navigates to.
|
|
671
|
+
href: taskHref,
|
|
672
|
+
// Every existing caller (the Work Board's backlog rows, the Goals page's
|
|
673
|
+
// ledger rows and read-only cards) passes a TASK id — navigate straight to
|
|
674
|
+
// the real page rather than round-tripping through the hash redirect above.
|
|
675
|
+
open: function (id) { location.href = taskHref(id); },
|
|
664
676
|
};
|
|
665
677
|
})();
|
|
@@ -26,10 +26,12 @@
|
|
|
26
26
|
<div class="page-head">
|
|
27
27
|
<!-- task 2175: the breadcrumb sits in the page head, above the card, so a
|
|
28
28
|
deep-linked visitor (this page is deliberately not in the nav) has a
|
|
29
|
-
way back before the task body has loaded.
|
|
29
|
+
way back before the task body has loaded. task 1003468: task.js
|
|
30
|
+
points it at where the reader came from (?from=, then the
|
|
31
|
+
referrer); /work is only the fallback it ships with. -->
|
|
30
32
|
<div class="rec-crumb">
|
|
31
|
-
<a class="rec-back" href="/work">
|
|
32
|
-
<svg class="rec-back__ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M9.4 3.6 5 8l4.4 4.4"/><path d="M5 8h7.2"/></svg>Back to tasks</a>
|
|
33
|
+
<a class="rec-back" id="task-back" href="/work">
|
|
34
|
+
<svg class="rec-back__ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M9.4 3.6 5 8l4.4 4.4"/><path d="M5 8h7.2"/></svg><span id="task-back-label">Back to tasks</span></a>
|
|
33
35
|
<p class="rec-sub" id="task-sub">Loading task</p>
|
|
34
36
|
</div>
|
|
35
37
|
</div>
|
|
@@ -126,6 +126,59 @@
|
|
|
126
126
|
return m ? m[1] : null;
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
// task 1003468 — where the reader came from, so the record can send them back
|
|
130
|
+
// there rather than always to the work board. Precedence: `?from=` (written
|
|
131
|
+
// by the list that opened the record — a query param so it survives a reload
|
|
132
|
+
// and a copied link) → a same-origin referrer → the work board. A value is
|
|
133
|
+
// honoured only as a path on THIS host: one leading slash (a second is another
|
|
134
|
+
// origin), no backslash (browsers read `/\host` as `//host`), no whitespace,
|
|
135
|
+
// never the API and never this same record.
|
|
136
|
+
const EXIT_MAX_LEN = 2048;
|
|
137
|
+
function safeHallPath(v) {
|
|
138
|
+
if (typeof v !== 'string' || !v || v.length > EXIT_MAX_LEN) return null;
|
|
139
|
+
if (!/^\/(?!\/)/.test(v) || /[\\\s]/.test(v)) return null;
|
|
140
|
+
if (/^(\/builders)?\/(api|lib)\//.test(v)) return null;
|
|
141
|
+
if (v.split(/[?#]/)[0] === location.pathname) return null;
|
|
142
|
+
return v;
|
|
143
|
+
}
|
|
144
|
+
function referrerPath() {
|
|
145
|
+
let u;
|
|
146
|
+
try { u = new URL(document.referrer); } catch (_) { return null; }
|
|
147
|
+
if (u.origin !== location.origin) return null;
|
|
148
|
+
return safeHallPath(u.pathname + u.search + u.hash);
|
|
149
|
+
}
|
|
150
|
+
// "goal 1000081", "task 12", "the work board", "sessions" — said the way the
|
|
151
|
+
// sidebar says it, so the exit reads as a place and not as a URL.
|
|
152
|
+
function exitLabel(href) {
|
|
153
|
+
const rel = href.replace(/^\/builders(?=[/?#]|$)/, '');
|
|
154
|
+
let m;
|
|
155
|
+
if ((m = /#\/goal\/(\d+)/.exec(rel))) return `goal ${m[1]}`;
|
|
156
|
+
if ((m = /^\/(task|idea)\/(\d+)/.exec(rel))) return `${m[1]} ${m[2]}`;
|
|
157
|
+
const seg = (/^\/([a-z][a-z-]*)/.exec(rel) || [])[1];
|
|
158
|
+
if (!seg) return 'home';
|
|
159
|
+
if (seg === 'work') return 'the work board';
|
|
160
|
+
return seg.replace(/-/g, ' ');
|
|
161
|
+
}
|
|
162
|
+
function exitTarget() {
|
|
163
|
+
let from = null;
|
|
164
|
+
try { from = new URLSearchParams(location.search).get('from'); } catch (_) { /* no query */ }
|
|
165
|
+
const href = safeHallPath(from) || referrerPath() || `${hallBase()}/work`;
|
|
166
|
+
return { href, label: exitLabel(href) };
|
|
167
|
+
}
|
|
168
|
+
function exitLinkHtml() {
|
|
169
|
+
const { href, label } = exitTarget();
|
|
170
|
+
return `<a class="td-btn" href="${escapeHtml(href)}">Back to ${escapeHtml(label)}</a>`;
|
|
171
|
+
}
|
|
172
|
+
// The crumb in the page head is set before the fetch, so the way back exists
|
|
173
|
+
// in every state — loading, not found, failed — not just the happy one.
|
|
174
|
+
function applyExit() {
|
|
175
|
+
const { href, label } = exitTarget();
|
|
176
|
+
const back = $('#task-back');
|
|
177
|
+
if (back) back.href = href;
|
|
178
|
+
const lbl = $('#task-back-label');
|
|
179
|
+
if (lbl) lbl.textContent = `Back to ${label}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
129
182
|
function rankText(rr) {
|
|
130
183
|
const r = (rr || 'xenos').toLowerCase();
|
|
131
184
|
if (r === 'xenos') return 'any rank';
|
|
@@ -389,7 +442,7 @@
|
|
|
389
442
|
<div class="rec-actions">
|
|
390
443
|
${lifecycle}
|
|
391
444
|
<button class="td-btn" type="button" id="copy-prompt-btn">Copy prompt</button>
|
|
392
|
-
|
|
445
|
+
${exitLinkHtml()}
|
|
393
446
|
</div>
|
|
394
447
|
</section>
|
|
395
448
|
`);
|
|
@@ -434,7 +487,7 @@
|
|
|
434
487
|
<p class="rec-kind">Task ${statusPill('no such task')}</p>
|
|
435
488
|
<h1 class="rec-big" id="task-h">${escapeHtml(String(id))}, no such task</h1>
|
|
436
489
|
<p class="rec-body">Tasks in the ledger are numbered separately from GitHub pull requests, so this may be a PR number rather than a task. The work board lists everything that does exist.</p>
|
|
437
|
-
<div class="rec-actions rec-sec"
|
|
490
|
+
<div class="rec-actions rec-sec">${exitLinkHtml()}</div>
|
|
438
491
|
`;
|
|
439
492
|
const sub = $('#task-sub');
|
|
440
493
|
if (sub) sub.textContent = 'Not found';
|
|
@@ -445,14 +498,15 @@
|
|
|
445
498
|
if (body) body.innerHTML = `
|
|
446
499
|
<p class="rec-kind">Task ${statusPill('feed unavailable')}</p>
|
|
447
500
|
<h1 class="rec-big" id="task-h">Could not load ${escapeHtml(String(id))}</h1>
|
|
448
|
-
<p class="rec-body">The task feed did not answer. Nothing on the record has changed, only the read failed. Try again in a moment, or
|
|
449
|
-
<div class="rec-actions rec-sec"
|
|
501
|
+
<p class="rec-body">The task feed did not answer. Nothing on the record has changed, only the read failed. Try again in a moment, or go back.</p>
|
|
502
|
+
<div class="rec-actions rec-sec">${exitLinkHtml()}</div>
|
|
450
503
|
`;
|
|
451
504
|
const sub = $('#task-sub');
|
|
452
505
|
if (sub) sub.textContent = 'Could not load';
|
|
453
506
|
}
|
|
454
507
|
|
|
455
508
|
async function load() {
|
|
509
|
+
applyExit();
|
|
456
510
|
const id = taskIdFromPath();
|
|
457
511
|
if (!id) { renderNotFound(''); return; }
|
|
458
512
|
try {
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
"ignoreConsole": ["/api/(gds|bongos)/", "\b401\b", "\b404\b", "Unauthorized", "Not Found", "load failed"],
|
|
9
9
|
"states": {
|
|
10
10
|
"record": { "auth": true, "actions": [["wait", 600]], "expect": { "visible": ["#task-body", "#copy-prompt-btn"] } },
|
|
11
|
+
"from-goal": { "auth": true, "url": "/task/2166?from=%2Fgoals%23%2Fgoal%2F1000081", "actions": [["wait", 600]], "expect": { "visible": ["#task-back", "#copy-prompt-btn"], "attr": [["#task-back", "href", "/goals#/goal/1000081"]] } },
|
|
11
12
|
"missing": { "auth": true, "url": "/task/1003999", "actions": [["wait", 600]], "expect": { "visible": ["#task-h"] } }
|
|
12
13
|
},
|
|
13
14
|
"reducedMotion": { "auth": true, "actions": [["wait", 600]] }
|
|
@@ -87,6 +87,9 @@
|
|
|
87
87
|
const BACKLOG_LIMIT = 500;
|
|
88
88
|
const fmtDate = (iso) => window.OTB.fmtDate(iso);
|
|
89
89
|
const toast = (msg) => window.OTB.toast(msg, { duration: 2200, clear: true });
|
|
90
|
+
// task 1003468 — the record link a card's title carries; it names where it
|
|
91
|
+
// was opened from so the record can offer the way back (task-detail.js).
|
|
92
|
+
const taskHref = (id) => window.OTBTaskDetail.href(id);
|
|
90
93
|
const curLabel = (caps) => { const l = (window.__BRANDING__ && window.__BRANDING__.currency && window.__BRANDING__.currency.label) || 'credits'; return caps ? l.charAt(0).toUpperCase() + l.slice(1) : l; };
|
|
91
94
|
|
|
92
95
|
// Coarse relative time ("just now", "3h", "2d") for how long a claim's been
|
|
@@ -404,10 +407,9 @@
|
|
|
404
407
|
<div class="task__id">
|
|
405
408
|
<input type="checkbox" class="task__select" data-task-id="${t.id}" aria-label="Select task ${t.id} for bulk actions"${selected ? ' checked' : ''}>
|
|
406
409
|
<span class="task__num">#${t.id}</span>
|
|
407
|
-
<button class="task__open" type="button" data-task-id="${t.id}" title="Open task details" aria-label="Open details for task ${t.id}">⤢</button>
|
|
408
410
|
</div>
|
|
409
411
|
<div class="task__body">
|
|
410
|
-
<p class="task__title">${escapeHtml(t.title)}</p>
|
|
412
|
+
<p class="task__title"><a class="task__link" href="${escapeHtml(taskHref(t.id))}">${escapeHtml(t.title)}</a></p>
|
|
411
413
|
<div class="task__meta">
|
|
412
414
|
<span class="task__tag">${escapeHtml(t.version_id || '?')}</span>
|
|
413
415
|
<span class="task__tag">${escapeHtml(t.kind || 'unclassified')}</span>
|
|
@@ -1334,15 +1336,12 @@
|
|
|
1334
1336
|
if (claimBtn) { doClaim(claimBtn.dataset.taskId, claimBtn); return; }
|
|
1335
1337
|
const copyBtn = e.target.closest('.task__copy');
|
|
1336
1338
|
if (copyBtn) { copyPromptForTask(copyBtn.dataset.taskId, copyBtn); return; }
|
|
1337
|
-
// task
|
|
1338
|
-
// the
|
|
1339
|
-
//
|
|
1340
|
-
//
|
|
1341
|
-
|
|
1342
|
-
if (
|
|
1343
|
-
window.OTBTaskDetail.open(openBtn.dataset.taskId);
|
|
1344
|
-
return;
|
|
1345
|
-
}
|
|
1339
|
+
// task 1003468: the title is the way into the record — a real link, so
|
|
1340
|
+
// the browser navigates (and a middle-click opens a tab); let it be.
|
|
1341
|
+
// Task 1869's ⤢ glyph used to be the only door and the owner never found
|
|
1342
|
+
// it; the card body still toggles selection below, since multi-select is
|
|
1343
|
+
// the primary card interaction now that 1759 shipped.
|
|
1344
|
+
if (e.target.closest('.task__link')) return;
|
|
1346
1345
|
// task 1869: clicking the card anywhere else toggles its selection
|
|
1347
1346
|
// checkbox (matches the affordance a checkbox already implies).
|
|
1348
1347
|
const card = e.target.closest('.task');
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.477",
|
|
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.477",
|
|
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.477",
|
|
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
|
@@ -34,7 +34,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
34
34
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
35
35
|
// the entry to that file. Look for a version's history there, not here.
|
|
36
36
|
// ---------------------------------------------------------------------------
|
|
37
|
-
const CORE_VERSION = '1.19.
|
|
37
|
+
const CORE_VERSION = '1.19.477'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
38
38
|
|
|
39
39
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
40
40
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -34,6 +34,7 @@ import { test } from 'node:test';
|
|
|
34
34
|
import assert from 'node:assert/strict';
|
|
35
35
|
import fs from 'node:fs';
|
|
36
36
|
import path from 'node:path';
|
|
37
|
+
import vm from 'node:vm';
|
|
37
38
|
import { fileURLToPath } from 'node:url';
|
|
38
39
|
|
|
39
40
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
@@ -151,3 +152,71 @@ test('both re-skinned pages have a states file, so the floors are reproducible',
|
|
|
151
152
|
assert.ok(Object.keys(json.states).length >= 4, `${f} renders more than the default state`);
|
|
152
153
|
}
|
|
153
154
|
});
|
|
155
|
+
|
|
156
|
+
// ---- 7. the way INTO a record is the title ----------------------------------
|
|
157
|
+
//
|
|
158
|
+
// task 1003468. Since 1759/1869 the card body toggles selection (bulk claim is
|
|
159
|
+
// the primary card interaction) and the only door into the record was a 26px ⤢
|
|
160
|
+
// glyph in --ink-faint — one the owner never found. The title is now a real
|
|
161
|
+
// link on both surfaces that render the card, the board and the goal pane, and
|
|
162
|
+
// the glyph is gone. A title that becomes a control owes the 24px floor on both
|
|
163
|
+
// axes, and the card's line must not re-space to pay for it. The link also
|
|
164
|
+
// carries WHERE it was opened from, which is the record's way back (§10 of
|
|
165
|
+
// tests/hall_record_world.mjs).
|
|
166
|
+
|
|
167
|
+
const cardWorkJs = read('work.js');
|
|
168
|
+
const cardGoalsRenderJs = read('goals-render.js');
|
|
169
|
+
const cardGoalsPageJs = read('goals-page.js');
|
|
170
|
+
const cardTaskDetailJs = read('task-detail.js');
|
|
171
|
+
|
|
172
|
+
test('the task title is a link on the board and on the goal pane, and the ⤢ glyph is gone', () => {
|
|
173
|
+
for (const [name, src] of [['work.js', cardWorkJs], ['goals-render.js', cardGoalsRenderJs]]) {
|
|
174
|
+
assert.match(src, /<p class="task__title"><a class="task__link" href="\$\{escapeHtml\(taskHref\(/,
|
|
175
|
+
`${name} renders the title as the door`);
|
|
176
|
+
assert.doesNotMatch(src, /task__open/, `${name} still renders the glyph`);
|
|
177
|
+
}
|
|
178
|
+
assert.doesNotMatch(cardGoalsPageJs, /task__open/, 'goals-page.js still listens for the glyph');
|
|
179
|
+
assert.doesNotMatch(styleCss.replace(/\/\*[\s\S]*?\*\//g, ''), /\.task__open/, 'style.css still styles a glyph nothing renders');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('both click handlers let the title link through instead of toggling selection under it', () => {
|
|
183
|
+
for (const [name, src] of [['work.js', cardWorkJs], ['goals-page.js', cardGoalsPageJs]]) {
|
|
184
|
+
assert.match(src, /if \(e\.target\.closest\('\.task__link'\)\) return;/, `${name} returns before the card-body toggle`);
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('.task__link buys the 24px floor on both axes and hands the room back', () => {
|
|
189
|
+
const link = rule(styleCss, '.task__link');
|
|
190
|
+
assert.ok(link, '.task__link has a rule');
|
|
191
|
+
assert.match(link, /display:\s*inline-block/, 'inline-block, so min-height applies to a run of text');
|
|
192
|
+
assert.match(link, /min-width:\s*24px/, 'width is half the floor');
|
|
193
|
+
assert.match(link, /min-height:\s*24px/, 'height is the other half');
|
|
194
|
+
const pad = /padding:\s*(\d+)px\s+(\d+)px/.exec(link);
|
|
195
|
+
const mar = /margin:\s*-(\d+)px\s+-(\d+)px/.exec(link);
|
|
196
|
+
assert.ok(pad && mar, 'padding and a negative margin are both declared');
|
|
197
|
+
assert.deepEqual([mar[1], mar[2]], [pad[1], pad[2]], 'the negative margin equals the padding, so the card does not re-space');
|
|
198
|
+
assert.match(link, /color:\s*inherit/, 'ink at rest — sixty accent titles would take the board over');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('the link a list writes carries WHERE it was opened from — path, query and hash', () => {
|
|
202
|
+
const sandbox = {
|
|
203
|
+
window: { BongosClient: { createClient: () => ({}) }, addEventListener() {}, OTB: {} },
|
|
204
|
+
document: { readyState: 'complete', addEventListener() {}, getElementById() { return null; }, body: {} },
|
|
205
|
+
location: { hostname: 'cloudbongos.com', pathname: '/builders/goals', search: '', hash: '#/goal/1000081', href: '' },
|
|
206
|
+
sessionStorage: { getItem() { return null; }, setItem() {}, removeItem() {} },
|
|
207
|
+
console, setTimeout, clearTimeout, Date,
|
|
208
|
+
};
|
|
209
|
+
sandbox.globalThis = sandbox;
|
|
210
|
+
sandbox.window.location = sandbox.location;
|
|
211
|
+
vm.createContext(sandbox);
|
|
212
|
+
vm.runInContext(cardTaskDetailJs, sandbox, { filename: 'task-detail.js' });
|
|
213
|
+
const api = sandbox.window.OTBTaskDetail;
|
|
214
|
+
assert.equal(api.href('1003468'), '/builders/task/1003468?from=%2Fbuilders%2Fgoals%23%2Fgoal%2F1000081',
|
|
215
|
+
'the goal\'s #/goal/N rides the query, where a reload and a copied link keep it');
|
|
216
|
+
api.open('1003468');
|
|
217
|
+
assert.equal(sandbox.location.href, api.href('1003468'), 'open() navigates to the same link the title carries');
|
|
218
|
+
sandbox.location.hostname = 'builders.cloudbongos.com';
|
|
219
|
+
sandbox.location.pathname = '/work';
|
|
220
|
+
sandbox.location.hash = '#backlog';
|
|
221
|
+
assert.equal(api.href('7'), '/task/7?from=%2Fwork%23backlog', 'on the builders host the hall is mounted at /');
|
|
222
|
+
});
|
|
@@ -259,7 +259,7 @@ test('the preview harness can reach both record pages', () => {
|
|
|
259
259
|
// re-implementation of it.
|
|
260
260
|
const TASK_SRC = read('task.js');
|
|
261
261
|
|
|
262
|
-
function renderTask(task, { lastClaim = null, viewer = null, goal = null, status = 200 } = {}) {
|
|
262
|
+
function renderTask(task, { lastClaim = null, viewer = null, goal = null, status = 200, search = '', referrer = '' } = {}) {
|
|
263
263
|
const els = new Map();
|
|
264
264
|
const makeEl = () => ({
|
|
265
265
|
dataset: {}, style: {}, textContent: '', hidden: false, disabled: false, _html: '',
|
|
@@ -273,6 +273,7 @@ function renderTask(task, { lastClaim = null, viewer = null, goal = null, status
|
|
|
273
273
|
let onReady = null;
|
|
274
274
|
const documentObj = {
|
|
275
275
|
title: 'Task — Cloud Bongos',
|
|
276
|
+
referrer,
|
|
276
277
|
body: { dataset: { page: 'task' } },
|
|
277
278
|
documentElement: { dataset: {}, style: {}, classList: { add() {}, remove() {} } },
|
|
278
279
|
getElementById(id) { if (!els.has(id)) els.set(id, makeEl()); return els.get(id); },
|
|
@@ -302,17 +303,22 @@ function renderTask(task, { lastClaim = null, viewer = null, goal = null, status
|
|
|
302
303
|
categoryChip: () => '',
|
|
303
304
|
toast() {},
|
|
304
305
|
},
|
|
305
|
-
location: { hostname: 'builders.cloudbongos.com', pathname: `/task/${task && task.id ? task.id : '1'}`, origin: 'https://builders.cloudbongos.com', assign() {} },
|
|
306
|
+
location: { hostname: 'builders.cloudbongos.com', pathname: `/task/${task && task.id ? task.id : '1'}`, search, origin: 'https://builders.cloudbongos.com', assign() {} },
|
|
306
307
|
addEventListener() {},
|
|
307
308
|
confirm: () => true,
|
|
308
309
|
navigator: { clipboard: { writeText: async () => {} } },
|
|
309
310
|
};
|
|
310
|
-
const sandbox = { window: windowObj, document: documentObj, location: windowObj.location, console, setTimeout, clearTimeout, Date };
|
|
311
|
+
const sandbox = { window: windowObj, document: documentObj, location: windowObj.location, console, setTimeout, clearTimeout, Date, URL, URLSearchParams };
|
|
311
312
|
sandbox.globalThis = sandbox;
|
|
312
313
|
vm.createContext(sandbox);
|
|
313
314
|
vm.runInContext(TASK_SRC, sandbox, { filename: 'task.js' });
|
|
314
315
|
assert.ok(onReady, 'task.js registers its DOMContentLoaded boot');
|
|
315
|
-
return {
|
|
316
|
+
return {
|
|
317
|
+
boot: onReady,
|
|
318
|
+
html: () => documentObj.getElementById('task-body').innerHTML,
|
|
319
|
+
sub: () => documentObj.getElementById('task-sub').textContent,
|
|
320
|
+
el: (id) => documentObj.getElementById(id),
|
|
321
|
+
};
|
|
316
322
|
}
|
|
317
323
|
|
|
318
324
|
const settle = () => new Promise((r) => setTimeout(r, 0));
|
|
@@ -410,3 +416,76 @@ test('the idea grade badge IS the archetype pill, not a private copy of it', ()
|
|
|
410
416
|
assert.doesNotMatch(decomment(ideaCss), /\.ig-grade(?![\w-])/,
|
|
411
417
|
'idea.css carried a near-verbatim copy of .rec-pill down to the token triples — a hundred lines waiting to drift');
|
|
412
418
|
});
|
|
419
|
+
|
|
420
|
+
// ---- 10. the way out — the record exits to where it was opened from ----------
|
|
421
|
+
//
|
|
422
|
+
// task 1003468. task.js hardcoded "Open the work board" in three places and
|
|
423
|
+
// task.html's crumb a fourth, so a builder who walked goal → task had only the
|
|
424
|
+
// browser's own Back, and the two failure renders sent them somewhere they had
|
|
425
|
+
// never been. The list that opens a record now writes `?from=` on the link
|
|
426
|
+
// (task-detail.js's href()) and the record offers THAT as its exit — in the
|
|
427
|
+
// crumb before the fetch lands, and in the actions row of every render
|
|
428
|
+
// including the two failures. A same-origin referrer is the fallback for a link
|
|
429
|
+
// written without it; the board is the fallback for a reader with no history.
|
|
430
|
+
// Every value is honoured only as a path on this host.
|
|
431
|
+
|
|
432
|
+
const FROM_GOAL = '?from=' + encodeURIComponent('/goals#/goal/1000081');
|
|
433
|
+
|
|
434
|
+
test('opened from a goal, the record exits to that goal — crumb and actions row alike', async () => {
|
|
435
|
+
const r = renderTask(BASE_TASK, { search: FROM_GOAL });
|
|
436
|
+
await r.boot(); await settle();
|
|
437
|
+
assert.match(r.html(), /<a class="td-btn" href="\/goals#\/goal\/1000081">Back to goal 1000081<\/a>/);
|
|
438
|
+
assert.doesNotMatch(r.html(), /Open the work board/, 'the hardcoded exit is gone from the happy path');
|
|
439
|
+
assert.equal(r.el('task-back').href, '/goals#/goal/1000081', 'the crumb points at the goal');
|
|
440
|
+
assert.equal(r.el('task-back-label').textContent, 'Back to goal 1000081');
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test('opened from the board, the exit is the board — on the tab it was opened from', async () => {
|
|
444
|
+
const r = renderTask(BASE_TASK, { search: '?from=' + encodeURIComponent('/work#backlog') });
|
|
445
|
+
await r.boot(); await settle();
|
|
446
|
+
assert.match(r.html(), /href="\/work#backlog">Back to the work board</);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
test('without ?from=, a same-origin referrer is the way back', async () => {
|
|
450
|
+
const r = renderTask(BASE_TASK, { referrer: 'https://builders.cloudbongos.com/sessions?page=2' });
|
|
451
|
+
await r.boot(); await settle();
|
|
452
|
+
assert.match(r.html(), /href="\/sessions\?page=2">Back to sessions</);
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
test('a cross-origin referrer, a broken one, or none, falls back to the work board', async () => {
|
|
456
|
+
for (const referrer of ['', 'https://example.org/elsewhere', 'not a url']) {
|
|
457
|
+
const r = renderTask(BASE_TASK, { referrer });
|
|
458
|
+
await r.boot(); await settle();
|
|
459
|
+
assert.match(r.html(), /href="\/work">Back to the work board</, `referrer ${JSON.stringify(referrer)}`);
|
|
460
|
+
assert.equal(r.el('task-back').href, '/work');
|
|
461
|
+
}
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
test('a hostile or useless ?from= is refused, never followed', async () => {
|
|
465
|
+
const bad = [
|
|
466
|
+
'//evil.example', '/\\evil.example', 'javascript:alert(1)', 'https://evil.example/x',
|
|
467
|
+
'/api/gds/auth/web/callback', '/lib/bongos-client.js',
|
|
468
|
+
'/task/1003305', // this same record — a loop, not a way back
|
|
469
|
+
'work', '/ work', '',
|
|
470
|
+
];
|
|
471
|
+
for (const from of bad) {
|
|
472
|
+
const r = renderTask(BASE_TASK, { search: '?from=' + encodeURIComponent(from) });
|
|
473
|
+
await r.boot(); await settle();
|
|
474
|
+
assert.match(r.html(), /href="\/work">Back to the work board</, `from=${JSON.stringify(from)}`);
|
|
475
|
+
assert.equal(r.el('task-back').href, '/work', `the crumb refuses from=${JSON.stringify(from)} too`);
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
test('the two honest failures exit the same way the record does', async () => {
|
|
480
|
+
for (const status of [404, 500]) {
|
|
481
|
+
const r = renderTask(BASE_TASK, { status, search: FROM_GOAL });
|
|
482
|
+
await r.boot(); await settle();
|
|
483
|
+
assert.match(r.html(), /href="\/goals#\/goal\/1000081">Back to goal 1000081</, `status ${status}`);
|
|
484
|
+
assert.doesNotMatch(r.html(), /Open the work board/, `status ${status} still hardcodes the board`);
|
|
485
|
+
}
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
test('the crumb is an element task.js can point, with its label apart from the icon', () => {
|
|
489
|
+
assert.match(taskHtml, /<a class="rec-back" id="task-back" href="\/work">/, 'the static href is the fallback the page ships with');
|
|
490
|
+
assert.match(taskHtml, /<span id="task-back-label">Back to tasks<\/span>/, 'the label is its own node, so the icon survives a relabel');
|
|
491
|
+
});
|