@bongos/core 1.19.373 → 1.19.375
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 +98 -48
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +8 -0
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/adr/0194-joinability-one-door-decision.md +42 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +159 -4
- package/docs/api-reference.md +7 -4
- package/docs/copy-inventory.md +252 -233
- package/docs/copy-registry.json +503 -301
- package/modules/builder-settings/profile-nudge-prefs.js +125 -0
- package/modules/builder-settings/routes/settings.js +48 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +6 -0
- package/modules/government/catalog.js +1 -1
- package/modules/hall-ui/public/builders.js +5 -3
- package/modules/hall-ui/public/goal-inbox.js +10 -0
- package/modules/hall-ui/public/hall-render.js +63 -22
- package/modules/hall-ui/public/index.html +4 -0
- package/modules/hall-ui/public/my-projects.js +6 -1
- package/modules/hall-ui/public/profile-nudge.js +122 -0
- package/modules/hall-ui/public/watch.css +8 -0
- package/modules/hall-ui/public/watch.js +56 -3
- package/modules/onboarding/module.json +1 -1
- package/modules/onboarding/routes/access-requests.js +87 -1
- package/modules/platform-identity/routes/my-projects.js +35 -9
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/projects.html +169 -3
- package/scripts/gds/fitness-checks-write-validation.js +1 -0
- package/scripts/gds/login.js +24 -3
- package/scripts/gds/ship-land.js +94 -3
- package/src/bongos/auth-admission.js +50 -5
- package/src/bongos/auth.js +2 -1
- package/src/bongos/routes/auth.js +15 -0
- package/src/branding.js +3 -2
- package/src/module-api.js +22 -1
- package/tests/access_requests_invite.mjs +165 -0
- package/tests/auth_admission_gate.mjs +42 -0
- package/tests/join_relay_outcome.mjs +61 -0
- package/tests/joinability_apply.mjs +72 -0
- package/tests/joinability_invite_only.mjs +192 -0
- package/tests/joinability_open.mjs +89 -0
- package/tests/module_api.mjs +1 -0
- package/tests/profile_nudge_prefs.mjs +97 -0
- package/tests/projects_hub.mjs +76 -2
- package/tests/projects_hub_pre_uat.mjs +4 -2
- package/tests/public_landing_projects.mjs +10 -3
- package/tests/ship_push_diagnosis.mjs +78 -0
- package/tests/sound_player.mjs +38 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// modules/builder-settings/profile-nudge-prefs.js — the "Complete your profile"
|
|
2
|
+
// nudge dismissal (task 1002802 / P06, goal 1000045).
|
|
3
|
+
//
|
|
4
|
+
// WHY A KNOB AND NOT A WIZARD. Decision 2 of the builder-profile spec
|
|
5
|
+
// (docs/specs/bongos-v1-builder-profile-page.md) rejected a profile-setup
|
|
6
|
+
// wizard: profiles are auto-created at first sign-in and enriched
|
|
7
|
+
// progressively, and an interstitial between sign-in and the hall adds friction
|
|
8
|
+
// at exactly the drop-off point. The replacement is a DISMISSIBLE line item in
|
|
9
|
+
// the needs-attention inbox — the hall's placement rule (modules/hall-ui/
|
|
10
|
+
// CLAUDE.md → Placement: an action awaiting the builder is an inbox line, never
|
|
11
|
+
// a new Home card).
|
|
12
|
+
//
|
|
13
|
+
// Dismissible has to mean dismissed-for-good, across devices: a builder who has
|
|
14
|
+
// decided not to write a bio should not be asked again on their laptop. So the
|
|
15
|
+
// flag is SERVER-side, not localStorage.
|
|
16
|
+
//
|
|
17
|
+
// Storage: one reserved key ('profile_nudge_dismissed') inside the
|
|
18
|
+
// `behavior_prefs` jsonb on `builders` (migration 119 — the same blob the
|
|
19
|
+
// wandering/render/interaction knobs use), so this adds NO migration. Value is
|
|
20
|
+
// a scalar boolean; a missing key means "not dismissed", which keeps the column
|
|
21
|
+
// sparse. applyPatch leaves sibling keys alone.
|
|
22
|
+
//
|
|
23
|
+
// This module is the single source of truth: GET/PATCH /api/gds/me/profile-nudge
|
|
24
|
+
// read and write it, and the hall's attention-card slice reads the resolved
|
|
25
|
+
// boolean to decide whether to render the line at all.
|
|
26
|
+
|
|
27
|
+
const RESERVED_KEY = 'profile_nudge_dismissed';
|
|
28
|
+
const DEFAULT_DISMISSED = false; // never dismissed until the builder says so
|
|
29
|
+
|
|
30
|
+
// ---- pure helpers ----
|
|
31
|
+
|
|
32
|
+
// The raw override a builder has stored, or null when unset/invalid. Kept
|
|
33
|
+
// separate from the effective value so a caller can tell "chose to keep it" from
|
|
34
|
+
// "never answered" (mirrors render-prefs' overrideEnabled).
|
|
35
|
+
function overrideDismissed(behaviorPrefs) {
|
|
36
|
+
const raw = behaviorPrefs && behaviorPrefs[RESERVED_KEY];
|
|
37
|
+
return typeof raw === 'boolean' ? raw : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The resolved boolean — the value the hall acts on.
|
|
41
|
+
function nudgeDismissed(behaviorPrefs) {
|
|
42
|
+
const o = overrideDismissed(behaviorPrefs);
|
|
43
|
+
return o === null ? DEFAULT_DISMISSED : o;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Which profile fields are still empty. PURE over the two inputs the nudge
|
|
47
|
+
// cares about, so the decision is testable without a DB or an HTTP round-trip.
|
|
48
|
+
// `bio` lives on the platform-identity account (hub-side), `preferred_disciplines`
|
|
49
|
+
// on `builders` — the caller passes both; this file never reaches for either.
|
|
50
|
+
// Whitespace-only bio counts as empty: a space is not a bio.
|
|
51
|
+
function missingProfileFields({ bio, preferredDisciplines } = {}) {
|
|
52
|
+
const missing = [];
|
|
53
|
+
if (typeof bio !== 'string' || bio.trim() === '') missing.push('bio');
|
|
54
|
+
if (!Array.isArray(preferredDisciplines) || preferredDisciplines.length === 0) missing.push('disciplines');
|
|
55
|
+
return missing;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Should the nudge render? Only when something is actually missing AND the
|
|
59
|
+
// builder has not dismissed it. The two halves are deliberately separate above
|
|
60
|
+
// so a caller can say WHAT is missing even when the nudge is dismissed.
|
|
61
|
+
function shouldNudge({ behaviorPrefs, bio, preferredDisciplines } = {}) {
|
|
62
|
+
if (nudgeDismissed(behaviorPrefs)) return false;
|
|
63
|
+
return missingProfileFields({ bio, preferredDisciplines }).length > 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Everything the API + the hall slice need in one object.
|
|
67
|
+
function describeForApi(behaviorPrefs, { bio, preferredDisciplines } = {}) {
|
|
68
|
+
const missing = missingProfileFields({ bio, preferredDisciplines });
|
|
69
|
+
return {
|
|
70
|
+
dismissed: nudgeDismissed(behaviorPrefs),
|
|
71
|
+
default_dismissed: DEFAULT_DISMISSED,
|
|
72
|
+
override: overrideDismissed(behaviorPrefs),
|
|
73
|
+
missing,
|
|
74
|
+
show: !nudgeDismissed(behaviorPrefs) && missing.length > 0,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Validate a PATCH body: { dismissed: <bool> | null }. `null` clears the
|
|
79
|
+
// override (back to not-dismissed, so the nudge can return — the "undismiss"
|
|
80
|
+
// path a Settings toggle would use). Unknown keys / non-booleans → 422. An empty
|
|
81
|
+
// body is a valid no-op.
|
|
82
|
+
function validatePatch(body) {
|
|
83
|
+
if (body == null || typeof body !== 'object' || Array.isArray(body)) {
|
|
84
|
+
return { ok: false, errors: [{ error: 'body_must_be_object' }] };
|
|
85
|
+
}
|
|
86
|
+
const errors = [];
|
|
87
|
+
const patch = {};
|
|
88
|
+
for (const [key, value] of Object.entries(body)) {
|
|
89
|
+
if (key !== 'dismissed') {
|
|
90
|
+
errors.push({ key, error: 'unknown_key', allowed: ['dismissed'] });
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (value === null || typeof value === 'boolean') {
|
|
94
|
+
patch[RESERVED_KEY] = value; // null = clear → default (not dismissed)
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
errors.push({ key, error: 'invalid_value', value, allowed: 'boolean or null' });
|
|
98
|
+
}
|
|
99
|
+
if (errors.length > 0) return { ok: false, errors };
|
|
100
|
+
return { ok: true, patch };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Apply a validated patch to a builder's existing behavior_prefs. Pure. null
|
|
104
|
+
// deletes the key; a boolean overwrites. Sibling keys (wandering, widgets,
|
|
105
|
+
// interaction) are left untouched.
|
|
106
|
+
function applyPatch(existing, patch) {
|
|
107
|
+
const next = { ...(existing || {}) };
|
|
108
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
109
|
+
if (value === null) delete next[key];
|
|
110
|
+
else next[key] = value;
|
|
111
|
+
}
|
|
112
|
+
return next;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
module.exports = {
|
|
116
|
+
RESERVED_KEY,
|
|
117
|
+
DEFAULT_DISMISSED,
|
|
118
|
+
overrideDismissed,
|
|
119
|
+
nudgeDismissed,
|
|
120
|
+
missingProfileFields,
|
|
121
|
+
shouldNudge,
|
|
122
|
+
describeForApi,
|
|
123
|
+
validatePatch,
|
|
124
|
+
applyPatch,
|
|
125
|
+
};
|
|
@@ -28,6 +28,7 @@ const wanderingPrefs = require('../wandering-prefs');
|
|
|
28
28
|
const interactionPrefs = require('../interaction-prefs');
|
|
29
29
|
const modelAllocPrefs = require('../model-alloc-prefs');
|
|
30
30
|
const renderPrefs = require('../render-prefs');
|
|
31
|
+
const profileNudgePrefs = require('../profile-nudge-prefs');
|
|
31
32
|
const displayName = require('../display-name');
|
|
32
33
|
// task 1003208: structured logging (pino via the doorway) — was console.*.
|
|
33
34
|
const log = api.logger('builder-settings');
|
|
@@ -378,6 +379,53 @@ module.exports = function buildBuilderSettingsRouter() {
|
|
|
378
379
|
}
|
|
379
380
|
});
|
|
380
381
|
|
|
382
|
+
// The "Complete your profile" nudge dismissal (task 1002802 / P06). The nudge
|
|
383
|
+
// itself is a DISMISSIBLE line item in the hall's needs-attention inbox — the
|
|
384
|
+
// replacement for the profile-setup wizard Decision 2 rejected. These two
|
|
385
|
+
// routes own only the dismissal FLAG; whether anything is actually missing is
|
|
386
|
+
// composed by the caller, which already holds both inputs (preferred_disciplines
|
|
387
|
+
// from GET /me, bio from GET /me/cross-project — bio lives on the platform
|
|
388
|
+
// identity account, and this module must never reach across to that one).
|
|
389
|
+
//
|
|
390
|
+
// rank: any-builder — own profile config; same posture as /me/render.
|
|
391
|
+
router.get('/me/profile-nudge', api.requireBuilder, async (req, res) => {
|
|
392
|
+
try {
|
|
393
|
+
// preferred_disciplines is NOT on req.builder (requireBuilder attaches an
|
|
394
|
+
// identity+rank subset), so read the row through the doorway — the same
|
|
395
|
+
// getBuilderById the wandering/interaction routes above use.
|
|
396
|
+
const [stored, builder] = await Promise.all([
|
|
397
|
+
db.getBuilderBehaviorPrefs(req.builder.id),
|
|
398
|
+
api.getBuilderById(req.builder.id),
|
|
399
|
+
]);
|
|
400
|
+
res.json(profileNudgePrefs.describeForApi(stored, {
|
|
401
|
+
preferredDisciplines: builder && builder.preferred_disciplines,
|
|
402
|
+
}));
|
|
403
|
+
} catch (err) {
|
|
404
|
+
log.error('GET /me/profile-nudge', err);
|
|
405
|
+
res.fail('read_failed', { status: 500, message: 'internal error' });
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
// PATCH the dismissal. Body: { dismissed: <bool> | null } — null clears the
|
|
410
|
+
// override so the nudge can come back (the undismiss path). Sibling behavior
|
|
411
|
+
// keys are preserved by applyPatch.
|
|
412
|
+
router.patch('/me/profile-nudge', api.requireBuilder, async (req, res) => {
|
|
413
|
+
try {
|
|
414
|
+
const v = profileNudgePrefs.validatePatch(req.body || {});
|
|
415
|
+
if (!v.ok) return res.fail('invalid_patch', 422, { errors: v.errors });
|
|
416
|
+
const existing = await db.getBuilderBehaviorPrefs(req.builder.id);
|
|
417
|
+
const next = profileNudgePrefs.applyPatch(existing, v.patch);
|
|
418
|
+
const saved = await db.setBuilderBehaviorPrefs(req.builder.id, next);
|
|
419
|
+
const builder = await api.getBuilderById(req.builder.id);
|
|
420
|
+
res.json(profileNudgePrefs.describeForApi(saved, {
|
|
421
|
+
preferredDisciplines: builder && builder.preferred_disciplines,
|
|
422
|
+
}));
|
|
423
|
+
} catch (err) {
|
|
424
|
+
log.error('PATCH /me/profile-nudge', err);
|
|
425
|
+
res.fail('write_failed', { status: 500, message: 'internal error' });
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
|
|
381
429
|
// Per-builder DISPLAY NAME (task 1669). The free label shown next to a
|
|
382
430
|
// builder's work in the hall (header, leaderboard, work feed — all already read
|
|
383
431
|
// display_name, so a DB update propagates). Seeded once from GitHub at account
|
|
@@ -90,6 +90,8 @@ function createClient(opts = {}) {
|
|
|
90
90
|
postAccessRequests: (args) => request("POST", "/access-requests", { hasBody: true }, args),
|
|
91
91
|
// PATCH /access-requests/{id} — rank: archon — PATCH /access-requests/:id
|
|
92
92
|
patchAccessRequestsId: (args) => request("PATCH", "/access-requests/{id}", { hasBody: true }, args),
|
|
93
|
+
// POST /access-requests/invite — rank: archon — POST /access-requests/invite
|
|
94
|
+
postAccessRequestsInvite: (args) => request("POST", "/access-requests/invite", { hasBody: true }, args),
|
|
93
95
|
// GET /access-requests/status — rank: public — GET /access-requests/status
|
|
94
96
|
getAccessRequestsStatus: (args) => request("GET", "/access-requests/status", { hasBody: false }, args),
|
|
95
97
|
},
|
|
@@ -500,6 +502,10 @@ function createClient(opts = {}) {
|
|
|
500
502
|
patchMeModelAllocation: (args) => request("PATCH", "/me/model-allocation", { hasBody: true }, args),
|
|
501
503
|
// PATCH /me/profile — rank: any-builder — PATCH /me/profile
|
|
502
504
|
patchMeProfile: (args) => request("PATCH", "/me/profile", { hasBody: true }, args),
|
|
505
|
+
// GET /me/profile-nudge — rank: any-builder — GET /me/profile-nudge
|
|
506
|
+
getMeProfileNudge: (args) => request("GET", "/me/profile-nudge", { hasBody: false }, args),
|
|
507
|
+
// PATCH /me/profile-nudge — rank: any-builder — PATCH /me/profile-nudge
|
|
508
|
+
patchMeProfileNudge: (args) => request("PATCH", "/me/profile-nudge", { hasBody: true }, args),
|
|
503
509
|
// GET /me/recent-ships — rank: any-builder — GET /me/recent-ships
|
|
504
510
|
getMeRecentShips: (args) => request("GET", "/me/recent-ships", { hasBody: false }, args),
|
|
505
511
|
// GET /me/render — rank: any-builder — GET /me/render
|
|
@@ -127,7 +127,7 @@ const PERMISSIONS = [
|
|
|
127
127
|
// one of them would make this delegation self-reversible.
|
|
128
128
|
{ key: 'builder.status.set', system: false, floor: 'archon', guards: 'PATCH /builders/:id/status — offboard / (de)activate' },
|
|
129
129
|
{ key: 'auth.session.revoke', system: false, floor: 'archon', guards: 'POST /auth/revoke, /auth/revoke-all' },
|
|
130
|
-
{ key: 'access_request.review', system: false, floor: 'archon', guards: 'GET/PATCH /access-requests/:id — admit a builder' },
|
|
130
|
+
{ key: 'access_request.review', system: false, floor: 'archon', guards: 'GET/PATCH /access-requests/:id, POST /access-requests/invite — admit a builder' },
|
|
131
131
|
|
|
132
132
|
// ── operational — floor metic (the working rank's review/triage/authoring) ────
|
|
133
133
|
{ key: 'idea.triage', system: false, floor: 'metic', guards: 'PATCH /inbox/:id' },
|
|
@@ -79,9 +79,11 @@
|
|
|
79
79
|
let code = null;
|
|
80
80
|
try {
|
|
81
81
|
const j = res.data;
|
|
82
|
-
// Prefer the human-facing message; fall back to the machine code.
|
|
83
|
-
|
|
84
|
-
|
|
82
|
+
// Prefer the human-facing message; fall back to the machine code. The
|
|
83
|
+
// envelope (ADR 0116) nests both under `error`; older handlers answer flat.
|
|
84
|
+
const env = j.error && typeof j.error === 'object' ? j.error : null;
|
|
85
|
+
detail = (env && env.message) || j.message || j.detail || (typeof j.error === 'string' ? j.error : detail);
|
|
86
|
+
code = (env && env.code) || (typeof j.error === 'string' ? j.error : null);
|
|
85
87
|
} catch (_) { /* keep default */ }
|
|
86
88
|
const err = new Error(detail);
|
|
87
89
|
err.status = res.status;
|
|
@@ -273,6 +273,13 @@
|
|
|
273
273
|
host.innerHTML =
|
|
274
274
|
'<h2 id="attention-h" class="scroll__h">Needs your attention<span class="count-pill" id="attention-count" hidden></span></h2>' +
|
|
275
275
|
'<div id="attention-goals"></div>' +
|
|
276
|
+
// task 1002802 (P06): "Complete your profile" — a DISMISSIBLE nudge when
|
|
277
|
+
// bio or preferred_disciplines are empty. Rendered by builders.js (which
|
|
278
|
+
// already holds the /me state the condition reads), exactly like the
|
|
279
|
+
// ideas slice. Placement rule: an action awaiting the builder is an inbox
|
|
280
|
+
// LINE, never a new Home card — that is why the rejected profile wizard
|
|
281
|
+
// (task 1002783) became this.
|
|
282
|
+
'<div id="attention-profile"></div>' +
|
|
276
283
|
'<div id="attention-ideas"></div>' +
|
|
277
284
|
// task 1003066 (BV1.R05): routed filings that SOFT-LANDED — a reported bug
|
|
278
285
|
// now sitting at 'backlog' in a real goal, one Metic+ nod from claimable.
|
|
@@ -284,6 +291,9 @@
|
|
|
284
291
|
host.addEventListener('click', onClick);
|
|
285
292
|
load();
|
|
286
293
|
loadNodQueue();
|
|
294
|
+
// task 1002802 (P06): the profile slice lives in its own file (builders.js is
|
|
295
|
+
// over the size budget); this card owns its div, so it boots it here.
|
|
296
|
+
window.OTBProfileNudge?.load();
|
|
287
297
|
}
|
|
288
298
|
|
|
289
299
|
// The card's shared visibility contract: any slice renderer calls refresh()
|
|
@@ -238,23 +238,43 @@
|
|
|
238
238
|
else standingRail = window.OTBKit.makeSectionRail({ mount: rail, items, mode: 'scrollspy' });
|
|
239
239
|
}
|
|
240
240
|
}
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
const
|
|
247
|
-
|
|
241
|
+
// The project's door, as the platform pushed it into this instance's pack
|
|
242
|
+
// (window.__BRANDING__.project.joinability, task 1003044): what a stranger is
|
|
243
|
+
// told to do next depends on it. An older pack reports none → 'apply', today's
|
|
244
|
+
// behaviour. Cosmetic — the server refuses on its own terms either way.
|
|
245
|
+
function joinabilityMode() {
|
|
246
|
+
const p = window.__BRANDING__ && window.__BRANDING__.project;
|
|
247
|
+
const v = p && p.joinability;
|
|
248
|
+
return v === 'open' || v === 'invite_only' ? v : 'apply';
|
|
249
|
+
}
|
|
248
250
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
251
|
+
// One landing per door: open → sign in and you're in; invite_only → nothing to
|
|
252
|
+
// apply for, sign in once invited; apply → the request form.
|
|
253
|
+
function landingDoorHtml(mode) {
|
|
254
|
+
const hub = `This is the builders' hub for ${brandWorld()} — where builders claim work, earn ${curLabel()}, and track their standing.`;
|
|
255
|
+
const admitter = escapeHtml(rankLabel('archon'));
|
|
256
|
+
const signIn = `<p class="landing__cta"><a class="landing__btn" href="${API}/auth/web/start">Sign in with GitHub</a></p>`;
|
|
257
|
+
if (mode === 'open') {
|
|
258
|
+
return `
|
|
259
|
+
<p class="landing__lede">${hub} Anyone with a GitHub account can join: sign in and you're in, starting at the newcomer rank.</p>
|
|
260
|
+
<div class="landing__request" id="landing-request">
|
|
261
|
+
<h3 class="landing__request-h">New here — sign in to join</h3>
|
|
262
|
+
<p class="landing__request-lede">No request and no invite needed. Sign in with GitHub, and a step-by-step guide takes you the rest of the way.</p>
|
|
263
|
+
${signIn}
|
|
264
|
+
</div>`;
|
|
265
|
+
}
|
|
266
|
+
if (mode === 'invite_only') {
|
|
267
|
+
return `
|
|
268
|
+
<p class="landing__lede">${hub} This project is invite-only: the ${admitter} invites each builder, and there is nothing to apply for here.</p>
|
|
269
|
+
<div class="landing__request" id="landing-request">
|
|
270
|
+
<h3 class="landing__request-h">Invited? Sign in</h3>
|
|
271
|
+
<p class="landing__request-lede">If the ${admitter} has invited you, sign in with GitHub and you're in. If not, ask them for an invite — this project doesn't take requests.</p>
|
|
272
|
+
${signIn}
|
|
273
|
+
</div>`;
|
|
274
|
+
}
|
|
275
|
+
return `
|
|
255
276
|
<p class="landing__lede">
|
|
256
|
-
|
|
257
|
-
work, earn ${curLabel()}, and track their standing. Access is
|
|
277
|
+
${hub} Access is
|
|
258
278
|
invite-based: an Archon approves each new builder. To begin, request
|
|
259
279
|
access below.
|
|
260
280
|
</p>
|
|
@@ -290,7 +310,24 @@
|
|
|
290
310
|
<button type="submit" class="landing__btn landing__btn--request" id="ar-submit">Request access</button>
|
|
291
311
|
<p class="landing__request-status" id="ar-status" aria-live="polite"></p>
|
|
292
312
|
</form>
|
|
293
|
-
</div
|
|
313
|
+
</div>`;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function renderLanding() {
|
|
317
|
+
// task 1723: collapse the shell nav to the signed-out minimum (Home + a
|
|
318
|
+
// Sign-in button). Cosmetic — the server gates every page regardless.
|
|
319
|
+
window.OTBShell?.applyAccess({ authed: false });
|
|
320
|
+
|
|
321
|
+
const welcome = $('#welcome-line');
|
|
322
|
+
if (welcome) welcome.textContent = '';
|
|
323
|
+
|
|
324
|
+
const landing = $('#landing-root');
|
|
325
|
+
if (landing) {
|
|
326
|
+
landing.hidden = false;
|
|
327
|
+
landing.innerHTML = `
|
|
328
|
+
<div class="scroll landing">
|
|
329
|
+
<h2 class="scroll__h">Welcome</h2>
|
|
330
|
+
${landingDoorHtml(joinabilityMode())}
|
|
294
331
|
|
|
295
332
|
<p class="landing__signin-note">
|
|
296
333
|
Already a member? <a href="${API}/auth/web/start">Sign in with GitHub</a>.
|
|
@@ -346,15 +383,19 @@
|
|
|
346
383
|
</p>`;
|
|
347
384
|
}
|
|
348
385
|
} catch (err) {
|
|
349
|
-
//
|
|
350
|
-
//
|
|
351
|
-
// collapse the form into
|
|
352
|
-
|
|
386
|
+
// The door's own answers — already a builder, already approved, or a
|
|
387
|
+
// project that takes no requests (invite-only) / needs none (open) —
|
|
388
|
+
// collapse the form into what to do instead: sign in. Never a red error.
|
|
389
|
+
const code = err && err.code;
|
|
390
|
+
if (code === 'already_member' || code === 'already_approved' || code === 'invite_only' || code === 'open_enrollment') {
|
|
353
391
|
const req = $('#landing-request');
|
|
354
392
|
if (req) {
|
|
393
|
+
const heading = code === 'invite_only' ? 'This project is invite-only'
|
|
394
|
+
: code === 'open_enrollment' ? 'No request needed'
|
|
395
|
+
: 'You already have access ✓';
|
|
355
396
|
req.innerHTML = `
|
|
356
|
-
<h3 class="landing__request-h"
|
|
357
|
-
<p class="landing__request-lede">${escapeHtml(err.message || '
|
|
397
|
+
<h3 class="landing__request-h">${heading}</h3>
|
|
398
|
+
<p class="landing__request-lede">${escapeHtml(err.message || 'Sign in with GitHub to continue.')}</p>
|
|
358
399
|
<p class="landing__cta">
|
|
359
400
|
<a class="landing__btn" href="${API}/auth/web/start">Sign in with GitHub</a>
|
|
360
401
|
</p>`;
|
|
@@ -123,6 +123,10 @@
|
|
|
123
123
|
hall-widget tags the server injects still anchor on builders.js's tag
|
|
124
124
|
below (it matches by filename), so they land between the two. -->
|
|
125
125
|
<script src="/builders/hall-render.js?v=2026-08-24-hallrender1" defer></script>
|
|
126
|
+
<!-- task 1002802 (P06): the profile-completion nudge slice. Exposes
|
|
127
|
+
window.OTBProfileNudge; goal-inbox.js's mount() calls it once it has
|
|
128
|
+
created the #attention-profile div, so load order is not load-bearing. -->
|
|
129
|
+
<script src="/builders/profile-nudge.js?v=2026-08-25-profile-nudge" defer></script>
|
|
126
130
|
<script src="/builders/builders.js?v=2026-06-27-r75" defer></script>
|
|
127
131
|
<script src="/builders/copy-on-click.js?v=2026-06-01-copy-1" defer></script>
|
|
128
132
|
</body>
|
|
@@ -130,7 +130,12 @@
|
|
|
130
130
|
.then(function (r) { return r.json().then(function (b) { return { ok: r.ok, body: b }; }); })
|
|
131
131
|
.then(function (res) {
|
|
132
132
|
joinBtn.disabled = false;
|
|
133
|
-
if (res.ok && res.body && res.body.message) {
|
|
133
|
+
if (res.ok && res.body && res.body.message) {
|
|
134
|
+
joinNote.textContent = res.body.message;
|
|
135
|
+
/* a door refusal rides a 200 (task 1003044): keep the address, and don't repaint as if a request went out */
|
|
136
|
+
var refused = res.body.status === 'invite_only' || res.body.status === 'open_enrollment' || res.body.status === 'members_only';
|
|
137
|
+
if (!refused) { joinInput.value = ''; load(); }
|
|
138
|
+
}
|
|
134
139
|
else if (res.body && res.body.error && res.body.error.code === 'unknown_project') { joinNote.textContent = 'That project isn’t on the platform.'; }
|
|
135
140
|
else { joinNote.textContent = 'Could not request access right now.'; }
|
|
136
141
|
})
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// profile-nudge.js — the "Complete your profile" slice of the hall's
|
|
2
|
+
// "Needs your attention" card (task 1002802 / P06, goal 1000045).
|
|
3
|
+
//
|
|
4
|
+
// Its own file rather than a block inside builders.js: the slice needs no state
|
|
5
|
+
// from that file (both its inputs come from its own two fetches), and builders.js
|
|
6
|
+
// is already far over the 1,500-line budget the quality ratchets enforce.
|
|
7
|
+
//
|
|
8
|
+
// goal-inbox.js owns the CARD (id 'attention-scroll') and creates this slice's
|
|
9
|
+
// div (#attention-profile); card visibility + the headline count derive from
|
|
10
|
+
// every slice via window.OTBAttention.refresh().
|
|
11
|
+
(function () {
|
|
12
|
+
'use strict';
|
|
13
|
+
if (!window.OTB) return;
|
|
14
|
+
const { escapeHtml } = window.OTB;
|
|
15
|
+
const API = '/api/bongos';
|
|
16
|
+
// Result-mode client: a 401/404 must be inspectable control flow, never a throw
|
|
17
|
+
// that breaks a sibling slice (the goal-inbox.js pattern).
|
|
18
|
+
const api = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false });
|
|
19
|
+
|
|
20
|
+
async function getJSON(path) {
|
|
21
|
+
const res = await api.request('GET', path);
|
|
22
|
+
if (!res.ok) { const e = new Error(`${path} → ${res.status}`); e.status = res.status; throw e; }
|
|
23
|
+
return res.data;
|
|
24
|
+
}
|
|
25
|
+
async function patchJSON(path, body) {
|
|
26
|
+
const res = await api.request('PATCH', path, { body: body || {} });
|
|
27
|
+
if (!res.ok) { const e = new Error(`${path} → ${res.status}`); e.status = res.status; throw e; }
|
|
28
|
+
return res.data;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
// Renders the PROFILE slice of the "Needs your attention" card. goal-inbox.js
|
|
33
|
+
// owns the card (id 'attention-scroll') + the slice div; visibility and the
|
|
34
|
+
// headline count derive from every slice via OTBAttention.
|
|
35
|
+
//
|
|
36
|
+
// WHY A LINE AND NOT A CARD OR A WIZARD: spec Decision 2 rejected a
|
|
37
|
+
// profile-setup wizard (profiles are auto-created and enriched
|
|
38
|
+
// progressively; an interstitial after sign-in adds friction at the drop-off
|
|
39
|
+
// point). The hall's placement rule then puts an action awaiting the builder
|
|
40
|
+
// in the inbox as a LINE, never a new Home card.
|
|
41
|
+
//
|
|
42
|
+
// The two inputs live in different places — preferred_disciplines on the
|
|
43
|
+
// builder row (already in the /me payload this file holds) and bio on the
|
|
44
|
+
// platform-identity account (GET /me/cross-project) — so the SHOW decision is
|
|
45
|
+
// composed here. The server owns only the dismissal flag.
|
|
46
|
+
// Uses .ginbox__item so OTBAttention.refresh() counts it with no selector change.
|
|
47
|
+
function renderProfileNudge(state) {
|
|
48
|
+
const box = document.getElementById('attention-profile');
|
|
49
|
+
if (!box) return;
|
|
50
|
+
if (!state || !state.show) {
|
|
51
|
+
box.innerHTML = '';
|
|
52
|
+
window.OTBAttention?.refresh();
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
// Name what is actually missing: "add a bio" is actionable, "complete your
|
|
56
|
+
// profile" is a chore of unknown size.
|
|
57
|
+
const missing = Array.isArray(state.missing) ? state.missing : [];
|
|
58
|
+
const parts = [];
|
|
59
|
+
if (missing.includes('bio')) parts.push('a short bio');
|
|
60
|
+
if (missing.includes('disciplines')) parts.push('the kinds of work you want');
|
|
61
|
+
const what = parts.length === 2 ? `${parts[0]} and ${parts[1]}` : (parts[0] || 'a detail or two');
|
|
62
|
+
box.innerHTML = '<h3 class="ginbox__sec-h">Complete your profile</h3>'
|
|
63
|
+
+ '<ul class="ginbox">'
|
|
64
|
+
+ '<li class="ginbox__item">'
|
|
65
|
+
// .ginbox__body / .ginbox__actions are the classes style.css actually
|
|
66
|
+
// defines (the body is the flex column that stacks label over sub-line).
|
|
67
|
+
+ '<div class="ginbox__body">'
|
|
68
|
+
+ `<a class="ginbox__goal" href="/settings">Add ${escapeHtml(what)}</a>`
|
|
69
|
+
+ '<span class="ginbox__sub">Shown on your public profile — it helps others know what you work on.</span>'
|
|
70
|
+
+ '</div>'
|
|
71
|
+
+ '<div class="ginbox__actions">'
|
|
72
|
+
+ '<button class="btn-ghost" type="button" data-profile-nudge-dismiss="1">Dismiss</button>'
|
|
73
|
+
+ '</div>'
|
|
74
|
+
+ '</li></ul>';
|
|
75
|
+
window.OTBAttention?.refresh();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Fetch the dismissal flag + the bio in parallel, then compose. Any failure
|
|
79
|
+
// renders NOTHING (a nudge is the most skippable thing on the page — it must
|
|
80
|
+
// never surface an error card or break a sibling slice).
|
|
81
|
+
async function loadProfileNudge() {
|
|
82
|
+
try {
|
|
83
|
+
const [nudge, cross] = await Promise.all([
|
|
84
|
+
getJSON(`${API}/me/profile-nudge`),
|
|
85
|
+
getJSON(`${API}/me/cross-project`).catch(() => null),
|
|
86
|
+
]);
|
|
87
|
+
if (!nudge) return;
|
|
88
|
+
// The server computed `missing` from preferred_disciplines only (bio is not
|
|
89
|
+
// its to read — different module). Fold the bio in here.
|
|
90
|
+
const missing = Array.isArray(nudge.missing) ? nudge.missing.slice() : [];
|
|
91
|
+
const bio = cross && typeof cross.bio === 'string' ? cross.bio : '';
|
|
92
|
+
const bioMissing = bio.trim() === '';
|
|
93
|
+
// A null cross-project (older server, or a principal with no platform
|
|
94
|
+
// identity) means "cannot tell" — do not invent a missing bio from it.
|
|
95
|
+
if (bioMissing && cross && !missing.includes('bio')) missing.push('bio');
|
|
96
|
+
if (!bioMissing) { const i = missing.indexOf('bio'); if (i !== -1) missing.splice(i, 1); }
|
|
97
|
+
renderProfileNudge({ ...nudge, missing, show: !nudge.dismissed && missing.length > 0 });
|
|
98
|
+
} catch (_) {
|
|
99
|
+
/* signed out, older server, or a hiccup: the nudge simply does not appear */
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// task 1002802 (P06): dismiss the profile nudge. Delegated for the same reason
|
|
104
|
+
// as the triage verb above — the slice re-renders. Optimistic: the line goes
|
|
105
|
+
// away immediately (a dismissal that appears to fail is worse than one that
|
|
106
|
+
// silently retries), and the server flag makes it stay gone on every device.
|
|
107
|
+
document.addEventListener('click', (e) => {
|
|
108
|
+
const btn = e.target.closest('[data-profile-nudge-dismiss]');
|
|
109
|
+
if (!btn) return;
|
|
110
|
+
btn.disabled = true;
|
|
111
|
+
renderProfileNudge({ show: false });
|
|
112
|
+
patchJSON(`${API}/me/profile-nudge`, { dismissed: true })
|
|
113
|
+
.catch(() => loadProfileNudge()); // write failed → put the line back, honestly
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// The CARD OWNER boots its slices: goal-inbox.js's mount() creates
|
|
117
|
+
// #attention-profile and then calls this, exactly as it calls its own load()
|
|
118
|
+
// and loadNodQueue(). No DOMContentLoaded race and no polling for the div —
|
|
119
|
+
// the widget registry mounts asynchronously after /me resolves, so a
|
|
120
|
+
// load-time call would reliably find nothing.
|
|
121
|
+
window.OTBProfileNudge = { load: loadProfileNudge, render: renderProfileNudge };
|
|
122
|
+
})();
|
|
@@ -182,3 +182,11 @@ table.watch tr:hover td { background: var(--surface-2); }
|
|
|
182
182
|
font-size: 13px; font-weight: 500;
|
|
183
183
|
color: var(--accent-text);
|
|
184
184
|
}
|
|
185
|
+
|
|
186
|
+
/* Invite by GitHub username (task 1003044) — the Access requests section's own
|
|
187
|
+
form: the way in on an invite-only project, a shortcut on an apply one. */
|
|
188
|
+
.watch-invite { margin: 0 0 14px; padding: 12px 14px; border: 1px solid var(--line, #e5e0d6); border-radius: 10px; }
|
|
189
|
+
.watch-invite__label { display: block; font-weight: 600; margin-bottom: 6px; }
|
|
190
|
+
.watch-invite__row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
|
191
|
+
.watch-invite__row input[type="text"] { flex: 1 1 200px; min-width: 0; }
|
|
192
|
+
.watch-invite__why { margin: 8px 0 0; font-size: 13px; opacity: .8; }
|
|
@@ -88,7 +88,11 @@
|
|
|
88
88
|
const res = await api.request('POST', `${API}${path}`, { body: body || {} });
|
|
89
89
|
if (res.status === 401) { window.location.assign(`${API}/auth/web/start`); throw { kind: '401' }; }
|
|
90
90
|
const data = res.data || {};
|
|
91
|
-
if (!res.ok)
|
|
91
|
+
if (!res.ok) {
|
|
92
|
+
// the envelope (ADR 0116) nests the sentence under `error`; older handlers answer flat
|
|
93
|
+
const env = data.error && typeof data.error === 'object' ? data.error : null;
|
|
94
|
+
throw new Error((env && env.message) || data.message || (typeof data.error === 'string' ? data.error : '') || `${path} → ${res.status}`);
|
|
95
|
+
}
|
|
92
96
|
return data;
|
|
93
97
|
}
|
|
94
98
|
|
|
@@ -183,14 +187,62 @@
|
|
|
183
187
|
// 1. GATE — access requests (pending)
|
|
184
188
|
// =========================================================================
|
|
185
189
|
|
|
190
|
+
// Invite by GitHub username (task 1003044): admits someone BEFORE any request
|
|
191
|
+
// exists — the only way in on an invite-only project, where nobody can apply,
|
|
192
|
+
// and a shortcut on an apply one. POST /access-requests/invite writes the same
|
|
193
|
+
// 'invited' row the sign-in gate reads. The door the project runs comes from
|
|
194
|
+
// the pack the server injected (window.__BRANDING__.project.joinability).
|
|
195
|
+
function inviteFormHtml() {
|
|
196
|
+
const p = window.__BRANDING__ && window.__BRANDING__.project;
|
|
197
|
+
const mode = (p && p.joinability) || 'apply';
|
|
198
|
+
const why = mode === 'invite_only'
|
|
199
|
+
? 'This project is invite-only — nobody can apply, so this is how people get in.'
|
|
200
|
+
: mode === 'open'
|
|
201
|
+
? 'This project is open — anyone who signs in is admitted, so an invite is rarely needed.'
|
|
202
|
+
: 'Admit someone before they ask — once invited, their sign-in gets in.';
|
|
203
|
+
return (
|
|
204
|
+
'<form class="watch-invite" id="gate-invite" novalidate>' +
|
|
205
|
+
'<label class="watch-invite__label" for="gate-invite-login">Invite by GitHub username</label>' +
|
|
206
|
+
'<div class="watch-invite__row">' +
|
|
207
|
+
'<input type="text" id="gate-invite-login" name="github_login" autocomplete="off" placeholder="e.g. octocat" maxlength="40">' +
|
|
208
|
+
'<button type="submit" class="watch-btn">Invite</button>' +
|
|
209
|
+
'</div>' +
|
|
210
|
+
`<p class="watch-invite__why">${escapeHtml(why)}</p>` +
|
|
211
|
+
'</form>'
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function wireInviteForm(target) {
|
|
216
|
+
const form = target.querySelector('#gate-invite');
|
|
217
|
+
if (!form) return;
|
|
218
|
+
form.addEventListener('submit', async (e) => {
|
|
219
|
+
e.preventDefault();
|
|
220
|
+
const input = form.querySelector('#gate-invite-login');
|
|
221
|
+
const login = ((input && input.value) || '').trim().replace(/^@/, '');
|
|
222
|
+
if (!login) { toast('A GitHub username is needed.'); return; }
|
|
223
|
+
const btn = form.querySelector('button[type="submit"]');
|
|
224
|
+
if (btn) btn.disabled = true;
|
|
225
|
+
try {
|
|
226
|
+
const r = await postJson('/access-requests/invite', { github_login: login });
|
|
227
|
+
toast(r.already_invited ? `@${login} is already invited.` : `@${login} invited — their sign-in gets in.`);
|
|
228
|
+
await loadGate();
|
|
229
|
+
} catch (err) {
|
|
230
|
+
if (err && err.kind === '401') return;
|
|
231
|
+
if (btn) btn.disabled = false;
|
|
232
|
+
toast(`Could not invite: ${err.message || err}`);
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
186
237
|
function renderGate(data, target) {
|
|
187
238
|
const requests = Array.isArray(data.requests) ? data.requests : [];
|
|
188
239
|
setAttention('att-access', requests.length); // tile count = table content
|
|
189
240
|
if (!requests.length) {
|
|
190
|
-
target.innerHTML = emptyStateHtml('No pending access requests.');
|
|
241
|
+
target.innerHTML = inviteFormHtml() + emptyStateHtml('No pending access requests.');
|
|
242
|
+
wireInviteForm(target);
|
|
191
243
|
return;
|
|
192
244
|
}
|
|
193
|
-
target.innerHTML = requests
|
|
245
|
+
target.innerHTML = inviteFormHtml() + requests
|
|
194
246
|
.map((r) => {
|
|
195
247
|
const who = r.display_name
|
|
196
248
|
? `${escapeHtml(r.display_name)} <span class="watch-mono">@${escapeHtml(r.github_login)}</span>`
|
|
@@ -221,6 +273,7 @@
|
|
|
221
273
|
toast(status === 'invited' ? 'Builder invited.' : 'Request dismissed.');
|
|
222
274
|
await loadGate();
|
|
223
275
|
}, 'Could not resolve');
|
|
276
|
+
wireInviteForm(target);
|
|
224
277
|
}
|
|
225
278
|
|
|
226
279
|
function loadGate() {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"key": "onboarding",
|
|
3
3
|
"title": "Onboarding (admission · setup · newcomer floor)",
|
|
4
4
|
"description": "The newcomer ADMISSION + ORIENTATION domain — the public access-request → Archon-approval gate, the server-ticked onboarding state machine (the 'Path into the City' checklist that graduates a Xenos to Thetes), the friction analyzer (feedback-without-asking on consented onboarding transcripts), and the evergreen newcomer-chore restock (a fresh first-builder task is always waiting). A core-domain module carved out of the GDS core in BV1.R81 (ADR 0093 §1); default-on because every instance onboards its own builders. The access-requests admission routes mount here; the core lifecycle (db.js claim/ship), auth callbacks, and the me/builders profile readers resolve the `onboarding` kernel PORT for the state-machine + restock surface they need — never importing the module.",
|
|
5
|
-
"coreVersion": "^1.
|
|
5
|
+
"coreVersion": "^1.19.374",
|
|
6
6
|
"default": true,
|
|
7
7
|
"maintenance": { "status": "core-maintained" },
|
|
8
8
|
"contributes": { "routes": ["onboarding"] },
|