@bongos/core 1.19.473 → 1.19.475
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 -31
- package/docs/adr/0228-the-reading-room-owes-a-reference-content-rules-not-a-second-archetype.md +44 -0
- package/docs/adr/README.md +1 -0
- package/docs/copy-inventory.md +144 -140
- package/docs/copy-registry.json +262 -206
- 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/drachmae.html +50 -44
- package/modules/hall-ui/public/drachmae.js +147 -59
- package/modules/hall-ui/public/drachmae.states.json +20 -26
- 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/md-reader.js +10 -4
- package/modules/hall-ui/public/profile.js +1 -1
- package/modules/hall-ui/public/room.css +116 -3
- 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/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/hall_nav.mjs +36 -1
- package/tests/hall_page_gate_map.mjs +8 -0
- package/tests/hall_reference_world.mjs +222 -0
- 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
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.475",
|
|
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.475",
|
|
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.475",
|
|
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.475'; // 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');
|
package/tests/hall_nav.mjs
CHANGED
|
@@ -42,11 +42,17 @@ function boot({ page = 'settings', hash = '', pathname = '/settings' } = {}) {
|
|
|
42
42
|
return el;
|
|
43
43
|
};
|
|
44
44
|
|
|
45
|
+
// replace() is recorded, not ignored: task 1003441 retired the #leaderboard
|
|
46
|
+
// VIEW and route() answers that hash with location.replace('/roster'). A stub
|
|
47
|
+
// without it throws the moment a boot carries that hash, which is precisely
|
|
48
|
+
// why the redirect shipped untested the first time.
|
|
49
|
+
const replaced = [];
|
|
45
50
|
const location = {
|
|
46
51
|
hostname: 'builders.cloudbongos.com',
|
|
47
52
|
origin: 'https://builders.cloudbongos.com',
|
|
48
53
|
pathname, hash,
|
|
49
54
|
assign() {},
|
|
55
|
+
replace(target) { replaced.push(String(target)); },
|
|
50
56
|
};
|
|
51
57
|
|
|
52
58
|
const documentObj = {
|
|
@@ -90,6 +96,7 @@ function boot({ page = 'settings', hash = '', pathname = '/settings' } = {}) {
|
|
|
90
96
|
sidebarHtml: documentObj.getElementById('app-sidebar').innerHTML,
|
|
91
97
|
viewHidden: (v) => documentObj.getElementById('view-' + v).hidden,
|
|
92
98
|
shell: windowObj.OTBShell,
|
|
99
|
+
replaced,
|
|
93
100
|
};
|
|
94
101
|
}
|
|
95
102
|
|
|
@@ -155,7 +162,11 @@ test('sidebar groups read by audience, in order', () => {
|
|
|
155
162
|
// First within the group because it is the one page here you ACT on; the
|
|
156
163
|
// leaderboard and the directory are browsing surfaces. Updating this oracle is
|
|
157
164
|
// the reviewed decision the pin exists to force.
|
|
158
|
-
|
|
165
|
+
// task 1003441: `leaderboard` → `roster`. The id changed because the THING
|
|
166
|
+
// changed: it was a hash view on index.html (href /#leaderboard, view:
|
|
167
|
+
// 'leaderboard') and is now a real page at /roster labelled Builders. That is
|
|
168
|
+
// what made this group read as one real page plus a link into the home page.
|
|
169
|
+
assert.deepEqual(nav[2].items.map((i) => i.id), ['collab', 'roster', 'people'], 'Community = everyone else');
|
|
159
170
|
assert.deepEqual(nav[3].items.map((i) => i.id),
|
|
160
171
|
['primer', 'ranks', 'economy', 'diagrams', 'atlas', 'modules', 'apidocs'], 'Docs = reference material');
|
|
161
172
|
// 'government' (the Permissions tab, BV1.R113) is the group's one archon-gated item.
|
|
@@ -194,6 +205,30 @@ test('group-gate check actually CATCHES a head gated wider than its items', () =
|
|
|
194
205
|
assert.equal(headOutOfRange({ label: 'X', headGate: 'archon', items: [{ gate: 'archon' }] }), false);
|
|
195
206
|
});
|
|
196
207
|
|
|
208
|
+
test('#leaderboard REDIRECTS to /roster from the index, and only from the index', () => {
|
|
209
|
+
// task 1003441: the Roll became a page, so its hash has no host view to
|
|
210
|
+
// alias onto. Without the redirect viewFromHash returns nothing and route()
|
|
211
|
+
// leaves the reader on Home — a dead link that looks like it worked, which
|
|
212
|
+
// is the failure this pins. The nav item points at the page, not the hash.
|
|
213
|
+
const ids = parseNav(boot().sidebarHtml).flatMap((g) => g.items.map((i) => i.id));
|
|
214
|
+
assert.ok(!ids.includes('leaderboard'), 'the hash-view nav entry retired with the view');
|
|
215
|
+
assert.ok(ids.includes('roster'), 'the page took its place');
|
|
216
|
+
|
|
217
|
+
const onIndex = boot({ page: 'home', hash: '#leaderboard', pathname: '/' });
|
|
218
|
+
assert.deepEqual(onIndex.replaced, ['/roster'],
|
|
219
|
+
'the index answers the retired hash with a replace() to the page');
|
|
220
|
+
|
|
221
|
+
// The guard matters: the hash is not this shell’s to interpret on a page
|
|
222
|
+
// that has no views, and hijacking it there would break an in-page anchor
|
|
223
|
+
// that happens to be spelled the same.
|
|
224
|
+
const offIndex = boot({ page: 'settings', hash: '#leaderboard', pathname: '/settings' });
|
|
225
|
+
assert.deepEqual(offIndex.replaced, [], 'a non-index page redirects nothing');
|
|
226
|
+
|
|
227
|
+
// And a hash the map does not name is still left alone entirely.
|
|
228
|
+
const unknown = boot({ page: 'home', hash: '#nope', pathname: '/' });
|
|
229
|
+
assert.deepEqual(unknown.replaced, [], 'an unknown hash is not a redirect');
|
|
230
|
+
});
|
|
231
|
+
|
|
197
232
|
test('Achievements has no nav entry, but #achievements still routes to Standing', () => {
|
|
198
233
|
const ids = parseNav(boot().sidebarHtml).flatMap((g) => g.items.map((i) => i.id));
|
|
199
234
|
assert.ok(!ids.includes('achievements'), 'the nav entry retired with task 2175');
|
|
@@ -79,6 +79,14 @@ const ORACLE = {
|
|
|
79
79
|
|
|
80
80
|
// Deliberately UNGATED — the landing card, the rank ladder, the reward
|
|
81
81
|
// schedule and a builder's public portfolio all render for a stranger.
|
|
82
|
+
//
|
|
83
|
+
// /roster joins them (task 1003441): it is this project’s own ledger, and its
|
|
84
|
+
// one data source — GET /leaderboard — is deliberately unauthenticated,
|
|
85
|
+
// mirroring the public status surface. The page must not be gated tighter than
|
|
86
|
+
// the API it fronts, which is the same rule that puts /people at
|
|
87
|
+
// project.curate. It was reachable to a stranger as the #leaderboard view on
|
|
88
|
+
// the ungated index, so this preserves the audience rather than widening it.
|
|
89
|
+
'/builders/roster': null,
|
|
82
90
|
'/builders/': null,
|
|
83
91
|
'/builders/ranks': null,
|
|
84
92
|
'/builders/drachmae': null,
|