@bongos/core 1.20.75 → 1.20.77

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.
@@ -0,0 +1,170 @@
1
+ // modules/hall-ui/public/profile-roles.js — what the profile's header and "By
2
+ // the numbers" say, for each craft (task 1004434 / WA6.RP04, goal 1000095,
3
+ // criterion wa6-roles-read-true-in-public).
4
+ //
5
+ // WHY. The profile led with "Works shipped" for everybody, so an artist with
6
+ // nine approved pages and an ideator with seven ignited thoughts both read as
7
+ // "0 works shipped": a person who had done real work looked like a person who
8
+ // had done none. The owner-approved mock (docs/design/mocks/profile-roles/, the
9
+ // pixel contract) has each craft lead with its own work: an engineer with works
10
+ // shipped, an artist with pages approved and the per-surface "N of M tweaked",
11
+ // an ideator with thoughts filed, ignited, size and what is still alive.
12
+ //
13
+ // WHERE THE NUMBERS COME FROM. The profile route's `roles` block (task 1004433,
14
+ // src/bongos/role-stats.js): `main` is the craft the builder chose (or, until
15
+ // they choose, the one they earned most in), `shown` is main first and then
16
+ // every other craft that has EARNED credits, in the order of their preferred
17
+ // crafts. This file decides nothing about which crafts a person has; it only
18
+ // says, for the crafts the server named, what to put on the page.
19
+ //
20
+ // PURE, like profile-charts.js: no document, no fetch, no window beyond the
21
+ // export at the foot, so a test requires it and runs the real wording against
22
+ // the real fixture instead of agreeing with a copy of it. profile.js turns the
23
+ // result into markup.
24
+ //
25
+ // A NUMBER THAT COULD NOT BE READ IS A DASH, NEVER A ZERO. The role ports are
26
+ // optional (an instance without the copy desk has no artist numbers); the
27
+ // server says so with `unavailable` and nulls, and "0 pages approved" would be
28
+ // a claim about the person rather than about the instance.
29
+
30
+ (function () {
31
+ 'use strict';
32
+
33
+ const CRAFT_WORD = Object.freeze({ engineer: 'Engineer', artist: 'Artist', ideator: 'Ideator' });
34
+ const DASH = '—';
35
+
36
+ function count(n) {
37
+ return Number.isFinite(n) ? n : null;
38
+ }
39
+ function plural(n, one, many) {
40
+ return n === 1 ? one : many;
41
+ }
42
+
43
+ // The crafts to show, main first. A response from before the roles block
44
+ // existed reads as the engineer it always rendered.
45
+ function craftsOf(roles) {
46
+ const shown = roles && Array.isArray(roles.shown) ? roles.shown.filter((c) => CRAFT_WORD[c]) : [];
47
+ return shown.length ? shown : ['engineer'];
48
+ }
49
+
50
+ function makeNum(fmt) {
51
+ return (n) => (count(n) == null ? DASH : fmt(n));
52
+ }
53
+
54
+ // The share of the work a person's thoughts caused that is still alive, as the
55
+ // tile shows it. A person whose thoughts caused nothing yet has no share at all
56
+ // (zero over zero), and one whose work could not be traced has an unknown one;
57
+ // both say so in the foot rather than drawing a 0%.
58
+ function aliveTile(alive) {
59
+ const a = alive || {};
60
+ if (Number.isFinite(a.share)) return { n: `${Math.round(a.share * 100)}%`, w: 'still alive', f: 'of the work they caused' };
61
+ if (a.has_work === false) return { n: DASH, w: 'still alive', f: 'nothing has come of them yet' };
62
+ return { n: DASH, w: 'still alive', f: 'not measurable yet' };
63
+ }
64
+
65
+ // headline(data, opts) — everything the header and "By the numbers" print.
66
+ // data the profile route's response ({ builder, shipped_count, achievements, roles })
67
+ // opts { fmt, currency, rank } — the page's number format, its currency word
68
+ // (branding, never hardcoded) and the rank's display label
69
+ // Returns { crafts, tally, tiles, surfaces, sub }.
70
+ function headline(data, opts = {}) {
71
+ const fmt = typeof opts.fmt === 'function' ? opts.fmt : (n) => Number(n).toLocaleString('en-US');
72
+ const num = makeNum(fmt);
73
+ const cur = opts.currency || 'credits';
74
+ const d = data || {};
75
+ const builder = d.builder || {};
76
+ const roles = d.roles || null;
77
+ const crafts = craftsOf(roles);
78
+ const main = crafts[0];
79
+ const shipped = Number(d.shipped_count) || 0;
80
+ const artist = (roles && roles.artist) || {};
81
+ const ideator = (roles && roles.ideator) || {};
82
+ const achievements = Array.isArray(d.achievements) ? d.achievements.length : 0;
83
+ const credits = { n: num(count(Number(builder.total_credits))), w: cur, lead: true };
84
+ const weekGain = (n) => (count(n) == null ? DASH : `+${fmt(n)}`);
85
+
86
+ // The tally: the person's total, then the two numbers their main craft leads with.
87
+ const LEAD = {
88
+ engineer: () => [{ n: num(shipped), w: plural(shipped, 'work shipped', 'works shipped') },
89
+ { n: num(count(Number(builder.karma))), w: 'karma' }],
90
+ artist: () => [{ n: num(artist.pages_approved), w: plural(artist.pages_approved, 'page approved', 'pages approved') },
91
+ { n: weekGain(artist.this_week), w: 'this week' }],
92
+ ideator: () => [{ n: num(ideator.filed), w: plural(ideator.filed, 'thought filed', 'thoughts filed') },
93
+ { n: num(ideator.ignited), w: 'ignited' }],
94
+ };
95
+
96
+ // The main craft's full set of tiles.
97
+ const surfacesWord = (n) => (count(n) == null ? '' : `${fmt(n)} ${plural(n, 'surface', 'surfaces')}`);
98
+ const MAIN = {
99
+ engineer: () => [
100
+ { n: num(shipped), w: plural(shipped, 'work shipped', 'works shipped') },
101
+ { n: num(count(Number(builder.total_credits))), w: cur, f: 'all kinds, see below' },
102
+ { n: num(count(Number(builder.karma))), w: 'karma' },
103
+ { n: num(achievements), w: plural(achievements, 'achievement', 'achievements'), f: 'earned' },
104
+ ],
105
+ artist: () => [
106
+ { n: num(artist.pages_approved), w: plural(artist.pages_approved, 'page approved', 'pages approved'), f: surfacesWord(artist.surfaces_touched) },
107
+ { n: num(artist.credits), w: `${cur} from pages` },
108
+ { n: weekGain(artist.this_week), w: 'this week' },
109
+ ],
110
+ ideator: () => {
111
+ const size = ideator.size || {};
112
+ const ratified = count(ideator.ratified);
113
+ return [
114
+ { n: num(ideator.filed), w: plural(ideator.filed, 'thought filed', 'thoughts filed') },
115
+ { n: num(ideator.ignited), w: 'ignited', f: 'became work' },
116
+ { n: num(ideator.full_ideas), w: plural(ideator.full_ideas, 'Full Idea', 'Full Ideas'), f: ratified == null ? '' : `${fmt(ratified)} ratified by the board` },
117
+ // The ~ is the sky's own mark for a size that includes a historical
118
+ // estimate (idea-size.js): the ledger did not record idea pay exactly
119
+ // before the streams were split, so part of the number is estimated.
120
+ { n: count(size.total) == null ? DASH : `${fmt(size.total)}${size.historical ? '~' : ''}`, w: 'total size',
121
+ f: size.historical ? `${cur}, every idea stream; ~ partly estimated` : `${cur}, every idea stream`,
122
+ title: size.historical ? `Includes ${fmt(size.estimated)} ${cur} of historical estimate, from before the ledger recorded idea pay exactly.` : '' },
123
+ aliveTile(ideator.alive),
124
+ ];
125
+ },
126
+ };
127
+
128
+ // Each OTHER craft a person has earned in gets its one headline tile, after
129
+ // the main craft's, in the order the server put them.
130
+ const SECOND = {
131
+ engineer: () => ({ n: num(shipped), w: plural(shipped, 'work shipped', 'works shipped') }),
132
+ artist: () => ({ n: num(artist.pages_approved), w: plural(artist.pages_approved, 'page approved', 'pages approved'), f: surfacesWord(artist.surfaces_touched) }),
133
+ ideator: () => ({ n: num(ideator.filed), w: plural(ideator.filed, 'thought filed', 'thoughts filed'), f: count(ideator.ignited) == null ? '' : `${fmt(ideator.ignited)} ignited` }),
134
+ };
135
+
136
+ const tiles = MAIN[main]().concat(crafts.slice(1).map((c) => SECOND[c]()));
137
+ // Karma is on every profile (the owner, 2026-10-01, reviewing this build:
138
+ // "karma for artists and ideators is definitely key"). The engineer's set
139
+ // already carries it; every other main craft ends with it.
140
+ if (main !== 'engineer') tiles.push({ n: num(count(Number(builder.karma))), w: 'karma' });
141
+
142
+ // The artist leads with the studio's "N of M tweaked", one row per surface.
143
+ const surfaces = main === 'artist' && Array.isArray(artist.surfaces)
144
+ ? artist.surfaces.filter((s) => s && Number(s.total) > 0).map((s) => ({
145
+ label: String(s.label || s.id || ''),
146
+ tweaked: Number(s.tweaked) || 0,
147
+ total: Number(s.total) || 0,
148
+ line: s.line || `${Number(s.tweaked) || 0} of ${Number(s.total) || 0} tweaked`,
149
+ }))
150
+ : [];
151
+
152
+ // The page subtitle under the h1: the main craft's headline, not always
153
+ // "works shipped".
154
+ const first = LEAD[main]()[0];
155
+ const name = builder.display_name || builder.github_login || 'Builder';
156
+ const sub = `${name} — ${opts.rank || ''} · ${first.n} ${first.w}`;
157
+
158
+ return {
159
+ crafts: crafts.map((c, i) => ({ craft: c, label: i === 0 ? `${CRAFT_WORD[c]}, main role` : CRAFT_WORD[c], lead: i === 0 })),
160
+ tally: [credits].concat(LEAD[main]()),
161
+ tiles,
162
+ surfaces,
163
+ sub,
164
+ };
165
+ }
166
+
167
+ const api = { headline };
168
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
169
+ if (typeof window !== 'undefined') window.OTBProfileRoles = api;
170
+ })();
@@ -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 .profile__credits { grid-column: 2; align-items: flex-start; text-align: left; }
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> · N works shipped". -->
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 — credits, karma, works shipped. -->
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 class="stat-row" id="numbers-body">
62
- <div class="stat-tile"><span class="stat-tile__label">Loading…</span></div>
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
- <script src="/builders/profile.js?v=2026-09-30-founding"></script>
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
- function renderHead(builder) {
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
- const disciplines = prefs.length
87
- ? `<div class="profile__disciplines">
88
- <span class="profile__disc-label">Preferred work:</span>
89
- <span class="profile__disc-value">${escapeHtml(prefs.join(', '))}</span>
90
- </div>
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
- ${disciplines}
129
+ ${crafts}
130
+ ${responsibilitiesBlock(prefs)}
106
131
  </div>
107
- <div class="profile__credits">
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
- function renderNumbers(shippedCount, achievements) {
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
- const achCount = Array.isArray(achievements) ? achievements.length : 0;
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
- const name = builder.display_name || builder.github_login || 'Builder';
781
- const shipped = Number(data.shipped_count) || 0;
782
- showMessage(`${name} — ${rankLabel(normalizeRank(builder.rank))} · ${fmtNum(shipped)} work${shipped === 1 ? '' : 's'} shipped`);
783
- renderHead(builder);
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(data.shipped_count, data.achievements);
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.
@@ -14,6 +14,9 @@
14
14
  // 6 rewards reward_intents (all being considered — intent, never binding)
15
15
  // 7 framing framing (A–D: managed, hybrid, local, open-source) and services
16
16
  //
17
+ // plus `voice` (planet · professional), which nobody is asked: it follows the entity
18
+ // answer (spec D5) and can be switched later.
19
+ //
17
20
  // THREE KINDS OF QUESTION. `one` takes a single member of its list, `many` takes an
18
21
  // array of members (stored de-duplicated, in the list's own order, so two spellings of
19
22
  // one answer store the same bytes), and `count` takes a whole number in a range.
@@ -100,6 +103,11 @@ const PLANET_PHYSICS_QUESTIONS = Object.freeze({
100
103
  reward_intents: Object.freeze({ kind: 'many', values: Object.freeze(['non-monetary', 'cash', 'equity', 'future-payouts']) }),
101
104
  framing: Object.freeze({ kind: 'one', values: FRAMINGS }),
102
105
  services: Object.freeze({ kind: 'many', values: SERVICES }),
106
+ // Not a question: the VOICE the project's copy speaks in (spec D5, task 1004419). The
107
+ // startup flow sets it from the entity answer — the planet voice for individuals and
108
+ // groups, a plain professional one for a formed organization — and stores it here
109
+ // so the project's settings can switch it later with an ordinary PATCH …/detail.
110
+ voice: Object.freeze({ kind: 'one', values: Object.freeze(['planet', 'professional']) }),
103
111
  });
104
112
 
105
113
  // The answer as it may be STORED, or undefined when it is not one. `many` answers come
@@ -42,6 +42,15 @@ const SOFT_LIMIT_RULES = Object.freeze([
42
42
  effect: 'hide',
43
43
  reason: 'Your planet is new, so there is nothing built yet to describe.',
44
44
  }),
45
+ // Task 1004419: question 6 is hidden for a founder building alone with nobody
46
+ // expected — one builder now AND one by month three. Expecting company keeps it.
47
+ Object.freeze({
48
+ id: 'rewards-need-other-builders',
49
+ when: { builders_at_start: { in: [1] }, builders_expected: { in: [1] } },
50
+ options: ['reward_intents'],
51
+ effect: 'hide',
52
+ reason: "You're building solo, so there is nobody to reward yet. Expect builders to join and the question comes back.",
53
+ }),
45
54
  Object.freeze({
46
55
  id: 'equity-needs-a-company',
47
56
  when: { entity: { in: ['intent-company', 'intent-nonprofit', 'intent-solo', 'intent-unsure', 'university-project', 'social-group', 'private-individual'] } },