@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.
- package/.bongos-core.json +53 -33
- package/docs/copy-inventory.md +546 -482
- package/docs/copy-registry.json +1208 -631
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +10 -1
- package/docs/page-readings.json +1204 -1061
- 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/modules/provisioning/planet-physics.js +8 -0
- package/modules/provisioning/soft-limits.js +9 -0
- package/modules/public-landing/public/projects.html +579 -37
- package/modules/public-landing/public/projects.probes.json +6 -3
- package/modules/public-landing/public/projects.states.json +20 -14
- package/modules/ui-design/kit/serve.js +26 -3
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -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
- package/tests/projects_hub_app_step.mjs +4 -1
- package/tests/provisioning_planet_physics.mjs +24 -2
- package/tests/wizard_physics.mjs +11 -4
- package/tests/wizard_physics_more.mjs +470 -0
|
@@ -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 .
|
|
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.
|
|
@@ -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'] } },
|