@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.
- package/.bongos-core.json +56 -36
- 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 +255 -258
- package/docs/copy-registry.json +376 -414
- package/docs/module-api-changelog.md +4 -0
- package/modules/hall-ui/public/builders.js +13 -64
- package/modules/hall-ui/public/dom-utils.js +2 -1
- 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/hall-render.js +10 -142
- package/modules/hall-ui/public/hall-widgets.js +6 -16
- package/modules/hall-ui/public/index.html +0 -3
- package/modules/hall-ui/public/index.states.json +3 -2
- package/modules/hall-ui/public/profile.js +1 -1
- package/modules/hall-ui/public/roster.html +63 -0
- package/modules/hall-ui/public/roster.js +227 -0
- package/modules/hall-ui/public/roster.states.json +100 -0
- package/modules/hall-ui/public/shell.js +28 -4
- 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_nav.mjs +36 -1
- package/tests/hall_page_gate_map.mjs +8 -0
- package/tests/hall_record_world.mjs +83 -4
- 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
|
|
17
|
-
// #standing
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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.476",
|
|
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.476",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|