@bongos/core 1.19.781 → 1.19.782

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.
@@ -4,7 +4,7 @@
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>Collab — {{worldName}}</title>
7
- <meta name="description" content="Requests for help addressed to your craft or to you by name, and the asks you have open — with how long each has been waiting.">
7
+ <meta name="description" content="Builders asking builders for help: what is waiting on you, what you asked, and every open ask across the project.">
8
8
  <link rel="icon" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text x='16' y='24' font-size='24' text-anchor='middle' font-family='sans-serif'>⌂</text></svg>">
9
9
  <link rel="preconnect" href="https://fonts.googleapis.com">
10
10
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
@@ -20,30 +20,53 @@
20
20
  <div class="app-body">
21
21
  <header class="app-topbar" id="app-topbar"></header>
22
22
  <div class="app-content">
23
- <!-- The board page head: the page's own name in the LABEL voice, not a
24
- display line — on a working surface it is the least interesting
25
- thing on it. -->
23
+ <!-- The board page head: the page's own name in the LABEL voice. The sub
24
+ line is the one sentence of orientation the page keeps: who reaches
25
+ you, written by collab.js. -->
26
26
  <div class="page-head page-head--board">
27
27
  <h1 class="page-head__h">Collab</h1>
28
28
  <p class="page-head__sub" id="cb-sub">Loading…</p>
29
29
  </div>
30
30
 
31
+ <!-- THREE BLOCKS (owner direction, task 1003942). Top: ASKED OF YOU on the
32
+ left and YOUR ASKS on the right, each condensed to the three newest
33
+ with a "show all" that reveals the rest. Below, one wide block: every
34
+ open ask in the project. Each block's head carries its count and,
35
+ where people are asking, their faces. The rows are the hall's row
36
+ archetype (oversight.css) led by a face; collab.js fills them. -->
31
37
  <main id="hall-main">
32
- <!-- ASKED OF YOU comes first: it is the half where someone is waiting on a
33
- human. Your own asks are below, because they are a status check, not a
34
- call to act. The rows are the hall's row archetype (oversight.css);
35
- collab.js fills the mount with one run per addressee. -->
36
- <section class="scroll" aria-labelledby="cb-for-me-h">
37
- <h2 class="scroll__h" id="cb-for-me-h">Asked of you</h2>
38
- <p class="scroll__lede" id="cb-for-me-note">
39
- Requests addressed to your craft, or to you by name. Answering is optional
40
- and declining costs nothing — nobody is assigned by being asked.
41
- </p>
42
- <div id="cb-for-me"></div>
43
- <div class="empty-state" id="cb-for-me-empty" hidden>
44
- <p>Nothing is waiting on you. Requests reach you by your craft — set yours in
45
- <a href="/builders/settings">Settings</a> if this stays empty.</p>
38
+ <div class="view-zone view-zone--band cb-band">
39
+ <section class="scroll cb-block" aria-labelledby="cb-for-me-h">
40
+ <div class="cb-block__head">
41
+ <h2 class="scroll__h" id="cb-for-me-h">Asked of you <span class="cb-block__n" id="cb-for-me-n"></span></h2>
42
+ <span class="cb-faces" id="cb-for-me-faces" aria-hidden="true"></span>
43
+ </div>
44
+ <ul class="ov-rows" id="cb-for-me"></ul>
45
+ <div class="empty-state cb-empty" id="cb-for-me-empty" hidden>
46
+ <p>Nobody is waiting on you. Requests reach you by craft —
47
+ <a href="/builders/settings">set yours in Settings</a>.</p>
48
+ </div>
49
+ <button class="btn-ghost cb-more" id="cb-for-me-more" type="button" aria-expanded="false" aria-controls="cb-for-me" hidden></button>
50
+ </section>
51
+
52
+ <section class="scroll cb-block" aria-labelledby="cb-mine-h">
53
+ <div class="cb-block__head">
54
+ <h2 class="scroll__h" id="cb-mine-h">Your asks <span class="cb-block__n" id="cb-mine-n"></span></h2>
55
+ </div>
56
+ <ul class="ov-rows" id="cb-mine"></ul>
57
+ <div class="empty-state cb-empty" id="cb-mine-empty" hidden><p>You have no open asks.</p></div>
58
+ <button class="btn-ghost cb-more" id="cb-mine-more" type="button" aria-expanded="false" aria-controls="cb-mine" hidden></button>
59
+ </section>
60
+ </div>
61
+
62
+ <section class="scroll cb-block cb-block--all" aria-labelledby="cb-all-h">
63
+ <div class="cb-block__head">
64
+ <h2 class="scroll__h" id="cb-all-h">All open asks <span class="cb-block__n" id="cb-all-n"></span></h2>
65
+ <span class="cb-faces" id="cb-all-faces" aria-hidden="true"></span>
46
66
  </div>
67
+ <ul class="ov-rows" id="cb-all"></ul>
68
+ <div class="empty-state cb-empty" id="cb-all-empty" hidden><p>Nobody has an open ask right now.</p></div>
69
+ <button class="btn-ghost cb-more" id="cb-all-more" type="button" aria-expanded="false" aria-controls="cb-all" hidden></button>
47
70
  </section>
48
71
 
49
72
  <!-- SOCIAL MODE ONLY (task 1003178 / C08). Not a page and not a nav item:
@@ -52,28 +75,19 @@
52
75
  from /me, and collab.js reveals this section from that. With social
53
76
  off the page is exactly what it was, which is the point: the hall
54
77
  changes, it does not gain a destination. -->
55
- <section class="scroll" id="cb-mingle-section" aria-labelledby="cb-mingle-h" hidden>
78
+ <section class="scroll cb-block" id="cb-mingle-section" aria-labelledby="cb-mingle-h" hidden>
56
79
  <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>
80
+ <p class="scroll__lede" id="cb-mingle-note">Mingle pairs you with someone from another craft. Saying no costs nothing.</p>
62
81
  <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>
82
+ <div class="empty-state cb-empty" id="cb-mingle-empty" hidden>
83
+ <p>No introductions right now. One appears here when a pairing round runs.</p>
65
84
  </div>
66
85
  </section>
67
86
 
68
- <section class="scroll" aria-labelledby="cb-mine-h">
69
- <h2 class="scroll__h" id="cb-mine-h">Your asks</h2>
70
- <p class="scroll__lede">
71
- Only you can settle your own request — mark it answered when someone
72
- helped, or withdraw it when you no longer need it.
73
- </p>
74
- <ul class="ov-rows" id="cb-mine"></ul>
75
- <div class="empty-state" id="cb-mine-empty" hidden><p>You have no open asks.</p></div>
76
- </section>
87
+ <!-- The one sentence of rules, said ONCE, at the foot. Answering is
88
+ optional by design (ADR 0187 §3), and the page still says so; it
89
+ just stops saying it first. -->
90
+ <p class="cb-foot">Answering is optional and declining costs nothing. Nobody is assigned by being asked.</p>
77
91
  </main>
78
92
  </div>
79
93
  </div>
@@ -84,7 +98,7 @@
84
98
  <script src="/builders/shell.js?v=2026-07-02-shell1"></script>
85
99
  <script src="/builders/palette.js" defer></script>
86
100
  <script src="/builders/dom-utils.js?v=2026-06-23-contrib"></script>
87
- <script src="/builders/collab-lib.js?v=2026-09-01-redesign"></script>
88
- <script src="/builders/collab.js?v=2026-09-01-redesign"></script>
101
+ <script src="/builders/collab-lib.js?v=2026-09-16-blocks"></script>
102
+ <script src="/builders/collab.js?v=2026-09-16-blocks"></script>
89
103
  </body>
90
104
  </html>
@@ -1,19 +1,23 @@
1
1
  // modules/hall-ui/public/collab.js — the Collab page (/builders/collab).
2
2
  // Task 1003172 / C02 of goal 1000075, criterion collab-page-matches-my-craft;
3
- // rebuilt on the hall's row archetype by task 1003471 (goal 1000081).
3
+ // rebuilt on the hall's row archetype by task 1003471 (goal 1000081); made a
4
+ // THREE-BLOCK room of people by task 1003942 (owner direction: "way too text
5
+ // heavy, doesn't feel collaborative at all").
4
6
  //
5
- // The hall surface for C01's help requests. Two panels, in this order on
6
- // purpose: what is ASKED OF YOU first, because someone is waiting on a human
7
- // answer, then YOUR OWN ASKS, which are a status check rather than a call to act.
8
- // "Asked of you" reads in runs — by name first, then each craft — because a
9
- // personal ask is the one you owe an answer.
7
+ // The hall surface for C01's help requests, in three blocks. Top left, ASKED OF
8
+ // YOU: someone is waiting on a human answer, so it reads first. Top right, YOUR
9
+ // ASKS: a status check, with the two settle actions. Below, wide, ALL OPEN ASKS:
10
+ // everything anyone in the project has asked, which is what makes the page a
11
+ // room rather than an inbox. Each block shows its NEWEST THREE and a "show all"
12
+ // door to the rest; each head carries its count and the faces of who is asking.
10
13
  //
11
- // A row is the family's .ov-row: the ask's first sentence as the title and the
12
- // rest as the sub (collab-lib titleOf), WHO IS ASKING as a fact with a door to
13
- // their profile, the task or goal as a chip, and the AGE as a state pill in the
14
- // right column, so a glance down the list reads the ages in words and in the
15
- // verification colours. The band rules live in collab-lib.js where a test can
16
- // execute them.
14
+ // A row is the family's .ov-row--lead: the person's FACE first (collab-lib
15
+ // faceOf: the asker on what was asked of you and on the board, the addressee on
16
+ // your own asks), the ask's first sentence as the title and the rest as the sub
17
+ // (titleOf), the direction as a fact with a door to their profile, the task or
18
+ // goal as a chip, and the AGE as a state pill in the right column, so a glance
19
+ // down a list reads the ages in words and in the verification colours. Every
20
+ // rule that can be pure lives in collab-lib.js where a test can execute it.
17
21
  //
18
22
  // MATCHING IS THE SERVER'S JOB, not this file's. GET /help-requests/for-me derives
19
23
  // the viewer's crafts from their own profile; sending a craft from here would be
@@ -33,6 +37,10 @@
33
37
  const { escapeHtml, $, toast } = window.OTB;
34
38
  const L = window.OTBCollabLib;
35
39
 
40
+ // The condensed view: the newest three, the rest behind "show all".
41
+ const SHOWN = 3;
42
+ const MAX_FACES = 6;
43
+
36
44
  let settling = false;
37
45
 
38
46
  // The age pill: the band's words, in the band's colour (collab.css .cb-age--*).
@@ -59,6 +67,27 @@
59
67
  return `<a class="fact-chip" href="${escapeHtml(ctx.href)}">${escapeHtml(ctx.kind)} #${escapeHtml(String(ctx.id))}</a>`;
60
68
  }
61
69
 
70
+ // The face: the picture when the lib vouched for a src, else the initial on
71
+ // the glass; a craft wears its letter in the label face. Decorative on
72
+ // purpose (alt="", aria-hidden) — the name in the facts line is the door, and
73
+ // two doors to one profile on one row would be noise for a screen reader.
74
+ function faceHtml(face) {
75
+ if (!face) return '';
76
+ if (face.avatar) {
77
+ return `<img class="cb-face" src="${escapeHtml(face.avatar)}" alt="" loading="lazy" title="${escapeHtml(face.name)}">`;
78
+ }
79
+ const kind = face.kind === 'craft' ? ' cb-face--craft' : '';
80
+ return `<span class="cb-face cb-face--initial${kind}" aria-hidden="true" title="${escapeHtml(face.name)}">${escapeHtml(face.initial)}</span>`;
81
+ }
82
+
83
+ // The stack in a block's head: each distinct asker once, capped, then +N.
84
+ function facesHtml(list) {
85
+ if (!list.length) return '';
86
+ const shown = list.slice(0, MAX_FACES).map(faceHtml).join('');
87
+ const more = list.length > MAX_FACES ? `<span class="cb-face cb-face--initial" aria-hidden="true">+${list.length - MAX_FACES}</span>` : '';
88
+ return shown + more;
89
+ }
90
+
62
91
  // A name as a door to that builder's profile, or plain text when the server
63
92
  // sent no id to link to.
64
93
  function personLink(id, name) {
@@ -68,6 +97,18 @@
68
97
  : escapeHtml(name);
69
98
  }
70
99
 
100
+ // Splice a profile link over a person's name inside an escaped sentence. The
101
+ // replacement is a FUNCTION, not a string: String.prototype.replace treats
102
+ // `$&`, `$$`, "$`" and `$'` specially in a string replacement, and the name is
103
+ // builder-controlled (a display_name or login) — so a display name containing
104
+ // `$&` would duplicate or mangle the sentence around it. Not an injection
105
+ // (both halves are escaped either way), but a real way to corrupt someone
106
+ // else's row by choosing your own name.
107
+ function linkName(escapedLine, id, name) {
108
+ const link = personLink(id, name);
109
+ return (name && link) ? escapedLine.replace(escapeHtml(name), () => link) : escapedLine;
110
+ }
111
+
71
112
  // DIRECTION, on the row (owner report, task 1003274): "<name> asked you by
72
113
  // name" incoming, "You asked <name> by name" outgoing — so an outgoing ask can
73
114
  // never be mistaken for an incoming one, which is exactly the mistake the
@@ -79,26 +120,32 @@
79
120
  if (!line) return '';
80
121
  const name = dir === 'in' ? L.askerOf(req) : (req.needs_builder_name || req.needs_builder_login);
81
122
  const id = dir === 'in' ? req.requested_by : req.needs_builder_id;
82
- const link = personLink(id, name);
83
- // The replacement is a FUNCTION, not a string. String.prototype.replace
84
- // treats `$&`, `$$`, "$`" and `$'` specially in a string replacement, and the
85
- // name here is builder-controlled (a display_name or login) — so a display
86
- // name containing `$&` would duplicate or mangle the sentence around it. Not
87
- // an injection (both halves are escaped either way), but a real way to
88
- // corrupt someone else's row by choosing your own name.
89
- const html = (name && link)
90
- ? escapeHtml(line).replace(escapeHtml(name), () => link)
91
- : escapeHtml(line);
123
+ return `<span class="ov-fact">${linkName(escapeHtml(line), id, name)}</span>`;
124
+ }
125
+
126
+ // The board's third-person line ("Alpha asked Beta by name"), both people as
127
+ // doors. The asker is linked first, then the addressee, each with the
128
+ // function replacer above.
129
+ function boardFact(req) {
130
+ const line = L.boardLine(req);
131
+ if (!line) return '';
132
+ let html = linkName(escapeHtml(line), req.requested_by, L.askerOf(req));
133
+ if (req.needs_builder_id != null) {
134
+ html = linkName(html, req.needs_builder_id, req.needs_builder_name || req.needs_builder_login);
135
+ }
92
136
  return `<span class="ov-fact">${html}</span>`;
93
137
  }
94
138
 
95
- // A row for the "asked of you" list. There is deliberately NO "claim this"
139
+ // A row for the "asked of you" block. There is deliberately NO "claim this"
96
140
  // button: answering is a conversation, and inventing a reply thread here would
97
141
  // be reimplementing the Agora, which this goal explicitly forbids (C06 owns the
98
142
  // chat mirror). What the page owes a helper is the request, its age, its
99
- // context, and who to answer.
100
- function forMeRow(req, nowMs) {
101
- return `<li class="ov-row" data-id="${escapeHtml(String(req.id))}">
143
+ // context, and who to answer — and their face. A by-name ask is the personal
144
+ // call on this reader, so its row is the LIT one (.cb-row--live).
145
+ function forMeRow(req, nowMs, extra) {
146
+ const live = req.needs_builder_id != null ? ' cb-row--live' : '';
147
+ return `<li class="ov-row ov-row--lead cb-row${live}" data-id="${escapeHtml(String(req.id))}"${extra ? ' data-extra hidden' : ''}>
148
+ ${faceHtml(L.faceOf(req, 'in'))}
102
149
  <div class="ov-row__main">${titleHtml(req)}
103
150
  <div class="ov-row__facts">${directionFact(req, 'in')}${contextChip(req)}</div>
104
151
  </div>
@@ -106,11 +153,13 @@
106
153
  </li>`;
107
154
  }
108
155
 
109
- // Your own ask, with the two settle actions. Only the asker can settle — the
110
- // server enforces it in the UPDATE's WHERE, this is just the affordance.
111
- function mineRow(req, nowMs) {
156
+ // Your own ask, led by who you asked, with the two settle actions. Only the
157
+ // asker can settle — the server enforces it in the UPDATE's WHERE, this is
158
+ // just the affordance.
159
+ function mineRow(req, nowMs, extra) {
112
160
  const id = escapeHtml(String(req.id));
113
- return `<li class="ov-row" data-id="${id}">
161
+ return `<li class="ov-row ov-row--lead cb-row" data-id="${id}"${extra ? ' data-extra hidden' : ''}>
162
+ ${faceHtml(L.faceOf(req, 'out'))}
114
163
  <div class="ov-row__main">${titleHtml(req)}
115
164
  <div class="ov-row__facts">${directionFact(req, 'out')}${contextChip(req)}</div>
116
165
  </div>
@@ -123,48 +172,79 @@
123
172
  </li>`;
124
173
  }
125
174
 
126
- // "Asked of you" as runs under a group head: by name first, then each craft.
127
- function groupedHtml(rows, nowMs) {
128
- const groups = [];
129
- for (const r of rows) {
130
- const g = L.groupOf(r);
131
- let hit = groups.find((x) => x.id === g.id);
132
- if (!hit) { hit = { id: g.id, label: g.label, rows: [] }; groups.push(hit); }
133
- hit.rows.push(r);
175
+ // A row on the board of everyone's asks: the asker's face, the third-person
176
+ // line, no actions — settling is the asker's, in their own block.
177
+ function allRow(req, nowMs, extra) {
178
+ return `<li class="ov-row ov-row--lead cb-row" data-id="${escapeHtml(String(req.id))}"${extra ? ' data-extra hidden' : ''}>
179
+ ${faceHtml(L.faceOf(req, 'in'))}
180
+ <div class="ov-row__main">${titleHtml(req)}
181
+ <div class="ov-row__facts">${boardFact(req)}${contextChip(req)}</div>
182
+ </div>
183
+ <div class="ov-row__right">${ageChip(req, nowMs)}${whenLine(req.created_at)}</div>
184
+ </li>`;
185
+ }
186
+
187
+ // ---- a block: count, faces, the newest three, the door to the rest -----------
188
+ //
189
+ // `key` names the block's ids (cb-<key>, cb-<key>-n, -faces, -empty, -more).
190
+ // Rows past SHOWN render with data-extra + hidden, and the button flips them —
191
+ // no re-render, no second fetch, no motion. The button says the whole number
192
+ // ("Show all 7"), so a reader knows what is behind the door before opening it.
193
+ function paintBlock(key, rows, rowFn, nowMs, faces) {
194
+ const list = rows.slice().sort(L.byNewestFirst);
195
+ const { shown, hidden } = L.splitShown(list, SHOWN);
196
+ const host = $(`#cb-${key}`);
197
+ if (host) {
198
+ host.innerHTML = shown.map((r) => rowFn(r, nowMs, false)).join('')
199
+ + hidden.map((r) => rowFn(r, nowMs, true)).join('');
200
+ }
201
+ const n = $(`#cb-${key}-n`);
202
+ if (n) n.textContent = list.length ? String(list.length) : '';
203
+ const empty = $(`#cb-${key}-empty`);
204
+ if (empty) empty.hidden = list.length > 0;
205
+ const facesEl = $(`#cb-${key}-faces`);
206
+ if (facesEl) facesEl.innerHTML = faces ? facesHtml(L.askers(list)) : '';
207
+ const more = $(`#cb-${key}-more`);
208
+ if (more) {
209
+ more.hidden = hidden.length === 0;
210
+ more.setAttribute('aria-expanded', 'false');
211
+ more.textContent = `Show all ${list.length}`;
212
+ more.dataset.total = String(list.length);
134
213
  }
135
- groups.sort((a, b) => Number(b.id === 'you') - Number(a.id === 'you'));
136
- return groups.map((g) => `<section class="ov-group">
137
- <div class="ov-group__head"><span class="ov-group__name">${escapeHtml(g.label)}</span><span class="ov-group__n">${g.rows.length}</span></div>
138
- <ul class="ov-rows">${g.rows.map((r) => forMeRow(r, nowMs)).join('')}</ul>
139
- </section>`).join('');
140
214
  }
141
215
 
142
- function paint(hostSel, emptySel, html, n) {
143
- const host = $(hostSel);
144
- const empty = $(emptySel);
145
- if (host) host.innerHTML = html;
146
- if (empty) empty.hidden = n > 0;
216
+ function toggleMore(btn) {
217
+ const key = btn.id.replace(/^cb-|-more$/g, '');
218
+ const host = $(`#cb-${key}`);
219
+ if (!host) return;
220
+ const open = btn.getAttribute('aria-expanded') === 'true';
221
+ host.querySelectorAll('[data-extra]').forEach((el) => { el.hidden = open; });
222
+ btn.setAttribute('aria-expanded', open ? 'false' : 'true');
223
+ btn.textContent = open ? `Show all ${btn.dataset.total}` : 'Show fewer';
147
224
  }
148
225
 
149
226
  async function load() {
150
227
  const nowMs = Date.now();
151
- const [forMe, mine] = await Promise.all([
228
+ const [forMe, mine, all] = await Promise.all([
152
229
  api.helpRequests.getHelpRequestsForMe({}),
153
230
  api.helpRequests.getHelpRequestsMine({ query: { status: 'open' } }),
231
+ // The board read (task 1003942) — GET /help-requests, every open ask in the
232
+ // project. Through api.request because the generated client predates it.
233
+ api.request('GET', '/help-requests'),
154
234
  ]);
155
235
 
156
- const forMeRows = ((forMe.ok && forMe.data && forMe.data.help_requests) || []).slice().sort(L.byAgeOldestFirst);
157
- const mineRows = ((mine.ok && mine.data && mine.data.help_requests) || []).slice().sort(L.byAgeOldestFirst);
236
+ const rowsOf = (r) => ((r && r.ok && r.data && r.data.help_requests) || []);
237
+ const crafts = (forMe.ok && forMe.data && forMe.data.crafts) || [];
158
238
 
159
- paint('#cb-for-me', '#cb-for-me-empty', groupedHtml(forMeRows, nowMs), forMeRows.length);
160
- paint('#cb-mine', '#cb-mine-empty', mineRows.map((r) => mineRow(r, nowMs)).join(''), mineRows.length);
239
+ paintBlock('for-me', rowsOf(forMe), forMeRow, nowMs, true);
240
+ paintBlock('mine', rowsOf(mine), mineRow, nowMs, false);
241
+ paintBlock('all', rowsOf(all), allRow, nowMs, true);
161
242
 
162
243
  const sub = $('#cb-sub');
163
244
  if (sub) {
164
- const crafts = (forMe.ok && forMe.data && forMe.data.crafts) || [];
165
245
  sub.textContent = crafts.length
166
- ? `Matching your craft: ${crafts.join(', ')}.`
167
- : 'You have not set a craft, so only requests addressed to you by name reach you.';
246
+ ? `Builders asking builders. Asks to ${crafts.join(', ')}s, and to you by name, land in your block.`
247
+ : 'Builders asking builders. With no craft set, only asks addressed to you by name land in your block.';
168
248
  }
169
249
 
170
250
  // The social half. Best-effort and LAST: an introductions read that fails
@@ -192,8 +272,11 @@
192
272
 
193
273
  function mingleRow(pair, meId) {
194
274
  const o = otherOf(pair, meId);
195
- const who = escapeHtml(o.name || o.login || `builder ${o.id}`);
196
- return `<li class="ov-row">
275
+ const whoName = o.name || o.login || `builder ${o.id}`;
276
+ const who = escapeHtml(whoName);
277
+ const face = { kind: 'person', id: o.id, name: whoName, avatar: null, initial: L.initialOf(whoName) };
278
+ return `<li class="ov-row ov-row--lead cb-row">
279
+ ${faceHtml(face)}
197
280
  <div class="ov-row__main">
198
281
  <a class="ov-row__title" href="/builders/profile?id=${escapeHtml(String(o.id))}">${who}</a>
199
282
  <p class="ov-row__sub">A ${escapeHtml(String(o.craft))} — you are a ${escapeHtml(String(o.myCraft))}. Mingle pairs across crafts.</p>
@@ -207,6 +290,13 @@
207
290
  </li>`;
208
291
  }
209
292
 
293
+ function paint(hostSel, emptySel, html, n) {
294
+ const host = $(hostSel);
295
+ const empty = $(emptySel);
296
+ if (host) host.innerHTML = html;
297
+ if (empty) empty.hidden = n > 0;
298
+ }
299
+
210
300
  async function loadMingle() {
211
301
  const section = $('#cb-mingle-section');
212
302
  if (!section) return;
@@ -257,13 +347,16 @@
257
347
  }
258
348
 
259
349
  document.addEventListener('click', (ev) => {
260
- const mingleBtn = ev.target && ev.target.closest ? ev.target.closest('[data-mingle]') : null;
350
+ const closest = (sel) => (ev.target && ev.target.closest ? ev.target.closest(sel) : null);
351
+ const more = closest('.cb-more');
352
+ if (more) { ev.preventDefault(); toggleMore(more); return; }
353
+ const mingleBtn = closest('[data-mingle]');
261
354
  if (mingleBtn) {
262
355
  ev.preventDefault();
263
356
  decideMingle(Number(mingleBtn.getAttribute('data-id')), mingleBtn.getAttribute('data-mingle'));
264
357
  return;
265
358
  }
266
- const btn = ev.target && ev.target.closest ? ev.target.closest('[data-settle]') : null;
359
+ const btn = closest('[data-settle]');
267
360
  if (!btn) return;
268
361
  ev.preventDefault();
269
362
  settle(Number(btn.getAttribute('data-id')), btn.getAttribute('data-settle'));
@@ -254,7 +254,9 @@
254
254
  // task 1003172 (C02, goal 1000075): help requests addressed to you. In
255
255
  // Community because the group is picked by WHOSE data it is, and this is
256
256
  // other builders reaching you — not your own numbers (that is You).
257
- { id: 'collab', label: 'Collab', icon: 'leaderboard', href: '/collab', gate: 'signed' },
257
+ // task 1003942: the second consumer of the count badge. `help_requests` is
258
+ // the need id builder-needs.js emits with a `count`; applyCounts reads it.
259
+ { id: 'collab', label: 'Collab', icon: 'leaderboard', href: '/collab', gate: 'signed', count: 'help_requests' },
258
260
  // task 1003441: was the #leaderboard hash view on the home page, which is
259
261
  // why this group read as one real page plus a link into index.html. Now a
260
262
  // page, so no `view` key. Labelled Builders — 'roster' is the word
@@ -55,7 +55,9 @@ const COLS = 'id, requested_by, needs_craft, needs_builder_id, what_is_stuck, ta
55
55
  // bare for the writes, whose RETURNING cannot reach a joined alias.
56
56
  const LIST_COLS = `${COLS.split(', ').map((c) => `h.${c}`).join(', ')}, `
57
57
  + 'a.github_login AS requested_by_login, a.display_name AS requested_by_name, '
58
- + 'n.github_login AS needs_builder_login, n.display_name AS needs_builder_name';
58
+ + 'a.avatar_url AS requested_by_avatar_url, '
59
+ + 'n.github_login AS needs_builder_login, n.display_name AS needs_builder_name, '
60
+ + 'n.avatar_url AS needs_builder_avatar_url';
59
61
  const LIST_FROM = 'FROM lifecycle_help_requests h '
60
62
  + 'LEFT JOIN builders a ON a.id = h.requested_by '
61
63
  + 'LEFT JOIN builders n ON n.id = h.needs_builder_id';
@@ -200,6 +202,26 @@ async function listMine({ builderId, status = null, limit = MAX_ROWS }, deps = {
200
202
  return rows;
201
203
  }
202
204
 
205
+ // The BOARD read (task 1003942, owner direction): every open ask in the project,
206
+ // newest first, for the Collab page's third block. The two reads above answer
207
+ // "what is addressed to me" and "what did I ask"; this one answers "what is
208
+ // anyone asking", which is what makes the page a room rather than an inbox.
209
+ // Any signed-in builder may read it: an ask is addressed to a craft or a person
210
+ // INSIDE this project, and every reader here is a member of it. It never takes
211
+ // a craft or a builder from the caller — there is nothing to narrow by, so the
212
+ // ?craft= hole C01's grader found cannot reopen here.
213
+ async function listOpenAll({ limit = MAX_ROWS } = {}, deps = {}) {
214
+ const activePool = deps.pool || pool;
215
+ const { rows } = await activePool.query(
216
+ `SELECT ${LIST_COLS} ${LIST_FROM}
217
+ WHERE h.status = 'open'
218
+ ORDER BY h.created_at DESC, h.id DESC
219
+ LIMIT $1`,
220
+ [capRows(limit)]
221
+ );
222
+ return rows;
223
+ }
224
+
203
225
  async function getHelpRequest(id, deps = {}) {
204
226
  const activePool = deps.pool || pool;
205
227
  const { rows } = await activePool.query(`SELECT ${COLS} FROM lifecycle_help_requests WHERE id = $1`, [id]);
@@ -224,6 +246,6 @@ async function setHelpRequestStatus({ id, builderId, status }, deps = {}) {
224
246
  module.exports = {
225
247
  HELP_CRAFTS, helpCrafts, disciplineForCraft, craftForDiscipline, craftsForBuilder,
226
248
  validateHelpRequest, MAX_STUCK, MAX_ROWS, capRows,
227
- builderExists, createHelpRequest, listOpenForBuilder, listMine, getHelpRequest, setHelpRequestStatus,
249
+ builderExists, createHelpRequest, listOpenForBuilder, listMine, listOpenAll, getHelpRequest, setHelpRequestStatus,
228
250
  countOpenForBuilder, craftMapSql,
229
251
  };
@@ -2,7 +2,8 @@
2
2
  // read for "I am stuck" (task 1003171 / C01, goal 1000075; criterion
3
3
  // collab-ask-for-help).
4
4
  //
5
- // Four routes, no more: ask, see what is addressed to me, see my own, settle mine.
5
+ // Five routes: ask, see what is addressed to me, see my own, see the whole open
6
+ // board (task 1003942), settle mine.
6
7
  // The Collab PAGE is C02 and the matching rules beyond craft are its job — this
7
8
  // surface deliberately stops at "the request is addressed and answerable".
8
9
  //
@@ -88,6 +89,15 @@ module.exports = function buildHelpRequestsRouter() {
88
89
  return res.json({ help_requests: rows, count: rows.length });
89
90
  }, { errorCode: 'help_request_list_failed' }));
90
91
 
92
+ // The BOARD read (task 1003942): every open ask in the project, newest first,
93
+ // for the Collab page's "all open asks" block. rank: any-builder — an ask is
94
+ // addressed inside this project and every reader here is a member; the route
95
+ // takes no craft and no builder from the caller, so there is nothing to widen.
96
+ router.get('/help-requests', api.requireBuilder, asyncHandler('GET /help-requests', async (req, res) => {
97
+ const rows = await help.listOpenAll();
98
+ return res.json({ help_requests: rows, count: rows.length });
99
+ }, { errorCode: 'help_request_list_failed' }));
100
+
91
101
  // Only the ASKER settles their own request: 'answered' when someone helped,
92
102
  // 'withdrawn' when they no longer need it. Nobody is assigned by being asked —
93
103
  // the same consent posture ADR 0187 §3 gives task recommendations.
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.781",
3
+ "version": "1.19.782",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.781",
9
+ "version": "1.19.782",
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.781",
3
+ "version": "1.19.782",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.781'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.782'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -331,5 +331,29 @@ t('nothing waiting renders nothing at all', () => {
331
331
  assert.equal(html, '');
332
332
  });
333
333
 
334
+ // task 1003942: the help-requests need feeds the Collab nav badge, which reads
335
+ // the need's `count` (the field boardVotesNeed already carried) and follows its
336
+ // action to the page. Both were missing: no count, and a '/#/collab' hash href
337
+ // pointing at a view the index stopped routing when Collab became a page.
338
+ console.log('help_requests need carries a count and points at the page (task 1003942):');
339
+
340
+ t('open help requests state their number, machine-readable', () => {
341
+ const n = needs.helpRequestsNeed({ openHelpRequests: 4 });
342
+ assert.equal(n.count, 4);
343
+ assert.equal(n.state, 'action_needed');
344
+ assert.equal(needs.helpRequestsNeed({ openHelpRequests: 1 }).count, 1);
345
+ });
346
+
347
+ t('the action goes to /collab, the page — not the retired hash view', () => {
348
+ assert.equal(needs.helpRequestsNeed({ openHelpRequests: 2 }).action.href, '/collab');
349
+ assert.equal(needs.taskRecommendationsNeed({ openTaskRecommendations: 1 }).action.href, '/collab');
350
+ assert.equal(needs.mingleIntroductionsNeed({ openMingleIntroductions: 1 }).action.href, '/collab');
351
+ });
352
+
353
+ t('zero open requests is no need at all — nothing for a badge to count', () => {
354
+ assert.equal(needs.helpRequestsNeed({ openHelpRequests: 0 }), null);
355
+ assert.equal(needs.helpRequestsNeed({}), null);
356
+ });
357
+
334
358
  console.log(`\n${passed} passed, ${failed} failed`);
335
359
  process.exit(failed ? 1 : 0);