@bongos/core 1.19.474 → 1.19.476

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.
Files changed (33) hide show
  1. package/.bongos-core.json +56 -36
  2. package/docs/adr/0229-the-title-is-the-door-and-the-record-exits-to-where-it-was-opened-from.md +38 -0
  3. package/docs/adr/README.md +1 -0
  4. package/docs/copy-inventory.md +255 -258
  5. package/docs/copy-registry.json +376 -414
  6. package/docs/module-api-changelog.md +4 -0
  7. package/modules/hall-ui/public/builders.js +13 -64
  8. package/modules/hall-ui/public/dom-utils.js +2 -1
  9. package/modules/hall-ui/public/goals-page.js +4 -3
  10. package/modules/hall-ui/public/goals-render.js +5 -3
  11. package/modules/hall-ui/public/hall-render.js +10 -142
  12. package/modules/hall-ui/public/hall-widgets.js +6 -16
  13. package/modules/hall-ui/public/index.html +0 -3
  14. package/modules/hall-ui/public/index.states.json +3 -2
  15. package/modules/hall-ui/public/profile.js +1 -1
  16. package/modules/hall-ui/public/roster.html +63 -0
  17. package/modules/hall-ui/public/roster.js +227 -0
  18. package/modules/hall-ui/public/roster.states.json +100 -0
  19. package/modules/hall-ui/public/shell.js +28 -4
  20. package/modules/hall-ui/public/style.css +20 -18
  21. package/modules/hall-ui/public/task-detail.js +16 -4
  22. package/modules/hall-ui/public/task.html +5 -3
  23. package/modules/hall-ui/public/task.js +58 -4
  24. package/modules/hall-ui/public/task.states.json +1 -0
  25. package/modules/hall-ui/public/work.js +10 -11
  26. package/package-lock.json +2 -2
  27. package/package.json +1 -1
  28. package/src/module-api.js +1 -1
  29. package/tests/hall_board_world.mjs +69 -0
  30. package/tests/hall_nav.mjs +36 -1
  31. package/tests/hall_page_gate_map.mjs +8 -0
  32. package/tests/hall_record_world.mjs +83 -4
  33. package/tests/rank_tier_single_source.mjs +8 -1
@@ -0,0 +1,227 @@
1
+ // roster.js — the Builders page: who builds on THIS project, ranked.
2
+ //
3
+ // Task 1003441 (goal 1000075, criterion community-is-one-builders-page). This
4
+ // was the `#leaderboard` HASH VIEW on index.html, which is why the hall's
5
+ // Community nav group read as one real page plus a link into the home page. It
6
+ // is a page now, and `/#leaderboard` redirects here (shell.js VIEW_REDIRECTS).
7
+ //
8
+ // PROJECT-OWN, not cross-project. /people is the cross-project directory behind
9
+ // `project.curate`; this is the project's own ledger and is deliberately
10
+ // ungated, because its one data source — GET /leaderboard
11
+ // (modules/economy/routes/leaderboard.js) — is deliberately unauthenticated and
12
+ // mirrors the public status surface. No visibility predicate is applied and that
13
+ // is CORRECT: privacy suppresses cross-project surfaces only, so a builder
14
+ // marked `private` still appears on the roster of a project they build on
15
+ // (owner, 2026-08-30; ADR 0225, task 1003443).
16
+ //
17
+ // The laurels (ACH_EMOJI + achRowHtml) and the sort lenses MOVED here from
18
+ // hall-render.js rather than being copied — the leaderboard row was their only
19
+ // caller, so the home page is left carrying no dead renderer.
20
+ (function () {
21
+ 'use strict';
22
+ var kit = window.OTBKit;
23
+ var OTB = window.OTB;
24
+ var esc = kit.esc;
25
+ var body = document.getElementById('roster-body');
26
+ var sub = document.getElementById('roster-sub');
27
+ var countPill = document.getElementById('roster-count');
28
+
29
+ var fmtInt = function (n) { return OTB && OTB.fmtInt ? OTB.fmtInt(n) : String(n == null ? 0 : n); };
30
+ var rankLabel = function (r) { return OTB && OTB.rankLabel ? OTB.rankLabel(r) : r; };
31
+ var currencyLabel = function () {
32
+ var b = window.OTBBranding;
33
+ return (b && b.currencyLabel && b.currencyLabel({ caps: true })) || 'Credits';
34
+ };
35
+
36
+ // Local, the way builders.js and goals-render.js each keep their own — a
37
+ // four-entry ladder constant, not a shared module's worth of behaviour.
38
+ var RANK_ORDER = { archon: 4, metic: 3, thetes: 2, xenos: 1 };
39
+ function rankWeight(r) { return RANK_ORDER[r] != null ? RANK_ORDER[r] : -1; }
40
+ function rankBadgeHtml(rank) {
41
+ var variant = RANK_ORDER[rank] ? 'rank-badge--' + rank : 'rank-badge--unknown';
42
+ var aria = rank ? 'Rank: ' + rankLabel(rank) : 'Rank not yet recorded';
43
+ return '<span class="rank-badge ' + variant + '" aria-label="' + esc(aria) + '">' + esc(rankLabel(rank)) + '</span>';
44
+ }
45
+
46
+ var ACH_EMOJI = {
47
+ 'first-ship': '\u{1F6A2}',
48
+ 'first-claim': '\u{1FA99}',
49
+ 'first-parallel-claim': '⚓',
50
+ 'streak-3': '\u{1F525}',
51
+ 'streak-7': '\u{1F30B}',
52
+ 'art-pipeline-shipper': '\u{1F3A8}',
53
+ 'gds-shipper': '⚙️',
54
+ 'breaking-the-100c-bar': '\u{1F4AF}',
55
+ };
56
+ function achRowHtml(ids) {
57
+ if (!Array.isArray(ids) || !ids.length) return '';
58
+ var inner = ids.map(function (id) {
59
+ var emoji = ACH_EMOJI[id];
60
+ // Title is the achievement id (kebab) — terse enough that hover discloses
61
+ // the laurel without overstating the prose.
62
+ return emoji ? '<span class="lb-ach" title="' + esc(id) + '" aria-label="' + esc(id) + '">' + emoji + '</span>' : '';
63
+ }).join('');
64
+ return inner ? '<span class="lb-ach-row" aria-label="achievements">' + inner + '</span>' : '';
65
+ }
66
+
67
+ function builderName(b) { return b.display_name || b.github_login || ('#' + b.id); }
68
+ function initialOf(b) {
69
+ var n = String(builderName(b)).replace(/^[@#]/, '');
70
+ return (n.charAt(0) || '?').toUpperCase();
71
+ }
72
+ // Defence in depth on the avatar src. Escaping alone cannot stop a non-http
73
+ // scheme reaching an attribute the browser resolves; <img> would not execute
74
+ // javascript: in any current browser, but the allowlist costs one test and
75
+ // removes the question. Falls back to the initial rather than rendering a
76
+ // src the page did not vouch for.
77
+ function httpUrl(u) {
78
+ return /^https?:\/\//i.test(String(u || '')) ? String(u) : null;
79
+ }
80
+ // The identity cell, shared with /people through people.css. The name opens
81
+ // the builder's profile PAGE here: this is a project surface, so a local
82
+ // profile id exists — unlike the cross-project directory, which can only
83
+ // offer GitHub. Guarded on b.id so a row without one degrades to plain text
84
+ // rather than a broken /profile?id=undefined link (task 1467).
85
+ function whoHtml(b) {
86
+ var name = esc(builderName(b));
87
+ var src = httpUrl(b.avatar_url);
88
+ var avatar = src
89
+ ? '<img class="dir-avatar" src="' + esc(src) + '" alt="" loading="lazy">'
90
+ : '<span class="dir-avatar dir-avatar--initial" aria-hidden="true">' + esc(initialOf(b)) + '</span>';
91
+ var nameHtml = b.id != null
92
+ ? '<a class="lb-name dir-name" href="/profile?id=' + encodeURIComponent(b.id) + '">' + name + '</a>'
93
+ : '<span class="dir-name">' + name + '</span>';
94
+ var login = b.github_login ? '<span class="dir-login">@' + esc(b.github_login) + '</span>' : '';
95
+ return '<span class="dir-who">' + avatar + '<span class="dir-who__t">' + nameHtml + login + '</span></span>';
96
+ }
97
+
98
+ var all = [];
99
+ var query = '';
100
+ var sortBy = '';
101
+
102
+ // PLACE IS CANONICAL, not the row's position in the current sort.
103
+ // hall-render's leaderboardPlace() already defines a builder's place as
104
+ // credits-descending "independent of whatever column the table is currently
105
+ // sorted by", and the Standing card still says "#N on the leaderboard" from
106
+ // that definition — so a Place column that renumbered itself under the karma
107
+ // lens disagreed with the card about the same builder. Stamped once, at load.
108
+ function stampPlaces(rows) {
109
+ rows.slice()
110
+ .sort(function (a, b) { return (Number(b.total_credits) || 0) - (Number(a.total_credits) || 0); })
111
+ .forEach(function (b, i) { b.__place = i + 1; });
112
+ // The efficiency title is stamped here too, so the division and the string
113
+ // work happen ONCE per load rather than per visible row per render (every
114
+ // sort, search keystroke and page turn re-renders).
115
+ rows.forEach(function (b) { b.__eff = efficiencyTitle(b); });
116
+ return rows;
117
+ }
118
+
119
+ function matches(b) {
120
+ if (!query) return true;
121
+ var hay = ((b.github_login || '') + ' ' + (b.display_name || '')).toLowerCase();
122
+ return hay.indexOf(query) !== -1;
123
+ }
124
+ // The server returns credits-DESC, which is also the default lens; the others
125
+ // re-order the loaded set client-side, ties keeping the server's order so
126
+ // equal-value rows do not jitter between renders.
127
+ var SORTS = {
128
+ '': null,
129
+ rank: function (b) { return -rankWeight(b.rank); },
130
+ karma: function (b) { return -(Number(b.karma) || 0); },
131
+ tokens: function (b) { return -(Number(b.tokens_used) || 0); },
132
+ };
133
+ function ordered(rows) {
134
+ var key = SORTS[sortBy];
135
+ if (!key) return rows;
136
+ return rows.map(function (b, i) { return { b: b, i: i }; })
137
+ .sort(function (x, y) { return (key(x.b) - key(y.b)) || (x.i - y.i); })
138
+ .map(function (x) { return x.b; });
139
+ }
140
+
141
+ var pager = kit.makePager('roster', 'hall-roster-per-page', { defaultPerPage: 25 });
142
+ var ledger = null;
143
+
144
+ function efficiencyTitle(b) {
145
+ var credits = Number(b.total_credits) || 0;
146
+ var one = currencyLabel().toLowerCase().replace(/s$/, '');
147
+ if (credits <= 0) return 'no ' + currencyLabel().toLowerCase() + ' earned yet';
148
+ return fmtInt(Math.round((Number(b.tokens_used) || 0) / credits)) + ' tokens per ' + one;
149
+ }
150
+
151
+ function rowHtml(b) {
152
+ return '<tr>'
153
+ + '<td class="ledger__num">' + esc(String(b.__place || '—')) + '</td>'
154
+ + '<td>' + whoHtml(b) + achRowHtml(b.achievements) + '</td>'
155
+ + '<td class="ledger__rank">' + rankBadgeHtml(b.rank) + '</td>'
156
+ + '<td class="ledger__num">' + esc(fmtInt(b.total_credits)) + '</td>'
157
+ + '<td class="ledger__num">' + esc(fmtInt(b.karma)) + '</td>'
158
+ + '<td class="ledger__num" title="' + esc(b.__eff || '') + '">' + esc(fmtInt(b.tokens_used)) + '</td>'
159
+ + '</tr>';
160
+ }
161
+
162
+ function rerender() {
163
+ if (ledger) ledger.render(ordered(all.filter(matches)));
164
+ }
165
+
166
+ function notice(msg) {
167
+ body.innerHTML = '';
168
+ body.className = 'note';
169
+ body.textContent = msg;
170
+ }
171
+
172
+ function mountRoster(rows) {
173
+ all = stampPlaces(Array.isArray(rows) ? rows : []);
174
+ if (!all.length) {
175
+ notice('Nobody has earned anything on this project yet. A builder joins the roster the '
176
+ + 'first time they ship a claimed task.');
177
+ return;
178
+ }
179
+ if (countPill) { countPill.textContent = String(all.length); countPill.hidden = false; }
180
+ if (sub) {
181
+ sub.textContent = fmtInt(all.length) + ' builder' + (all.length === 1 ? '' : 's')
182
+ + ' on this project, ranked by ' + currencyLabel().toLowerCase() + ' earned. This '
183
+ + 'project’s own ledger — everyone who builds here is listed.';
184
+ }
185
+ kit.makeFilterBar({
186
+ mount: document.getElementById('roster-filters'),
187
+ search: { id: 'roster-q', label: 'Search', placeholder: 'name or login' },
188
+ primary: [
189
+ { id: 'roster-sort',
190
+ label: 'Sort',
191
+ options: [
192
+ { value: '', label: 'place' },
193
+ { value: 'rank', label: 'rank' },
194
+ { value: 'karma', label: 'most karma' },
195
+ { value: 'tokens', label: 'most tokens' },
196
+ ] },
197
+ ],
198
+ onChange: function (vals) {
199
+ query = String(vals['roster-q'] || '').trim().toLowerCase();
200
+ sortBy = String(vals['roster-sort'] || '');
201
+ pager.page = 1;
202
+ rerender();
203
+ },
204
+ });
205
+ body.textContent = '';
206
+ body.className = '';
207
+ ledger = kit.makeLedger({
208
+ mount: body,
209
+ columns: ['Place', 'Builder', 'Rank',
210
+ { label: currencyLabel(), class: 'ledger__num' },
211
+ { label: 'Karma', class: 'ledger__num' },
212
+ { label: 'Tokens', class: 'ledger__num' }],
213
+ rowHtml: rowHtml,
214
+ emptyMsg: 'No builder on the roster matches that search.',
215
+ pager: pager,
216
+ });
217
+ rerender();
218
+ }
219
+
220
+ fetch('/api/gds/leaderboard', { headers: { Accept: 'application/json' } })
221
+ .then(function (r) {
222
+ if (!r.ok) throw new Error('http ' + r.status);
223
+ return r.json();
224
+ })
225
+ .then(function (data) { mountRoster(data && data.leaderboard); })
226
+ .catch(function () { notice('Couldn’t load the roster right now.'); });
227
+ }());
@@ -0,0 +1,100 @@
1
+ {
2
+ "_": "Builders — this project's OWN roster, ranked by credits earned (task 1003441, goal 1000075). Was the #leaderboard hash view on index.html; /#leaderboard now redirects here. Deliberately UNGATED, because its one data source (GET /leaderboard) is deliberately unauthenticated and mirrors the public status surface — so unlike people.states.json the `out` state below renders the real page rather than a seal. Populated look: the hall-preview harness (scripts/hall-preview/fixtures/leaderboard.json carries thirteen builders with avatars, ranks, karma and tokens).",
3
+ "page": "/builders/roster",
4
+ "surface": "hall-ui",
5
+ "stub": {
6
+ "prefix": "/builders/"
7
+ },
8
+ "modeQuery": false,
9
+ "_feeds": "The kit's own stub carries no feed for this page, so against it the page renders its EMPTY and SKELETON states — which is the point: those are states the floors must hold in. Against the hall-preview harness the same states render populated. Every declared 404 below is a fixture the harness does not carry, named rather than hidden.",
10
+ "ignoreRequests": [
11
+ "/api/(gds|bongos)/me$",
12
+ "/api/(gds|bongos)/me/",
13
+ "/api/(gds|bongos)/goals",
14
+ "/api/(gds|bongos)/inbox/",
15
+ "/api/(gds|bongos)/versions",
16
+ "/api/(gds|bongos)/builders",
17
+ "/api/(gds|bongos)/leaderboard",
18
+ "/api/(gds|bongos)/public/"
19
+ ],
20
+ "ignoreConsole": [
21
+ "/api/(gds|bongos)/",
22
+ "\\b401\\b",
23
+ "\\b403\\b",
24
+ "\\b404\\b",
25
+ "\\b501\\b",
26
+ "Unauthorized",
27
+ "Forbidden",
28
+ "Not Found",
29
+ "Not Implemented",
30
+ "load failed"
31
+ ],
32
+ "states": {
33
+ "roster": {
34
+ "auth": true,
35
+ "actions": [
36
+ [
37
+ "wait",
38
+ 700
39
+ ]
40
+ ],
41
+ "expect": {
42
+ "visible": [
43
+ "#roster-scroll"
44
+ ]
45
+ }
46
+ },
47
+ "search": {
48
+ "auth": true,
49
+ "actions": [
50
+ [
51
+ "wait",
52
+ 700
53
+ ],
54
+ [
55
+ "fill",
56
+ "#roster-q",
57
+ "prev"
58
+ ],
59
+ [
60
+ "wait",
61
+ 200
62
+ ]
63
+ ],
64
+ "expect": {
65
+ "visible": [
66
+ "#roster-scroll"
67
+ ],
68
+ "value": [
69
+ [
70
+ "#roster-q",
71
+ "prev"
72
+ ]
73
+ ]
74
+ }
75
+ },
76
+ "out": {
77
+ "auth": false,
78
+ "actions": [
79
+ [
80
+ "wait",
81
+ 700
82
+ ]
83
+ ],
84
+ "expect": {
85
+ "visible": [
86
+ "#roster-scroll"
87
+ ]
88
+ }
89
+ }
90
+ },
91
+ "reducedMotion": {
92
+ "auth": true,
93
+ "actions": [
94
+ [
95
+ "wait",
96
+ 600
97
+ ]
98
+ ]
99
+ }
100
+ }
@@ -13,8 +13,9 @@
13
13
  // (#app-sidebar / #app-topbar / #app-foot) every hall page carries;
14
14
  // - rank-gate nav items COSMETICALLY (server-side page gates remain the real
15
15
  // access control — serve-internal.js requireGovernmentPage etc.);
16
- // - on the index (body[data-page="home"]) route the three views (#home,
17
- // #standing, #leaderboard) plus the #achievements alias into Standing. The
16
+ // - on the index (body[data-page="home"]) route the two views (#home,
17
+ // #standing) plus the #achievements alias into Standing, and REDIRECT the
18
+ // retired #leaderboard hash to the /roster page (task 1003441). The
18
19
  // `#/task|idea|blocker/N` deep-link hashes belong to builders.js and are
19
20
  // deliberately ignored here;
20
21
  // - expose window.OTBShell.applyAccess(...) so builders.js (index) or the
@@ -30,7 +31,10 @@
30
31
  var brand = window.__BRANDING__ || {};
31
32
  var PAGE = (document.body && document.body.dataset.page) || '';
32
33
  var IS_INDEX = PAGE === 'home';
33
- var VIEWS = ['home', 'standing', 'leaderboard'];
34
+ // task 1003441: 'leaderboard' left this list — the Roll is the /roster PAGE
35
+ // now. Its hash is not an alias (there is no host view to land on) but a
36
+ // REDIRECT, declared in VIEW_REDIRECTS below.
37
+ var VIEWS = ['home', 'standing'];
34
38
  // task 2174: #achievements retired as a view — the achievements grid folded
35
39
  // into Standing's Achievements rail section; task 2175 then retired its nav
36
40
  // entry. Old links (bookmarks, the stat tiles) keep working: the hash resolves
@@ -47,6 +51,16 @@
47
51
  'standing-progress': { view: 'standing', anchor: 'standing-progress' },
48
52
  'standing-achievements': { view: 'standing', anchor: 'standing-achievements' },
49
53
  };
54
+ // A retired view whose content moved to its OWN PAGE, so there is no host
55
+ // view to alias onto. Every existing #leaderboard link — bookmarks, the
56
+ // Standing card's stat tiles, profile.js's prose link — lands on the roster
57
+ // instead of silently falling through to Home (task 1003441).
58
+ // Object.create(null): the key is an attacker-controlled hash string, so a
59
+ // plain object would answer for "constructor"/"toString" as well as the real
60
+ // entries. Harmless with one literal key, wrong to leave as the pattern.
61
+ var VIEW_REDIRECTS = Object.assign(Object.create(null), {
62
+ leaderboard: '/roster',
63
+ });
50
64
  // builders.js owns these hashes (deep-link overlay) — never route them.
51
65
  var DEEPLINK_RE = /^#\/(task|idea|blocker)\/(\d+)$/;
52
66
 
@@ -157,7 +171,11 @@
157
171
  // Community because the group is picked by WHOSE data it is, and this is
158
172
  // other builders reaching you — not your own numbers (that is You).
159
173
  { id: 'collab', label: 'Collab', icon: 'leaderboard', href: '/collab', gate: 'member' },
160
- { id: 'leaderboard', label: 'Leaderboard', icon: 'leaderboard', href: '/#leaderboard', view: 'leaderboard', gate: 'member' },
174
+ // task 1003441: was the #leaderboard hash view on the home page, which is
175
+ // why this group read as one real page plus a link into index.html. Now a
176
+ // page, so no `view` key. Labelled Builders — 'roster' is the word
177
+ // modules/hall-ui/CLAUDE.md uses, but Builders is what a reader scans for.
178
+ { id: 'roster', label: 'Builders', icon: 'leaderboard', href: '/roster', gate: 'member' },
161
179
  // task 1002577: the cross-project builder directory (/scouting graduated
162
180
  // into the hall). Gated to MATCH its data — /api/gds/scouting checks
163
181
  // `project.curate`, whose catalog floor is METIC. This read 'archon' until
@@ -566,6 +584,12 @@
566
584
  }
567
585
 
568
586
  function route(opts) {
587
+ // The redirect runs BEFORE the view lookup: #leaderboard is no longer a
588
+ // view, so viewFromHash would return nothing and route() would leave the
589
+ // page on Home — a dead link that looks like it worked. replace() rather
590
+ // than assign() so Back does not bounce the reader between the two.
591
+ var redirect = IS_INDEX && VIEW_REDIRECTS[String(window.location.hash || "").replace(/^#/, "")];
592
+ if (redirect) { window.location.replace(redirect); return; }
569
593
  var target = viewFromHash(window.location.hash);
570
594
  if (target) {
571
595
  // An aliased hash owns its scroll position, so suppress setView's
@@ -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
- // Every existing caller (Work Board's open-detail button + backlog rows,
661
- // the Goals page) passes a TASK id — navigate straight to the real page
662
- // rather than round-tripping through the hash redirect above.
663
- open: function (id) { location.href = hallBase() + '/task/' + encodeURIComponent(id); },
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
- <a class="td-btn" href="${hallBase()}/work">Open the work board</a>
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"><a class="td-btn" href="${hallBase()}/work">Open the work board</a></div>
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 open the work board.</p>
449
- <div class="rec-actions rec-sec"><a class="td-btn" href="${hallBase()}/work">Open the work board</a></div>
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 1869: the explicit open-detail hitbox — now the ONLY way to open
1338
- // the shared task-detail overlay from a claimable card (the card body
1339
- // itself toggles selection below, since multi-select is the primary card
1340
- // interaction now that 1759 shipped).
1341
- const openBtn = e.target.closest('.task__open');
1342
- if (openBtn && openBtn.dataset.taskId && window.OTBTaskDetail) {
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.474",
3
+ "version": "1.19.476",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.474",
9
+ "version": "1.19.476",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",