@bongos/core 1.19.332 → 1.19.334
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 +173 -43
- package/docs/adr/0187-oauth-handshake-cookie-lifetime.md +84 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +2 -2
- package/docs/api-reference.md +1 -1
- package/docs/copy-inventory.md +202 -202
- package/docs/copy-registry.json +384 -384
- package/modules/grading/grader-config.js +98 -0
- package/modules/grading/grader-model.js +150 -0
- package/modules/grading/grader-prompt.js +139 -0
- package/modules/grading/grader-score.js +706 -0
- package/modules/grading/grader-subagent.js +328 -0
- package/modules/grading/grader.js +5 -1287
- package/modules/hall-ui/public/builders.js +19 -1182
- package/modules/hall-ui/public/goals-page.js +11 -997
- package/modules/hall-ui/public/goals-render.js +1032 -0
- package/modules/hall-ui/public/goals.html +4 -0
- package/modules/hall-ui/public/hall-render.js +1211 -0
- package/modules/hall-ui/public/index.html +6 -0
- package/modules/lifecycle/routes/goal-route-authz.js +400 -0
- package/modules/lifecycle/routes/goals.js +1 -367
- package/modules/lifecycle/routes/task-route-helpers.js +219 -0
- package/modules/lifecycle/routes/task-write-routes.js +542 -0
- package/modules/lifecycle/routes/tasks.js +3 -696
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/box-infra.js +280 -0
- package/scripts/gds/box.js +1 -245
- package/scripts/gds/fitness-checks-identity.js +320 -0
- package/scripts/gds/fitness-checks-vocabulary.js +230 -0
- package/scripts/gds/fitness-checks-write-validation.js +144 -0
- package/scripts/gds/fitness-lib.js +82 -0
- package/scripts/gds/fitness.js +55 -668
- package/scripts/gds/provision-config.js +204 -0
- package/scripts/gds/provision-net.js +221 -0
- package/scripts/gds/provision-repo.js +849 -0
- package/scripts/gds/provision-units.js +262 -0
- package/scripts/gds/provision.js +4 -1395
- package/src/bongos/auth-admission.js +264 -0
- package/src/bongos/auth-config.js +151 -0
- package/src/bongos/auth-discord.js +198 -0
- package/src/bongos/auth-github.js +336 -0
- package/src/bongos/auth-idp.js +330 -0
- package/src/bongos/auth.js +5 -1100
- package/src/bongos/routes/auth.js +54 -10
- package/src/module-api.js +5 -1
- package/tests/auth_admission_gate.mjs +3 -1
- package/tests/auth_web_recovery.mjs +63 -0
- package/tests/est_advisory.mjs +3 -1
- package/tests/goal_advisory.mjs +3 -2
- package/tests/goal_criterion_confirm_ui.mjs +6 -0
- package/tests/goal_subtitle.mjs +5 -2
- package/tests/goals_card_view.mjs +10 -3
- package/tests/grader_generated_provenance.mjs +3 -1
- package/tests/hall_palette.mjs +3 -1
- package/tests/helpers.mjs +58 -25
- package/tests/lib_sh_resolution.mjs +3 -1
- package/tests/membership_kind_lockstep.mjs +3 -1
- package/tests/migration_allocation.mjs +5 -2
- package/tests/patch_gate_ordering.mjs +3 -1
- package/tests/rank_tier_single_source.mjs +16 -3
- package/tests/reward_gate.mjs +3 -2
|
@@ -0,0 +1,1211 @@
|
|
|
1
|
+
// hall-render.js — the Builders' Hall RENDERERS, exposed as window.OTBHallRender.
|
|
2
|
+
//
|
|
3
|
+
// Carved out of builders.js by task 1003206 (goal 1000079, the R03 de-monolith
|
|
4
|
+
// plan): that file was 2,558 lines, the largest first-party file left over the
|
|
5
|
+
// 1,500-line ratchet. This holds the view painters — landing, profile, welcome,
|
|
6
|
+
// onboarding, recent ships, the AI-dev curve, the leaderboard, the nav and hall
|
|
7
|
+
// widgets — plus the helpers and view state only they touch.
|
|
8
|
+
//
|
|
9
|
+
// WHY A FACTORY, and not a flat namespace like goals-lib.js. goals-lib.js can be
|
|
10
|
+
// flat because everything in it is pure. These renderers are not: they need the
|
|
11
|
+
// page's api client and formatters, and — the part that matters — page state that
|
|
12
|
+
// KEEPS CHANGING after load (the last /me payload, the karma/cost/needs/rebase
|
|
13
|
+
// snapshots, the newcomer gate). Copying those in once would freeze them at boot
|
|
14
|
+
// and the hall would render stale forever. So builders.js passes a ctx whose live
|
|
15
|
+
// fields are GETTERS and the bodies below read `ctx.lastMeState`; everything
|
|
16
|
+
// stable is destructured once, keeping those call sites byte-identical.
|
|
17
|
+
//
|
|
18
|
+
// Load order: index.html loads this BEFORE builders.js (the goals-lib.js /
|
|
19
|
+
// hall-kit.js pattern), and builders.js calls the factory once, after its own
|
|
20
|
+
// declarations, so nothing is read before it exists.
|
|
21
|
+
(function () {
|
|
22
|
+
'use strict';
|
|
23
|
+
|
|
24
|
+
window.OTBHallRender = function createHallRender(ctx) {
|
|
25
|
+
const { API, RANK_ORDER, api, brandWorld, curLabel, emptyRow, fmtDate, mins, normalizeRank, pct, postJSON, rankLabel, rankWeight, signClasses, toast, usd } = ctx;
|
|
26
|
+
const { escapeHtml, $, fmtNum, moduleOn } = window.OTB;
|
|
27
|
+
|
|
28
|
+
const RANK_UNLOCKS = {
|
|
29
|
+
xenos: [
|
|
30
|
+
'Read every public page.',
|
|
31
|
+
'Claim and ship tasks an Archon has flagged newcomer-friendly.',
|
|
32
|
+
`Earn ${curLabel()} for shipped work and karma for ratified ideas.`,
|
|
33
|
+
],
|
|
34
|
+
thetes: [
|
|
35
|
+
'Claim and ship any task in the general queue — no longer only newcomer-friendly work.',
|
|
36
|
+
`Keep earning ${curLabel()} and karma for every clean ship.`,
|
|
37
|
+
'No triage, blockers, planning, or task-shaping yet — those levers open at Metic.',
|
|
38
|
+
],
|
|
39
|
+
metic: [
|
|
40
|
+
'Claim and ship any task in the ready queue.',
|
|
41
|
+
'Triage the idea inbox; resolve blockers; run planning sessions.',
|
|
42
|
+
'Propose new version criteria for Archon ratification.',
|
|
43
|
+
],
|
|
44
|
+
archon: [
|
|
45
|
+
'Create, promote, and edit tasks; reshape the dependency graph.',
|
|
46
|
+
'Grant or revoke ranks; close versions; confirm ships past the grader.',
|
|
47
|
+
'Adjudicate vulnerability reports and override requests.',
|
|
48
|
+
],
|
|
49
|
+
};
|
|
50
|
+
let rankTipSeq = 0;
|
|
51
|
+
|
|
52
|
+
function renderRankBadge(rank, opts = {}) {
|
|
53
|
+
const label = escapeHtml(rankLabel(rank));
|
|
54
|
+
const ariaLabel = rank ? `Rank: ${rankLabel(rank)}` : 'Rank not yet recorded';
|
|
55
|
+
const variant = RANK_ORDER[rank] ? `rank-badge--${rank}` : 'rank-badge--unknown';
|
|
56
|
+
|
|
57
|
+
// Plain plaque (default — used in tables, roster, leaderboard).
|
|
58
|
+
if (!opts.tip || !RANK_UNLOCKS[rank]) {
|
|
59
|
+
const title = opts.title ? ` title="${escapeHtml(opts.title)}"` : '';
|
|
60
|
+
return `<span class="rank-badge ${variant}" aria-label="${escapeHtml(ariaLabel)}"${title}>${label}</span>`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Tooltip variant — a hover/focus popover lists what this rank unlocks,
|
|
64
|
+
// and a transparent "stretched link" overlay makes the whole plaque
|
|
65
|
+
// navigate to the full ladder. We keep the badge a <span> (not an <a>) on
|
|
66
|
+
// purpose: the hall's global a:hover rule repaints links terracotta, which
|
|
67
|
+
// would otherwise bleed into the plaque AND the tip text. The overlay
|
|
68
|
+
// anchor carries the navigation + keyboard focus without touching colour.
|
|
69
|
+
// NB: this markup contains a <ul>, which is NOT valid inside a <p>. If a
|
|
70
|
+
// caller drops this tipped badge into a <p>, the HTML parser auto-closes
|
|
71
|
+
// the <p> at the <ul> and hoists the list out of .rank-tip — the unlocks
|
|
72
|
+
// then render as a visible block instead of a hover popover (#530). Render
|
|
73
|
+
// it inside a <div>/inline container, never a <p>.
|
|
74
|
+
const tipId = `rank-tip-${rank}-${rankTipSeq++}`;
|
|
75
|
+
const items = RANK_UNLOCKS[rank].map((u) => `<li>${escapeHtml(u)}</li>`).join('');
|
|
76
|
+
return `<span class="rank-badge-wrap">` +
|
|
77
|
+
`<span class="rank-badge ${variant} rank-badge--tipped" aria-hidden="true">${label}</span>` +
|
|
78
|
+
`<a class="rank-badge-stretch" href="/ranks" ` +
|
|
79
|
+
`aria-describedby="${tipId}" ` +
|
|
80
|
+
`aria-label="${escapeHtml(ariaLabel)} — see all standings"></a>` +
|
|
81
|
+
`<span class="rank-tip" id="${tipId}" role="tooltip">` +
|
|
82
|
+
`<span class="rank-tip__label">What ${label} unlocks</span>` +
|
|
83
|
+
`<ul class="rank-tip__list">${items}</ul>` +
|
|
84
|
+
`<span class="rank-tip__more">Walk the full ladder →</span>` +
|
|
85
|
+
`</span>` +
|
|
86
|
+
`</span>`;
|
|
87
|
+
}
|
|
88
|
+
const VIEW_CONTAINERS = {
|
|
89
|
+
home: 'zone-home-main',
|
|
90
|
+
standing: 'zone-standing',
|
|
91
|
+
leaderboard: 'zone-leaderboard',
|
|
92
|
+
};
|
|
93
|
+
const STANDING_GROUPS = [
|
|
94
|
+
{ key: 'work', label: 'Work' },
|
|
95
|
+
{ key: 'karma', label: 'Karma' },
|
|
96
|
+
{ key: 'progress', label: 'Progress' },
|
|
97
|
+
{ key: 'achievements', label: 'Achievements' },
|
|
98
|
+
];
|
|
99
|
+
const standingGroupId = (key) => `standing-${key}`;
|
|
100
|
+
function ensureStandingGroups() {
|
|
101
|
+
const zone = document.getElementById('zone-standing');
|
|
102
|
+
if (!zone || document.getElementById(standingGroupId(STANDING_GROUPS[0].key))) return;
|
|
103
|
+
for (const g of STANDING_GROUPS) {
|
|
104
|
+
const el = document.createElement('div');
|
|
105
|
+
el.id = standingGroupId(g.key);
|
|
106
|
+
el.className = 'standing-group';
|
|
107
|
+
zone.appendChild(el);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
function widgetHost(wgt) {
|
|
111
|
+
const zone = wgt.zone ? document.getElementById(`zone-${wgt.zone}`) : null;
|
|
112
|
+
if (zone) return zone;
|
|
113
|
+
if (wgt.group) {
|
|
114
|
+
ensureStandingGroups();
|
|
115
|
+
const group = document.getElementById(standingGroupId(wgt.group));
|
|
116
|
+
if (group) return group;
|
|
117
|
+
}
|
|
118
|
+
const viewId = VIEW_CONTAINERS[wgt.view || 'home'] || VIEW_CONTAINERS.home;
|
|
119
|
+
return document.getElementById(viewId) || document.querySelector('main');
|
|
120
|
+
}
|
|
121
|
+
const ZONE_BUDGETS = { 'home-stats': 1, 'home-band': 2, 'home-main': 3 };
|
|
122
|
+
function mountHallWidgets() {
|
|
123
|
+
const main = document.querySelector('main');
|
|
124
|
+
if (!main || typeof window.OTB.hallWidgets !== 'function') return;
|
|
125
|
+
const zoneCounts = {};
|
|
126
|
+
const overflow = [];
|
|
127
|
+
let overflowHost = null;
|
|
128
|
+
for (const wgt of window.OTB.hallWidgets()) {
|
|
129
|
+
try {
|
|
130
|
+
let host = widgetHost(wgt) || main;
|
|
131
|
+
const isHomeZone = (wgt.view || 'home') === 'home' && wgt.zone && ZONE_BUDGETS[wgt.zone] != null;
|
|
132
|
+
if (isHomeZone) {
|
|
133
|
+
zoneCounts[wgt.zone] = (zoneCounts[wgt.zone] || 0) + 1;
|
|
134
|
+
if (zoneCounts[wgt.zone] > ZONE_BUDGETS[wgt.zone]) {
|
|
135
|
+
if (!overflowHost) {
|
|
136
|
+
overflowHost = document.createElement('div');
|
|
137
|
+
overflowHost.id = 'zone-home-overflow';
|
|
138
|
+
overflowHost.hidden = true;
|
|
139
|
+
document.getElementById('zone-home-main')?.appendChild(overflowHost);
|
|
140
|
+
}
|
|
141
|
+
host = overflowHost || host;
|
|
142
|
+
overflow.push({ id: wgt.id, label: wgt.label });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
let section = document.getElementById(wgt.id);
|
|
146
|
+
if (!section) {
|
|
147
|
+
section = document.createElement('section');
|
|
148
|
+
section.id = wgt.id;
|
|
149
|
+
host.appendChild(section);
|
|
150
|
+
}
|
|
151
|
+
section.className = wgt.sectionClass || 'scroll';
|
|
152
|
+
if (wgt.labelledby) section.setAttribute('aria-labelledby', wgt.labelledby);
|
|
153
|
+
// hiddenByDefault widgets (welcome/onboarding/karma/acclaim/curve) start
|
|
154
|
+
// hidden exactly as the old inline markup did; their own render code
|
|
155
|
+
// un-hides them when there's something to show.
|
|
156
|
+
if (wgt.hiddenByDefault) section.hidden = true;
|
|
157
|
+
wgt.mount(section);
|
|
158
|
+
} catch (err) {
|
|
159
|
+
console.error(`[builders] hall widget "${wgt && wgt.id}" failed to mount`, err);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (overflow.length) {
|
|
163
|
+
const more = document.createElement('section');
|
|
164
|
+
more.className = 'scroll scroll--bare';
|
|
165
|
+
more.id = 'home-more-row';
|
|
166
|
+
more.innerHTML = `<p class="home-more">More: ${overflow.map((o) =>
|
|
167
|
+
`<a href="#" data-reveal="${o.id}">${o.label}</a>`).join(' · ')}</p>`;
|
|
168
|
+
document.getElementById('zone-home-main')?.appendChild(more);
|
|
169
|
+
more.addEventListener('click', (e) => {
|
|
170
|
+
const a = e.target.closest('[data-reveal]');
|
|
171
|
+
if (!a) return;
|
|
172
|
+
e.preventDefault();
|
|
173
|
+
const section = document.getElementById(a.dataset.reveal);
|
|
174
|
+
if (section) {
|
|
175
|
+
document.getElementById('zone-home-main')?.insertBefore(section, more);
|
|
176
|
+
section.hidden = false;
|
|
177
|
+
}
|
|
178
|
+
a.remove();
|
|
179
|
+
if (!more.querySelector('[data-reveal]')) more.remove();
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
// brand-apply.js's currency-label pass ran at DOMContentLoaded, BEFORE these
|
|
183
|
+
// skeletons existed — re-run it over the mounted widgets so [data-currency-
|
|
184
|
+
// label]/[data-currency-tmpl] cells show the instance's unit, not the fallback.
|
|
185
|
+
window.OTBBranding?.applyCurrencyLabels?.(main);
|
|
186
|
+
}
|
|
187
|
+
let standingRail = null;
|
|
188
|
+
|
|
189
|
+
// task 1723: the old sticky chip strip is gone. renderNav now settles the
|
|
190
|
+
// three nav surfaces the shell owns or hosts:
|
|
191
|
+
// 1. the sidebar's gated entries, via window.OTBShell.applyAccess (cosmetic —
|
|
192
|
+
// the server enforces every page + endpoint regardless, ADR 0016);
|
|
193
|
+
function renderNav(builder, onboarding) {
|
|
194
|
+
const isArchon = (builder?.rank || '').toLowerCase() === 'archon';
|
|
195
|
+
const graduated = !!(onboarding && onboarding.graduated);
|
|
196
|
+
// #833: for a newcomer the Work entry surfaces only once the "ship three"
|
|
197
|
+
// stage is current — before then the path reads one action at a time.
|
|
198
|
+
const stages = Array.isArray(onboarding?.stages) ? onboarding.stages : [];
|
|
199
|
+
const shipStepCurrent = stages.some((s) => s.id === 'ship_three' && s.is_current && !s.cleared);
|
|
200
|
+
window.OTBShell?.applyAccess({
|
|
201
|
+
authed: true,
|
|
202
|
+
rank: normalizeRank(builder?.rank),
|
|
203
|
+
graduated,
|
|
204
|
+
showWork: shipStepCurrent,
|
|
205
|
+
builder,
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// Re-open affordance: pre-graduation, when the welcome banner is dismissed
|
|
209
|
+
// (hidden), a small link brings it back (reopenWelcomeFromNav).
|
|
210
|
+
const reopen = document.getElementById('welcome-reopen');
|
|
211
|
+
if (reopen) {
|
|
212
|
+
const welcomeEl = document.getElementById('welcome-scroll');
|
|
213
|
+
reopen.hidden = !(welcomeEl && welcomeEl.hidden && !graduated);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Standing section rail: one link per group with a visible member.
|
|
217
|
+
const rail = document.getElementById('standing-rail');
|
|
218
|
+
if (rail) {
|
|
219
|
+
const shown = new Set(
|
|
220
|
+
window.OTB.hallWidgets()
|
|
221
|
+
.filter((wgt) => {
|
|
222
|
+
if (!wgt.group) return false;
|
|
223
|
+
if (wgt.archonOnly && !isArchon) return false;
|
|
224
|
+
const el = document.getElementById(wgt.id);
|
|
225
|
+
return !!el && !el.hidden;
|
|
226
|
+
})
|
|
227
|
+
.map((wgt) => wgt.group),
|
|
228
|
+
);
|
|
229
|
+
// An empty group would otherwise leave a gap in the panel column.
|
|
230
|
+
for (const g of STANDING_GROUPS) {
|
|
231
|
+
const el = document.getElementById(standingGroupId(g.key));
|
|
232
|
+
if (el) el.hidden = !shown.has(g.key);
|
|
233
|
+
}
|
|
234
|
+
const items = STANDING_GROUPS
|
|
235
|
+
.filter((g) => shown.has(g.key))
|
|
236
|
+
.map((g) => ({ id: standingGroupId(g.key), label: g.label }));
|
|
237
|
+
if (standingRail) standingRail.refresh(items);
|
|
238
|
+
else standingRail = window.OTBKit.makeSectionRail({ mount: rail, items, mode: 'scrollspy' });
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
function renderLanding() {
|
|
242
|
+
// task 1723: collapse the shell nav to the signed-out minimum (Home + a
|
|
243
|
+
// Sign-in button). Cosmetic — the server gates every page regardless.
|
|
244
|
+
window.OTBShell?.applyAccess({ authed: false });
|
|
245
|
+
|
|
246
|
+
const welcome = $('#welcome-line');
|
|
247
|
+
if (welcome) welcome.textContent = '';
|
|
248
|
+
|
|
249
|
+
const landing = $('#landing-root');
|
|
250
|
+
if (landing) {
|
|
251
|
+
landing.hidden = false;
|
|
252
|
+
landing.innerHTML = `
|
|
253
|
+
<div class="scroll landing">
|
|
254
|
+
<h2 class="scroll__h">Welcome</h2>
|
|
255
|
+
<p class="landing__lede">
|
|
256
|
+
This is the builders' hub for ${brandWorld()} — where builders claim
|
|
257
|
+
work, earn ${curLabel()}, and track their standing. Access is
|
|
258
|
+
invite-based: an Archon approves each new builder. To begin, request
|
|
259
|
+
access below.
|
|
260
|
+
</p>
|
|
261
|
+
|
|
262
|
+
<div class="landing__request" id="landing-request">
|
|
263
|
+
<h3 class="landing__request-h">New here — request access</h3>
|
|
264
|
+
<p class="landing__request-lede">
|
|
265
|
+
Leave your GitHub username and an Archon will let you in. No coding
|
|
266
|
+
needed to start — once you're approved, sign in with GitHub and a
|
|
267
|
+
step-by-step guide takes you the rest of the way.
|
|
268
|
+
</p>
|
|
269
|
+
<form class="landing__form" id="access-request-form" novalidate>
|
|
270
|
+
<label class="landing__field">
|
|
271
|
+
<span>GitHub username <span class="landing__req-mark" aria-hidden="true">*</span></span>
|
|
272
|
+
<input type="text" id="ar-login" name="github_login" autocomplete="username"
|
|
273
|
+
placeholder="e.g. octocat" maxlength="40" required>
|
|
274
|
+
</label>
|
|
275
|
+
<label class="landing__field">
|
|
276
|
+
<span>Your name <span class="landing__opt">(optional)</span></span>
|
|
277
|
+
<input type="text" id="ar-name" name="display_name" autocomplete="name"
|
|
278
|
+
placeholder="So the Archon knows who you are" maxlength="100">
|
|
279
|
+
</label>
|
|
280
|
+
<label class="landing__field">
|
|
281
|
+
<span>A word on why <span class="landing__opt">(optional)</span></span>
|
|
282
|
+
<input type="text" id="ar-note" name="note"
|
|
283
|
+
placeholder="How you got here, what you'd build" maxlength="500">
|
|
284
|
+
</label>
|
|
285
|
+
<p class="landing__form-hint">
|
|
286
|
+
No GitHub account yet? Make a free one at
|
|
287
|
+
<a href="https://github.com/signup" target="_blank" rel="noopener noreferrer">github.com</a>
|
|
288
|
+
first — your username is all we need.
|
|
289
|
+
</p>
|
|
290
|
+
<button type="submit" class="landing__btn landing__btn--request" id="ar-submit">Request access</button>
|
|
291
|
+
<p class="landing__request-status" id="ar-status" aria-live="polite"></p>
|
|
292
|
+
</form>
|
|
293
|
+
</div>
|
|
294
|
+
|
|
295
|
+
<p class="landing__signin-note">
|
|
296
|
+
Already a member? <a href="${API}/auth/web/start">Sign in with GitHub</a>.
|
|
297
|
+
We store only your GitHub login, avatar, and the work you ship.
|
|
298
|
+
</p>
|
|
299
|
+
</div>
|
|
300
|
+
`;
|
|
301
|
+
wireAccessRequestForm();
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Hide every widget section — nothing to show until the visitor signs in.
|
|
305
|
+
document.querySelectorAll('main .scroll').forEach((el) => {
|
|
306
|
+
if (!landing || !landing.contains(el)) el.hidden = true;
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
function wireAccessRequestForm() {
|
|
310
|
+
const form = $('#access-request-form');
|
|
311
|
+
if (!form) return;
|
|
312
|
+
form.addEventListener('submit', async (e) => {
|
|
313
|
+
e.preventDefault();
|
|
314
|
+
const loginEl = $('#ar-login');
|
|
315
|
+
const nameEl = $('#ar-name');
|
|
316
|
+
const noteEl = $('#ar-note');
|
|
317
|
+
const submit = $('#ar-submit');
|
|
318
|
+
const status = $('#ar-status');
|
|
319
|
+
const login = (loginEl?.value || '').trim().replace(/^@/, '');
|
|
320
|
+
if (!login) {
|
|
321
|
+
if (status) { status.dataset.kind = 'err'; status.textContent = 'A GitHub username is needed.'; }
|
|
322
|
+
if (loginEl) loginEl.focus();
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (submit) { submit.disabled = true; submit.textContent = 'Sending…'; }
|
|
326
|
+
if (status) { status.dataset.kind = ''; status.textContent = ''; }
|
|
327
|
+
try {
|
|
328
|
+
const data = await postJSON(`${API}/access-requests`, {
|
|
329
|
+
github_login: login,
|
|
330
|
+
display_name: (nameEl?.value || '').trim() || null,
|
|
331
|
+
note: (noteEl?.value || '').trim() || null,
|
|
332
|
+
});
|
|
333
|
+
const req = $('#landing-request');
|
|
334
|
+
if (req) {
|
|
335
|
+
req.innerHTML = `
|
|
336
|
+
<h3 class="landing__request-h">Your request is sent ✓</h3>
|
|
337
|
+
<p class="landing__request-lede">
|
|
338
|
+
${data && data.already_pending
|
|
339
|
+
? 'You were already in the queue — an Archon will approve you. Once approved, sign in with GitHub to get started.'
|
|
340
|
+
: 'An Archon has been notified and will approve you. Once approved, sign in with GitHub to get started.'}
|
|
341
|
+
</p>
|
|
342
|
+
<p class="landing__request-lede landing__request-lede--soft">
|
|
343
|
+
While you wait: install the Claude desktop app at
|
|
344
|
+
<a href="https://claude.com/download" target="_blank" rel="noopener noreferrer">claude.com/download</a>.
|
|
345
|
+
The full step-by-step guide appears here once you're in.
|
|
346
|
+
</p>`;
|
|
347
|
+
}
|
|
348
|
+
} catch (err) {
|
|
349
|
+
// Already known to the city — they don't need a request, they need to
|
|
350
|
+
// sign in. Both the "already a builder" and "already approved" cases
|
|
351
|
+
// collapse the form into a sign-in prompt rather than a red error.
|
|
352
|
+
if (err && (err.code === 'already_member' || err.code === 'already_approved')) {
|
|
353
|
+
const req = $('#landing-request');
|
|
354
|
+
if (req) {
|
|
355
|
+
req.innerHTML = `
|
|
356
|
+
<h3 class="landing__request-h">You already have access ✓</h3>
|
|
357
|
+
<p class="landing__request-lede">${escapeHtml(err.message || 'You already have access.')}</p>
|
|
358
|
+
<p class="landing__cta">
|
|
359
|
+
<a class="landing__btn" href="${API}/auth/web/start">Sign in with GitHub</a>
|
|
360
|
+
</p>`;
|
|
361
|
+
}
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
if (submit) { submit.disabled = false; submit.textContent = 'Request access'; }
|
|
365
|
+
if (status) {
|
|
366
|
+
status.dataset.kind = 'err';
|
|
367
|
+
status.textContent = (err && err.status === 400)
|
|
368
|
+
? 'That does not look like a GitHub username — check it and try again.'
|
|
369
|
+
: 'Could not send just now. Try again in a moment.';
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
function renderProfile(builder) {
|
|
375
|
+
const karma = ctx.lastKarmaState;
|
|
376
|
+
const cost = ctx.lastCostState;
|
|
377
|
+
const needs = ctx.lastNeedsState;
|
|
378
|
+
const rebase = ctx.lastRebaseState;
|
|
379
|
+
const avatar = builder.avatar_url
|
|
380
|
+
? `<img class="profile__avatar" src="${escapeHtml(builder.avatar_url)}" alt="">`
|
|
381
|
+
: `<div class="profile__avatar" aria-hidden="true"></div>`;
|
|
382
|
+
|
|
383
|
+
const prefs = Array.isArray(builder.preferred_disciplines) ? builder.preferred_disciplines : [];
|
|
384
|
+
|
|
385
|
+
const rankBadge = builder.rank
|
|
386
|
+
? renderRankBadge(normalizeRank(builder.rank), { tip: true })
|
|
387
|
+
: renderRankBadge(builder.rank, { title: 'No rank recorded yet' });
|
|
388
|
+
|
|
389
|
+
// #757: this builder's place on the Roll. Null until the leaderboard rows
|
|
390
|
+
// load (they're fetched async after the Standing card's first paint; the
|
|
391
|
+
// card re-renders once they arrive) — when null the stat simply omits.
|
|
392
|
+
const rollPlace = leaderboardPlace(builder.github_login);
|
|
393
|
+
|
|
394
|
+
// V3.R26 (#246): karma badge + 30-day delta. Drachmae and karma are two
|
|
395
|
+
// separate counters — drachmae = work shipped, karma = good citizenship
|
|
396
|
+
// (peer recognition + Archon ratifications). The tooltip on each badge
|
|
397
|
+
// spells the distinction out so a new builder can tell them apart.
|
|
398
|
+
const karmaTotal = Number(builder.karma) || 0;
|
|
399
|
+
const karma30d = karma ? Number(karma.delta_30d) || 0 : 0;
|
|
400
|
+
const karmaTrendText = karma30d > 0
|
|
401
|
+
? `+${fmtNum(karma30d)} in 30d`
|
|
402
|
+
: (karma30d < 0 ? `${fmtNum(karma30d)} in 30d` : '±0 in 30d');
|
|
403
|
+
const karmaTrend = `<span class="karma-badge__trend ${signClasses(karma30d, 'karma-badge__trend')}">${karmaTrendText}</span>`;
|
|
404
|
+
const karmaTooltip = `Karma = peer recognition and approvals. ${curLabel(true)} = work shipped. The two counters move independently — see the karma feed for the audit trail.`;
|
|
405
|
+
const drachmaeTooltip = `${curLabel(true)} = total payout from shipped work. Karma is a separate counter for peer recognition and approvals.`;
|
|
406
|
+
|
|
407
|
+
// V3.R84 (#302), criterion C3: monthly-budget banner. `warn` (>=80%) is a
|
|
408
|
+
// heads-up; `over` (>=100%) means cost-incurring actions (ship, art pipeline)
|
|
409
|
+
// are blocked until an Archon raises the budget. Nothing renders under 80%.
|
|
410
|
+
const budgetBanner = (cost && (cost.level === 'warn' || cost.level === 'over'))
|
|
411
|
+
? `<div class="profile__budget profile__budget--${cost.level}" role="status">
|
|
412
|
+
<strong>${cost.level === 'over' ? 'Monthly budget exceeded' : 'Approaching monthly budget'}</strong>
|
|
413
|
+
— $${escapeHtml(String(cost.spend_mtd_usd))} of $${escapeHtml(String(cost.budget_mtd_usd))} this month (${escapeHtml(String(cost.pct_used))}%).
|
|
414
|
+
${cost.level === 'over'
|
|
415
|
+
? 'Shipping and art generation are paused until an Archon raises your budget.'
|
|
416
|
+
: 'At 100% these actions pause until an Archon raises your budget.'}
|
|
417
|
+
</div>`
|
|
418
|
+
: '';
|
|
419
|
+
|
|
420
|
+
// task 1013 / ADR 0073: the consistent "Action needed" / "we cover it"
|
|
421
|
+
// banner. Renders the highest-priority `needs` item that isn't satisfied —
|
|
422
|
+
// amber + a Settings button when the system needs an input (e.g. the image
|
|
423
|
+
// key once free coverage ends), a calm green note while we cover it. Reads
|
|
424
|
+
// the SAME /me `needs` data the CLI session nudge does (builder-needs.js).
|
|
425
|
+
const need = (needs && Array.isArray(needs.items))
|
|
426
|
+
? needs.items.find((n) => n && n.state !== 'satisfied')
|
|
427
|
+
: null;
|
|
428
|
+
const needsBanner = need
|
|
429
|
+
? `<div class="profile__needs profile__needs--${need.state === 'action_needed' ? 'action' : 'covered'}" role="status">
|
|
430
|
+
<strong>${escapeHtml(need.title)}</strong> — ${escapeHtml(need.message)}
|
|
431
|
+
${need.action && need.action.href
|
|
432
|
+
? ` <a class="profile__needs-act" href="${escapeHtml(need.action.href)}">${escapeHtml(need.action.label || 'Set it up')} →</a>`
|
|
433
|
+
: ''}
|
|
434
|
+
</div>`
|
|
435
|
+
: '';
|
|
436
|
+
|
|
437
|
+
// ADR 0120 part 5: the "Rebase owed" banner. `needs_rebase_tasks` are un-landed
|
|
438
|
+
// confirmed tasks the publish-reconciler flagged — they GATE new claims (part 3)
|
|
439
|
+
// and their per-task reward is PENDING until they land on ship (part 1). Rendered
|
|
440
|
+
// above the other banners since it blocks the queue; reads the SAME /me field the
|
|
441
|
+
// CLI (/builder-start) does. `task N` form, never a bare #N (the ref rule).
|
|
442
|
+
const rebaseTasks = Array.isArray(rebase) ? rebase.filter((t) => t && t.id != null) : [];
|
|
443
|
+
const rebaseBanner = rebaseTasks.length
|
|
444
|
+
? `<div class="profile__rebase" role="status">
|
|
445
|
+
<strong>Rebase owed — new claims paused</strong> — ${rebaseTasks.length} confirmed ${rebaseTasks.length === 1 ? 'task' : 'tasks'} can't land yet, so ${rebaseTasks.length === 1 ? 'its' : 'their'} reward is pending (it lands on ship). Rebase + re-ship or release ${rebaseTasks.length === 1 ? 'it' : 'them'}: ${rebaseTasks.map((t) => 'task ' + escapeHtml(String(t.id))).join(', ')}.
|
|
446
|
+
</div>`
|
|
447
|
+
: '';
|
|
448
|
+
|
|
449
|
+
$('#profile-body').innerHTML = `
|
|
450
|
+
${rebaseBanner}
|
|
451
|
+
${needsBanner}
|
|
452
|
+
${budgetBanner}
|
|
453
|
+
${avatar}
|
|
454
|
+
<div>
|
|
455
|
+
<div class="profile__name-row">
|
|
456
|
+
<p class="profile__name">${escapeHtml(builder.display_name || builder.github_login || 'Builder')}</p>
|
|
457
|
+
${rankBadge}
|
|
458
|
+
</div>
|
|
459
|
+
<p class="profile__handle">@${escapeHtml(builder.github_login || 'unknown')}</p>
|
|
460
|
+
<div class="profile__stats">
|
|
461
|
+
<span>Member since ${fmtDate(builder.created_at)}</span>
|
|
462
|
+
<span title="${escapeHtml(karmaTooltip)}">${karmaTrend.replace(' in 30d', ' karma in 30d')}</span>
|
|
463
|
+
${rollPlace ? `<span title="Your place on the leaderboard (top ${rollPlace.total}), ranked by ${curLabel()}.">#${rollPlace.place} on the leaderboard</span>` : ''}
|
|
464
|
+
</div>
|
|
465
|
+
<div class="profile__disciplines">
|
|
466
|
+
<span class="profile__disc-label">Preferred work:</span>
|
|
467
|
+
<span class="profile__disc-value">${prefs.length ? escapeHtml(prefs.join(', ')) : 'none chosen yet'}</span>
|
|
468
|
+
<a class="profile__disc-edit" href="/settings">Change in Settings →</a>
|
|
469
|
+
</div>
|
|
470
|
+
</div>
|
|
471
|
+
<div class="profile__credits">
|
|
472
|
+
<span class="profile__credits-num" title="${escapeHtml(drachmaeTooltip)}">${fmtNum(builder.total_credits)}</span>
|
|
473
|
+
<span class="profile__credits-label">${(window.OTBBranding?.currencyLabel?.() ?? 'credits')}</span>
|
|
474
|
+
<span class="profile__karma" title="${escapeHtml(karmaTooltip)}">
|
|
475
|
+
<span class="profile__karma-num">${fmtNum(karmaTotal)}</span>
|
|
476
|
+
<span class="profile__karma-label">karma</span>
|
|
477
|
+
</span>
|
|
478
|
+
</div>
|
|
479
|
+
`;
|
|
480
|
+
const welcomeLine = $('#welcome-line');
|
|
481
|
+
if (welcomeLine) {
|
|
482
|
+
welcomeLine.textContent = `Welcome back, ${builder.display_name || builder.github_login || 'builder'}.`;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// task 1723: the Home dashboard mirrors — quick-stat tiles + the standing
|
|
486
|
+
// summary card — are fed from the same /me payload. All writes are guarded:
|
|
487
|
+
// the widgets are registry-owned and a module could unregister them.
|
|
488
|
+
const setText = (id, text) => {
|
|
489
|
+
const el = document.getElementById(id);
|
|
490
|
+
if (el) el.textContent = text;
|
|
491
|
+
};
|
|
492
|
+
setText('stat-credits', fmtNum(builder.total_credits));
|
|
493
|
+
setText('stat-karma', fmtNum(karmaTotal));
|
|
494
|
+
setText('stat-karma-sub', karmaTrendText);
|
|
495
|
+
const statRank = document.getElementById('stat-rank');
|
|
496
|
+
if (statRank) statRank.innerHTML = builder.rank ? renderRankBadge(normalizeRank(builder.rank), { tip: false }) : '—';
|
|
497
|
+
setText('stat-rank-sub', rollPlace ? `#${rollPlace.place} on the leaderboard` : '');
|
|
498
|
+
const statsSection = document.getElementById('home-stats');
|
|
499
|
+
if (statsSection && !ctx.newcomerGated) statsSection.hidden = false;
|
|
500
|
+
|
|
501
|
+
const homeStanding = document.getElementById('home-standing-body');
|
|
502
|
+
if (homeStanding) {
|
|
503
|
+
homeStanding.innerHTML = `
|
|
504
|
+
${rebaseBanner}
|
|
505
|
+
${needsBanner}
|
|
506
|
+
${budgetBanner}
|
|
507
|
+
<div class="profile profile--compact">
|
|
508
|
+
${avatar}
|
|
509
|
+
<div>
|
|
510
|
+
<div class="profile__name-row">
|
|
511
|
+
<p class="profile__name">${escapeHtml(builder.display_name || builder.github_login || 'Builder')}</p>
|
|
512
|
+
${rankBadge}
|
|
513
|
+
</div>
|
|
514
|
+
<p class="profile__handle">@${escapeHtml(builder.github_login || 'unknown')}</p>
|
|
515
|
+
<div class="profile__stats">
|
|
516
|
+
<span>Member since ${fmtDate(builder.created_at)}</span>
|
|
517
|
+
<span>${prefs.length ? escapeHtml(prefs.join(', ')) : 'no preferred work yet'}</span>
|
|
518
|
+
</div>
|
|
519
|
+
</div>
|
|
520
|
+
</div>
|
|
521
|
+
`;
|
|
522
|
+
}
|
|
523
|
+
const homeStandingSection = document.getElementById('home-standing');
|
|
524
|
+
if (homeStandingSection && !ctx.newcomerGated) homeStandingSection.hidden = false;
|
|
525
|
+
}
|
|
526
|
+
const ONBOARDING_STAGE_ORDER = [
|
|
527
|
+
'auth_complete',
|
|
528
|
+
'pick_disciplines',
|
|
529
|
+
'first_start',
|
|
530
|
+
'first_claim',
|
|
531
|
+
'ship_three',
|
|
532
|
+
'graduated',
|
|
533
|
+
];
|
|
534
|
+
|
|
535
|
+
function stageIndex(stageId) {
|
|
536
|
+
const i = ONBOARDING_STAGE_ORDER.indexOf(stageId);
|
|
537
|
+
return i === -1 ? 0 : i;
|
|
538
|
+
}
|
|
539
|
+
function welcomeDismissalKey(builderId) {
|
|
540
|
+
return `builders:welcome-dismissed:${builderId}`;
|
|
541
|
+
}
|
|
542
|
+
function readDismissedStageIndex(builderId) {
|
|
543
|
+
try {
|
|
544
|
+
const raw = window.localStorage.getItem(welcomeDismissalKey(builderId));
|
|
545
|
+
if (raw === null) return null;
|
|
546
|
+
const n = Number(raw);
|
|
547
|
+
return Number.isInteger(n) ? n : null;
|
|
548
|
+
} catch (_) {
|
|
549
|
+
// localStorage can throw in private-browsing modes. Treat as "never
|
|
550
|
+
// dismissed" — the panel renders, which is the right fail-open
|
|
551
|
+
// behaviour for an onboarding affordance.
|
|
552
|
+
return null;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
const SETUP_CONFIG_KEY = 'builders:setup-config';
|
|
556
|
+
function readSetupConfig() {
|
|
557
|
+
try {
|
|
558
|
+
const v = window.localStorage.getItem(SETUP_CONFIG_KEY);
|
|
559
|
+
return v === 'online' ? 'online' : 'desktop';
|
|
560
|
+
} catch (_) { return 'desktop'; }
|
|
561
|
+
}
|
|
562
|
+
function renderWelcomeCore(builder, onboarding) {
|
|
563
|
+
const scroll = $('#welcome-scroll');
|
|
564
|
+
const body = $('#welcome-body');
|
|
565
|
+
if (!scroll || !body) return;
|
|
566
|
+
|
|
567
|
+
// No builder yet (unauth landing) → hide and exit.
|
|
568
|
+
if (!builder || !builder.id) { scroll.hidden = true; return; }
|
|
569
|
+
|
|
570
|
+
// Graduated → hide entirely. The checklist below will also hide itself.
|
|
571
|
+
if (onboarding && onboarding.graduated) { scroll.hidden = true; return; }
|
|
572
|
+
|
|
573
|
+
// Compute current stage index. When the /me payload didn't carry an
|
|
574
|
+
// onboarding object (legacy), default to the first stage so the panel
|
|
575
|
+
// still greets the builder rather than vanishing silently.
|
|
576
|
+
const currentStageId = (onboarding && onboarding.current_stage) || ONBOARDING_STAGE_ORDER[0];
|
|
577
|
+
const currentIdx = stageIndex(currentStageId);
|
|
578
|
+
|
|
579
|
+
// Dismissal check. Reappears on stage regression (currentIdx < dismissed).
|
|
580
|
+
const dismissedAt = readDismissedStageIndex(builder.id);
|
|
581
|
+
if (dismissedAt !== null && currentIdx >= dismissedAt) {
|
|
582
|
+
scroll.hidden = true;
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const rankBadge = renderRankBadge(normalizeRank(builder.rank), { tip: true });
|
|
587
|
+
const greeting = `Welcome, ${escapeHtml(builder.display_name || builder.github_login || 'builder')}.`;
|
|
588
|
+
|
|
589
|
+
// #777: the welcome panel now shows ONLY setup — a two-config selector that
|
|
590
|
+
// gets a zero-technical newcomer from "signed into the hall" to a working
|
|
591
|
+
// `claude` session on their dev box. Both paths reach the SAME hosted dev
|
|
592
|
+
// box (Xenos are box-only, ADR 0035): the Desktop app connects over an
|
|
593
|
+
// automated SSH link (/builder-connect — no keys to copy), and Claude Online
|
|
594
|
+
// is the in-browser ttyd terminal (ADR 0038 — no install, no SSH). Steps are
|
|
595
|
+
// short, scannable, and plain-spoken (theme stays in the headings). The
|
|
596
|
+
// "what to do next" arc lives in the Path checklist below — not here.
|
|
597
|
+
// #785: restore the builder's last-chosen config (persisted in localStorage)
|
|
598
|
+
// so a re-render / reload / tab switch doesn't snap back to Desktop.
|
|
599
|
+
const setupCfg = readSetupConfig();
|
|
600
|
+
// R60 (task 1199): the hosted dev-box onboarding below is the `dev-box`
|
|
601
|
+
// feature module's UI. A vanilla Cloud Bongos instance has dev-box OFF, so
|
|
602
|
+
// there is no box to host — show the bring-your-own-machine path instead.
|
|
603
|
+
// OTB (dev-box ON) renders the existing flow verbatim, so prod is unchanged.
|
|
604
|
+
const devBoxOn = moduleOn('dev-box');
|
|
605
|
+
// The #support pointer assumes the Discord module; drop it when discord is off.
|
|
606
|
+
const supportFoot = moduleOn('discord')
|
|
607
|
+
? `Stuck on any step? <a href="${API}/auth/discord/start">Join our Discord</a> and ask in <strong>#support</strong>.`
|
|
608
|
+
: `Stuck on any step? See the project's README and docs.`;
|
|
609
|
+
const setupPanes = devBoxOn ? `
|
|
610
|
+
<p class="setup__intro">Your work runs on a dev box we host for you — there's almost nothing to install. Choose how to connect:</p>
|
|
611
|
+
<div class="setup__toggle" role="tablist" aria-label="Choose a setup">
|
|
612
|
+
<button type="button" class="setup__tab setup__tab--desktop" data-config="desktop" role="tab" aria-selected="${setupCfg === 'desktop'}">Claude Desktop app <span class="setup__tab-tag setup__tab-tag--rec">recommended</span></button>
|
|
613
|
+
<button type="button" class="setup__tab setup__tab--online" data-config="online" role="tab" aria-selected="${setupCfg === 'online'}">Claude Online <span class="setup__tab-tag">browser only</span></button>
|
|
614
|
+
</div>
|
|
615
|
+
|
|
616
|
+
<div class="setup__pane setup__pane--desktop">
|
|
617
|
+
<ol class="setup__steps">
|
|
618
|
+
<li><strong>Download the Dev Box app:</strong>
|
|
619
|
+
<a href="/downloads/devbox/mac-arm64">Mac</a> ·
|
|
620
|
+
<a href="/downloads/devbox/mac-x64">Mac (Intel)</a> ·
|
|
621
|
+
<a href="/downloads/devbox/win-x64">Windows</a> ·
|
|
622
|
+
<a href="/downloads/devbox/win-arm64">Windows (ARM)</a>.
|
|
623
|
+
<span class="setup__hint">First open: your computer warns about an unknown developer — that's
|
|
624
|
+
expected (we haven't bought the signing certificates yet; the app is ours). Mac: System Settings
|
|
625
|
+
→ Privacy & Security → “Open Anyway”. Windows: “More info” → “Run anyway”.</span></li>
|
|
626
|
+
<li><strong>Sign in from the app.</strong> It opens this hall in your browser — click one
|
|
627
|
+
<em>Approve</em> button and you're in. No terminal, nothing to paste.</li>
|
|
628
|
+
<li><strong>Flip the switch.</strong> Your dev box builds itself in a minute or two and the app
|
|
629
|
+
chimes when it's ready. It lives in the menu bar on Mac and the system tray on Windows, and it
|
|
630
|
+
wires up Claude Desktop for you along the way.</li>
|
|
631
|
+
<li><strong><a href="https://claude.com/download" target="_blank" rel="noopener noreferrer">Download Claude</a></strong>
|
|
632
|
+
for Mac or Windows, sign in with your <a href="https://claude.com" target="_blank" rel="noopener noreferrer">Claude</a>
|
|
633
|
+
account, then open <strong>Code → Environment → “${brandWorld()} Dev Box”</strong>. You're inside —
|
|
634
|
+
build from <code>/workspace</code>.</li>
|
|
635
|
+
</ol>
|
|
636
|
+
</div>
|
|
637
|
+
|
|
638
|
+
<div class="setup__pane setup__pane--online">
|
|
639
|
+
<ol class="setup__steps">
|
|
640
|
+
<li><strong>Create your <a href="https://claude.com" target="_blank" rel="noopener noreferrer">Claude</a> account.</strong></li>
|
|
641
|
+
<li><strong>Open your online environment.</strong> This is the environment you will build in — no SSH, nothing to install.
|
|
642
|
+
<div class="setup__box" data-box-panel></div>
|
|
643
|
+
</li>
|
|
644
|
+
<li><strong>Sign in when Claude prompts you.</strong> Claude starts automatically when you open the terminal.
|
|
645
|
+
<span class="setup__hint">To paste the sign-in code: <strong>Ctrl+Shift+V</strong> or right-click — plain Ctrl+V will not paste here. Claude takes over the terminal with its own interface once you're signed in. This guide stays open in its own tab. If Claude didn't start automatically, type <code>claude</code> and press Enter.</span></li>
|
|
646
|
+
<li><strong>Then drive it however you like:</strong>
|
|
647
|
+
<span class="setup__hint"><strong>Remote control (recommended)</strong> — type <code>/remote-control</code> for a link that runs this session in Claude's own web/app interface, the same UI you'd use on a computer. <strong>Or just stay in the online terminal</strong> and build right here in the browser — no remote control needed. Either way, reopening the online terminal later resumes your session right where you left it (closing the tab keeps it running).</span></li>
|
|
648
|
+
</ol>
|
|
649
|
+
</div>
|
|
650
|
+
` : `
|
|
651
|
+
<p class="setup__intro">Build from your own machine — clone the repository, run the platform locally, and sign in from the terminal.</p>
|
|
652
|
+
<div class="setup__pane">
|
|
653
|
+
<ol class="setup__steps">
|
|
654
|
+
<li><strong>Clone the repository</strong> and install dependencies (<code>npm install</code>).</li>
|
|
655
|
+
<li><strong><a href="https://claude.com/download" target="_blank" rel="noopener noreferrer">Download Claude</a></strong> (or use the Claude Code CLI) and open the project.</li>
|
|
656
|
+
<li><strong>Run <code>/builder-setup</code></strong> to authenticate this machine, then <code>/builder-start</code> to claim your first task.</li>
|
|
657
|
+
</ol>
|
|
658
|
+
</div>
|
|
659
|
+
`;
|
|
660
|
+
// task 469: a "Set up your machine / get the code" door, available in BOTH
|
|
661
|
+
// the hosted dev-box flow and the bring-your-own-machine flow. The flows
|
|
662
|
+
// above get you a session running; this section answers the separate
|
|
663
|
+
// question a first-timer always has — "where does the code come from, and
|
|
664
|
+
// how do I get it onto my own laptop?". It's the hall mirror of the primer's
|
|
665
|
+
// "Step 0 — get the project onto your machine". Collapsed by default so it
|
|
666
|
+
// doesn't crowd the recommended path; the clone block is click-to-copy
|
|
667
|
+
// (copy-on-click.js picks up .welcome__paste). The commands mirror
|
|
668
|
+
// scripts/setup-builder.sh's own header verbatim so they can't drift.
|
|
669
|
+
// The clone commands come from the instance's own repo coords (loaded from
|
|
670
|
+
// GET /api/bongos/instance), not a hardcoded repo, so a fresh instance's newcomer
|
|
671
|
+
// clones ITS project (ADR 0062 §3, task 1705). Falls back to generic guidance
|
|
672
|
+
// until the coords load (or if they're unavailable).
|
|
673
|
+
const cloneBlock = ctx.instanceRepo
|
|
674
|
+
? `git clone ${escapeHtml(ctx.instanceRepo.url)}.git\ncd ${escapeHtml(ctx.instanceRepo.repo)}\n./scripts/setup-builder.sh`
|
|
675
|
+
: `# clone your instance's repository, cd into it, then run:\n./scripts/setup-builder.sh`;
|
|
676
|
+
const getCode = `
|
|
677
|
+
<details class="setup__getcode">
|
|
678
|
+
<summary class="setup__getcode-summary">Set up your machine / get the code</summary>
|
|
679
|
+
<div class="setup__getcode-body">
|
|
680
|
+
<p class="setup__hint">First install <a href="https://git-scm.com/downloads" target="_blank" rel="noopener noreferrer">Git</a>
|
|
681
|
+
and <a href="https://claude.com/download" target="_blank" rel="noopener noreferrer">Claude Code</a> — that's all the tooling you need up front.</p>
|
|
682
|
+
<p class="setup__getcode-lede">Then copy the project onto your machine and run the one-shot bootstrap (it checks your setup, installs dependencies, and signs you in):</p>
|
|
683
|
+
<code class="welcome__paste">${cloneBlock}</code>
|
|
684
|
+
<p class="setup__hint">On Windows, run <code>node scripts/setup-builder.js</code> instead of the last line. New to the steps? The
|
|
685
|
+
<a href="/primer">primer</a> walks the whole path in plain language.</p>
|
|
686
|
+
</div>
|
|
687
|
+
</details>`;
|
|
688
|
+
body.innerHTML = `
|
|
689
|
+
<div class="welcome__lede">${greeting} Your standing is ${rankBadge}.</div>
|
|
690
|
+
<div class="setup" data-config="${setupCfg}">
|
|
691
|
+
${setupPanes}
|
|
692
|
+
${getCode}
|
|
693
|
+
<p class="setup__foot">${supportFoot}</p>
|
|
694
|
+
</div>
|
|
695
|
+
`;
|
|
696
|
+
// Remember the stage we rendered for, so the dismiss handler can record it.
|
|
697
|
+
scroll.dataset.stageIdx = String(currentIdx);
|
|
698
|
+
scroll.hidden = false;
|
|
699
|
+
// #783/#807: mount the shared dev-box panel into the online pane and paint
|
|
700
|
+
// it from live server state. Browser-only onboarding hides SSH details
|
|
701
|
+
// (showSsh:false) — a newcomer sees only the online terminal. The panel runs
|
|
702
|
+
// its own poll while anything is in flight (survives tab flips / re-renders /
|
|
703
|
+
// reloads / background approvals); mount re-binds to the freshly-rendered DOM.
|
|
704
|
+
const boxMount = body.querySelector('[data-box-panel]');
|
|
705
|
+
if (boxMount) onboardingBox().mount(boxMount);
|
|
706
|
+
}
|
|
707
|
+
let _onboardingBox = null;
|
|
708
|
+
function onboardingBox() {
|
|
709
|
+
if (!_onboardingBox && window.OTBBoxPanel) {
|
|
710
|
+
_onboardingBox = window.OTBBoxPanel.create({ api: API, showSsh: false, toast });
|
|
711
|
+
}
|
|
712
|
+
return _onboardingBox || { mount() {}, refresh() {}, stop() {} };
|
|
713
|
+
}
|
|
714
|
+
function renderOnboarding(onboarding, discord) {
|
|
715
|
+
const scroll = $('#onboarding-scroll');
|
|
716
|
+
const list = $('#onboarding-list');
|
|
717
|
+
if (!scroll || !list) return;
|
|
718
|
+
if (!onboarding) {
|
|
719
|
+
scroll.hidden = true;
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
// Hide the panel once the builder has graduated — no point lingering.
|
|
723
|
+
if (onboarding.graduated) {
|
|
724
|
+
scroll.hidden = true;
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
// task 2169 (banner budget: one at a time): while the welcome panel is
|
|
728
|
+
// showing, the checklist waits its turn — it appears when the welcome is
|
|
729
|
+
// dismissed or outgrown. renderWelcome runs first in the same loadAll pass
|
|
730
|
+
// (and every SSE nudge re-runs both), so this stays current.
|
|
731
|
+
const welcomeEl = document.getElementById('welcome-scroll');
|
|
732
|
+
if (welcomeEl && !welcomeEl.hidden) {
|
|
733
|
+
scroll.hidden = true;
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
scroll.hidden = false;
|
|
737
|
+
|
|
738
|
+
const discordConfigured = !!(discord && discord.configured);
|
|
739
|
+
const stages = Array.isArray(onboarding.stages) ? onboarding.stages : [];
|
|
740
|
+
if (stages.length === 0) {
|
|
741
|
+
list.innerHTML = `<li class="onboarding-item onboarding-item--placeholder">No onboarding steps recorded yet.</li>`;
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// task 470: a static, INFORMATIONAL lead-in step that sits AHEAD of the
|
|
746
|
+
// data-driven steps — the genuine first action into the city is "obtain the
|
|
747
|
+
// repo" (clone it, or open the hosted dev box where it's already cloned).
|
|
748
|
+
// It mirrors onboarding-state.js PRE_AUTH_STEP. It is NOT one of the tracked
|
|
749
|
+
// checklist steps (it carries no engine stage, never gates graduation, and
|
|
750
|
+
// isn't counted in the "N of M steps cleared" status line) — it just makes
|
|
751
|
+
// the checklist a newcomer sees acknowledge the very first real action,
|
|
752
|
+
// which the auto-ticking steps below silently assumed already happened.
|
|
753
|
+
const preAuthLead = `
|
|
754
|
+
<li class="onboarding-item onboarding-item--informational" data-stage="obtain_repo">
|
|
755
|
+
<span class="onboarding-item__mark" aria-hidden="true">›</span>
|
|
756
|
+
<div class="onboarding-item__body">
|
|
757
|
+
<p class="onboarding-item__label">First — obtain the repo</p>
|
|
758
|
+
<p class="onboarding-item__hint">Clone the project to your machine, or open your hosted dev box (where it is already cloned). The build commands live inside the repo, so this comes first. The remaining steps below are tracked automatically.</p>
|
|
759
|
+
</div>
|
|
760
|
+
</li>`;
|
|
761
|
+
|
|
762
|
+
list.innerHTML = preAuthLead + stages.map((s) => {
|
|
763
|
+
const status = s.cleared ? 'cleared' : (s.is_current ? 'current' : 'locked');
|
|
764
|
+
const mark = s.cleared ? '✓' : (s.is_current ? '◆' : '○');
|
|
765
|
+
const hint = s.hint ? `<p class="onboarding-item__hint">${escapeHtml(s.hint)}</p>` : '';
|
|
766
|
+
// #766: the terminal 'ship_three' stage carries a live "N of 3" counter
|
|
767
|
+
// in its `detail` field — render it as a small progress line so the
|
|
768
|
+
// builder watches themselves climb toward Thetes with each ship.
|
|
769
|
+
const detail = s.detail
|
|
770
|
+
? `<p class="onboarding-item__detail">${escapeHtml(s.detail)}</p>`
|
|
771
|
+
: '';
|
|
772
|
+
// Per-step affordance, shown ONLY while the step is the current, uncleared
|
|
773
|
+
// one — every step now behaves uniformly (Lars, #833): locked/future steps
|
|
774
|
+
// and cleared steps carry no control, so the path reads as one action at a
|
|
775
|
+
// time. (The Discord step already did this; the craft picker and the
|
|
776
|
+
// See-the-works link used to render on locked/cleared steps too.) Every
|
|
777
|
+
// step auto-detects its own completion server-side (#777): join_discord
|
|
778
|
+
// clears on the Discord link, cli_setup once the GDS CLI is authenticated,
|
|
779
|
+
// ship_three at the third ship — no manual ticking.
|
|
780
|
+
let action = '';
|
|
781
|
+
if (s.is_current && !s.cleared) {
|
|
782
|
+
if (s.id === 'pick_disciplines') {
|
|
783
|
+
// V3 (2026-05-30): inline discipline picker → PATCH /me/disciplines.
|
|
784
|
+
// Multi-select: each button is a toggle reflecting the saved prefs, so
|
|
785
|
+
// a builder can pick engineer, artist, ideator, or any mix while the
|
|
786
|
+
// step is current. (Settings can revise crafts later for graduated citizens.)
|
|
787
|
+
const prefs = (ctx.lastMeState && ctx.lastMeState.builder && Array.isArray(ctx.lastMeState.builder.preferred_disciplines))
|
|
788
|
+
? ctx.lastMeState.builder.preferred_disciplines : [];
|
|
789
|
+
const discBtn = (value, label) => {
|
|
790
|
+
const on = prefs.includes(value);
|
|
791
|
+
return `<button type="button" class="btn-ghost onboarding-item__disc${on ? ' onboarding-item__disc--on' : ''}" data-disc="${value}" aria-pressed="${on}">${label}</button>`;
|
|
792
|
+
};
|
|
793
|
+
// R59 (task 1198): 'artist' is offered only when the art-pipeline module
|
|
794
|
+
// is enabled — engineer + ideator are methodology core. moduleOn defaults
|
|
795
|
+
// to true if the flag is missing, so OTB (art on) is unchanged; a vanilla
|
|
796
|
+
// instance hides the button the server would reject anyway.
|
|
797
|
+
action = `
|
|
798
|
+
<div class="onboarding-item__action onboarding-item__disc-pick">
|
|
799
|
+
${discBtn('engineer', 'Engineer')}
|
|
800
|
+
${OTB.moduleOn('art-pipeline') ? discBtn('artist', 'Artist') : ''}
|
|
801
|
+
${discBtn('ideator', 'Ideator')}
|
|
802
|
+
</div>`;
|
|
803
|
+
} else if (s.id === 'ship_three') {
|
|
804
|
+
action = `
|
|
805
|
+
<div class="onboarding-item__action">
|
|
806
|
+
<a class="btn-ghost" href="/work">See the work board →</a>
|
|
807
|
+
</div>`;
|
|
808
|
+
} else if (s.id === 'join_discord' && discordConfigured) {
|
|
809
|
+
// #777: the Connect button runs the existing OAuth flow (GET
|
|
810
|
+
// /auth/discord/start → auto-join + rank role). Full-page nav so the
|
|
811
|
+
// server can set the CSRF state cookie before redirecting to Discord.
|
|
812
|
+
// The step then clears itself once the link lands (discord.linked) —
|
|
813
|
+
// no manual mark. Same control as /builders/settings.
|
|
814
|
+
action = `
|
|
815
|
+
<div class="onboarding-item__action">
|
|
816
|
+
<a class="btn-ghost onboarding-item__discord" href="${API}/auth/discord/start">Connect Discord & join the server</a>
|
|
817
|
+
</div>`;
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
return `
|
|
821
|
+
<li class="onboarding-item onboarding-item--${status}" data-stage="${escapeHtml(s.id)}">
|
|
822
|
+
<span class="onboarding-item__mark" aria-hidden="true">${mark}</span>
|
|
823
|
+
<div class="onboarding-item__body">
|
|
824
|
+
<p class="onboarding-item__label">${escapeHtml(s.label)}</p>
|
|
825
|
+
${hint}
|
|
826
|
+
${detail}
|
|
827
|
+
</div>
|
|
828
|
+
${action}
|
|
829
|
+
</li>
|
|
830
|
+
`;
|
|
831
|
+
}).join('');
|
|
832
|
+
|
|
833
|
+
const cleared = onboarding.progress?.cleared ?? 0;
|
|
834
|
+
const total = onboarding.progress?.total ?? stages.length;
|
|
835
|
+
const status = $('#onboarding-status');
|
|
836
|
+
if (status) {
|
|
837
|
+
status.hidden = false;
|
|
838
|
+
status.textContent = `${cleared} of ${total} steps cleared.`;
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
const shipState = { all: [] };
|
|
842
|
+
const shipsPager = window.OTBKit.makePager('ships', 'otb.standing.shipsPerPage', { defaultPerPage: 10 });
|
|
843
|
+
function filterShips(ships) {
|
|
844
|
+
const win = $('#f-ships-window')?.value || 'all';
|
|
845
|
+
const ver = $('#f-ships-version')?.value || '';
|
|
846
|
+
let cutoff = null;
|
|
847
|
+
if (win !== 'all') {
|
|
848
|
+
const days = Number(win);
|
|
849
|
+
if (Number.isFinite(days) && days > 0) {
|
|
850
|
+
cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
return ships.filter((s) => {
|
|
854
|
+
if (ver && s.version_id !== ver) return false;
|
|
855
|
+
if (cutoff !== null) {
|
|
856
|
+
const t = s.shipped_at ? new Date(s.shipped_at).getTime() : 0;
|
|
857
|
+
if (!t || t < cutoff) return false;
|
|
858
|
+
}
|
|
859
|
+
return true;
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
function shipCredit(s) {
|
|
863
|
+
return Number(window.OTBBranding?.taskCreditValue?.(s) ?? s.credits_reward) || 0;
|
|
864
|
+
}
|
|
865
|
+
function sortShips(ships, mode) {
|
|
866
|
+
const copy = ships.slice();
|
|
867
|
+
if (mode === 'oldest') {
|
|
868
|
+
copy.sort((a, b) => new Date(a.shipped_at || 0) - new Date(b.shipped_at || 0));
|
|
869
|
+
} else if (mode === 'drachmae-high') {
|
|
870
|
+
copy.sort((a, b) => shipCredit(b) - shipCredit(a));
|
|
871
|
+
} else if (mode === 'drachmae-low') {
|
|
872
|
+
copy.sort((a, b) => shipCredit(a) - shipCredit(b));
|
|
873
|
+
} else {
|
|
874
|
+
// default: most recent first
|
|
875
|
+
copy.sort((a, b) => new Date(b.shipped_at || 0) - new Date(a.shipped_at || 0));
|
|
876
|
+
}
|
|
877
|
+
return copy;
|
|
878
|
+
}
|
|
879
|
+
function renderRecentShips() {
|
|
880
|
+
const tbody = $('#recent-ships tbody');
|
|
881
|
+
const countEl = $('#ships-count');
|
|
882
|
+
const pagerEl = $('#ships-pager');
|
|
883
|
+
|
|
884
|
+
const filtered = filterShips(shipState.all);
|
|
885
|
+
const mode = $('#f-ships-sort')?.value || 'recent';
|
|
886
|
+
const sorted = sortShips(filtered, mode);
|
|
887
|
+
|
|
888
|
+
// task 1723: Home mirrors — the shipped-count stat tile and the compact
|
|
889
|
+
// 5-row recent-work table are fed from the same feed. Guarded writes.
|
|
890
|
+
const statShipped = document.getElementById('stat-shipped');
|
|
891
|
+
if (statShipped) statShipped.textContent = fmtNum(shipState.all.length);
|
|
892
|
+
const homeRecent = document.querySelector('#home-recent-table tbody');
|
|
893
|
+
if (homeRecent) {
|
|
894
|
+
const latest = sortShips(shipState.all, 'recent').slice(0, 5);
|
|
895
|
+
homeRecent.innerHTML = latest.length
|
|
896
|
+
? latest.map((s) => `
|
|
897
|
+
<tr>
|
|
898
|
+
<td class="ledger__num">#${s.id}</td>
|
|
899
|
+
<td>${escapeHtml(s.title)}</td>
|
|
900
|
+
<td class="ledger__drachmae">${fmtNum(window.OTBBranding?.taskCreditValue?.(s) ?? s.credits_reward)}</td>
|
|
901
|
+
<td class="ledger__date">${fmtDate(s.shipped_at)}</td>
|
|
902
|
+
</tr>`).join('')
|
|
903
|
+
: emptyRow(4, 'Nothing shipped yet. Claim a task on the Work board to get started.');
|
|
904
|
+
}
|
|
905
|
+
const homeRecentSection = document.getElementById('home-recent');
|
|
906
|
+
if (homeRecentSection && !ctx.newcomerGated) homeRecentSection.hidden = false;
|
|
907
|
+
|
|
908
|
+
if (countEl) {
|
|
909
|
+
const total = shipState.all.length;
|
|
910
|
+
const shown = sorted.length;
|
|
911
|
+
countEl.textContent = total
|
|
912
|
+
? (shown === total
|
|
913
|
+
? `${fmtNum(total)} work${total === 1 ? '' : 's'} shipped`
|
|
914
|
+
: `${fmtNum(shown)} of ${fmtNum(total)} in view`)
|
|
915
|
+
: '';
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
if (!sorted.length) {
|
|
919
|
+
tbody.innerHTML = shipState.all.length
|
|
920
|
+
? emptyRow(4, 'No work matches these filters. Widen the window or pick another version.')
|
|
921
|
+
: emptyRow(4, 'Nothing shipped yet. Claim a task on the Work board to get started.');
|
|
922
|
+
if (pagerEl) pagerEl.hidden = true;
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
const slice = window.OTBKit.paginate(sorted, shipsPager);
|
|
927
|
+
|
|
928
|
+
tbody.innerHTML = slice.map((s) => `
|
|
929
|
+
<tr>
|
|
930
|
+
<td class="ledger__num">#${s.id}</td>
|
|
931
|
+
<td>
|
|
932
|
+
<div>${escapeHtml(s.title)}</div>
|
|
933
|
+
${s.value_summary ? `<div style="color:var(--ink-soft);font-size:0.88rem;font-style:italic;margin-top:2px">${escapeHtml(s.value_summary)}</div>` : ''}
|
|
934
|
+
</td>
|
|
935
|
+
<td class="ledger__drachmae">${fmtNum(window.OTBBranding?.taskCreditValue?.(s) ?? s.credits_reward)}</td>
|
|
936
|
+
<td class="ledger__date">${fmtDate(s.shipped_at)}</td>
|
|
937
|
+
</tr>
|
|
938
|
+
`).join('');
|
|
939
|
+
}
|
|
940
|
+
const analyticsState = { builderId: null, isArchon: false, scope: 'self', self: null, eco: null };
|
|
941
|
+
function sparkline(values, opts = {}) {
|
|
942
|
+
const W = opts.width || 260;
|
|
943
|
+
const H = opts.height || 46;
|
|
944
|
+
const pad = 3;
|
|
945
|
+
const color = opts.color || 'var(--accent-deep)';
|
|
946
|
+
const nums = (values || []).map((v) => (Number.isFinite(Number(v)) ? Number(v) : 0));
|
|
947
|
+
if (nums.length === 0) {
|
|
948
|
+
return `<svg class="spark" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" role="img" aria-label="no data"></svg>`;
|
|
949
|
+
}
|
|
950
|
+
const min = Math.min(...nums);
|
|
951
|
+
const max = Math.max(...nums);
|
|
952
|
+
const span = max - min || 1;
|
|
953
|
+
const n = nums.length;
|
|
954
|
+
const x = (i) => (n === 1 ? W / 2 : pad + (i * (W - 2 * pad)) / (n - 1));
|
|
955
|
+
const y = (v) => H - pad - ((v - min) / span) * (H - 2 * pad);
|
|
956
|
+
const pts = nums.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`);
|
|
957
|
+
const line = pts.join(' ');
|
|
958
|
+
const area = `${pad},${H - pad} ${line} ${(W - pad)},${H - pad}`;
|
|
959
|
+
const last = nums[n - 1];
|
|
960
|
+
return `<svg class="spark" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" role="img" aria-label="trend, latest ${last}">
|
|
961
|
+
<polyline class="spark__area" points="${area}" fill="${color}" />
|
|
962
|
+
<polyline class="spark__line" points="${line}" fill="none" stroke="${color}" />
|
|
963
|
+
${n === 1 ? `<circle cx="${x(0).toFixed(1)}" cy="${y(last).toFixed(1)}" r="2.4" fill="${color}" />` : ''}
|
|
964
|
+
</svg>`;
|
|
965
|
+
}
|
|
966
|
+
function statCard(label, value, sub) {
|
|
967
|
+
return `<div class="curve__stat">
|
|
968
|
+
<div class="curve__stat-value">${escapeHtml(String(value))}</div>
|
|
969
|
+
<div class="curve__stat-label">${escapeHtml(label)}</div>
|
|
970
|
+
${sub ? `<div class="curve__stat-sub">${escapeHtml(sub)}</div>` : ''}
|
|
971
|
+
</div>`;
|
|
972
|
+
}
|
|
973
|
+
function chartCard(title, values, color, footer) {
|
|
974
|
+
const has = Array.isArray(values) && values.length > 0;
|
|
975
|
+
return `<div class="curve__chart">
|
|
976
|
+
<div class="curve__chart-title">${escapeHtml(title)}</div>
|
|
977
|
+
${has ? sparkline(values, { color }) : `<div class="curve__chart-empty">no data in window</div>`}
|
|
978
|
+
${footer ? `<div class="curve__chart-foot">${escapeHtml(footer)}</div>` : ''}
|
|
979
|
+
</div>`;
|
|
980
|
+
}
|
|
981
|
+
function renderCurveBody() {
|
|
982
|
+
const headlineEl = $('#curve-headline');
|
|
983
|
+
const gridEl = $('#curve-grid');
|
|
984
|
+
const kindsEl = $('#curve-kinds');
|
|
985
|
+
const emptyEl = $('#curve-empty');
|
|
986
|
+
const noteEl = $('#curve-scope-note');
|
|
987
|
+
if (!headlineEl || !gridEl || !kindsEl) return;
|
|
988
|
+
|
|
989
|
+
const isEco = analyticsState.scope === 'eco';
|
|
990
|
+
const data = isEco ? analyticsState.eco : analyticsState.self;
|
|
991
|
+
|
|
992
|
+
// Scope toggle (Archon only).
|
|
993
|
+
if (analyticsState.isArchon && noteEl) {
|
|
994
|
+
noteEl.innerHTML =
|
|
995
|
+
`<span class="curve__scope" role="group" aria-label="Analytics scope">` +
|
|
996
|
+
`<button type="button" class="curve__scope-btn${!isEco ? ' is-active' : ''}" data-scope="self">You</button>` +
|
|
997
|
+
`<button type="button" class="curve__scope-btn${isEco ? ' is-active' : ''}" data-scope="eco">All builders</button>` +
|
|
998
|
+
`</span>`;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
if (!data) { gridEl.innerHTML = ''; headlineEl.innerHTML = ''; kindsEl.innerHTML = ''; return; }
|
|
1002
|
+
|
|
1003
|
+
const totalShipped = data.headline?.total_shipped || 0;
|
|
1004
|
+
if (totalShipped === 0) {
|
|
1005
|
+
if (emptyEl) emptyEl.style.display = '';
|
|
1006
|
+
headlineEl.innerHTML = ''; gridEl.innerHTML = ''; kindsEl.innerHTML = '';
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
if (emptyEl) emptyEl.style.display = 'none';
|
|
1010
|
+
|
|
1011
|
+
// Headline stat cards.
|
|
1012
|
+
const h = data.headline;
|
|
1013
|
+
const cards = [];
|
|
1014
|
+
cards.push(statCard(isEco ? 'Works shipped' : 'Works shipped', totalShipped, `last ${data.window_days}d`));
|
|
1015
|
+
if (isEco) {
|
|
1016
|
+
cards.push(statCard('Active builders', h.active_builders ?? 0));
|
|
1017
|
+
cards.push(statCard('Avg time / work', mins(h.avg_duration_min)));
|
|
1018
|
+
} else {
|
|
1019
|
+
cards.push(statCard('Cost / work', usd(h.cost_per_task)));
|
|
1020
|
+
cards.push(statCard('Avg time / work', mins(h.avg_duration_min)));
|
|
1021
|
+
cards.push(statCard('Confirmation rate', pct(h.confirmation_rate), `${h.resolved_claims || 0} claims`));
|
|
1022
|
+
cards.push(statCard('Grade pass rate', pct(h.grade_pass_rate), `${h.graded_n || 0} graded`));
|
|
1023
|
+
}
|
|
1024
|
+
headlineEl.innerHTML = cards.join('');
|
|
1025
|
+
|
|
1026
|
+
// Sparkline charts.
|
|
1027
|
+
const charts = [];
|
|
1028
|
+
charts.push(chartCard('Works shipped / day', (data.ship_rate || []).map((r) => r.n), 'var(--accent-deep)',
|
|
1029
|
+
`${(data.ship_rate || []).reduce((s, r) => s + Number(r.n), 0)} over ${data.window_days}d`));
|
|
1030
|
+
charts.push(chartCard('Time / work (min)', (data.duration_trend || []).map((r) => r.avg_actual), 'var(--sea)',
|
|
1031
|
+
'actual minutes per shipped work'));
|
|
1032
|
+
if (!isEco) {
|
|
1033
|
+
charts.push(chartCard('Cost / work ($)', (data.cost_trend || []).map((r) => r.per_work), 'var(--terracotta)',
|
|
1034
|
+
`total ${usd(h.total_cost_usd)}`));
|
|
1035
|
+
// n counts quality-graded rows only (task 1002652); a day whose grades
|
|
1036
|
+
// were all outages has n=0 and must not plot as a 0% dip.
|
|
1037
|
+
charts.push(chartCard('Grade pass rate', (data.grade_trend || []).filter((r) => Number(r.n) > 0).map((r) => r.n_passed / r.n), 'var(--accent)',
|
|
1038
|
+
'share of work passing review'));
|
|
1039
|
+
}
|
|
1040
|
+
gridEl.innerHTML = charts.join('');
|
|
1041
|
+
|
|
1042
|
+
// Top kinds — horizontal bars.
|
|
1043
|
+
const kinds = (data.top_kinds || []).slice(0, 8);
|
|
1044
|
+
if (kinds.length) {
|
|
1045
|
+
const maxN = Math.max(...kinds.map((k) => Number(k.n)));
|
|
1046
|
+
kindsEl.innerHTML =
|
|
1047
|
+
`<div class="curve__chart-title">Kinds of work shipped</div>` +
|
|
1048
|
+
kinds.map((k) => {
|
|
1049
|
+
const w = maxN ? Math.round((Number(k.n) / maxN) * 100) : 0;
|
|
1050
|
+
return `<div class="curve__kind">
|
|
1051
|
+
<span class="curve__kind-label">${escapeHtml(k.kind)}</span>
|
|
1052
|
+
<span class="curve__kind-bar"><span class="curve__kind-fill" style="width:${w}%"></span></span>
|
|
1053
|
+
<span class="curve__kind-n">${k.n}</span>
|
|
1054
|
+
</div>`;
|
|
1055
|
+
}).join('');
|
|
1056
|
+
} else {
|
|
1057
|
+
kindsEl.innerHTML = '';
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
const ACH_EMOJI = {
|
|
1061
|
+
'first-ship': '🚢',
|
|
1062
|
+
'first-claim': '🪙',
|
|
1063
|
+
'first-parallel-claim': '⚓',
|
|
1064
|
+
'streak-3': '🔥',
|
|
1065
|
+
'streak-7': '🌋',
|
|
1066
|
+
'art-pipeline-shipper': '🎨',
|
|
1067
|
+
'gds-shipper': '⚙️',
|
|
1068
|
+
'breaking-the-100c-bar': '💯',
|
|
1069
|
+
};
|
|
1070
|
+
function renderAchievementRow(ids) {
|
|
1071
|
+
if (!Array.isArray(ids) || ids.length === 0) return '';
|
|
1072
|
+
const inner = ids
|
|
1073
|
+
.map((id) => {
|
|
1074
|
+
const emoji = ACH_EMOJI[id];
|
|
1075
|
+
if (!emoji) return '';
|
|
1076
|
+
// Title attribute is the achievement id (kebab) — terse enough that hover
|
|
1077
|
+
// discloses the laurel without overstating the prose.
|
|
1078
|
+
return `<span class="lb-ach" title="${escapeHtml(id)}" aria-label="${escapeHtml(id)}">${emoji}</span>`;
|
|
1079
|
+
})
|
|
1080
|
+
.join('');
|
|
1081
|
+
return inner ? `<span class="lb-ach-row" aria-label="achievements">${inner}</span>` : '';
|
|
1082
|
+
}
|
|
1083
|
+
const lbState = {
|
|
1084
|
+
rows: [],
|
|
1085
|
+
sortBy: 'drachmae', // 'rank' | 'drachmae' | 'karma' | 'tokens'
|
|
1086
|
+
sortDir: 'desc', // 'desc' | 'asc'
|
|
1087
|
+
};
|
|
1088
|
+
const lbPager = window.OTBKit.makePager('lb', 'otb.standing.leaderboardPerPage', { defaultPerPage: 25 });
|
|
1089
|
+
function sortedLeaderboardRows() {
|
|
1090
|
+
const indexed = lbState.rows.map((row, idx) => ({ row, idx }));
|
|
1091
|
+
const dir = lbState.sortDir === 'asc' ? 1 : -1;
|
|
1092
|
+
if (lbState.sortBy === 'rank') {
|
|
1093
|
+
return indexed
|
|
1094
|
+
.sort((a, b) => {
|
|
1095
|
+
const diff = (rankWeight(a.row.rank) - rankWeight(b.row.rank)) * dir;
|
|
1096
|
+
return diff !== 0 ? diff : a.idx - b.idx;
|
|
1097
|
+
})
|
|
1098
|
+
.map(({ row }) => row);
|
|
1099
|
+
}
|
|
1100
|
+
if (lbState.sortBy === 'karma') {
|
|
1101
|
+
return indexed
|
|
1102
|
+
.sort((a, b) => {
|
|
1103
|
+
const ak = Number(a.row.karma) || 0;
|
|
1104
|
+
const bk = Number(b.row.karma) || 0;
|
|
1105
|
+
const diff = (ak - bk) * dir;
|
|
1106
|
+
if (diff !== 0) return diff;
|
|
1107
|
+
// Tie-break on drachmae DESC, then stable on server order.
|
|
1108
|
+
const ac = Number(a.row.total_credits) || 0;
|
|
1109
|
+
const bc = Number(b.row.total_credits) || 0;
|
|
1110
|
+
if (ac !== bc) return bc - ac;
|
|
1111
|
+
return a.idx - b.idx;
|
|
1112
|
+
})
|
|
1113
|
+
.map(({ row }) => row);
|
|
1114
|
+
}
|
|
1115
|
+
if (lbState.sortBy === 'tokens') {
|
|
1116
|
+
// task 1473: sort by total tokens used. tokens_used arrives as a string
|
|
1117
|
+
// (pg bigint), so coerce. Tie-break on drachmae DESC then server order,
|
|
1118
|
+
// matching the karma branch.
|
|
1119
|
+
return indexed
|
|
1120
|
+
.sort((a, b) => {
|
|
1121
|
+
const at = Number(a.row.tokens_used) || 0;
|
|
1122
|
+
const bt = Number(b.row.tokens_used) || 0;
|
|
1123
|
+
const diff = (at - bt) * dir;
|
|
1124
|
+
if (diff !== 0) return diff;
|
|
1125
|
+
const ac = Number(a.row.total_credits) || 0;
|
|
1126
|
+
const bc = Number(b.row.total_credits) || 0;
|
|
1127
|
+
if (ac !== bc) return bc - ac;
|
|
1128
|
+
return a.idx - b.idx;
|
|
1129
|
+
})
|
|
1130
|
+
.map(({ row }) => row);
|
|
1131
|
+
}
|
|
1132
|
+
if (lbState.sortBy === 'drachmae') {
|
|
1133
|
+
// Server already DESCs by drachmae, so the default mode is a no-op
|
|
1134
|
+
// copy. The ASC branch flips it.
|
|
1135
|
+
if (lbState.sortDir === 'desc') return lbState.rows.slice();
|
|
1136
|
+
return indexed
|
|
1137
|
+
.sort((a, b) => {
|
|
1138
|
+
const ac = Number(a.row.total_credits) || 0;
|
|
1139
|
+
const bc = Number(b.row.total_credits) || 0;
|
|
1140
|
+
const diff = (ac - bc) * dir;
|
|
1141
|
+
return diff !== 0 ? diff : a.idx - b.idx;
|
|
1142
|
+
})
|
|
1143
|
+
.map(({ row }) => row);
|
|
1144
|
+
}
|
|
1145
|
+
return lbState.rows.slice();
|
|
1146
|
+
}
|
|
1147
|
+
const LB_SORT_HEADERS = {
|
|
1148
|
+
rank: 'lb-sort-rank',
|
|
1149
|
+
drachmae: 'lb-sort-drachmae',
|
|
1150
|
+
karma: 'lb-sort-karma',
|
|
1151
|
+
tokens: 'lb-sort-tokens',
|
|
1152
|
+
};
|
|
1153
|
+
function leaderboardPlace(login) {
|
|
1154
|
+
const rows = Array.isArray(lbState.rows) ? lbState.rows : [];
|
|
1155
|
+
if (!rows.length || !login) return null;
|
|
1156
|
+
const ranked = [...rows].sort(
|
|
1157
|
+
(a, b) => (Number(b.total_credits) || 0) - (Number(a.total_credits) || 0),
|
|
1158
|
+
);
|
|
1159
|
+
const idx = ranked.findIndex((r) => r.github_login === login);
|
|
1160
|
+
return idx === -1 ? null : { place: idx + 1, total: ranked.length };
|
|
1161
|
+
}
|
|
1162
|
+
function renderLeaderboard() {
|
|
1163
|
+
const rows = sortedLeaderboardRows();
|
|
1164
|
+
// paginate() clamps the page, slices, and paints the whole #lb-* bar.
|
|
1165
|
+
const slice = window.OTBKit.paginate(rows, lbPager);
|
|
1166
|
+
const start = (lbPager.page - 1) * lbPager.perPage;
|
|
1167
|
+
const tbody = $('#leaderboard tbody');
|
|
1168
|
+
|
|
1169
|
+
if (!slice.length) {
|
|
1170
|
+
tbody.innerHTML = emptyRow(6, 'No more builders to show.');
|
|
1171
|
+
} else {
|
|
1172
|
+
tbody.innerHTML = slice.map((b, i) => {
|
|
1173
|
+
const place = start + i + 1;
|
|
1174
|
+
const name = b.display_name || b.github_login || 'unknown builder';
|
|
1175
|
+
const badges = renderAchievementRow(b.achievements);
|
|
1176
|
+
const rank = renderRankBadge(normalizeRank(b.rank));
|
|
1177
|
+
// task 1467: link the name to the builder's public profile page. Guarded
|
|
1178
|
+
// on b.id (the /leaderboard row carries it) so a row without an id degrades
|
|
1179
|
+
// to plain text rather than a broken /profile?id=undefined link.
|
|
1180
|
+
const nameCell = b.id != null
|
|
1181
|
+
? `<a class="lb-name" href="/profile?id=${encodeURIComponent(b.id)}">${escapeHtml(name)}</a>`
|
|
1182
|
+
: escapeHtml(name);
|
|
1183
|
+
return `
|
|
1184
|
+
<tr>
|
|
1185
|
+
<td class="ledger__num">${place}</td>
|
|
1186
|
+
<td>${nameCell} <span style="color:var(--ink-faint);font-size:0.86rem">@${escapeHtml(b.github_login || '')}</span>${badges}</td>
|
|
1187
|
+
<td class="ledger__rank">${rank}</td>
|
|
1188
|
+
<td class="ledger__drachmae">${fmtNum(b.total_credits)}</td>
|
|
1189
|
+
<td class="ledger__drachmae">${fmtNum(b.karma)}</td>
|
|
1190
|
+
<td class="ledger__drachmae" title="${Number(b.total_credits) > 0 ? `${fmtNum(Math.round(Number(b.tokens_used) / Number(b.total_credits)))} tokens per drachma` : 'no drachmae earned yet'}">${fmtNum(b.tokens_used)}</td>
|
|
1191
|
+
</tr>
|
|
1192
|
+
`;
|
|
1193
|
+
}).join('');
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
// Reflect the active sort on every sortable header. Only one column
|
|
1197
|
+
// shows its arrow at a time; the others reset to "none" — matches the
|
|
1198
|
+
// ARIA-sort spec.
|
|
1199
|
+
for (const [key, headerId] of Object.entries(LB_SORT_HEADERS)) {
|
|
1200
|
+
const head = document.getElementById(headerId);
|
|
1201
|
+
if (!head) continue;
|
|
1202
|
+
const ariaSort = (lbState.sortBy === key && lbState.sortDir)
|
|
1203
|
+
? (lbState.sortDir === 'asc' ? 'ascending' : 'descending')
|
|
1204
|
+
: 'none';
|
|
1205
|
+
head.setAttribute('aria-sort', ariaSort);
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
return { LB_SORT_HEADERS, SETUP_CONFIG_KEY, analyticsState, lbPager, lbState, mountHallWidgets, onboardingBox, renderCurveBody, renderLanding, renderLeaderboard, renderNav, renderOnboarding, renderProfile, renderRecentShips, renderWelcomeCore, shipState, shipsPager, welcomeDismissalKey };
|
|
1210
|
+
};
|
|
1211
|
+
})();
|