@bongos/core 1.19.516 → 1.19.518

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.
@@ -1469,5 +1469,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
1469
1469
  landed since 1.19.514 with no explicit bump. run 33797825811. (task 1002620)
1470
1470
  1.19.516 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1471
1471
  landed since 1.19.515 with no explicit bump. run 33798954769. (task 1002620)
1472
+ 1.19.517 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1473
+ landed since 1.19.516 with no explicit bump. run 33799755382. (task 1002620)
1474
+ 1.19.518 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1475
+ landed since 1.19.517 with no explicit bump. run 33801792364. (task 1002620)
1472
1476
  ---------------------------------------------------------------------------
1473
1477
  ```
@@ -191,7 +191,33 @@ function boardVotesNeed(ctx) {
191
191
  };
192
192
  }
193
193
 
194
- const NEEDS = [artKeyNeed, helpRequestsNeed, taskRecommendationsNeed, boardVotesNeed];
194
+ // task 1003178 (C08, goal 1000075): an open mingle INTRODUCTION waiting on you.
195
+ // Third sibling of the two collab needs above, here for the same reason and with
196
+ // the same posture — an introduction nobody surfaces is merely stored, and the
197
+ // needs roster is how a collab feature reaches the whole hall instead of sitting
198
+ // on a page of its own (which is the thing C08's criterion refuses).
199
+ //
200
+ // 'info' and NEVER a demand, and the wording carries that: mingle is declinable
201
+ // without penalty (C07, ADR 0187 §5), so copy that pressured the reader would BE
202
+ // the penalty the decision forbids. The same argument the recommendation need
203
+ // makes, applied to a social invitation, where it matters more.
204
+ function mingleIntroductionsNeed(ctx) {
205
+ const n = Number(ctx.openMingleIntroductions) || 0;
206
+ if (n <= 0) return null;
207
+ const subject = n === 1 ? 'An introduction is' : `${n} introductions are`;
208
+ return {
209
+ id: 'mingle_introductions',
210
+ title: n === 1 ? 'Someone from another craft was introduced to you' : 'You have introductions waiting',
211
+ what: 'Mingle pairs builders across crafts — you opted in, and a pairing is waiting on your answer.',
212
+ state: 'action_needed',
213
+ severity: 'info',
214
+ message: `${subject} waiting on you. Saying no costs nothing at all — no rank, no karma, no streak, `
215
+ + 'and it does not change how often you are paired again.',
216
+ action: { label: n === 1 ? 'See the introduction' : 'See the introductions', href: '/#/collab' },
217
+ };
218
+ }
219
+
220
+ const NEEDS = [artKeyNeed, helpRequestsNeed, taskRecommendationsNeed, boardVotesNeed, mingleIntroductionsNeed];
195
221
 
196
222
  // ---- founding grace (task 1002739 / direction §5.6, idea 1000701) -----------
197
223
  //
@@ -269,7 +295,7 @@ module.exports = {
269
295
  computeNeeds,
270
296
  // exported for tests / direct reuse
271
297
  artKeyNeed,
272
- helpRequestsNeed, taskRecommendationsNeed, boardVotesNeed,
298
+ helpRequestsNeed, taskRecommendationsNeed, boardVotesNeed, mingleIntroductionsNeed,
273
299
  foundingGraceActive,
274
300
  FOUNDING_GRACE_DAYS,
275
301
  SETTINGS_PATH,
@@ -46,6 +46,25 @@
46
46
  </div>
47
47
  </section>
48
48
 
49
+ <!-- SOCIAL MODE ONLY (task 1003178 / C08). Not a page and not a nav item:
50
+ this panel is part of Collab, and it appears because the hall is in
51
+ social mode — the shell stamps data-social="on" on the document root
52
+ from /me, and collab.js reveals this section from that. With social
53
+ off the page is exactly what it was, which is the point: the hall
54
+ changes, it does not gain a destination. -->
55
+ <section class="scroll" id="cb-mingle-section" aria-labelledby="cb-mingle-h" hidden>
56
+ <h2 class="scroll__h" id="cb-mingle-h">Introductions</h2>
57
+ <p class="scroll__lede" id="cb-mingle-note">
58
+ Mingle pairs you with someone from a different craft. Saying no costs nothing
59
+ at all — no rank, no karma, no streak, and it does not change how often you
60
+ are paired again.
61
+ </p>
62
+ <ul class="ov-rows" id="cb-mingle"></ul>
63
+ <div class="empty-state" id="cb-mingle-empty" hidden>
64
+ <p>No introductions right now. You will see one here when a pairing round runs.</p>
65
+ </div>
66
+ </section>
67
+
49
68
  <section class="scroll" aria-labelledby="cb-mine-h">
50
69
  <h2 class="scroll__h" id="cb-mine-h">Your asks</h2>
51
70
  <p class="scroll__lede">
@@ -143,6 +143,78 @@
143
143
  ? `Matching your craft: ${crafts.join(', ')}.`
144
144
  : 'You have not set a craft, so only requests addressed to you by name reach you.';
145
145
  }
146
+
147
+ // The social half. Best-effort and LAST: an introductions read that fails
148
+ // must never take the help requests down with it — those are the page.
149
+ try { await loadMingle(); } catch (_) { /* the panel stays as it was */ }
150
+ }
151
+
152
+ // ---- social mode: introductions (task 1003178 / C08) ----------------------
153
+ //
154
+ // READ THE MODE OFF THE DOCUMENT, never re-derive it. The shell stamps
155
+ // data-social="on" from /me's resolved pair of switches (C07: the project
156
+ // enabled mingle AND this builder opted in). Asking the server again here would
157
+ // be a second answer to one question, and a page that decided for itself
158
+ // whether social was on could disagree with the rest of the hall.
159
+ const socialOn = () => document.documentElement.dataset.social === 'on';
160
+
161
+ // The other person in the pair — the row is written from the VIEWER's side, so
162
+ // it names who they were introduced to rather than echoing both ids back.
163
+ function otherOf(pair, meId) {
164
+ const lo = String(pair.builder_lo), hi = String(pair.builder_hi);
165
+ return String(meId) === lo
166
+ ? { id: hi, login: pair.hi_login, name: pair.hi_name, craft: pair.craft_hi, myCraft: pair.craft_lo }
167
+ : { id: lo, login: pair.lo_login, name: pair.lo_name, craft: pair.craft_lo, myCraft: pair.craft_hi };
168
+ }
169
+
170
+ function mingleRow(pair, meId) {
171
+ const o = otherOf(pair, meId);
172
+ const who = escapeHtml(o.name || o.login || `builder ${o.id}`);
173
+ return `<li class="ov-row">
174
+ <div class="ov-row__main">
175
+ <a class="ov-row__title" href="/builders/profile?id=${escapeHtml(String(o.id))}">${who}</a>
176
+ <p class="ov-row__sub">A ${escapeHtml(String(o.craft))} — you are a ${escapeHtml(String(o.myCraft))}. Mingle pairs across crafts.</p>
177
+ </div>
178
+ <div class="ov-row__right">
179
+ <div class="ov-row__actions">
180
+ <button class="btn-accent" data-mingle="accepted" data-id="${escapeHtml(String(pair.id))}">Say yes</button>
181
+ <button class="btn-ghost" data-mingle="declined" data-id="${escapeHtml(String(pair.id))}">Not now</button>
182
+ </div>
183
+ </div>
184
+ </li>`;
185
+ }
186
+
187
+ async function loadMingle() {
188
+ const section = $('#cb-mingle-section');
189
+ if (!section) return;
190
+ // Off is off: the section stays hidden and nothing is fetched. A page that
191
+ // fetched anyway would make "social is off" cost a request on every load.
192
+ if (!socialOn()) { section.hidden = true; return; }
193
+ section.hidden = false;
194
+
195
+ const r = await api.request('GET', '/mingle');
196
+ const pairs = (r.ok && r.data && r.data.open_pairs) || [];
197
+ const meId = (r.ok && r.data && r.data.you) || null;
198
+ paint('#cb-mingle', '#cb-mingle-empty', pairs.map((p) => mingleRow(p, meId)).join(''), pairs.length);
199
+ }
200
+
201
+ async function decideMingle(id, decision) {
202
+ if (settling) return;
203
+ settling = true;
204
+ try {
205
+ const r = await api.request('POST', `/mingle/pairs/${id}/decide`, { decision });
206
+ if (!r.ok) {
207
+ toast(window.OTB.errMessage ? window.OTB.errMessage(r.data) : 'Could not answer that introduction.');
208
+ return;
209
+ }
210
+ // Neutral either way. "Declined" must not read as a failure — declining is
211
+ // free, and a toast that sounded disappointed would be the penalty C07's
212
+ // decision forbids, delivered by the UI instead of the schema.
213
+ toast(decision === 'accepted' ? 'Said yes — say hello.' : 'Not now. Nothing changes for you.');
214
+ await load();
215
+ } finally {
216
+ settling = false;
217
+ }
146
218
  }
147
219
 
148
220
  async function settle(id, status) {
@@ -162,6 +234,12 @@
162
234
  }
163
235
 
164
236
  document.addEventListener('click', (ev) => {
237
+ const mingleBtn = ev.target && ev.target.closest ? ev.target.closest('[data-mingle]') : null;
238
+ if (mingleBtn) {
239
+ ev.preventDefault();
240
+ decideMingle(Number(mingleBtn.getAttribute('data-id')), mingleBtn.getAttribute('data-mingle'));
241
+ return;
242
+ }
165
243
  const btn = ev.target && ev.target.closest ? ev.target.closest('[data-settle]') : null;
166
244
  if (!btn) return;
167
245
  ev.preventDefault();
@@ -102,6 +102,9 @@
102
102
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
103
103
  <script src="/builders/palette.js" defer></script>
104
104
  <script src="/builders/md-reader.js?v=2026-09-02-mdreader-2"></script>
105
+ <!-- The chart primitive, for the treasury section (task 1003446). Before
106
+ drachmae.js, which reads window.OTBKit when it inserts that section. -->
107
+ <script src="/builders/hall-kit.js" defer></script>
105
108
  <script src="/builders/drachmae.js?v=2026-09-02-drachmae-2" defer></script>
106
109
  <script src="/builders/copy-on-click.js?v=2026-06-01-copy-1" defer></script>
107
110
  </body>
@@ -31,6 +31,15 @@
31
31
  // The doc lives at docs/onboarding/, so relative links resolve against it.
32
32
  const DOC_DIR = 'docs/onboarding/';
33
33
  const BOUNTY_URL = '/api/gds/public/bounty-table';
34
+ // task 1003446: the treasury feed. NOT a new endpoint — this is the same
35
+ // /public/cost-summary the signed-out status page already draws as a
36
+ // cumulative line and the Work board already fetches to read ONE scalar off
37
+ // (`total_value_credits`). Its own route comment calls `daily[]` "the
38
+ // treasury chart's cumulative line"; the series was in the payload the whole
39
+ // time, and the stranger got the chart while the builder got the number.
40
+ const TREASURY_URL = '/api/gds/public/cost-summary';
41
+ const TREASURY_SECTION_ID = 'the-treasury';
42
+ const TREASURY_SECTION_TITLE = 'The treasury, so far';
34
43
  const BOUNTY_SECTION_ID = 'security-bounty-rates';
35
44
  const BOUNTY_SECTION_TITLE = 'Security bounty rates';
36
45
  // The section the live table is inserted AHEAD of: the doc's closing links.
@@ -109,6 +118,129 @@
109
118
  return true;
110
119
  }
111
120
 
121
+ // ---- the treasury (task 1003446) -----------------------------------------
122
+
123
+ // One cumulative figure: the plot, the number it ends on, and the table twin
124
+ // that keeps it out of picture-only territory. `fmt` renders a value for a
125
+ // human; `rows` is the twin's own text, so the money and the credits can read
126
+ // differently without the chart knowing about either.
127
+ function treasuryFigure({ id, title, points, fmt, foot }) {
128
+ const kit = window.OTBKit;
129
+ const esc = window.OTBMdReader.escapeHtml;
130
+ if (!points.length) return '';
131
+ const last = points[points.length - 1];
132
+ const series = kit.timeStepSeriesHtml(points, {
133
+ x0: points[0].x,
134
+ x1: last.x,
135
+ yMax: last.value,
136
+ });
137
+ const { endX, endY } = series.geometry;
138
+ const day = (t) => new Date(t).toISOString().slice(0, 10);
139
+ // Every 7th day plus the last: a twin with one row per calendar day since
140
+ // May would be ~120 rows behind a disclosure nobody finishes reading.
141
+ const twinRows = points.filter((p, i) => i % 7 === 0 || i === points.length - 1);
142
+ return `<figure class="pchart" id="${esc(id)}">
143
+ <figcaption class="pchart__title">${esc(title)}</figcaption>
144
+ ${kit.plotSvgHtml({
145
+ series: series.body,
146
+ // No readout is wired here, so no focus stop and no dead crosshair.
147
+ interactive: false,
148
+ ariaLabel: `${title}: ${fmt(last.value)} as of ${day(last.x)}`,
149
+ label: kit.plotValueLabelHtml(endX, endY - 8, fmt(last.value), 'end'),
150
+ leftTick: day(points[0].x),
151
+ rightTick: day(last.x),
152
+ })}
153
+ <details class="pchart__twin">
154
+ <summary>Read it as a table</summary>
155
+ <table class="ledger pchart__table"><thead><tr><th>Day</th><th>${esc(title)}</th></tr></thead><tbody>
156
+ ${twinRows.map((p) => `<tr><td>${esc(day(p.x))}</td><td>${esc(fmt(p.value))}</td></tr>`).join('')}
157
+ </tbody></table>
158
+ </details>
159
+ <p class="pchart__foot">${esc(foot)}</p>
160
+ </figure>`;
161
+ }
162
+
163
+ // The live treasury as a real section of the article, ahead of the rate card.
164
+ // Returns false when the summary can't be read: the prose stays and no empty
165
+ // panel or outline entry that leads nowhere is rendered — the same contract
166
+ // insertBountySection keeps.
167
+ async function insertTreasurySection(article) {
168
+ if (!window.OTBKit) {
169
+ console.warn('[drachmae] OTBKit missing — the treasury section needs the chart primitive');
170
+ return false;
171
+ }
172
+ let payload;
173
+ try {
174
+ const r = await fetch(TREASURY_URL, { credentials: 'same-origin' });
175
+ if (!r.ok) throw new Error(`${TREASURY_URL} → ${r.status}`);
176
+ payload = await r.json();
177
+ } catch (err) {
178
+ console.warn('[drachmae] cost summary unavailable:', err.message);
179
+ return false;
180
+ }
181
+ const esc = window.OTBMdReader.escapeHtml;
182
+ const kit = window.OTBKit;
183
+ const currency = (window.__BRANDING__ && window.__BRANDING__.currency
184
+ && window.__BRANDING__.currency.label) || 'credits';
185
+ const Currency = currency.charAt(0).toUpperCase() + currency.slice(1);
186
+ const usd = (n) => `$${Number(n || 0).toLocaleString(undefined, { maximumFractionDigits: 0 })}`;
187
+ const num = (n) => Number(n || 0).toLocaleString();
188
+
189
+ // The server already ran generate_series, so every calendar day is present
190
+ // and a flat run is a real flat run rather than a missing sample.
191
+ const spend = kit.cumulateByDay(payload.daily, { valueOf: (r) => r.amount_usd });
192
+ const earned = kit.cumulateByDay(payload.daily_credits, { valueOf: (r) => r.credits });
193
+ // Both series empty is a treasury with nothing in it yet — one honest line,
194
+ // not two empty plots.
195
+ if (!spend.length && !earned.length) return false;
196
+
197
+ const figures = [
198
+ treasuryFigure({
199
+ id: 'treasury-spend',
200
+ title: 'Spent, cumulative',
201
+ points: spend,
202
+ fmt: usd,
203
+ foot: 'Every recorded cost — API, infra, compute, art — added up day by day.',
204
+ }),
205
+ treasuryFigure({
206
+ id: 'treasury-earned',
207
+ title: `${Currency} awarded, cumulative`,
208
+ points: earned,
209
+ fmt: num,
210
+ foot: `What the project has paid builders, in ${esc(currency)}, day by day.`,
211
+ }),
212
+ ].filter(Boolean).join('');
213
+
214
+ // Where the money went, as the kit's bar series rather than a fourth
215
+ // hand-rolled one. Categories arrive sorted biggest-first.
216
+ const byCategory = (payload.by_category || []).slice(0, 8);
217
+ const categories = byCategory.length
218
+ ? `<h3 id="treasury-by-category">Where it went</h3>`
219
+ + kit.barSeriesHtml(
220
+ byCategory.map((c) => ({ label: c.category, value: c.total_usd, valueLabel: usd(c.total_usd) })),
221
+ { list: true, ariaLabel: 'Spend by category', valueWidth: '84px' },
222
+ )
223
+ : '';
224
+
225
+ const section = document.createElement('section');
226
+ section.innerHTML =
227
+ `<h2 id="${esc(TREASURY_SECTION_ID)}">${esc(TREASURY_SECTION_TITLE)}</h2>`
228
+ + `<p>These are this project's real numbers, read from the server when you `
229
+ + `opened this page. It has spent <strong>${esc(usd(payload.total_usd))}</strong> `
230
+ + `and awarded <strong>${esc(num(payload.total_value_credits))} ${esc(currency)}</strong> `
231
+ + `for the work that money bought.</p>`
232
+ + `<div class="pcharts">${figures}</div>`
233
+ + categories;
234
+
235
+ const bounty = article.querySelector(`#${BOUNTY_SECTION_ID}`);
236
+ const closing = [...article.querySelectorAll('h2')].find((h) => CLOSING_H2.test(h.textContent.trim()));
237
+ // Ahead of the rate card if it landed first, else ahead of the closing
238
+ // links, else appended: the doc keeps ending on "dig deeper" either way.
239
+ const before = (bounty && bounty.parentElement === article ? bounty : null) || closing;
240
+ if (before) article.insertBefore(section, before); else article.appendChild(section);
241
+ return true;
242
+ }
243
+
112
244
  // The outline is read from the RENDERED headings rather than the parser's raw
113
245
  // text, so `*not*` and a [link](…) in a heading arrive as plain words, and so
114
246
  // the live section inserted above is simply one more heading in order.
@@ -276,9 +408,13 @@
276
408
  article.innerHTML = parsed.html.replace(/^\s*<h1\b[^>]*>[\s\S]*?<\/h1>\s*/, '');
277
409
  article.removeAttribute('aria-busy');
278
410
  show('article');
279
- // Awaited before the outline is built so the rate card gets its entry and a
280
- // scrollspy target; the article itself is already on screen by now.
411
+ // Awaited before the outline is built so both live sections get their
412
+ // entries and scrollspy targets; the article itself is already on screen by
413
+ // now. The rate card goes in FIRST so the treasury can land ahead of it by
414
+ // id — the doc then reads prose, then what actually happened, then the
415
+ // published rates, then "dig deeper".
281
416
  await insertBountySection(article);
417
+ await insertTreasurySection(article);
282
418
  addAnchors(article);
283
419
  setupTocToggle();
284
420
  setupScrollspy(renderToc(collectHeadings(article)));
@@ -663,6 +663,35 @@
663
663
  return chartNums(values).map((v) => (run += v));
664
664
  }
665
665
 
666
+ // Timestamped rows → [{ x, value }] ascending, one point per calendar day
667
+ // that HAS a row, value cumulative. The third consumer of this shape put it
668
+ // here (task 1003446): the profile's works and credits lines, and
669
+ // /drachmae's treasury. Two callers is a coincidence; three is a primitive.
670
+ //
671
+ // `dateOf`/`valueOf` are the caller's, because only the caller knows which
672
+ // column is the date — `shipped_at` on an activity row, `recorded_at` on a
673
+ // credits row, `day` on a gap-filled daily feed. A row whose date will not
674
+ // parse is DROPPED rather than counted at the epoch, where it would stretch
675
+ // the axis back to 1970 and flatten every real point against the floor.
676
+ function cumulateByDay(rows, opts = {}) {
677
+ const dateOf = opts.dateOf || ((r) => r && r.day);
678
+ const valueOf = opts.valueOf || ((r) => r && r.value);
679
+ const byDay = new Map();
680
+ for (const r of rows || []) {
681
+ const t = Date.parse(dateOf(r));
682
+ if (!Number.isFinite(t)) continue;
683
+ // The epoch-day floor — UTC midnight, so two rows on one day land on one
684
+ // point. Same value as setUTCHours(0,0,0,0) without a Date per row, and
685
+ // the same expression profile-charts.js's own `midnight` used before this
686
+ // moved, so no series shifts by a day.
687
+ const d = Math.floor(t / 86400000) * 86400000;
688
+ byDay.set(d, (byDay.get(d) || 0) + (Number(valueOf(r)) || 0));
689
+ }
690
+ const days = [...byDay.keys()].sort((a, b) => a - b);
691
+ const totals = cumulate(days.map((d) => byDay.get(d)));
692
+ return days.map((d, i) => ({ x: d, value: totals[i] }));
693
+ }
694
+
666
695
  // The line geometry, separated from the markup so a chart that needs the
667
696
  // maths without this SVG can take just this. Returns { points, x, y, nums }.
668
697
  //
@@ -867,21 +896,30 @@
867
896
  return { body, xs, base, py, max };
868
897
  }
869
898
 
870
- // The plot itself: the series a geometry built, on the axis, with the hidden
871
- // crosshair a caller's readout moves and the two end ticks. `tabindex="0"` is
872
- // deliberate and paired with the readout — the chart is walkable by arrow
873
- // key, so it has to be reachable by tab.
899
+ // The plot itself: the series a geometry built, on the axis, with the two end
900
+ // ticks — and, when the caller wires a readout, the hidden crosshair it moves
901
+ // plus the `tabindex` that makes an arrow-key-walkable chart reachable by tab.
902
+ //
903
+ // `interactive: false` (task 1003446) drops BOTH of those, and the pairing is
904
+ // the point: a `role="img"` with a `tabindex="0"` is a focus stop a keyboard
905
+ // reader lands on with nothing to do there, and a crosshair nothing moves is
906
+ // markup that only looks like a feature. A plot whose whole reading is its
907
+ // aria-label and its table twin says so.
874
908
  function plotSvgHtml(opts = {}) {
875
909
  const { width, height, pad, base } = plotBox(opts);
910
+ const interactive = opts.interactive !== false;
876
911
  const tick = (x, text, anchor) => (text
877
912
  ? `<text class="kit-plot__tick" x="${x}" y="${height - 6}"`
878
913
  + `${anchor ? ` text-anchor="${anchor}"` : ''}>${esc(text)}</text>`
879
914
  : '');
880
- return `<svg class="kit-plot" viewBox="0 0 ${width} ${height}" role="img" tabindex="0"`
915
+ return `<svg class="kit-plot" viewBox="0 0 ${width} ${height}" role="img"`
916
+ + (interactive ? ' tabindex="0"' : '')
881
917
  + ` aria-label="${esc(opts.ariaLabel || '')}">`
882
918
  + (opts.series || '')
883
919
  + `<line class="kit-plot__axis" x1="${pad.l}" y1="${base}" x2="${width - pad.r}" y2="${base}"></line>`
884
- + `<line class="kit-plot__hair" x1="0" y1="${pad.t}" x2="0" y2="${base}" style="visibility:hidden"></line>`
920
+ + (interactive
921
+ ? `<line class="kit-plot__hair" x1="0" y1="${pad.t}" x2="0" y2="${base}" style="visibility:hidden"></line>`
922
+ : '')
885
923
  + (opts.label || '')
886
924
  + tick(pad.l, opts.leftTick)
887
925
  + tick(width - pad.r, opts.rightTick, 'end')
@@ -918,7 +956,7 @@
918
956
  // charts (task 1003445) — the ONE place the hall draws a series.
919
957
  // linePoints/cumulate are the maths on their own, for a chart that wants
920
958
  // the geometry without this SVG (the profile's timeChart).
921
- sparklineHtml, barSeriesHtml, chartCardHtml, linePoints, cumulate,
959
+ sparklineHtml, barSeriesHtml, chartCardHtml, linePoints, cumulate, cumulateByDay,
922
960
  // plotted charts: the axis frame + the two series shapes that ride it
923
961
  plotSvgHtml, plotValueLabelHtml, timeStepSeriesHtml, timeStepGeometry, columnSeriesHtml,
924
962
  // plotBox is exported so a caller mapping pointer x back into viewBox
@@ -205,6 +205,12 @@
205
205
  rank: normalizeRank(builder?.rank),
206
206
  graduated,
207
207
  showWork: shipStepCurrent,
208
+ // task 1003178 (C08): the index owns its own /me, so it passes the social
209
+ // mode through too — otherwise the hall's busiest page would be the one
210
+ // page the mode never reached. Read off ctx.lastMeState (the live getter),
211
+ // not a local: renderNav takes (builder, onboarding) and is called from
212
+ // five sites, so there is no `me` in scope here.
213
+ social: (ctx.lastMeState && ctx.lastMeState.social) || null,
208
214
  builder,
209
215
  });
210
216
 
@@ -101,20 +101,13 @@
101
101
  // point; a row with no parsable date is skipped, not zeroed. The lookback
102
102
  // clips this for drawing (clipToWindow) rather than re-accumulating, so the
103
103
  // running total stays true.
104
+ // The grouping AND the running total are the kit's since task 1003446, when
105
+ // /drachmae's treasury became the third caller of this shape. This wrapper
106
+ // survives only to keep the `{ day, value }` key this page's callers and
107
+ // clipToWindow already read; the arithmetic has one home.
104
108
  function cumulativeByDay(rows, valueOf, dateOf = (r) => r && r.shipped_at) {
105
- const byDay = new Map();
106
- for (const r of rows || []) {
107
- const t = Date.parse(dateOf(r));
108
- if (!Number.isFinite(t)) continue;
109
- const d = midnight(t);
110
- byDay.set(d, (byDay.get(d) || 0) + Number(valueOf(r)) || 0);
111
- }
112
- // The grouping is this function's; the running total is the kit's
113
- // (task 1003445), so the hall has ONE accumulator rather than two that
114
- // could disagree about what a hole in the series means.
115
- const days = [...byDay.keys()].sort((a, b) => a - b);
116
- const totals = window.OTBKit.cumulate(days.map((d) => byDay.get(d)));
117
- return days.map((d, i) => ({ day: d, value: totals[i] }));
109
+ return window.OTBKit.cumulateByDay(rows, { dateOf, valueOf })
110
+ .map((p) => ({ day: p.x, value: p.value }));
118
111
  }
119
112
 
120
113
  // Clip a cumulative series to [windowStart, ∞) for drawing. The line must
@@ -119,41 +119,12 @@
119
119
  (glass on the card, like settings' quote), the series in the accent and every
120
120
  word in ink. The SVG scales with its card; the axis band is inside the
121
121
  viewBox, so the card grows with its content and never scrolls. */
122
- .pcharts {
123
- display: grid;
124
- grid-template-columns: repeat(2, minmax(0, 1fr));
125
- gap: var(--sp-sm);
126
- }
127
- .pchart {
128
- margin: 0;
129
- padding: var(--sp-xs) var(--sp-sm) var(--sp-xs);
130
- background: var(--glass);
131
- border: var(--line) solid var(--glass-edge);
132
- border-radius: var(--radius-sm);
133
- min-width: 0;
134
- }
135
- .pchart__title { margin: 0; font-size: 13.5px; font-weight: 500; color: var(--ink); }
136
- .pchart__read { margin: 2px 0 var(--sp-hair); font-family: var(--label-face); font-size: var(--label-size); letter-spacing: var(--label-space); text-transform: var(--label-case); color: var(--ink-soft); min-height: 1.4em; }
137
- /* The plot itself — the svg, the line, the columns, the axis, the crosshair,
138
- the ticks — is OTBKit's `.kit-plot*` in style.css (task 1003445). It was
139
- written twice in this file's own script, and the goal's next charts want it
140
- again. The dataviz spec moved verbatim; what stays below is this page's
141
- FIGURE: the caption, the live readout, the table twin and the foot. */
142
- .pchart__twin { margin-top: var(--sp-hair); }
143
- .pchart__twin > summary { display: inline-flex; align-items: center; min-height: 24px; padding: 0 4px; margin: 0 -4px; font-family: var(--label-face); font-size: var(--label-size); letter-spacing: var(--label-space); text-transform: var(--label-case); color: var(--ink-soft); cursor: pointer; border-radius: var(--radius-sm); }
144
- .pchart__twin > summary:hover { color: var(--ink); background: var(--glass-strong); }
145
- .pchart__table { margin-top: var(--sp-hair); font-size: 12.5px; }
146
- .pchart__table td, .pchart__table th { padding: 3px 6px; }
147
- .pchart__foot { margin: var(--sp-hair) 0 0; font-size: 12px; color: var(--ink-faint); }
148
- .pchart__note { margin: 0; font-size: 14px; color: var(--ink-soft); }
149
- /* the craft mix: label, bar, count — a row is its own table row */
150
- /* The craft mix is OTBKit.barSeriesHtml (task 1003445) — the .pmix family was
151
- the hall's third copy of one horizontal bar. Only the vertical air around it
152
- is this page's; the row, the track and the 24px floor are the kit's. */
153
- .pchart .kit-bars { margin: var(--sp-hair) 0; }
154
- @media (max-width: 720px) {
155
- .pcharts { grid-template-columns: minmax(0, 1fr); }
156
- }
122
+ /* The plot is OTBKit's `.kit-plot*` (task 1003445) and the FIGURE around it —
123
+ `.pcharts` / `.pchart` / `__title` / `__read` / `__twin` / `__table` /
124
+ `__foot` — moved to style.css beside it in task 1003446, when /drachmae's
125
+ treasury became its second consumer. The names did not change: they are
126
+ spelled into profile.js AND drachmae.js. What stays in this sheet is the one
127
+ thing only this page has, the lookback control below. */
157
128
 
158
129
  /* the lookback control (task 1003489, owner request) — one segmented row above
159
130
  the grid, scoping every chart. The pressed side takes the accent TINT the
@@ -522,7 +522,32 @@
522
522
  }
523
523
  }
524
524
 
525
- // applyAccess({ authed, rank, graduated, showWork }) — settle nav visibility.
525
+ // SOCIAL MODE (task 1003178 / C08, goal 1000075). The owner's directive is that
526
+ // social is "scoped into the ENTIRE builders hall, not a single social tab", and
527
+ // the criterion says outright that a /social route with the same hall around it
528
+ // does NOT satisfy it. So social is a MODE stamped on the document root, beside
529
+ // data-theme and for the same reason: every hall page loads this shell, so one
530
+ // attribute lets any surface — a stylesheet, a page script, a widget — read the
531
+ // mode without a route, a nav item, or a second fetch.
532
+ //
533
+ // OFF is the default and the fallback. `/me.social` is null when lifecycle is
534
+ // disabled or the read failed, and null is OFF: a mode that cannot be read is
535
+ // not on. It is only ON when BOTH of C07's switches are (the project enabled
536
+ // mingle AND this builder opted in) — the server resolves that pair, this never
537
+ // re-derives it.
538
+ //
539
+ // Written as an attribute rather than kept in `access` on purpose: CSS can hit
540
+ // it (`:root[data-social="on"] …`), which is what lets a surface change how it
541
+ // READS without every page script learning about social.
542
+ function applySocial(social) {
543
+ var on = !!(social && social.enabled === true);
544
+ var root = document.documentElement;
545
+ if (on) root.dataset.social = 'on';
546
+ else delete root.dataset.social;
547
+ }
548
+
549
+ // applyAccess({ authed, rank, graduated, showWork, social }) — settle nav
550
+ // visibility and the social mode.
526
551
  // Called by builders.js on the index (it owns /me there) and by the shell's own
527
552
  // probe elsewhere. Cosmetic only; the server enforces (ADR 0016).
528
553
  function applyAccess(state) {
@@ -531,6 +556,7 @@
531
556
  if ('rank' in state) access.rank = state.rank ? String(state.rank).toLowerCase() : null;
532
557
  if ('graduated' in state) access.graduated = !!state.graduated;
533
558
  if ('showWork' in state) access.showWork = !!state.showWork;
559
+ if ('social' in state) applySocial(state.social);
534
560
  if (state.builder) renderUser(state.builder);
535
561
  }
536
562
  document.querySelectorAll('#app-sidebar [data-gate]').forEach(function (el) {
@@ -744,6 +770,9 @@
744
770
  rank: me.builder.rank,
745
771
  graduated: !!(me.onboarding && me.onboarding.graduated),
746
772
  showWork: showWork,
773
+ // task 1003178 (C08): the mode rides the identity every page already
774
+ // fetches, so no surface needs a route or a second call to know.
775
+ social: me.social || null,
747
776
  builder: me.builder,
748
777
  });
749
778
  }).catch(function () { /* cosmetic only */ });
@@ -2047,6 +2047,41 @@ main [id] { scroll-margin-top: calc(var(--topbar-h) + 16px); }
2047
2047
  .kit-plot__end { font-family: var(--font-body); font-size: 11.5px; font-weight: 500; fill: var(--ink); }
2048
2048
  .kit-plot__tick { font-family: var(--label-face); font-size: 10px; letter-spacing: var(--label-space); text-transform: var(--label-case); fill: var(--ink-faint); }
2049
2049
 
2050
+ /* The FIGURE a plot sits in — a card one tonal step down from the panel (glass
2051
+ on the card, like settings' quote), its caption, its live readout, its
2052
+ <details> table twin and its foot. Moved here from profile.css in task
2053
+ 1003446, when /drachmae's treasury became the second consumer; the `.pchart`
2054
+ names are unchanged because they are spelled into profile.js and drachmae.js.
2055
+ The kit does NOT emit these — a page composes its own figure and the kit
2056
+ draws the series inside it. */
2057
+ .pcharts {
2058
+ display: grid;
2059
+ grid-template-columns: repeat(2, minmax(0, 1fr));
2060
+ gap: var(--sp-sm);
2061
+ }
2062
+ .pchart {
2063
+ margin: 0;
2064
+ padding: var(--sp-xs) var(--sp-sm) var(--sp-xs);
2065
+ background: var(--glass);
2066
+ border: var(--line) solid var(--glass-edge);
2067
+ border-radius: var(--radius-sm);
2068
+ min-width: 0;
2069
+ }
2070
+ .pchart__title { margin: 0; font-size: 13.5px; font-weight: 500; color: var(--ink); }
2071
+ .pchart__read { margin: 2px 0 var(--sp-hair); font-family: var(--label-face); font-size: var(--label-size); letter-spacing: var(--label-space); text-transform: var(--label-case); color: var(--ink-soft); min-height: 1.4em; }
2072
+ .pchart__twin { margin-top: var(--sp-hair); }
2073
+ .pchart__twin > summary { display: inline-flex; align-items: center; min-height: 24px; padding: 0 4px; margin: 0 -4px; font-family: var(--label-face); font-size: var(--label-size); letter-spacing: var(--label-space); text-transform: var(--label-case); color: var(--ink-soft); cursor: pointer; border-radius: var(--radius-sm); }
2074
+ .pchart__twin > summary:hover { color: var(--ink); background: var(--glass-strong); }
2075
+ .pchart__table { margin-top: var(--sp-hair); font-size: 12.5px; }
2076
+ .pchart__table td, .pchart__table th { padding: 3px 6px; }
2077
+ .pchart__foot { margin: var(--sp-hair) 0 0; font-size: 12px; color: var(--ink-faint); }
2078
+ .pchart__note { margin: 0; font-size: 14px; color: var(--ink-soft); }
2079
+ /* The bar series inside a figure keeps the figure's own vertical air. */
2080
+ .pchart .kit-bars { margin: var(--sp-hair) 0; }
2081
+ @media (max-width: 720px) {
2082
+ .pcharts { grid-template-columns: minmax(0, 1fr); }
2083
+ }
2084
+
2050
2085
  .curve__kinds { display: flex; flex-direction: column; gap: 6px; margin-top: 16px; }
2051
2086
 
2052
2087
  /* ---------- onboarding checklist ---------- */
@@ -22,6 +22,7 @@ const goalSuggest = require('./goal-suggest');
22
22
  const helpRequests = require('./help-requests');
23
23
  const taskRecommendations = require('./task-recommendations');
24
24
  const activity = require('./activity');
25
+ const mingle = require('./mingle');
25
26
 
26
27
  module.exports = {
27
28
  // --- retired doorway leaks (ADR 0093 §3) ---------------------------------
@@ -52,6 +53,13 @@ module.exports = {
52
53
  // builder, exposed the same way and for the same reason — a recommendation
53
54
  // nobody surfaces is merely stored.
54
55
  openTaskRecommendationsFor: (...a) => taskRecommendations.countOpenForRecipient(...a),
56
+ // task 1003178 (C08): social MODE, and the count of open introductions. The
57
+ // mode is the pair of switches C07 resolved (project enabled AND this builder
58
+ // opted in) — the kernel's /me carries it so every hall surface can read the
59
+ // mode WITHOUT importing this module, which is what makes social a mode rather
60
+ // than a page. The count feeds the needs roster, the same slot C01 and C03 use.
61
+ socialModeFor: (...a) => mingle.mingleStateFor(...a),
62
+ openMingleIntroductionsFor: (...a) => mingle.countOpenPairsFor(...a),
55
63
  getTask: (...a) => db.getTask(...a),
56
64
  // findTaskBySourceRef: the create-vector idempotency read (task 1003114 / R03
57
65
  // of goal 1000074). copy-desk turns an artist's copy proposal into a task and