@bongos/core 1.20.75 → 1.20.76
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 +39 -24
- package/docs/copy-inventory.md +37 -38
- package/docs/copy-registry.json +58 -77
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-inventory.json +4 -1
- package/docs/page-readings.json +146 -116
- package/modules/hall-ui/public/profile-roles.js +170 -0
- package/modules/hall-ui/public/profile.css +42 -5
- package/modules/hall-ui/public/profile.html +12 -5
- package/modules/hall-ui/public/profile.js +66 -27
- package/modules/hall-ui/public/profile.states.json +39 -1
- package/modules/hall-ui/records/profile-roles.md +32 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/src/module-api.js +1 -1
- package/tests/founding_builders.mjs +2 -1
- package/tests/helpers.mjs +4 -0
- package/tests/profile_role_headline.mjs +201 -0
- package/tests/profile_ui_cross_project.mjs +3 -0
|
@@ -50,12 +50,47 @@
|
|
|
50
50
|
.profile--ident .profile__resp-craft { font-weight: 500; }
|
|
51
51
|
.profile--ident .profile__resp-craft::after { content: " — "; font-weight: 400; color: var(--ink-faint); }
|
|
52
52
|
.profile--ident .profile__resp-text { color: var(--ink-soft); }
|
|
53
|
-
.profile--ident .profile__credits-num { font-family: var(--font-display); font-weight: 300; font-size: clamp(26px, 2.4vw, 32px); letter-spacing: -.035em; line-height: 1; color: var(--ink); }
|
|
54
|
-
.profile--ident .profile__credits-label,
|
|
55
|
-
.profile--ident .profile__karma-label { font-family: var(--label-face); font-size: var(--label-size); font-weight: 400; letter-spacing: var(--label-space); text-transform: var(--label-case); color: var(--ink-faint); }
|
|
56
|
-
.profile--ident .profile__karma-num { font-weight: 500; }
|
|
57
53
|
.profile--ident .profile__placeholder { grid-column: 1 / -1; }
|
|
58
54
|
|
|
55
|
+
/* The crafts, main role first (task 1004434, the approved mock at
|
|
56
|
+
docs/design/mocks/profile-roles/). A chip is glass on the card; the main role
|
|
57
|
+
takes the stronger glass and the ink, the others the soft ink. */
|
|
58
|
+
.profile--ident .profile__crafts { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
|
|
59
|
+
.profile--ident .profile__craft { display: inline-flex; align-items: center; min-height: 26px; padding: 0 11px; border-radius: var(--radius-pill); background: var(--glass); font-size: 12.5px; color: var(--ink-soft); }
|
|
60
|
+
.profile--ident .profile__craft--lead { background: var(--glass-strong); color: var(--ink); }
|
|
61
|
+
|
|
62
|
+
/* The headline tally: the studio's corner tally, carried onto the profile. The
|
|
63
|
+
person's total first, then the two numbers their main craft leads with,
|
|
64
|
+
hairline separators between. It replaces the credits/karma column. */
|
|
65
|
+
.profile--ident .profile__tally { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 18px; padding: 12px 22px; border-radius: var(--radius-pill); background: var(--glass); min-height: 48px; align-self: start; }
|
|
66
|
+
.profile--ident .profile__tally-item { display: flex; align-items: baseline; gap: 6px; }
|
|
67
|
+
.profile--ident .profile__tally-n { font-size: 20px; font-weight: 300; font-variant-numeric: tabular-nums; color: var(--ink); }
|
|
68
|
+
.profile--ident .profile__tally-w { font-size: 13px; color: var(--ink-soft); }
|
|
69
|
+
.profile--ident .profile__tally-sep { width: 1px; height: 20px; background: var(--rule); }
|
|
70
|
+
|
|
71
|
+
/* ---- by the numbers (task 1004434) -----------------------------------------
|
|
72
|
+
One tile per figure, a tonal step down from the panel: the number, what it
|
|
73
|
+
counts, then a quieter foot. The mock's .stat, on the hall's tokens. */
|
|
74
|
+
.profile-nums { display: grid; grid-template-columns: repeat(auto-fit, minmax(148px, 1fr)); gap: var(--sp-sm); }
|
|
75
|
+
/* Six tiles (an ideator with karma) sit as two even rows of three, not five
|
|
76
|
+
and an orphan. The phone's two columns below already divide six evenly. */
|
|
77
|
+
.profile-nums[data-count="6"] { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
|
78
|
+
.profile-num { padding: 14px 16px; border-radius: var(--radius-sm); background: var(--bg-frame); }
|
|
79
|
+
.profile-num__n { display: block; font-size: 28px; font-weight: 300; letter-spacing: -.02em; font-variant-numeric: tabular-nums; color: var(--ink); }
|
|
80
|
+
.profile-num__w { display: block; font-size: 13px; color: var(--ink-soft); }
|
|
81
|
+
.profile-num__f { display: block; margin-top: 4px; font-size: 12px; color: var(--ink-faint); }
|
|
82
|
+
.profile-surfaces { margin-top: var(--sp-md); }
|
|
83
|
+
.profile-surfaces__h { margin: 0 0 12px; font-family: var(--label-face); font-size: var(--label-size); font-weight: 400; letter-spacing: var(--label-space); text-transform: var(--label-case); color: var(--ink-faint); }
|
|
84
|
+
/* The mock's row: the surface's name and its count on one line, the bar under
|
|
85
|
+
them across the full width. The kit draws the bar; only the placement of its
|
|
86
|
+
three cells changes here, so a phone keeps a usable bar instead of a sliver
|
|
87
|
+
between two fixed columns. */
|
|
88
|
+
.profile-surfaces .kit-bars { gap: 12px; }
|
|
89
|
+
.profile-surfaces .kit-bars__row { grid-template-columns: minmax(0, 1fr) auto; row-gap: 7px; column-gap: 12px; }
|
|
90
|
+
.profile-surfaces .kit-bars__label { grid-column: 1; grid-row: 1; font-size: 16px; font-weight: 300; color: var(--ink); }
|
|
91
|
+
.profile-surfaces .kit-bars__val { grid-column: 2; grid-row: 1; font-size: 13.5px; color: var(--ink-soft); }
|
|
92
|
+
.profile-surfaces .kit-bars__track { grid-column: 1 / -1; grid-row: 2; }
|
|
93
|
+
|
|
59
94
|
/* ---- the shelf -------------------------------------------------------------
|
|
60
95
|
An achievement is a --bg-card tile on the ground (the section is bare, so a
|
|
61
96
|
tile is the panel and not a card in a card), a hairline on top, the mark as
|
|
@@ -106,7 +141,9 @@
|
|
|
106
141
|
@media (max-width: 640px) {
|
|
107
142
|
.profile--ident { grid-template-columns: 48px minmax(0, 1fr); }
|
|
108
143
|
.profile--ident .profile__avatar { width: 48px; height: 48px; }
|
|
109
|
-
.profile--ident .
|
|
144
|
+
.profile--ident .profile__tally { grid-column: 1 / -1; border-radius: var(--radius-sm); padding: 12px 16px; }
|
|
145
|
+
.profile--ident .profile__tally-sep { display: none; }
|
|
146
|
+
.profile-nums, .profile-nums[data-count="6"] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
110
147
|
}
|
|
111
148
|
|
|
112
149
|
/* ---- the rail and the groups (task 1003489) ---------------------------------
|
|
@@ -27,7 +27,8 @@
|
|
|
27
27
|
<header class="app-topbar" id="app-topbar"></header>
|
|
28
28
|
<div class="app-content">
|
|
29
29
|
<!-- The page's ONE h1 (one-header-main-h1), in the family's voice; the
|
|
30
|
-
sub-line is profile.js's — "<name> — <rank> ·
|
|
30
|
+
sub-line is profile.js's — "<name> — <rank> · <the main craft's
|
|
31
|
+
headline>": works shipped, pages approved or thoughts filed. -->
|
|
31
32
|
<div class="page-head page-head--room page-head--panel">
|
|
32
33
|
<p class="page-head__eyebrow">the page everyone else reads</p>
|
|
33
34
|
<h1 id="profile-h1" class="page-head__h">profile</h1>
|
|
@@ -55,11 +56,14 @@
|
|
|
55
56
|
</div>
|
|
56
57
|
</section>
|
|
57
58
|
|
|
58
|
-
<!-- By the numbers —
|
|
59
|
+
<!-- By the numbers — led by the person's own craft (task 1004434):
|
|
60
|
+
works shipped for an engineer, pages approved and the per-surface
|
|
61
|
+
"N of M tweaked" for an artist, thoughts, size and what is still
|
|
62
|
+
alive for an ideator. profile-roles.js says what; profile.js draws. -->
|
|
59
63
|
<section class="scroll" id="numbers-scroll" aria-labelledby="numbers-h" data-group="overview">
|
|
60
64
|
<h2 id="numbers-h" class="scroll__h">By the numbers</h2>
|
|
61
|
-
<div
|
|
62
|
-
<
|
|
65
|
+
<div id="numbers-body">
|
|
66
|
+
<p class="profile__placeholder">Loading…</p>
|
|
63
67
|
</div>
|
|
64
68
|
</section>
|
|
65
69
|
|
|
@@ -211,6 +215,9 @@
|
|
|
211
215
|
omits this script simply has no charts section content. -->
|
|
212
216
|
<script src="/builders/profile-charts.js?v=2026-09-04-calendar"></script>
|
|
213
217
|
<script src="/builders/founding-mark.js?v=2026-09-30-founding"></script>
|
|
214
|
-
|
|
218
|
+
<!-- What the header and By the numbers say, per craft (task 1004434). Pure, and
|
|
219
|
+
loaded BEFORE profile.js, which draws what it returns. -->
|
|
220
|
+
<script src="/builders/profile-roles.js?v=2026-10-01-roles"></script>
|
|
221
|
+
<script src="/builders/profile.js?v=2026-10-01-roles"></script>
|
|
215
222
|
</body>
|
|
216
223
|
</html>
|
|
@@ -75,7 +75,33 @@
|
|
|
75
75
|
</div>`;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
|
|
78
|
+
// What the header and "By the numbers" say for this person's crafts (task
|
|
79
|
+
// 1004434): profile-roles.js decides the words and numbers, this page draws them.
|
|
80
|
+
function roleHeadline(data) {
|
|
81
|
+
const builder = (data && data.builder) || {};
|
|
82
|
+
return window.OTBProfileRoles.headline(data, {
|
|
83
|
+
fmt: fmtNum,
|
|
84
|
+
currency: curLabel(),
|
|
85
|
+
rank: rankLabel(normalizeRank(builder.rank)),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// The headline tally (task 1004434): the studio's corner tally carried onto the
|
|
90
|
+
// profile, the person's total first and then the two numbers their main craft
|
|
91
|
+
// leads with.
|
|
92
|
+
function tallyHtml(items) {
|
|
93
|
+
return items.map((t, i) => {
|
|
94
|
+
const title = t.lead
|
|
95
|
+
? ` title="${escapeHtml(curLabel(true))} — everything earned: shipped work, approved pages and ideas."`
|
|
96
|
+
: '';
|
|
97
|
+
return (i ? '<span class="profile__tally-sep" aria-hidden="true"></span>' : '')
|
|
98
|
+
+ `<span class="profile__tally-item"${title}>`
|
|
99
|
+
+ `<span class="profile__tally-n">${escapeHtml(t.n)}</span>`
|
|
100
|
+
+ `<span class="profile__tally-w">${escapeHtml(t.w)}</span></span>`;
|
|
101
|
+
}).join('');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function renderHead(builder, view) {
|
|
79
105
|
const head = $('#profile-head');
|
|
80
106
|
if (!head) return;
|
|
81
107
|
const name = escapeHtml(builder.display_name || builder.github_login || 'Builder');
|
|
@@ -83,13 +109,11 @@
|
|
|
83
109
|
? `<img class="profile__avatar" src="${escapeHtml(builder.avatar_url)}" alt="">`
|
|
84
110
|
: `<div class="profile__avatar" aria-hidden="true"></div>`;
|
|
85
111
|
const prefs = Array.isArray(builder.preferred_disciplines) ? builder.preferred_disciplines : [];
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
${responsibilitiesBlock(prefs)}`
|
|
92
|
-
: '';
|
|
112
|
+
// The crafts as chips, main role first; any other craft shows only once it
|
|
113
|
+
// has earned (the owner, 2026-09-30) — the server's `roles.shown` says which.
|
|
114
|
+
const crafts = `<div class="profile__crafts" aria-label="Roles">${view.crafts
|
|
115
|
+
.map((c) => `<span class="profile__craft${c.lead ? ' profile__craft--lead' : ''}">${escapeHtml(c.label)}</span>`)
|
|
116
|
+
.join('')}</div>`;
|
|
93
117
|
|
|
94
118
|
head.innerHTML = `
|
|
95
119
|
${avatar}
|
|
@@ -102,16 +126,10 @@
|
|
|
102
126
|
<div class="profile__stats">
|
|
103
127
|
<span>Member since ${fmtDate(builder.created_at)}</span>
|
|
104
128
|
</div>
|
|
105
|
-
${
|
|
129
|
+
${crafts}
|
|
130
|
+
${responsibilitiesBlock(prefs)}
|
|
106
131
|
</div>
|
|
107
|
-
<div class="
|
|
108
|
-
<span class="profile__credits-num" title="${escapeHtml(curLabel(true))} — everything earned: shipped work, approved pages and ideas.">${fmtNum(builder.total_credits)}</span>
|
|
109
|
-
<span class="profile__credits-label">${escapeHtml(curLabel())}</span>
|
|
110
|
-
<span class="profile__karma" title="Karma — peer recognition and approvals, a separate counter from ${escapeHtml(curLabel())}.">
|
|
111
|
-
<span class="profile__karma-num">${fmtNum(builder.karma)}</span>
|
|
112
|
-
<span class="profile__karma-label">karma</span>
|
|
113
|
-
</span>
|
|
114
|
-
</div>`;
|
|
132
|
+
<div class="profile__tally" role="group" aria-label="Headline numbers">${tallyHtml(view.tally)}</div>`;
|
|
115
133
|
}
|
|
116
134
|
|
|
117
135
|
// ---- render: the founding mark (task 1004424) ----------------------------
|
|
@@ -142,13 +160,33 @@
|
|
|
142
160
|
</div>`;
|
|
143
161
|
}
|
|
144
162
|
|
|
145
|
-
|
|
163
|
+
// By the numbers, led by the person's own craft (task 1004434). Each tile is
|
|
164
|
+
// number, then what it counts, then a quieter foot, as the approved mock reads.
|
|
165
|
+
function numberTile(t) {
|
|
166
|
+
return `<div class="profile-num"${t.title ? ` title="${escapeHtml(t.title)}"` : ''}>`
|
|
167
|
+
+ `<span class="profile-num__n">${escapeHtml(t.n)}</span>`
|
|
168
|
+
+ `<span class="profile-num__w">${escapeHtml(t.w)}</span>`
|
|
169
|
+
+ (t.f ? `<span class="profile-num__f">${escapeHtml(t.f)}</span>` : '')
|
|
170
|
+
+ '</div>';
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// The studio's "N of M tweaked", one row per surface, on the kit's bar series:
|
|
174
|
+
// each bar is that surface's own share, so the axis is a percentage and the
|
|
175
|
+
// value cell says the count in words.
|
|
176
|
+
function surfacesHtml(surfaces) {
|
|
177
|
+
if (!surfaces.length || !window.OTBKit || !window.OTBKit.barSeriesHtml) return '';
|
|
178
|
+
const bars = window.OTBKit.barSeriesHtml(surfaces.map((s) => ({
|
|
179
|
+
label: s.label,
|
|
180
|
+
value: s.total > 0 ? (s.tweaked / s.total) * 100 : 0,
|
|
181
|
+
valueLabel: s.line,
|
|
182
|
+
})), { max: 100, tone: 'neutral', pill: true, track: 'soft', barHeight: '4px', list: true, ariaLabel: 'Pages tweaked, per surface' });
|
|
183
|
+
return `<div class="profile-surfaces"><h3 class="profile-surfaces__h">pages tweaked, per surface</h3>${bars}</div>`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function renderNumbers(view) {
|
|
146
187
|
const body = $('#numbers-body');
|
|
147
188
|
if (!body) return;
|
|
148
|
-
|
|
149
|
-
body.innerHTML =
|
|
150
|
-
tile('Works shipped', fmtNum(shippedCount)) +
|
|
151
|
-
tile('Achievements', fmtNum(achCount), 'earned');
|
|
189
|
+
body.innerHTML = `<div class="profile-nums" data-count="${view.tiles.length}">${view.tiles.map(numberTile).join('')}</div>${surfacesHtml(view.surfaces)}`;
|
|
152
190
|
}
|
|
153
191
|
|
|
154
192
|
// ---- render: achievements shelf -----------------------------------------
|
|
@@ -777,12 +815,13 @@
|
|
|
777
815
|
|
|
778
816
|
function renderProfile(data, selfId) {
|
|
779
817
|
const builder = data.builder || {};
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
818
|
+
// The subtitle, the header and By the numbers all lead with the person's
|
|
819
|
+
// main craft (task 1004434) — "N works shipped" only for an engineer.
|
|
820
|
+
const view = roleHeadline(data);
|
|
821
|
+
showMessage(view.sub);
|
|
822
|
+
renderHead(builder, view);
|
|
784
823
|
renderFoundingMark(builder);
|
|
785
|
-
renderNumbers(
|
|
824
|
+
renderNumbers(view);
|
|
786
825
|
// The charts (task 1003489) read the activity route and the credits ledger
|
|
787
826
|
// (task 1003491), so they need the same session the ledgers do; on the
|
|
788
827
|
// viewer's own profile they add the pace.
|
|
@@ -34,7 +34,9 @@
|
|
|
34
34
|
"#numbers-body",
|
|
35
35
|
"#charts-controls",
|
|
36
36
|
"#charts-grid",
|
|
37
|
-
"#ach-grid"
|
|
37
|
+
"#ach-grid",
|
|
38
|
+
".profile__tally",
|
|
39
|
+
".profile-nums"
|
|
38
40
|
]
|
|
39
41
|
}
|
|
40
42
|
},
|
|
@@ -88,6 +90,42 @@
|
|
|
88
90
|
]
|
|
89
91
|
}
|
|
90
92
|
},
|
|
93
|
+
"artist": {
|
|
94
|
+
"_": "An artist who has never shipped a task (task 1004434): the header and By the numbers lead with pages approved and the per-surface N of M tweaked. Fixture builders__2001__profile.",
|
|
95
|
+
"auth": true,
|
|
96
|
+
"url": "/profile?id=2001",
|
|
97
|
+
"actions": [
|
|
98
|
+
[
|
|
99
|
+
"wait",
|
|
100
|
+
1200
|
|
101
|
+
]
|
|
102
|
+
],
|
|
103
|
+
"expect": {
|
|
104
|
+
"visible": [
|
|
105
|
+
"#profile-head",
|
|
106
|
+
".profile__tally",
|
|
107
|
+
".profile-nums"
|
|
108
|
+
]
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
"ideator": {
|
|
112
|
+
"_": "An ideator who has never shipped a task (task 1004434): thoughts filed, ignited, Full Ideas, total size with its historical-estimate mark, and what is still alive. Fixture builders__2002__profile.",
|
|
113
|
+
"auth": true,
|
|
114
|
+
"url": "/profile?id=2002",
|
|
115
|
+
"actions": [
|
|
116
|
+
[
|
|
117
|
+
"wait",
|
|
118
|
+
1200
|
|
119
|
+
]
|
|
120
|
+
],
|
|
121
|
+
"expect": {
|
|
122
|
+
"visible": [
|
|
123
|
+
"#profile-head",
|
|
124
|
+
".profile__tally",
|
|
125
|
+
".profile-nums"
|
|
126
|
+
]
|
|
127
|
+
}
|
|
128
|
+
},
|
|
91
129
|
"out": {
|
|
92
130
|
"_": "A signed-out visitor with no ?id= gets renderEmpty(), not a bounce — but this state only RENDERS that empty markup against the kit's own stub. Run it WITHOUT --base-out: under the hall-preview harness's --fixture-me the /me read still answers, so `out` quietly renders the viewer's own populated profile and the empty state is never measured. That is how its two prose links sat at 18px, under the 24px control floor, through the whole re-skin (walk 1003309, fixed in task 1003497).",
|
|
93
131
|
"auth": false,
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# The role-led profile header and "By the numbers" — each person leads with their own craft (task 1004434)
|
|
2
|
+
|
|
3
|
+
**WA6.RP04 (goal 1000095, criterion `<redacted>`).** The pixel contract is the owner-approved mock `docs/design/mocks/profile-roles/index.html` (task 1004431). Before this, the header's subtitle and the first tile said "N works shipped" for everybody, so an artist with nine approved pages read as someone who had done nothing.
|
|
4
|
+
|
|
5
|
+
## What the page says now
|
|
6
|
+
|
|
7
|
+
- **The subtitle** under the h1 is the main craft's headline: "N works shipped" (engineer), "N pages approved" (artist), "N thoughts filed" (ideator).
|
|
8
|
+
- **The identity band** keeps the avatar, name, rank badge, handle, member-since and the "Answerable for" block unchanged. The "Preferred work:" line became **craft chips**, main role first ("Artist, main role"), then every other craft the server lists in `roles.shown`, which is a craft that has *earned* credits (the owner's 2026-09-30 rule; the server decides it, never this page).
|
|
9
|
+
- **The tally** replaces the credits/karma column: the studio's corner tally carried onto the profile, the total first and then the two numbers the main craft leads with (engineer: works shipped, karma; artist: pages approved, this week; ideator: thoughts filed, ignited).
|
|
10
|
+
- **By the numbers** is the main craft's set of tiles, then one headline tile per other shown craft. The artist adds the studio's "N of M tweaked" per surface underneath, on `OTBKit.barSeriesHtml` with this sheet only moving the three cells into the mock's layout (name and count on a line, the bar under them).
|
|
11
|
+
|
|
12
|
+
## Where the words live
|
|
13
|
+
|
|
14
|
+
`public/profile-roles.js` (`window.OTBProfileRoles.headline`) is **pure** and decides every word and number; `profile.js` only draws. This is the `profile-charts.js` precedent, and it is what lets `tests/profile_role_headline.mjs` run the real wording against the harness fixtures, the real page in a vm, and the real server reader (`src/bongos/role-stats.js`) through JSON into the real page.
|
|
15
|
+
|
|
16
|
+
## Rules an edit must keep
|
|
17
|
+
|
|
18
|
+
- **A number the instance could not read is a dash, never a zero.** The role ports are optional; "0 pages approved" would be a claim about the person.
|
|
19
|
+
- **The ~ on an ideator's total size** is the sky's historical-estimate mark; its tile's tooltip says how much is estimated.
|
|
20
|
+
- **The per-surface rows follow the MAIN craft only.**
|
|
21
|
+
- Every page that boots `profile.js` in a vm must load `profile-roles.js` first (`tests/helpers.mjs` `bootProfilePage` and `tests/profile_ui_cross_project.mjs` do).
|
|
22
|
+
|
|
23
|
+
## Harness
|
|
24
|
+
|
|
25
|
+
Three fixtures, one per craft: `builders__profile` (the engineer, id 95, the viewer's own), `<redacted>` (an artist who never shipped), `builders__2002__profile` (an ideator who never shipped, part of whose size is estimated). `profile.states.json` renders them as `own`, `artist` and `ideator`.
|
|
26
|
+
|
|
27
|
+
## Deliberate differences from the mock
|
|
28
|
+
|
|
29
|
+
- The engineer's tally shows **karma** where the mock shows "+120 this week", and the fourth engineer tile is **achievements** where the mock shows a shipping streak: neither weekly engineer credits nor a weekly streak is in the profile read, and a number the page cannot read is not drawn.
|
|
30
|
+
- The engineer tile "works shipped" has no "across N goals" foot, the ideator's "thoughts filed" no "since <month>" foot, the artist's "credits from pages" no "N a page" foot, and the surface rows no "where" sub-label: none of those is in the read.
|
|
31
|
+
- **Karma ends every artist's and ideator's set of tiles**, which the mock does not draw. The owner's call, reviewing this build on 2026-10-01: "karma for artists and ideators is definitely key". The engineer's set carries it already.
|
|
32
|
+
- The tiles' grid fits at 148px rather than 170px, so five tiles sit on one row inside the hall's narrower panel.
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.76",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.20.
|
|
9
|
+
"version": "1.20.76",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.76",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/release-notes.json
CHANGED
|
@@ -8579,5 +8579,11 @@
|
|
|
8579
8579
|
"id": "1004418",
|
|
8580
8580
|
"text": "Creating a project now starts with four questions about its \"planet physics\": is it new or already started, where is everyone, will anyone under 18 be building, and what kind of entity is behind it. A planet forms beside the q"
|
|
8581
8581
|
}
|
|
8582
|
+
],
|
|
8583
|
+
"1.20.76": [
|
|
8584
|
+
{
|
|
8585
|
+
"id": "1004434",
|
|
8586
|
+
"text": "A profile now leads with the person's own work. An artist's page opens on pages approved, this week's gain and how many pages of each part of the site they have tweaked; an ideator's on thoughts filed, how many became work, Fu"
|
|
8587
|
+
}
|
|
8582
8588
|
]
|
|
8583
8589
|
}
|
package/src/module-api.js
CHANGED
|
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
75
75
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
76
76
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
77
77
|
|
|
78
|
-
const CORE_VERSION = '1.20.
|
|
78
|
+
const CORE_VERSION = '1.20.76'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
79
79
|
|
|
80
80
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
81
81
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
|
@@ -251,7 +251,8 @@ test('a project that was never founded keeps the wall hidden', async () => {
|
|
|
251
251
|
test('the profile and the ranks page read the same list, and every page loads the script first', () => {
|
|
252
252
|
const profile = read('profile.js');
|
|
253
253
|
const ranks = read('ranks.js');
|
|
254
|
-
|
|
254
|
+
// renderHead takes the role headline since task 1004434; the mark still follows it.
|
|
255
|
+
assert.match(profile, /renderHead\(builder(?:, view)?\);\s*\n\s*renderFoundingMark\(builder\);/);
|
|
255
256
|
assert.match(ranks, /render\(builder \|\| \{\}\);\s*\n\s*renderFounding\(\);/);
|
|
256
257
|
assert.match(read('ranks.html'), /id="founding-scroll"[^>]*hidden/, 'the ranks section is hidden in the markup');
|
|
257
258
|
for (const [html, page] of [['roster.html', 'roster.js'], ['profile.html', 'profile.js'], ['ranks.html', 'ranks.js'], ['index.html', 'hall-widgets.js']]) {
|
package/tests/helpers.mjs
CHANGED
|
@@ -517,6 +517,7 @@ export function shallowCloneReason(root = ROOT) {
|
|
|
517
517
|
// page that changes, so the harness moved here and the fixture stayed with
|
|
518
518
|
// each caller — is the /inbox/by-builder/ payload to answer with.
|
|
519
519
|
const PROFILE_JS = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/profile.js'), 'utf8');
|
|
520
|
+
const PROFILE_ROLES_JS = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/profile-roles.js'), 'utf8');
|
|
520
521
|
// Boot the REAL profile.js against a stub DOM + a stub OTBKit that captures each
|
|
521
522
|
// ledger's rowHtml, and return what it rendered plus every request it made.
|
|
522
523
|
export function bootProfilePage({ signedIn = true, trail = [] } = {}) {
|
|
@@ -623,6 +624,9 @@ export function bootProfilePage({ signedIn = true, trail = [] } = {}) {
|
|
|
623
624
|
};
|
|
624
625
|
sandbox.globalThis = sandbox;
|
|
625
626
|
vm.createContext(sandbox);
|
|
627
|
+
// profile.js draws the header from profile-roles.js (task 1004434), which the
|
|
628
|
+
// page loads first.
|
|
629
|
+
vm.runInContext(PROFILE_ROLES_JS, sandbox, { filename: 'profile-roles.js' });
|
|
626
630
|
vm.runInContext(PROFILE_JS, sandbox, { filename: 'profile.js' });
|
|
627
631
|
|
|
628
632
|
const htmlFor = (mountId) => {
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// tests/profile_role_headline.mjs — the profile's header and "By the numbers"
|
|
2
|
+
// lead with each person's own craft (task 1004434 / WA6.RP04, goal 1000095,
|
|
3
|
+
// criterion wa6-roles-read-true-in-public).
|
|
4
|
+
//
|
|
5
|
+
// The done-when: "engineer, artist and ideator fixtures each render their own
|
|
6
|
+
// headline, matching the mock". Three layers, each run for real:
|
|
7
|
+
//
|
|
8
|
+
// 1. The words. profile-roles.js (pure) over the three harness fixtures the
|
|
9
|
+
// renders were taken from: each craft's tally, tiles and subtitle.
|
|
10
|
+
// 2. The page. The REAL profile.js in a vm, with the REAL hall kit, over the
|
|
11
|
+
// same fixtures: what lands in the header, the numbers and the subtitle.
|
|
12
|
+
// 3. The whole path. The REAL server reader (src/bongos/role-stats.js) with
|
|
13
|
+
// the three role ports stubbed, through JSON, into the REAL page, so a field
|
|
14
|
+
// the server renames or drops cannot leave this suite green on a fixture
|
|
15
|
+
// that still carries it.
|
|
16
|
+
|
|
17
|
+
import { strict as assert } from 'node:assert';
|
|
18
|
+
import { createRequire } from 'node:module';
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import vm from 'node:vm';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
import { makeRunner } from './helpers.mjs';
|
|
24
|
+
|
|
25
|
+
process.env.NODE_ENV = 'test';
|
|
26
|
+
const require = createRequire(import.meta.url);
|
|
27
|
+
const ROOT = fileURLToPath(new URL('..', import.meta.url));
|
|
28
|
+
const FIX = (n) => JSON.parse(fs.readFileSync(path.join(ROOT, 'scripts/hall-preview/fixtures', n + '.json'), 'utf8'));
|
|
29
|
+
|
|
30
|
+
const { headline } = require('../modules/hall-ui/public/profile-roles.js');
|
|
31
|
+
const OTBKit = require('../modules/hall-ui/public/hall-kit.js');
|
|
32
|
+
const ROLES_SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/profile-roles.js'), 'utf8');
|
|
33
|
+
const PAGE_SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/profile.js'), 'utf8');
|
|
34
|
+
|
|
35
|
+
const ENGINEER = FIX('builders__profile');
|
|
36
|
+
const ARTIST = FIX('builders__2001__profile');
|
|
37
|
+
const IDEATOR = FIX('builders__2002__profile');
|
|
38
|
+
const OPTS = { fmt: (n) => Number(n).toLocaleString('en-US'), currency: 'credits', rank: 'Contributor' };
|
|
39
|
+
const words = (items) => items.map((t) => `${t.n} ${t.w}`);
|
|
40
|
+
|
|
41
|
+
const { test, summary } = makeRunner();
|
|
42
|
+
|
|
43
|
+
// ---- 1. the words --------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
await test('an engineer leads with works shipped, and keeps karma', () => {
|
|
46
|
+
const v = headline(ENGINEER, OPTS);
|
|
47
|
+
assert.deepEqual(words(v.tally), ['4,630 credits', '24 works shipped', '1 karma']);
|
|
48
|
+
assert.deepEqual(words(v.tiles).slice(0, 4), ['24 works shipped', '4,630 credits', '1 karma', '2 achievements']);
|
|
49
|
+
assert.equal(v.sub, 'Builder 4 — Contributor · 24 works shipped');
|
|
50
|
+
assert.deepEqual(v.surfaces, [], 'the per-surface rows are the artist\'s');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
await test('an artist who never shipped a task leads with pages approved and the per-surface share', () => {
|
|
54
|
+
const v = headline(ARTIST, OPTS);
|
|
55
|
+
assert.deepEqual(words(v.tally), ['412 credits', '9 pages approved', '+38 this week']);
|
|
56
|
+
assert.deepEqual(words(v.tiles), ['9 pages approved', '270 credits from pages', '+38 this week', '4 thoughts filed', '6 karma']);
|
|
57
|
+
assert.equal(v.tiles[0].f, '3 surfaces');
|
|
58
|
+
assert.equal(v.sub, 'Builder 7 — Contributor · 9 pages approved');
|
|
59
|
+
assert.ok(!v.sub.includes('works shipped'), 'the subtitle no longer says works shipped for an artist');
|
|
60
|
+
assert.deepEqual(v.surfaces.map((s) => s.line), ['6 of 12 tweaked', '2 of 19 tweaked', '1 of 6 tweaked']);
|
|
61
|
+
assert.ok(!words(v.tiles).some((w) => /works shipped/.test(w)), 'no engineer figure on an artist\'s numbers');
|
|
62
|
+
assert.equal(words(v.tiles).at(-1), '6 karma', 'karma ends every non-engineer set (the owner, 2026-10-01)');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
await test('an ideator leads with thoughts, size and what is still alive', () => {
|
|
66
|
+
const v = headline(IDEATOR, OPTS);
|
|
67
|
+
assert.deepEqual(words(v.tally), ['186 credits', '31 thoughts filed', '7 ignited']);
|
|
68
|
+
assert.deepEqual(words(v.tiles), ['31 thoughts filed', '7 ignited', '3 Full Ideas', '186~ total size', '72% still alive', '3 karma']);
|
|
69
|
+
assert.equal(v.tiles[2].f, '1 ratified by the board');
|
|
70
|
+
assert.match(v.tiles[3].title, /Includes 24 credits of historical estimate/, 'the ~ says what it means');
|
|
71
|
+
assert.equal(v.sub, 'Builder 9 — Contributor · 31 thoughts filed');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
await test('several crafts each show, main first, in the server\'s order', () => {
|
|
75
|
+
assert.deepEqual(headline(ARTIST, OPTS).crafts.map((c) => c.label), ['Artist, main role', 'Ideator']);
|
|
76
|
+
assert.deepEqual(headline(ENGINEER, OPTS).crafts.map((c) => c.label), ['Engineer, main role', 'Ideator']);
|
|
77
|
+
// The other craft adds its one headline tile after the main craft's set.
|
|
78
|
+
assert.equal(words(headline(ENGINEER, OPTS).tiles).at(-1), '9 thoughts filed');
|
|
79
|
+
const reordered = { ...ARTIST, roles: { ...ARTIST.roles, main: 'ideator', shown: ['ideator', 'artist'] } };
|
|
80
|
+
const v = headline(reordered, OPTS);
|
|
81
|
+
assert.deepEqual(v.crafts.map((c) => c.label), ['Ideator, main role', 'Artist']);
|
|
82
|
+
assert.equal(words(v.tiles).at(-2), '9 pages approved', 'the other craft\'s tile, just before karma');
|
|
83
|
+
assert.deepEqual(v.surfaces, [], 'the per-surface rows follow the MAIN craft, not any craft');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
await test('a number the instance could not read is a dash, never a zero', () => {
|
|
87
|
+
const noDesk = { ...ARTIST, roles: { ...ARTIST.roles, artist: { pages_approved: null, credits: null, this_week: null, surfaces_touched: null, surfaces: [], unavailable: true } } };
|
|
88
|
+
const v = headline(noDesk, OPTS);
|
|
89
|
+
assert.deepEqual(words(v.tally).slice(1), ['— pages approved', '— this week']);
|
|
90
|
+
assert.equal(v.tiles[0].f, '', 'no "0 surfaces" under an unknown count');
|
|
91
|
+
const noWork = { ...IDEATOR, roles: { ...IDEATOR.roles, ideator: { ...IDEATOR.roles.ideator, alive: { share: null, has_work: false, attributable: null } } } };
|
|
92
|
+
assert.deepEqual(headline(noWork, OPTS).tiles.at(-2), { n: '—', w: 'still alive', f: 'nothing has come of them yet' });
|
|
93
|
+
const untraced = { ...IDEATOR, roles: { ...IDEATOR.roles, ideator: { ...IDEATOR.roles.ideator, alive: { share: null, has_work: true, attributable: false } } } };
|
|
94
|
+
assert.equal(headline(untraced, OPTS).tiles.at(-2).f, 'not measurable yet');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
await test('a response from before the roles block reads as the engineer it always was', () => {
|
|
98
|
+
const { roles, ...old } = ENGINEER;
|
|
99
|
+
const v = headline(old, OPTS);
|
|
100
|
+
assert.deepEqual(v.crafts.map((c) => c.label), ['Engineer, main role']);
|
|
101
|
+
assert.equal(v.sub, 'Builder 4 — Contributor · 24 works shipped');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// ---- 2. the page ---------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
async function boot(profileData) {
|
|
107
|
+
const els = new Map();
|
|
108
|
+
const makeEl = () => ({
|
|
109
|
+
hidden: false, textContent: '', _html: '', dataset: {}, style: {},
|
|
110
|
+
set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; },
|
|
111
|
+
insertAdjacentHTML() {}, addEventListener() {}, setAttribute() {}, querySelector: () => null, querySelectorAll: () => [],
|
|
112
|
+
classList: { add() {}, remove() {}, toggle() {} },
|
|
113
|
+
});
|
|
114
|
+
const $ = (sel) => { if (!els.has(sel)) els.set(sel, makeEl()); return els.get(sel); };
|
|
115
|
+
const escapeHtml = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
116
|
+
const json = JSON.parse(JSON.stringify(profileData)); // what the wire carries
|
|
117
|
+
const api = {
|
|
118
|
+
async request(_m, url) {
|
|
119
|
+
if (url === '/api/bongos/me') return { status: 401, ok: false, data: null };
|
|
120
|
+
if (/\/builders\/[^/]+\/profile$/.test(url)) return { status: 200, ok: true, data: json };
|
|
121
|
+
return { status: 404, ok: false, data: null };
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
const windowObj = {
|
|
125
|
+
__BRANDING__: { currency: { label: 'credits' } },
|
|
126
|
+
__MODULES__: { responsibilities: { engineer: 'E.', artist: 'A.', ideator: 'I.' } },
|
|
127
|
+
BongosClient: { createClient: () => api },
|
|
128
|
+
// The real kit draws the surface bars; the section rail needs a real DOM and
|
|
129
|
+
// is not what this suite is about, so it is left out (profile.js skips it).
|
|
130
|
+
OTBKit: { ...OTBKit, makeSectionRail: undefined },
|
|
131
|
+
OTB: {
|
|
132
|
+
$, escapeHtml,
|
|
133
|
+
fmtNum: (n) => Number(n || 0).toLocaleString('en-US'),
|
|
134
|
+
fmtDate: (d) => String(d).slice(0, 10),
|
|
135
|
+
rankLabel: () => 'Contributor',
|
|
136
|
+
roleAxisLabel: () => 'Role', roleLabel: (d) => d, roleIsUnset: (d) => !d, toast() {},
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
const sandbox = {
|
|
140
|
+
window: windowObj, location: { search: '?id=' + profileData.builder.id, hash: '' }, URLSearchParams, console,
|
|
141
|
+
document: { querySelector: () => null, querySelectorAll: () => [], addEventListener() {} },
|
|
142
|
+
addEventListener() {}, history: { replaceState() {} },
|
|
143
|
+
};
|
|
144
|
+
sandbox.globalThis = sandbox;
|
|
145
|
+
vm.createContext(sandbox);
|
|
146
|
+
vm.runInContext(ROLES_SRC, sandbox, { filename: 'profile-roles.js' });
|
|
147
|
+
vm.runInContext(PAGE_SRC, sandbox, { filename: 'profile.js' });
|
|
148
|
+
for (let i = 0; i < 6; i++) await new Promise((r) => setImmediate(r));
|
|
149
|
+
return { head: $('#profile-head').innerHTML, nums: $('#numbers-body').innerHTML, sub: $('#profile-sub').textContent };
|
|
150
|
+
}
|
|
151
|
+
const text = (html) => html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
|
152
|
+
|
|
153
|
+
await test('the real page draws the artist\'s tally, chips, tiles and surface rows', async () => {
|
|
154
|
+
const p = await boot(ARTIST);
|
|
155
|
+
assert.equal(p.sub, 'Builder 7 — Contributor · 9 pages approved');
|
|
156
|
+
assert.match(text(p.head), /Artist, main role Ideator/);
|
|
157
|
+
assert.match(text(p.head), /412 credits 9 pages approved \+38 this week/);
|
|
158
|
+
assert.match(p.head, /Answerable for:/, 'the responsibility line stays');
|
|
159
|
+
assert.match(text(p.nums), /^9 pages approved 3 surfaces 270 credits from pages \+38 this week 4 thoughts filed 1 ignited/);
|
|
160
|
+
assert.equal((p.nums.match(/kit-bars__row/g) || []).length, 3, 'one kit bar row per surface');
|
|
161
|
+
assert.match(text(p.nums), /Landing 6 of 12 tweaked/);
|
|
162
|
+
assert.ok(!/works shipped/i.test(p.sub + p.head + p.nums), 'nothing on an artist\'s header says works shipped');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
await test('the real page draws the ideator\'s and the engineer\'s own headlines', async () => {
|
|
166
|
+
const i = await boot(IDEATOR);
|
|
167
|
+
assert.match(text(i.head), /186 credits 31 thoughts filed 7 ignited/);
|
|
168
|
+
assert.match(text(i.nums), /186~ total size/);
|
|
169
|
+
assert.match(text(i.nums), /72% still alive/);
|
|
170
|
+
assert.ok(!i.nums.includes('kit-bars'), 'no surface rows for an ideator');
|
|
171
|
+
const e = await boot({ ...ENGINEER, recent_ships: [] }); // the works ledger is not this suite's
|
|
172
|
+
assert.equal(e.sub, 'Builder 4 — Contributor · 24 works shipped');
|
|
173
|
+
assert.match(text(e.head), /4,630 credits 24 works shipped 1 karma/);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// ---- 3. the whole path ----------------------------------------------------
|
|
177
|
+
|
|
178
|
+
await test('the server\'s roles block, through JSON, renders the artist\'s headline', async () => {
|
|
179
|
+
const seams = require('../src/module-seams.js');
|
|
180
|
+
const { readRoleStats } = require('../src/bongos/role-stats.js');
|
|
181
|
+
seams._reset();
|
|
182
|
+
seams.registerProvider('reward', {
|
|
183
|
+
craftForPreference: (d) => ({ engineer: 'engineer', artist: 'artist', ui: 'artist', ideator: 'ideator' }[d] || null),
|
|
184
|
+
async creditsByCraft(ids) { return new Map([[String(ids[0]), { engineer: 0, artist: 370, ideator: 42, other: 0, total: 412 }]]); },
|
|
185
|
+
});
|
|
186
|
+
seams.registerProvider('copy-desk.artistStats', async () => ARTIST.roles.artist);
|
|
187
|
+
seams.registerProvider('ideas.ideatorStats', async () => ARTIST.roles.ideator);
|
|
188
|
+
const roles = await readRoleStats('2001', {
|
|
189
|
+
shippedCount: 0,
|
|
190
|
+
getRoleChoice: async () => ({ main_discipline: 'artist', preferred_disciplines: ['artist', 'ideator'] }),
|
|
191
|
+
});
|
|
192
|
+
seams._reset();
|
|
193
|
+
const { roles: _fixture, ...rest } = ARTIST;
|
|
194
|
+
const p = await boot({ ...rest, roles });
|
|
195
|
+
assert.equal(p.sub, 'Builder 7 — Contributor · 9 pages approved');
|
|
196
|
+
assert.match(text(p.head), /Artist, main role Ideator/);
|
|
197
|
+
assert.match(text(p.head), /412 credits 9 pages approved \+38 this week/);
|
|
198
|
+
assert.equal((p.nums.match(/kit-bars__row/g) || []).length, 3);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
summary();
|
|
@@ -29,6 +29,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
29
29
|
|
|
30
30
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
31
31
|
const SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/profile.js'), 'utf8');
|
|
32
|
+
const ROLES_SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/profile-roles.js'), 'utf8');
|
|
32
33
|
const HTML = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/profile.html'), 'utf8');
|
|
33
34
|
|
|
34
35
|
// ---- fixtures -------------------------------------------------------------
|
|
@@ -98,6 +99,8 @@ async function boot({ search = '', me, profile } = {}) {
|
|
|
98
99
|
};
|
|
99
100
|
sandbox.globalThis = sandbox;
|
|
100
101
|
vm.createContext(sandbox);
|
|
102
|
+
// The header is drawn from profile-roles.js (task 1004434), loaded first as the page does.
|
|
103
|
+
vm.runInContext(ROLES_SRC, sandbox, { filename: 'profile-roles.js' });
|
|
101
104
|
vm.runInContext(SRC, sandbox, { filename: 'profile.js' });
|
|
102
105
|
// load() is fire-and-forget; let its await chain settle.
|
|
103
106
|
for (let i = 0; i < 4; i++) await new Promise((r) => setImmediate(r));
|