@bongos/core 1.19.538 → 1.19.540

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.
@@ -1525,5 +1525,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
1525
1525
  landed since 1.19.536 with no explicit bump. run 33888812605. (task 1002620)
1526
1526
  1.19.538 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1527
1527
  landed since 1.19.537 with no explicit bump. run 33890191596. (task 1002620)
1528
+ 1.19.539 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1529
+ landed since 1.19.538 with no explicit bump. run 33892948885. (task 1002620)
1530
+ 1.19.540 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1531
+ landed since 1.19.539 with no explicit bump. run 33895602533. (task 1002620)
1528
1532
  ---------------------------------------------------------------------------
1529
1533
  ```
@@ -1,12 +1,15 @@
1
1
  // modules/hall-ui/public/hall-kit.js — the hall's shared UI component kit
2
2
  // (task 2166, the hall UX-coherence program's foundation).
3
3
  //
4
- // One dependency-free IIFE exposing `window.OTBKit`: the SIX primitives every
5
- // page redesign (tasks 2169–2176) adopts instead of re-implementing — pager,
6
- // ledger, filter bar, tabs, section rail, empty/skeleton/collapsible. Before
7
- // this file, the ledger+pager block existed in ~10 drifted copies across
8
- // work.js / goals-page.js / builders.js; the section rail existed twice with
9
- // different mechanics (Standing scrollspy vs Settings panel-switch).
4
+ // One dependency-free IIFE exposing `window.OTBKit`: the SIX layout primitives
5
+ // every page redesign (tasks 2169–2176) adopts instead of re-implementing —
6
+ // pager, ledger, filter bar, tabs, section rail, empty/skeleton/collapsible —
7
+ // plus, since task 1003445, the hall's CHART families: a horizontal bar series
8
+ // and a sparkline, the plotted time-axis charts, and (task 1003572) the
9
+ // year-grid contribution calendar. Before this file, the ledger+pager block
10
+ // existed in ~10 drifted copies across work.js / goals-page.js / builders.js;
11
+ // the section rail existed twice with different mechanics (Standing scrollspy vs
12
+ // Settings panel-switch), and the same bar series was hand-drawn three times.
10
13
  //
11
14
  // Contract:
12
15
  // - Dependency-free like shell.js (own esc(), no window.OTB use) so load order
@@ -1030,6 +1033,100 @@
1030
1033
  + '</div>';
1031
1034
  }
1032
1035
 
1036
+ // calendarGridHtml(days, opts) — a year of ship-days as a contribution calendar:
1037
+ // one cell per calendar day, BANDED by count rather than scaled, its reading a
1038
+ // per-cell title (plus an aria summary on the whole grid), not a crosshair. The
1039
+ // hall's THIRD chart family (task 1003572, criterion legibility-series-are-drawn):
1040
+ // OTBKit's other two shapes are a horizontal bar series and a plot on a time axis;
1041
+ // a 2-D year grid is neither, so it earns its own primitive rather than a hand-roll
1042
+ // at the page (modules/hall-ui/CLAUDE.md; tests/hall_audit.mjs section 6b).
1043
+ //
1044
+ // THE READING, decided and written down (the task asked which): a NON-picture reader
1045
+ // gets the exact figure two ways — each cell carries a `title` ("2026-09-03 · 3
1046
+ // ships" / "… · no ships"), and the grid carries an aria-label summarising the
1047
+ // window (active days + total ships). No table twin: unlike a line, a calendar's
1048
+ // value IS its position, so a per-cell title reads the same fact the picture shows,
1049
+ // and a 366-row table beneath would be longer than the thing it explains.
1050
+ //
1051
+ // `days`: [{ local_date:'YYYY-MM-DD', count }] — SPARSE (only days that had ships),
1052
+ // already resolved in the BUILDER's timezone server-side (GET /me). Empty → '' so
1053
+ // the caller renders its own empty state, never an empty grid.
1054
+ //
1055
+ // THE TIMEZONE TRAP the task names: local_date is already the builder's own day, so
1056
+ // the grid is built by ORDINAL arithmetic on the 'YYYY-MM-DD' parts (Date.UTC on the
1057
+ // parsed integers, floored to an epoch day) — never `new Date('2026-09-03')`, which
1058
+ // is UTC-midnight and would shift a cell a day west of Greenwich. Same epoch-day
1059
+ // floor cumulateByDay uses, so no cell moves relative to the other charts.
1060
+ function calendarGridHtml(days, opts = {}) {
1061
+ const list = (days || []).filter((d) => d && typeof d.local_date === 'string');
1062
+ const ymdToOrd = (ymd) => {
1063
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(ymd);
1064
+ if (!m) return null;
1065
+ return Math.floor(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])) / 86400000);
1066
+ };
1067
+ const ordToYmd = (ord) => {
1068
+ const dt = new Date(ord * 86400000);
1069
+ return `${dt.getUTCFullYear()}-${String(dt.getUTCMonth() + 1).padStart(2, '0')}-${String(dt.getUTCDate()).padStart(2, '0')}`;
1070
+ };
1071
+ const counts = new Map();
1072
+ let maxOrd = null;
1073
+ let activeDays = 0;
1074
+ let totalShips = 0;
1075
+ for (const d of list) {
1076
+ const o = ymdToOrd(d.local_date);
1077
+ if (o == null) continue;
1078
+ const c = Math.max(0, Math.round(Number(d.count) || 0));
1079
+ if (c <= 0) continue;
1080
+ counts.set(o, (counts.get(o) || 0) + c);
1081
+ if (maxOrd == null || o > maxOrd) maxOrd = o;
1082
+ }
1083
+ if (maxOrd == null) return ''; // nothing real to draw → caller's empty state
1084
+ for (const [, c] of counts) { activeDays += 1; totalShips += c; }
1085
+
1086
+ // The grid ENDS at `today` (the builder's, when given) so a quiet recent week still
1087
+ // reads as time that passed, not a truncated chart; falls back to the last ship day.
1088
+ const todayOrd = (opts.today && ymdToOrd(opts.today)) || maxOrd;
1089
+ const endOrd = Math.max(todayOrd, maxOrd);
1090
+ const weeks = Math.max(1, Math.min(53, Math.round(Number(opts.weeks)) || 53));
1091
+
1092
+ // Columns are weeks, rows Sun..Sat. Epoch day 0 (1970-01-01) is a Thursday, so the
1093
+ // Sunday-indexed weekday is (ord + 4) mod 7. The last column is the week holding
1094
+ // endOrd; walk back `weeks` Sundays to the first cell.
1095
+ const dow = (o) => (((o + 4) % 7) + 7) % 7;
1096
+ const endSun = endOrd - dow(endOrd);
1097
+ const startSun = endSun - (weeks - 1) * 7;
1098
+
1099
+ // level 0 = empty; 1..5 map to the anti-gaming daily cap (streak.js caps a day at
1100
+ // 5), so the darkest band means "hit the day's max", not "over some threshold".
1101
+ const level = (c) => (c <= 0 ? 0 : Math.min(5, c));
1102
+
1103
+ let cells = '';
1104
+ for (let col = 0; col < weeks; col++) {
1105
+ for (let row = 0; row < 7; row++) {
1106
+ const o = startSun + col * 7 + row;
1107
+ if (o > endOrd) { cells += '<span class="kit-cal__cell kit-cal__cell--void" aria-hidden="true"></span>'; continue; }
1108
+ const c = counts.get(o) || 0;
1109
+ const ymd = ordToYmd(o);
1110
+ const title = c > 0 ? `${ymd} · ${c} ship${c === 1 ? '' : 's'}` : `${ymd} · no ships`;
1111
+ cells += `<span class="kit-cal__cell" data-level="${level(c)}" title="${esc(title)}"></span>`;
1112
+ }
1113
+ }
1114
+
1115
+ const summary = opts.ariaLabel
1116
+ || `Ships per day over the last year: ${totalShips} ship${totalShips === 1 ? '' : 's'} across ${activeDays} day${activeDays === 1 ? '' : 's'}.`;
1117
+ // The legend is aria-hidden: it decorates the bands, it carries no fact the cell
1118
+ // titles and the summary do not already state in words.
1119
+ const legend = '<div class="kit-cal__key" aria-hidden="true"><span class="kit-cal__key-label">less</span>'
1120
+ + [0, 1, 2, 3, 5].map((lv) => `<span class="kit-cal__cell" data-level="${lv}"></span>`).join('')
1121
+ + '<span class="kit-cal__key-label">more</span></div>';
1122
+ // The grid is wide, so it gets its OWN horizontal strip (the section-rail idiom) —
1123
+ // it never widens the document (modules/hall-ui/CLAUDE.md "no sideways scroll").
1124
+ return `<div class="kit-cal" role="img" aria-label="${esc(summary)}">`
1125
+ + `<div class="kit-cal__scroll"><div class="kit-cal__grid">${cells}</div></div>`
1126
+ + legend
1127
+ + '</div>';
1128
+ }
1129
+
1033
1130
  const api = {
1034
1131
  esc,
1035
1132
  // pager (low-level exports so pages can adopt incrementally)
@@ -1042,6 +1139,8 @@
1042
1139
  // linePoints/cumulate are the maths on their own, for a chart that wants
1043
1140
  // the geometry without this SVG (the profile's timeChart).
1044
1141
  sparklineHtml, barSeriesHtml, chartCardHtml, linePoints, cumulate, cumulateByDay,
1142
+ // the year-grid contribution calendar (task 1003572) — the hall's third chart family
1143
+ calendarGridHtml,
1045
1144
  // plotted charts: the axis frame + the two series shapes that ride it
1046
1145
  plotSvgHtml, plotValueLabelHtml, timeStepSeriesHtml, timeStepGeometry, columnSeriesHtml,
1047
1146
  // plotBox is exported so a caller mapping pointer x back into viewBox
@@ -258,6 +258,35 @@
258
258
  };
259
259
  }
260
260
 
261
+ // The contribution calendar (task 1003572, criterion legibility-series-are-drawn):
262
+ // a year of qualifying ship-days as OTBKit's third chart family, with the streak
263
+ // scalars read beside it. SELF-ONLY and range-INDEPENDENT — the data is streak.
264
+ // ship_days off the viewer's own /me (task added it there), and a calendar is a
265
+ // year by definition, so it ignores the lookback the other figures clip to. Returns
266
+ // null on an empty history so draw() simply omits it — never an empty grid (the
267
+ // module doc's honest-exit rule; DONE WHEN #4).
268
+ function calendarChart({ id, streak }) {
269
+ const days = (streak && Array.isArray(streak.ship_days)) ? streak.ship_days : [];
270
+ const grid = window.OTBKit.calendarGridHtml(days, { today: streak && streak.today });
271
+ if (!grid) return null;
272
+ const active = days.filter((d) => Number(d.count) > 0).length;
273
+ const cur = Number(streak && streak.current) || 0;
274
+ const lng = Number(streak && streak.longest) || 0;
275
+ // The scalars in words, in the .pchart__read slot the other figures use for their
276
+ // crosshair readout — here it is static because the calendar's reading is per-cell.
277
+ const read = `Current streak ${cur} day${cur === 1 ? '' : 's'} \u00b7 longest ${lng} \u00b7 `
278
+ + `${active} day${active === 1 ? '' : 's'} shipped this year`;
279
+ const html = `<figure class="pchart" id="${esc(id)}">
280
+ <figcaption class="pchart__title">Your shipping year</figcaption>
281
+ <p class="pchart__read">${esc(read)}</p>
282
+ ${grid}
283
+ <p class="pchart__foot">a year of qualifying ships, in your timezone \u00b7 only you see this one</p>
284
+ </figure>`;
285
+ // No wire: the reading is the per-cell title + the grid's aria summary, not a
286
+ // crosshair, so there is no pointer handler to attach.
287
+ return { id, html, wire() {} };
288
+ }
289
+
261
290
  // Weekly columns: at most 24px thick, a 4px rounded cap on a square base, a
262
291
  // 2px surface gap between neighbours, the tallest column labelled.
263
292
  function columnChart({ id, title, buckets, footer }) {
@@ -400,7 +429,7 @@
400
429
 
401
430
  // Draw every figure for one lookback window into #charts-grid. Split from
402
431
  // render() so the range buttons can re-draw without re-fetching the record.
403
- async function draw({ api, API, builderId, isOwn, rows, paidFull, estimated, now, rangeId }) {
432
+ async function draw({ api, API, builderId, isOwn, rows, paidFull, estimated, now, rangeId, selfStreak }) {
404
433
  const grid = $('#charts-grid');
405
434
  if (!grid) return;
406
435
  const range = RANGES.find((r) => r.id === rangeId) || RANGES[RANGES.length - 1];
@@ -431,6 +460,12 @@
431
460
  footer: `${fmtNum(inWindow.length)} work${inWindow.length === 1 ? '' : 's'} · ${span}`,
432
461
  }),
433
462
  ];
463
+ if (isOwn && selfStreak) {
464
+ // The calendar leads the viewer's own overview — the year-at-a-glance the goal's
465
+ // thesis is about. Null (no qualifying ships yet) → simply not shown.
466
+ const cal = calendarChart({ id: 'chart-calendar', streak: selfStreak });
467
+ if (cal) figs.unshift(cal);
468
+ }
434
469
  if (isOwn) {
435
470
  const paceDays = Math.min(range.days || PACE_MAX_DAYS, PACE_MAX_DAYS);
436
471
  const own = await fetchOwnRate(api, API, builderId, paceDays);
@@ -448,7 +483,7 @@
448
483
  figs.forEach((f) => { const el = grid.querySelector(`#${f.id}`); if (el) f.wire(el); });
449
484
  }
450
485
 
451
- async function render({ api, API, builderId, selfId, signedIn, now = Date.now() }) {
486
+ async function render({ api, API, builderId, selfId, signedIn, selfStreak = null, now = Date.now() }) {
452
487
  const mount = $('#charts-grid');
453
488
  const controls = $('#charts-controls');
454
489
  if (!mount) return;
@@ -500,10 +535,10 @@
500
535
  b.classList.toggle('is-active', on);
501
536
  b.setAttribute('aria-pressed', String(on));
502
537
  });
503
- draw({ api, API, builderId, isOwn, rows, paidFull, estimated, now, rangeId });
538
+ draw({ api, API, builderId, isOwn, rows, paidFull, estimated, now, rangeId, selfStreak });
504
539
  });
505
540
  }
506
- await draw({ api, API, builderId, isOwn, rows, paidFull, estimated, now, rangeId });
541
+ await draw({ api, API, builderId, isOwn, rows, paidFull, estimated, now, rangeId, selfStreak });
507
542
  }
508
543
 
509
544
  window.OTBProfileCharts = { render, cumulativeByDay, craftMix, weeklyBuckets, estimateOf, clipToWindow };
@@ -208,7 +208,7 @@
208
208
  <!-- The charts (task 1003489). Loaded BEFORE profile.js so window.OTBProfileCharts
209
209
  exists when renderProfile runs; profile.js guards on it, so a page that
210
210
  omits this script simply has no charts section content. -->
211
- <script src="/builders/profile-charts.js?v=2026-09-02-charts"></script>
211
+ <script src="/builders/profile-charts.js?v=2026-09-04-calendar"></script>
212
212
  <script src="/builders/profile.js?v=2026-09-02-rail"></script>
213
213
  </body>
214
214
  </html>
@@ -25,6 +25,11 @@
25
25
  // ({ status, ok, data }) preserves control flow 1:1. throwOnError:false so a
26
26
  // 401/404 is a value to branch on, not an exception.
27
27
  const api = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false });
28
+ // The viewer's own /me payload, captured wherever it is fetched so the Overview's
29
+ // contribution calendar (task 1003572) can read streak.ship_days without a second
30
+ // /me round-trip. Null until /me resolves; only ever the VIEWER's own, never a
31
+ // profile being viewed by id.
32
+ let selfMe = null;
28
33
 
29
34
  const { escapeHtml, $, fmtNum, fmtDate } = window.OTB;
30
35
  const rankLabel = (rank) => window.OTB.rankLabel(rank);
@@ -656,7 +661,7 @@
656
661
  // The charts (task 1003489) read the activity route and the credits ledger
657
662
  // (task 1003491), so they need the same session the ledgers do; on the
658
663
  // viewer's own profile they add the pace.
659
- if (window.OTBProfileCharts) window.OTBProfileCharts.render({ api, API, builderId: builder.id, selfId, signedIn: selfId != null });
664
+ if (window.OTBProfileCharts) window.OTBProfileCharts.render({ api, API, builderId: builder.id, selfId, signedIn: selfId != null, selfStreak: (selfMe && selfMe.streak) || null });
660
665
  renderCrossProject(data, selfId);
661
666
  renderAchievements(data.achievements);
662
667
  buildWorksLedger(builder.id, selfId != null, data.recent_ships);
@@ -722,6 +727,7 @@
722
727
  try {
723
728
  const me = await api.request('GET', `${API}/me`);
724
729
  if (!me.ok) return null;
730
+ selfMe = me.data || null;
725
731
  const builder = (me.data && me.data.builder) ? me.data.builder : me.data;
726
732
  return (builder && builder.id) || null;
727
733
  } catch (err) {
@@ -746,6 +752,7 @@
746
752
  return;
747
753
  }
748
754
  if (!me.ok) throw new Error(`me → ${me.status}`);
755
+ selfMe = me.data || null;
749
756
  const builder = (me.data && me.data.builder) ? me.data.builder : me.data;
750
757
  const selfId = builder && builder.id;
751
758
  if (!selfId) {
@@ -1988,6 +1988,37 @@ main [id] { scroll-margin-top: calc(var(--topbar-h) + 16px); }
1988
1988
  .kit-chart__empty { font-size: 12px; color: var(--ink-faint); }
1989
1989
  .kit-chart__foot { font-size: 11.5px; color: var(--ink-faint); margin-top: 4px; }
1990
1990
 
1991
+ /* The contribution calendar — OTBKit.calendarGridHtml (task 1003572), the hall's
1992
+ THIRD chart family. A year grid of small cells, banded by ship count 0..5. Theme-
1993
+ aware by construction: every band is a color-mix off --accent and --surface-2, both
1994
+ theme tokens, so light and dark read without a second palette and level 5 is the
1995
+ full accent in either. The grid is wide, so .kit-cal__scroll holds it in its OWN
1996
+ horizontal strip — the document never scrolls sideways (hall-ui/CLAUDE.md). */
1997
+ .kit-cal { display: flex; flex-direction: column; gap: 6px; --kit-cal-cell: 11px; }
1998
+ .kit-cal__scroll { overflow-x: auto; padding-bottom: 2px; }
1999
+ .kit-cal__grid {
2000
+ display: grid;
2001
+ grid-template-rows: repeat(7, var(--kit-cal-cell));
2002
+ grid-auto-flow: column;
2003
+ grid-auto-columns: var(--kit-cal-cell);
2004
+ gap: 3px;
2005
+ width: max-content; /* intrinsic width; the scroll strip, not the document, holds it */
2006
+ }
2007
+ .kit-cal__cell {
2008
+ width: var(--kit-cal-cell); height: var(--kit-cal-cell);
2009
+ border-radius: 2px;
2010
+ background: var(--surface-2);
2011
+ }
2012
+ .kit-cal__cell[data-level="1"] { background: color-mix(in srgb, var(--accent) 25%, var(--surface-2)); }
2013
+ .kit-cal__cell[data-level="2"] { background: color-mix(in srgb, var(--accent) 45%, var(--surface-2)); }
2014
+ .kit-cal__cell[data-level="3"] { background: color-mix(in srgb, var(--accent) 65%, var(--surface-2)); }
2015
+ .kit-cal__cell[data-level="4"] { background: color-mix(in srgb, var(--accent) 82%, var(--surface-2)); }
2016
+ .kit-cal__cell[data-level="5"] { background: var(--accent); }
2017
+ .kit-cal__cell--void { background: transparent; }
2018
+ .kit-cal__key { display: flex; align-items: center; gap: 3px; font-family: var(--label-face); font-size: var(--label-size); letter-spacing: var(--label-space); text-transform: var(--label-case); color: var(--ink-faint); }
2019
+ .kit-cal__key .kit-cal__cell { --kit-cal-cell: 10px; }
2020
+ .kit-cal__key-label { margin: 0 2px; }
2021
+
1991
2022
  .kit-spark { width: 100%; height: 44px; display: block; }
1992
2023
  /* The stroke/fill here are the FALLBACK: sparklineHtml writes the caller's
1993
2024
  colour inline, because the Curve spends a different accent per chart. */
@@ -411,6 +411,16 @@ h1{font-family:var(--font-display);font-weight:700;font-size:clamp(24px,3.4vw,32
411
411
  task 1003355 moved it to the project's own page, where the section's heading
412
412
  is the disclosure and the row it left carries only its two doors. */
413
413
  .attachNote{margin:8px 0 0;font-size:13px;line-height:1.45;color:var(--ink-faint);}
414
+ /* invites filed in this session (task 1002346): a hairline ledger under the pair
415
+ above — one line per person, the name in the body voice, what happened next to
416
+ it in the faint ink. Shared by the wizard's step and the project's own page. */
417
+ .inviteList{list-style:none;display:grid;margin:12px 0 0;padding:0;}
418
+ .inviteList:empty{display:none;}
419
+ .inviteList>li{display:grid;grid-template-columns:auto 1fr;gap:2px 10px;padding:10px 0 11px;border-top:1px solid var(--rule-soft);}
420
+ .inviteList>li:first-child{border-top:0;}
421
+ .inviteList>li::before{content:"";grid-column:1;grid-row:1;width:7px;height:7px;margin-top:7px;border-radius:50%;border:1px solid var(--accent);background:var(--bg);}
422
+ .inviteList b{grid-column:2;font-family:var(--font-body);font-weight:500;font-size:15px;color:var(--ink);}
423
+ .inviteList small{grid-column:2;font-family:var(--font-body);font-size:13px;font-weight:400;line-height:1.45;color:var(--ink-faint);}
414
424
  .attachNote code{font-family:var(--font-mono);}
415
425
  .projMsg{font-size:13.5px;font-weight:600;margin-top:10px;min-height:18px;color:var(--ink-soft);line-height:1.5;}
416
426
  .projMsg.ok{color:var(--ok);}
@@ -1516,6 +1526,14 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
1516
1526
  #view-new #wizSignin:hover{background:color-mix(in srgb,var(--ink) 88%,var(--bg));}
1517
1527
  /* the error alert: the error ink between two hairlines, no plate */
1518
1528
  #view-new .alert{background:none;border:0;border-top:1px solid var(--err);border-bottom:1px solid var(--err);border-radius:0;padding:12px 0;font-weight:400;}
1529
+ /* the invite step (task 1002346): the page's own input+button pair, wearing the
1530
+ wizard's hairline field rather than the plated one — the .attach width has to
1531
+ give way to the row's flex, or #view-new's own width:100% overflows it */
1532
+ #view-new .attach{gap:10px 14px;margin-top:14px;align-items:center;}
1533
+ #view-new .attach input[type=text]{width:auto;flex:1;min-width:220px;}
1534
+ #view-new .inviteBox{margin-top:34px;}
1535
+ #view-new .inviteBox .finePrint{margin-top:10px;}
1536
+ #view-new #wizInviteLater{margin-top:14px;}
1519
1537
  /* one fold for the whole wizard: 840 is where the page's bands and ledger stack
1520
1538
  (cosmos's createSplit collapse), so the rail folds with them, not before them */
1521
1539
  @media (max-width:840px){
@@ -849,6 +849,27 @@ summary{min-height:24px;padding:3px 0;}
849
849
  </details>
850
850
  </div>
851
851
 
852
+ <!-- live since task 1002346 (BV1.R27): the way to invite a builder AFTER
853
+ creation. The wizard's invite step is skippable, and — the mModules
854
+ argument beside it — an optional step is a default rather than a dead end
855
+ only if there is a way back. The SAME widget on the SAME route
856
+ (POST /projects/invite), admitted here by the project's own ownership
857
+ (ADR 0248); like its neighbour above, nothing is pushed to the project and
858
+ nothing restarts, so there is no "what your project runs" read-back line. -->
859
+ <div class="card mSec" id="mInvite">
860
+ <h2>Invite a builder</h2>
861
+ <div id="mInv">
862
+ <p class="finePrint">By GitHub username. They get an invitation to accept from their own projects page, and they need to have signed in here at least once.</p>
863
+ <div class="attach">
864
+ <label class="vh" for="mInviteWho">GitHub username</label>
865
+ <input type="text" id="mInviteWho" placeholder="GitHub username" autocomplete="off" autocapitalize="off" spellcheck="false" maxlength="200">
866
+ <button type="button" class="btn small" data-invite-send="mInvite">Invite</button>
867
+ </div>
868
+ <ul class="inviteList" id="mInviteList"></ul>
869
+ <div class="projMsg" id="mInviteMsg" role="status" aria-live="polite"></div>
870
+ </div>
871
+ </div>
872
+
852
873
  <!-- the planet picker (task 1003347; direction record §16, v1 the bands): the
853
874
  owner's own choice of how the platform draws this project — the sky,
854
875
  the list, this page's head and the card read the same row. The pick is
@@ -1203,7 +1224,29 @@ summary{min-height:24px;padding:3px 0;}
1203
1224
  <!-- the STABLE live region: the strip inside is rebuilt every 5s tick,
1204
1225
  and a live region that is itself replaced never announces -->
1205
1226
  <div id="liveProgress" role="status" aria-live="polite"></div>
1206
- <h2 class="stepsHead">Your steps — the parts only you can play</h2>
1227
+ <!-- the invite step (task 1002346, BV1.R27) — OPTIONAL, and it stands HERE
1228
+ rather than in the numbered rail because an invite needs a project that
1229
+ already exists: the catalog row a client_id resolves is projected at
1230
+ create (ADR 0246), so before the Create step there is nothing to invite
1231
+ anyone TO. The whole reasoning is ADR 0249. Hidden until the created
1232
+ record carries an id and a slug; the script is the only thing that
1233
+ shows it. -->
1234
+ <div class="inviteBox" id="wizInvite" hidden>
1235
+ <h2 class="stepsHead">Invite builders</h2>
1236
+ <p class="finePrint">Optional — invite people by GitHub username. Each one gets an invitation to accept from their own projects page, and they need to have signed in here at least once. Skip this and invite anyone later; nothing about your project waits on it.</p>
1237
+ <div class="attach">
1238
+ <label class="vh" for="wizInviteWho">GitHub username</label>
1239
+ <input type="text" id="wizInviteWho" placeholder="GitHub username" autocomplete="off" autocapitalize="off" spellcheck="false" maxlength="200">
1240
+ <button type="button" class="btn small" data-invite-send="wizInvite">Invite</button>
1241
+ <button type="button" class="btn small ghost" data-invite-skip="wizInvite">Skip</button>
1242
+ </div>
1243
+ <ul class="inviteList" id="wizInviteList"></ul>
1244
+ <div class="projMsg" id="wizInviteMsg" role="status" aria-live="polite"></div>
1245
+ </div>
1246
+ <!-- the way back, the doneModules idiom: an optional step is a default
1247
+ rather than a dead end only if the panel names where it lives after -->
1248
+ <p class="samePath" id="wizInviteLater"></p>
1249
+ <h2 class="stepsHead" id="doneStepsHead">Your steps — the parts only you can play</h2>
1207
1250
  <ol class="humanSteps" id="humanSteps"></ol>
1208
1251
  <!-- the modules step was optional (task 1002342, BV1.R23) — one line
1209
1252
  saying where it lives afterwards, so a skip is never a dead end -->
@@ -4098,6 +4141,14 @@ summary{min-height:24px;padding:3px 0;}
4098
4141
  skippable, so this panel names where it lives afterwards rather than
4099
4142
  leaving an owner to go looking for it. */
4100
4143
  $('doneModules').innerHTML = 'Add or remove modules later in ' + projectPageLink('the project’s settings') + '.';
4144
+ /* the invite step (task 1002346, BV1.R27). The CREATED RECORD is the proof
4145
+ the widget needs and the only one this panel has: no signed-in identity,
4146
+ no filed instance or no slug ⇒ nothing to invite to, and the step stays
4147
+ hidden. It is deliberately NOT keyed on instanceId, which startProgress
4148
+ sets only AFTER this runs. */
4149
+ var created = createdRecord();
4150
+ setInviteSlug('wizInvite', (me && created && created.id && created.slug) ? created.slug : '');
4151
+ $('wizInviteLater').innerHTML = 'You can invite builders any time from ' + projectPageLink('the project’s own page') + '.';
4101
4152
  renderSteps();
4102
4153
  }
4103
4154
 
@@ -5280,6 +5331,11 @@ summary{min-height:24px;padding:3px 0;}
5280
5331
  progress survives the tick because only a different project re-renders it */
5281
5332
  renderPick(inst);
5282
5333
  renderCardPick(inst);
5334
+ /* invite a builder (task 1002346): fetch-free too — the widget needs only the
5335
+ slug, and the API is the wall. This read is own-scoped and 404s for anyone
5336
+ who is not the owner, so reaching renderManage at all IS the ownership
5337
+ answer the widget does not ask for itself. */
5338
+ setInviteSlug('mInvite', inst.slug || '');
5283
5339
  /* the page names its project — the heading holds #mName; and in the tab */
5284
5340
  document.title = (inst.slug ? inst.slug + ' — ' : '') + BASE_TITLE;
5285
5341
  /* the card's own story, on the project's own page (task 1003232): the
@@ -6692,6 +6748,169 @@ summary{min-height:24px;padding:3px 0;}
6692
6748
  goToManage(m.getAttribute('data-manage'));
6693
6749
  });
6694
6750
 
6751
+ /* ══ invite a builder (task 1002346, BV1.R27; goal 1000046 criterion C6, ADR 0249)
6752
+ ONE widget, mounted twice: the creation wizard's optional invite step (on the
6753
+ done panel — an invite needs a project that already exists) and the project's
6754
+ own page, so a skipped step is a default rather than a dead end.
6755
+
6756
+ THE ROUTE IS USED AS IT STANDS: POST /projects/invite { client_id, github_login },
6757
+ where client_id IS the project's slug — ADR 0246 projects a control-plane project
6758
+ onto the catalog under its slug — and the project's owner is admitted by ADR 0248's
6759
+ ownership arm. Nothing here is a second gate; the server owns the answer.
6760
+
6761
+ TWO QUESTIONS THIS WIDGET DELIBERATELY DOES NOT ASK: "is anyone signed in" and
6762
+ "is this their project". Each caller already knows, and hands over a slug or
6763
+ nothing — which is the ONE door in (setInviteSlug). A widget that re-derived
6764
+ ownership on the page would be a second, weaker copy of a server rule.
6765
+
6766
+ THE REFUSALS ARE SAID IN THE ROUTE'S OWN VOCABULARY, and the 403 is said
6767
+ CAREFULLY: it is byte-identical whether the project is real, stealth, or absent
6768
+ (ADR 0248), so this page may never word it as "it exists but you may not" — that
6769
+ would hand back the membership oracle the route equalised away.
6770
+
6771
+ IT NEVER BLOCKS CREATION. The project is filed before the step is shown at all;
6772
+ a refusal leaves the box exactly as it was, ready to try again.
6773
+
6774
+ WHAT IT PUBLISHES OF AN INVITEE is the 201 row's own membership_kind and the
6775
+ login the inviter typed — never the github_id the row carries, which belongs to
6776
+ someone who has accepted nothing yet.
6777
+ Proof: ../../../tests/project_invite_ui.mjs ══ */
6778
+
6779
+ var INVITE_MOUNTS = {
6780
+ wizInvite: { who: 'wizInviteWho', list: 'wizInviteList', msg: 'wizInviteMsg', after: 'doneStepsHead' },
6781
+ mInvite: { who: 'mInviteWho', list: 'mInviteList', msg: 'mInviteMsg', after: null }
6782
+ };
6783
+ var inviteSlug = {}; /* mount id → the slug it invites to; '' means no step at all */
6784
+ var inviteRows = {}; /* mount id → the invites filed on THIS project this session */
6785
+ var inviteBusy = {}; /* a send is in flight — the mount is inert until it answers */
6786
+
6787
+ /* setInviteSlug(id, slug) — the only door in. A falsy slug closes the mount, which
6788
+ is how "signed out", "not the owner" and "no project yet" all reach this widget.
6789
+ A DIFFERENT slug starts clean: the pending lines belonged to the project they
6790
+ were filed on, and following them to another one would be a lie. */
6791
+ function setInviteSlug(id, slug) {
6792
+ var m = INVITE_MOUNTS[id];
6793
+ if (!m) return;
6794
+ var next = slug || '';
6795
+ if (inviteSlug[id] !== next) {
6796
+ inviteSlug[id] = next;
6797
+ inviteRows[id] = [];
6798
+ inviteSay(id, '', '');
6799
+ }
6800
+ var box = $(id);
6801
+ if (box) box.hidden = !next;
6802
+ renderInviteRows(id);
6803
+ }
6804
+
6805
+ /* One line per invite this session filed: the login the inviter typed, and the
6806
+ WORD THE ROW ITSELF CARRIES — so a membership_kind other than 'pending' shows
6807
+ up here rather than being papered over as one. */
6808
+ function renderInviteRows(id) {
6809
+ var m = INVITE_MOUNTS[id];
6810
+ var list = m && $(m.list);
6811
+ if (!list) return;
6812
+ list.innerHTML = (inviteRows[id] || []).map(function (r) {
6813
+ var said = r.kind === 'pending' ? 'invited — waiting for them to accept.' : r.kind;
6814
+ return '<li><b>' + esc(r.login) + '</b><small>' + esc(said) + '</small></li>';
6815
+ }).join('');
6816
+ }
6817
+
6818
+ function inviteSay(id, text, cls, isHtml) {
6819
+ var m = INVITE_MOUNTS[id];
6820
+ var el = m && $(m.msg);
6821
+ if (!el) return;
6822
+ el.className = 'projMsg' + (cls ? ' ' + cls : '');
6823
+ if (isHtml) el.innerHTML = text; else el.textContent = text;
6824
+ }
6825
+
6826
+ /* inviteRefusal(code, status, message) — the route's refusals as one sentence
6827
+ each. PURE, so the harness reads it directly. A code this page does not know
6828
+ falls through to the SERVER'S own words rather than an invented reason. */
6829
+ function inviteRefusal(code, status, message) {
6830
+ if (status === 404 && code === 'account_not_found') return 'That builder hasn’t signed in here yet — ask them to sign in once, then invite them.';
6831
+ if (status === 404 && code === 'unknown_project') return 'This project isn’t on the map yet — give it a moment and try again.';
6832
+ /* deliberately says nothing about the project: see the header */
6833
+ if (status === 403) return 'This account can’t invite people here.';
6834
+ return message || 'The invitation couldn’t be sent — try again in a moment.';
6835
+ }
6836
+
6837
+ /* doInvite(id) — one builder, one request. */
6838
+ function doInvite(id) {
6839
+ var m = INVITE_MOUNTS[id];
6840
+ var slug = inviteSlug[id] || '';
6841
+ if (!m || !slug || inviteBusy[id]) return;
6842
+ var field = $(m.who);
6843
+ var login = String((field && field.value) || '').replace(/^\s+|\s+$/g, '');
6844
+ if (!login) {
6845
+ inviteSay(id, 'Type the GitHub username of the person you want to invite.', 'err');
6846
+ if (field && field.focus) field.focus();
6847
+ return;
6848
+ }
6849
+ inviteBusy[id] = true;
6850
+ inviteSay(id, 'Sending the invitation…', '');
6851
+ fetch(API + '/projects/invite', {
6852
+ method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
6853
+ body: JSON.stringify({ client_id: slug, github_login: login })
6854
+ }).then(function (res) {
6855
+ return jsonOrEmpty(res).then(function (data) {
6856
+ inviteBusy[id] = false;
6857
+ if (res.ok) {
6858
+ if (data && data.invited && data.row) {
6859
+ inviteRows[id] = (inviteRows[id] || []).concat([{ login: login, kind: String(data.row.membership_kind || 'pending') }]);
6860
+ renderInviteRows(id);
6861
+ if (field) { field.value = ''; if (field.focus) field.focus(); }
6862
+ inviteSay(id, login + ' is invited — they accept it from their own projects page.', 'ok');
6863
+ return;
6864
+ }
6865
+ /* 200 { invited: false, alreadyMember: true } — not a failure, and not a
6866
+ pending invite either: they are already on the project */
6867
+ if (field) field.value = '';
6868
+ inviteSay(id, login + ' is already on this project.', 'ok');
6869
+ return;
6870
+ }
6871
+ if (res.status === 401) { inviteSay(id, signInAgain(), 'err', true); return; }
6872
+ var e = apiError(data, res.status);
6873
+ inviteSay(id, inviteRefusal(e.code, res.status, e.message), 'err');
6874
+ });
6875
+ }).catch(function () {
6876
+ inviteBusy[id] = false;
6877
+ inviteSay(id, 'Couldn’t reach the platform — check your connection and try again.', 'err');
6878
+ });
6879
+ }
6880
+
6881
+ /* skipInvite(id) — the step steps aside having sent NOTHING. Focus moves on
6882
+ rather than being stranded inside the box that just went away. */
6883
+ function skipInvite(id) {
6884
+ var m = INVITE_MOUNTS[id];
6885
+ if (!m || inviteBusy[id]) return;
6886
+ var box = $(id);
6887
+ if (box) box.hidden = true;
6888
+ inviteSay(id, '', '');
6889
+ var after = m.after && $(m.after);
6890
+ if (after && after.focus) { after.setAttribute('tabindex', '-1'); after.focus(); }
6891
+ }
6892
+
6893
+ /* One delegated pair per mount: the buttons name their own mount, so the two
6894
+ surfaces share every line of behaviour above without sharing state. */
6895
+ Object.keys(INVITE_MOUNTS).forEach(function (id) {
6896
+ var box = $(id);
6897
+ if (box) {
6898
+ box.addEventListener('click', function (ev) {
6899
+ var t = ev.target.closest && ev.target.closest('[data-invite-send],[data-invite-skip]');
6900
+ if (!t) return;
6901
+ if (t.hasAttribute('data-invite-skip')) skipInvite(id); else doInvite(id);
6902
+ });
6903
+ }
6904
+ var field = $(INVITE_MOUNTS[id].who);
6905
+ if (field) {
6906
+ field.addEventListener('keydown', function (ev) {
6907
+ if (ev.key !== 'Enter') return;
6908
+ ev.preventDefault();
6909
+ doInvite(id);
6910
+ });
6911
+ }
6912
+ });
6913
+
6695
6914
  /* ── boot: who am I → my instances ────────────────────────────────────── */
6696
6915
 
6697
6916
  function renderWho() {
@@ -22,6 +22,7 @@
22
22
  "in-manage-address": { "auth": true, "url": "/projects?view=manage&id=101", "expect": { "visible": ["#mAddress", "#mAddress h2", "#mDom .plain", "#mDomName", "#mDom [data-m-attach]", "#mDomWhat summary"], "count": [["#mDom .attach input[type=text]", 1], ["#mDom .attach button", 1]] } },
23
23
  "in-manage-planet": { "auth": true, "url": "/projects?view=manage&id=101", "expect": { "visible": ["#mPlanetPick", "#mPick .pick .pickPreview .worldOrb .disc", "#mPick [data-pick-save]", "#mPick [data-pick-reset]", "#mPickWhat summary"], "count": [["#mPick .pickCells[role=\"radiogroup\"]", 3], ["#mPick .pickCell", 10], ["#mPick .pickCell.on", 3], ["#mPick .pickSw", 5], ["#mPick .pickSw.on", 1]], "attr": [["#mPick .pickPreview .worldOrb", "data-template", "pair-obsidian-tint"], ["#mOrb .worldOrb", "data-template", "pair-obsidian-tint"]], "checked": "#mPick input[name=\"mPickAccent\"][value=\"sea\"]", "text": [["#mPlanet .v small", "your choice."]] } },
24
24
  "in-manage-card": { "auth": true, "url": "/projects?view=manage&id=101", "expect": { "visible": ["#mCard", "#mCardPick .pick .pickPreview .cardBand", "#mCardPick [data-card-save]", "#mCardWhat summary"], "count": [["#mCardPick .pickCells[role=\"radiogroup\"]", 1], ["#mCardPick .pickCell", 5], ["#mCardPick .pickCell.on", 1]], "checked": "#mCardPick input[name=\"mCardBackdrop\"][value=\"\"]", "attr": [["#mCardPick .cardBand", "data-own", ""]] } },
25
+ "in-manage-invite": { "auth": true, "url": "/projects?view=manage&id=101", "expect": { "visible": ["#mInvite", "#mInvite h2", "#mInviteWho", "#mInvite [data-invite-send]"] } },
25
26
  "in-manage-snag": { "auth": true, "url": "/projects?view=manage&id=103", "expect": { "visible": ["#mMain", "#mErr", "#mActs"], "hidden": ["#mAddress"], "text": [["#mName", "tidepool"]] } },
26
27
  "in-manage-gone": { "auth": true, "url": "/projects?view=manage&id=104", "expect": { "visible": ["#mMain", "#mActs"], "hidden": ["#mErr", "#mAddress"], "text": [["#mName", "old-lantern"]] } },
27
28
  "in-manage-missing": { "auth": true, "url": "/projects?view=manage&id=999", "expect": { "visible": ["#mMissing"], "hidden": ["#mMain", "#mGate"] } },
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.538",
3
+ "version": "1.19.540",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.538",
9
+ "version": "1.19.540",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.538",
3
+ "version": "1.19.540",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",