@bongos/core 1.19.529 → 1.19.531

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.
@@ -27,6 +27,9 @@
27
27
  //
28
28
  // Reaches core ONLY through the doorway src/module-api.js (the one-way rule, ADR
29
29
  // 0083); it requires this module's own provisioning.js (same-dir), never a sibling.
30
+ // The GitHub-App callback page's presentation (esc / ACCENT / the stylesheet / the
31
+ // client script / callbackPage) lives in ./callback-page.js — moved out under task
32
+ // 1003570 because this file had reached the 1500-line fitness ratchet.
30
33
  'use strict';
31
34
 
32
35
  const crypto = require('node:crypto');
@@ -39,6 +42,7 @@ const { onboardPlan, stepsForShape } = require('../onboard-plan');
39
42
  const { ALWAYS_ON_CORE, OPTIONAL_MODULES, starterBundleAnswerTable, optionalModules, effectiveModules } = require('../starter-bundles');
40
43
  const { recommendationsForDetails } = require('../recommendations');
41
44
  const { makeAskRateLimiter, clientIp } = require('../rate-limit');
45
+ const { callbackPage } = require('./callback-page');
42
46
  // task 1003208: structured logging (pino via the doorway) — was console.*.
43
47
  const log = api.logger('provisioning');
44
48
 
@@ -139,142 +143,6 @@ function badModuleSelection(modules) {
139
143
  return null;
140
144
  }
141
145
 
142
- // Minimal HTML escaper for the callback landing page (no template engine here).
143
- function esc(s) {
144
- return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
145
- { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
146
- ));
147
- }
148
-
149
- // Accent pair resolved from the instance's own branding — the pack is the palette
150
- // authority (task 1002810); the literal fallbacks are the neutral pack's values, so
151
- // a branding validation error still paints the current family, never a stale one.
152
- const ACCENT = (() => {
153
- try {
154
- const c = api.branding().theme?.ui?.colors || {};
155
- return { sun: c.accent || '#fb9569', sun600: c.accentDeep || '#a3481a' };
156
- } catch { return { sun: '#fb9569', sun600: '#a3481a' }; }
157
- })();
158
-
159
- // Brand styling for the standalone onboarding pages — sunrise palette + Baloo 2 /
160
- // Quicksand (accents from the branding pack, config/branding.neutral.json resolved
161
- // under the instance's own config/branding.json). Light + dark aware; no external
162
- // dependency beyond an optional Google-Fonts link (system fallback if it doesn't load).
163
- const PAGE_STYLE = `
164
- :root{color-scheme:light dark;--sun:${ACCENT.sun};--sun600:${ACCENT.sun600};--sunInk:#2a1305;--coral:#fb7185;--sky:#38bdf8;--sky600:#0284c7;--ok:#16a34a;--warn:#f59e0b;--ink:#2a1f17;--inkSoft:#6b5d52;--card:#fffdfa;--line:#f0e2cf;--bg1:#fde8c8;--bg2:#fff4e6;--bg3:#fffdfa}
165
- @media (prefers-color-scheme:dark){:root{--ink:#f5efe6;--inkSoft:#cbbdad;--card:#241b13;--line:#3a2c1e;--bg1:#2a1d10;--bg2:#1b130b;--bg3:#130d07}}
166
- *{box-sizing:border-box}html,body{height:100%}
167
- body{margin:0;font-family:"Quicksand",system-ui,-apple-system,"Segoe UI",sans-serif;color:var(--ink);background:linear-gradient(180deg,var(--bg1) 0%,var(--bg2) 45%,var(--bg3) 100%);background-attachment:fixed;display:flex;align-items:center;justify-content:center;padding:1.5rem;-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}
168
- .card{width:100%;max-width:30rem;background:var(--card);border:1px solid var(--line);border-radius:20px;padding:2.4rem 2rem 2rem;text-align:center;box-shadow:0 18px 50px -22px rgba(120,60,10,.4),0 2px 8px rgba(120,60,10,.07)}
169
- .badge-warn{font-size:2.4rem;line-height:1;margin:0 auto 1rem}
170
- h1{font-family:"Baloo 2",system-ui,sans-serif;font-weight:700;font-size:1.5rem;line-height:1.15;margin:.2rem 0 .55rem}
171
- .sub{color:var(--inkSoft);font-size:1.02rem;line-height:1.5;margin:0 auto;max-width:24rem}
172
- .sub p{margin:.6rem 0}
173
- code{font-family:"Fira Code",ui-monospace,SFMono-Regular,monospace;background:rgba(56,189,248,.14);color:var(--sky600);padding:.12em .45em;border-radius:6px;font-size:.9em;overflow-wrap:anywhere}
174
- @media (prefers-color-scheme:dark){code{color:var(--sky)}}
175
- .steps{display:flex;align-items:flex-start;justify-content:center;margin:1.9rem 0 1.1rem}
176
- .step{flex:1 1 0;max-width:9rem;position:relative}
177
- .step::before{content:"";position:absolute;top:16px;left:-50%;width:100%;height:3px;background:var(--line);z-index:0;transition:background .4s ease}
178
- .step:first-child::before{display:none}
179
- .dot{width:34px;height:34px;border-radius:50%;margin:0 auto;display:grid;place-items:center;background:var(--bg3);border:2px solid var(--line);color:var(--inkSoft);font-weight:700;font-size:.95rem;position:relative;z-index:1;transition:all .4s ease}
180
- .lbl{margin-top:.55rem;font-size:.78rem;color:var(--inkSoft);font-weight:600;line-height:1.25;padding:0 .2rem}
181
- .step.done .dot{background:var(--ok);border-color:var(--ok);color:#fff}
182
- .step.done::before{background:var(--ok)}
183
- .step.active .dot{border-color:var(--sun);color:var(--sun600);animation:pulse 1.5s ease-in-out infinite}
184
- .step.active .lbl{color:var(--ink)}
185
- .step.active .dot::after{content:"";position:absolute;inset:-3px;border-radius:50%;border:2px solid transparent;border-top-color:var(--sun);animation:spin .8s linear infinite}
186
- @keyframes spin{to{transform:rotate(360deg)}}
187
- @keyframes pulse{0%,100%{box-shadow:0 0 0 4px rgba(249,115,22,.15)}50%{box-shadow:0 0 0 9px rgba(249,115,22,.04)}}
188
- @media (prefers-reduced-motion:reduce){.step.active .dot,.step.active .dot::after{animation:none}}
189
- .status-line{min-height:1.4em;color:var(--inkSoft);font-size:.94rem;margin:.4rem auto 0;max-width:24rem;line-height:1.45}
190
- .cta{margin-top:1.4rem}
191
- .btn{display:inline-block;background:var(--sun);color:var(--sunInk);text-decoration:none;font-weight:700;font-family:"Baloo 2",system-ui,sans-serif;padding:.72rem 1.5rem;border-radius:999px;font-size:1.02rem;box-shadow:0 10px 22px -8px rgba(249,115,22,.7);transition:transform .15s ease,box-shadow .15s ease}
192
- .btn:hover{transform:translateY(-1px);box-shadow:0 14px 28px -8px rgba(249,115,22,.8)}
193
- .muted{color:var(--inkSoft);opacity:.9;font-size:.85rem;margin:1.1rem auto 0;max-width:24rem;line-height:1.45}
194
- .hidden{display:none!important}
195
- `;
196
-
197
- // Client script for the PROGRESS variant only (task 2096): poll the public status
198
- // endpoint, advance the stepper, and auto-open the instance the moment its credentials
199
- // land. Deliberately contains NO backticks and NO ${...} so it survives the outer
200
- // template literal verbatim; the only server data it reads is the JSON config block
201
- // written just above it. Fails soft — a poll hiccup just retries; a long wait reveals a
202
- // manual "open" button rather than ever falsely erroring. The auto-open countdown runs
203
- // ONLY when this page is the wizard's pop-up; a same-tab return (pop-up blocked) keeps
204
- // the founder here with the door and a way back to the checklist (task 1003236).
205
- const CALLBACK_SCRIPT = `
206
- (function(){
207
- var cfg=window.__CB_SETUP__||{};
208
- var state=cfg.state||'',domain=cfg.domain||'';
209
- if(!state||!domain)return;
210
- var target='https://'+domain;
211
- var pollUrl=location.pathname.replace('/callback','/status')+'?state='+encodeURIComponent(state);
212
- var steps=document.getElementById('steps'),statusEl=document.getElementById('status');
213
- var cta=document.getElementById('cta'),go=document.getElementById('go'),foot=document.getElementById('foot'),back=document.getElementById('back');
214
- /* Opened from the wizard as a pop-up (window.opener alive, or the tab still carries the name the wizard gave it):
215
- the checklist is in the other tab, so this one may auto-open the instance. Same-tab fallback (pop-up blocked,
216
- form target=_self): this IS the founder's checklist tab — never navigate it away; offer both doors (task 1003236). */
217
- var popup=!!window.opener||window.name==='ghAppSetup';
218
- var finished=false,tries=0,manual=false;
219
- function dot(n){return steps.querySelector('[data-step="'+n+'"]');}
220
- function setActive(n){for(var i=1;i<=3;i++){var el=dot(i);if(!el)continue;el.classList.remove('active','done');if(i<n){el.classList.add('done');el.querySelector('.dot').textContent='\\u2713';}else if(i===n){el.classList.add('active');}}}
221
- function allDone(){for(var i=1;i<=3;i++){var el=dot(i);if(!el)continue;el.classList.remove('active');el.classList.add('done');el.querySelector('.dot').textContent='\\u2713';}}
222
- function ready(){if(finished)return;finished=true;allDone();statusEl.textContent='Your instance is ready \\u2014 sign in with GitHub to claim it as owner.';cta.classList.remove('hidden');
223
- if(!popup){go.textContent='Open '+domain+' \\u2192';if(back)back.classList.remove('hidden');if(foot)foot.textContent='Your setup checklist is waiting where you left it \\u2014 finish it first, or open your instance now.';return;}
224
- var n=4;(function tick(){if(n<=0){location.href=target;return;}go.textContent='Opening '+domain+' in '+n+'\\u2026';n--;setTimeout(tick,1000);})();}
225
- function failed(){if(finished)return;finished=true;statusEl.textContent='Setup didn\\u2019t finish. Open your instance page and start the GitHub sign-in setup again.';cta.classList.remove('hidden');go.textContent='Open '+domain+' \\u2192';}
226
- function revealManual(){if(manual||finished)return;manual=true;cta.classList.remove('hidden');go.textContent='Open '+domain+' \\u2192';if(foot)foot.textContent='This is taking a little longer than usual \\u2014 you can open your instance now, or keep waiting.';}
227
- function schedule(){if(!finished)setTimeout(poll,3000);}
228
- function poll(){tries++;fetch(pollUrl,{headers:{accept:'application/json'},cache:'no-store'}).then(function(r){return r.ok?r.json():null;}).then(function(j){if(!j){schedule();return;}if(j.ready||j.status==='done'){ready();return;}if(j.status==='error'||j.status==='expired'){failed();return;}setActive(2);if(tries===40)revealManual();schedule();}).catch(function(){schedule();});}
229
- setActive(2);poll();
230
- })();
231
- `;
232
-
233
- // The standalone landing page for the public GitHub redirect (the mothership serves it;
234
- // it is NOT the hall shell). Two variants share the Cloud Bongos brand shell (task 2096):
235
- // • progress — ok + state + domain: an animated stepper that polls the status endpoint
236
- // and auto-opens the instance when its credentials are placed.
237
- // • message — everything else (errors + the no-domain success fallback): a calm badge
238
- // + `body` (server-authored, trusted HTML).
239
- function callbackPage({ ok, title, body, state, domain }) {
240
- const progress = !!(ok && state && domain);
241
- const cfgJson = JSON.stringify({ state: state || '', domain: domain || '' }).replace(/</g, '\\u003c');
242
- const head = `<!doctype html><html lang="en"><head><meta charset="utf-8">
243
- <meta name="viewport" content="width=device-width, initial-scale=1">
244
- <meta name="color-scheme" content="light dark">
245
- <title>${esc(title)}</title>
246
- <link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
247
- <link href="https://fonts.googleapis.com/css2?family=Baloo+2:wght@600;700;800&family=Quicksand:wght@400;500;600;700&family=Fira+Code&display=swap" rel="stylesheet">
248
- <style>${PAGE_STYLE}</style></head><body>`;
249
- if (progress) {
250
- return `${head}
251
- <main class="card">
252
- <h1>${esc(title)}</h1>
253
- <p class="sub">Finishing sign-in setup for <code>${esc(domain)}</code>.</p>
254
- <div class="steps" id="steps" aria-label="Setup progress">
255
- <div class="step done" data-step="1"><div class="dot">✓</div><div class="lbl">GitHub app created</div></div>
256
- <div class="step active" data-step="2"><div class="dot">2</div><div class="lbl">Placing credentials</div></div>
257
- <div class="step" data-step="3"><div class="dot">3</div><div class="lbl">Ready to sign in</div></div>
258
- </div>
259
- <p class="status-line" id="status">Hang tight — keep this tab open while we finish.</p>
260
- <div class="cta hidden" id="cta"><a class="btn" id="go" href="https://${esc(domain)}">Open ${esc(domain)} →</a></div>
261
- <p class="muted hidden" id="back"><a href="/projects?view=new">Finish your remaining setup steps →</a></p>
262
- <p class="muted" id="foot">This page opens <code>${esc(domain)}</code> for you the moment it’s ready.</p>
263
- <noscript><p class="muted">When setup finishes, open <a href="https://${esc(domain)}">${esc(domain)}</a> and sign in with GitHub — that first sign-in makes you the owner.</p></noscript>
264
- </main>
265
- <script>window.__CB_SETUP__=${cfgJson};</script>
266
- <script>${CALLBACK_SCRIPT}</script>
267
- </body></html>`;
268
- }
269
- return `${head}
270
- <main class="card">
271
- <div class="badge-warn">${ok ? '✓' : '⚠️'}</div>
272
- <h1>${esc(title)}</h1>
273
- <div class="sub">${body || ''}</div>
274
- </main>
275
- </body></html>`;
276
- }
277
-
278
146
  // Register this module's kernel seam (ADR 0083 / the registerBoxSeams precedent).
279
147
  // Idempotent — the loader calls this factory once at boot, but tests may
280
148
  // instantiate the router repeatedly; gating on hasProvider keeps the port
@@ -3,7 +3,7 @@
3
3
  <head>
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
- <title>Explore — featured projects</title>
6
+ <title>Explore — projects on the map</title>
7
7
  <style>
8
8
  /* Self-contained so the showcase renders correctly whether or not the host
9
9
  injects theme tokens. Warm Cloud Bongos defaults; overridable by the pack. */
@@ -15,6 +15,10 @@
15
15
  salmon --accent is a fill and measures 2.1:1 as type on the card. */
16
16
  --bg: #edecee; --card: #fbfafc; --ink: #17181a; --ink-soft: #55565a;
17
17
  --rule: #c6c6c8; --accent: #fb9569; --accent-deep: #a3481a;
18
+ /* The black hole's own colour, and deliberately NOT a pack token: --ink
19
+ inverts between the schemes and would paint the void white in dark mode.
20
+ A hole is the one thing on this page that is the same colour in both. */
21
+ --orb-void: #0b0b10;
18
22
  }
19
23
  * { box-sizing: border-box; }
20
24
  body {
@@ -39,6 +43,55 @@
39
43
  .card .origin { color: var(--accent-deep); font-size: .82rem; word-break: break-all; margin-top: auto; }
40
44
  .card .art { width: 100%; height: 120px; object-fit: cover; border-radius: 10px; margin-bottom: .3rem; }
41
45
  .empty, .err { text-align: center; color: var(--ink-soft); padding: 3rem 1rem; }
46
+
47
+ /* THE THREE ORBS (BV1.R07, task 1002326; ADR 0182 D1) — how a project stands on
48
+ the map, DRAWN. The label beside each one is the plain word (Public / Private /
49
+ Stealth): the space words belong to the shape, not to the copy, which is the
50
+ goal's own interview decision. The orb is aria-hidden, so that plain word is
51
+ its accessible name. */
52
+ .state { display: flex; align-items: center; gap: .5rem; }
53
+ .state-label {
54
+ font-size: .74rem; letter-spacing: .09em; text-transform: uppercase;
55
+ color: var(--ink-soft);
56
+ }
57
+ .orb { width: 22px; height: 22px; border-radius: 50%; flex: 0 0 auto; position: relative; }
58
+ /* planet — identified and open: a lit sphere, ringed so you can pick it out */
59
+ .orb-planet {
60
+ background: radial-gradient(circle at 33% 30%, #ffffff 0 9%, var(--accent) 42%, var(--accent-deep) 100%);
61
+ }
62
+ .orb-planet::after {
63
+ content: ''; position: absolute; left: -26%; right: -26%; top: 40%; height: 24%;
64
+ border: 1.5px solid var(--accent-deep); border-radius: 50%;
65
+ transform: rotate(-20deg); opacity: .8;
66
+ }
67
+ /* star — identified but closed: visible from anywhere, with no surface to land on */
68
+ .orb-star {
69
+ background: radial-gradient(circle, #ffffff 0 15%, var(--accent) 38%, transparent 70%);
70
+ }
71
+ /* the rays taper rather than end, so the orb reads as a point of light and not
72
+ as a plus sign */
73
+ .orb-star::before, .orb-star::after {
74
+ content: ''; position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
75
+ }
76
+ .orb-star::before {
77
+ width: 2px; height: 155%;
78
+ background: linear-gradient(to bottom, transparent, var(--accent) 50%, transparent);
79
+ }
80
+ .orb-star::after {
81
+ width: 155%; height: 2px;
82
+ background: linear-gradient(to right, transparent, var(--accent) 50%, transparent);
83
+ }
84
+ /* black hole — existence without identity: you are shown the ring, never the object */
85
+ .orb-black-hole {
86
+ background: var(--orb-void);
87
+ box-shadow: 0 0 0 1.5px var(--accent), 0 0 10px 1px var(--accent);
88
+ }
89
+ /* an anonymous card has nothing to click and nothing to name, so it does not
90
+ pretend otherwise — no lift on hover, and its heading reads as the placeholder
91
+ it is rather than as a project's name */
92
+ .card.anon:hover { transform: none; box-shadow: none; }
93
+ .card.anon h2 { color: var(--ink-soft); font-weight: 600; }
94
+
42
95
  @media (prefers-color-scheme: dark) {
43
96
  :root { --bg: #000000; --card: #101012; --ink: #ffffff; --ink-soft: #c0c0c0; --rule: rgba(255,255,255,.16);
44
97
  --accent-deep: color-mix(in srgb, var(--accent) 45%, #ffffff); }
@@ -49,10 +102,10 @@
49
102
  <div class="wrap">
50
103
  <header>
51
104
  <h1>Explore</h1>
52
- <p class="sub">Featured projects built on the platform.</p>
105
+ <p class="sub">Projects on the map, and how each one stands on it.</p>
53
106
  </header>
54
107
  <div id="grid" class="grid" hidden></div>
55
- <div id="empty" class="empty" hidden>No featured projects yet — check back soon.</div>
108
+ <div id="empty" class="empty" hidden>No projects on the map yet — check back soon.</div>
56
109
  <div id="err" class="err" hidden>Couldn't load projects right now.</div>
57
110
  </div>
58
111
  <script src="/explore.js"></script>
@@ -1,15 +1,39 @@
1
- // explore.js — the public Explore showcase (ADR 0141 §5, task #1002110). Fetches the
2
- // hub's featured projects and renders cards. On a NON-hub instance (platform-identity
3
- // off) the endpoint 404s / returns nothing, so the page shows a graceful empty state.
1
+ // explore.js — the public Explore showcase (ADR 0141 §5, task #1002110), and since
2
+ // BV1.R07 (task 1002326) the in-repo surface that DOGFOODS the visible-projects feed:
3
+ // every project on the map, drawn as the state it stands in.
4
+ //
5
+ // Fetches the hub's public projects feed and renders one card each. On a NON-hub
6
+ // instance (platform-identity off) the endpoint 404s / returns nothing, so the page
7
+ // shows a graceful empty state.
8
+ //
9
+ // WHAT A CARD SAYS ABOUT VISIBILITY (ADR 0182 D1/D2a). Each feed row carries the
10
+ // render type the server derived from the project's join door — planet, star or
11
+ // black hole. This page draws that shape and labels it with the plain word
12
+ // (Public / Private / Stealth), because the space words are the drawn orb's and
13
+ // not the copy's. A black hole is ANONYMOUS: the feed already nulled everything
14
+ // identifying server-side, and the branch that renders it here reads no
15
+ // identifying field at all, so a server that regressed still could not put a name
16
+ // on this page. Dark matter — a project that has not said what it is — is excluded
17
+ // in SQL and never arrives; a row this page cannot classify is dropped rather than
18
+ // guessed at, which is the same fail-closed rule one layer down.
4
19
  //
5
20
  // Project fields come from the network, so every value is set via textContent /
6
21
  // attribute assignment — never innerHTML — so a project name/tagline can't inject markup.
22
+ //
23
+ // Proof: ../../../tests/explore_visible_states.mjs
7
24
  (function () {
8
25
  'use strict';
9
26
  var grid = document.getElementById('grid');
10
27
  var empty = document.getElementById('empty');
11
28
  var err = document.getElementById('err');
12
29
 
30
+ // The map's render types → the plain word shown beside each one. Keyed on the
31
+ // feed's own `orb` field rather than re-derived from `state`: the state→orb
32
+ // mapping has exactly one home (modules/platform-identity/projects-feed.js) and a
33
+ // second copy here is a copy that drifts. An orb missing from this table is a row
34
+ // this page cannot honestly draw, so it draws nothing.
35
+ var ORB_LABEL = { planet: 'Public', star: 'Private', 'black-hole': 'Stealth' };
36
+
13
37
  function show(el) { if (el) el.hidden = false; }
14
38
 
15
39
  // Only http(s) origins are linkified — never javascript:/data: etc.
@@ -20,12 +44,50 @@
20
44
  } catch (_) { return null; }
21
45
  }
22
46
 
47
+ // The orb + its plain word. The shape is decorative (aria-hidden) — the label is
48
+ // what a screen reader gets, because a shape it cannot see is not a name.
49
+ function stateRow(orb) {
50
+ var row = document.createElement('div');
51
+ row.className = 'state';
52
+ var dot = document.createElement('span');
53
+ dot.className = 'orb orb-' + orb;
54
+ dot.setAttribute('aria-hidden', 'true');
55
+ row.appendChild(dot);
56
+ var label = document.createElement('span');
57
+ label.className = 'state-label';
58
+ label.textContent = ORB_LABEL[orb];
59
+ row.appendChild(label);
60
+ return row;
61
+ }
62
+
63
+ // A black hole publishes EXISTENCE and nothing else (ADR 0182 D2a). Deliberately
64
+ // takes no project argument: there is no identifying value in scope to leak by
65
+ // accident, now or when the feed grows a column.
66
+ function anonymousCard() {
67
+ var el = document.createElement('div');
68
+ el.className = 'card anon';
69
+ el.appendChild(stateRow('black-hole'));
70
+ var h = document.createElement('h2');
71
+ h.textContent = 'A project';
72
+ el.appendChild(h);
73
+ var p = document.createElement('p');
74
+ p.textContent = 'Something is here. Its name, and everything else about it, stay with its owner.';
75
+ el.appendChild(p);
76
+ return el;
77
+ }
78
+
79
+ // One feed row → one card, or null when the row is not drawable.
23
80
  function card(p) {
81
+ var orb = String((p && p.orb) || '');
82
+ if (!Object.prototype.hasOwnProperty.call(ORB_LABEL, orb)) return null;
83
+ if (orb === 'black-hole') return anonymousCard();
84
+
24
85
  var href = safeHref(p.origin);
25
86
  var el = document.createElement(href ? 'a' : 'div');
26
87
  el.className = 'card';
27
88
  if (href) { el.href = href; el.target = '_blank'; el.rel = 'noopener noreferrer'; }
28
89
 
90
+ el.appendChild(stateRow(orb));
29
91
  if (p.art_url && safeHref(p.art_url)) {
30
92
  var img = document.createElement('img');
31
93
  img.className = 'art';
@@ -55,8 +117,12 @@
55
117
  .then(function (r) { if (!r.ok) throw new Error('http ' + r.status); return r.json(); })
56
118
  .then(function (data) {
57
119
  var projects = (data && Array.isArray(data.projects)) ? data.projects : [];
58
- if (projects.length === 0) { show(empty); return; }
59
- projects.forEach(function (p) { grid.appendChild(card(p)); });
120
+ // Filtered BEFORE the count is read: a feed of rows none of which can be drawn
121
+ // is an empty map, and a visitor is owed the empty state rather than a grid
122
+ // with nothing in it.
123
+ var cards = projects.map(card).filter(Boolean);
124
+ if (cards.length === 0) { show(empty); return; }
125
+ cards.forEach(function (el) { grid.appendChild(el); });
60
126
  show(grid);
61
127
  })
62
128
  .catch(function () {
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.529",
3
+ "version": "1.19.531",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.529",
9
+ "version": "1.19.531",
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.529",
3
+ "version": "1.19.531",
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
@@ -45,7 +45,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
45
45
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
46
46
  // the entry to that file. Look for a version's history there, not here.
47
47
  // ---------------------------------------------------------------------------
48
- const CORE_VERSION = '1.19.529'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
48
+ const CORE_VERSION = '1.19.531'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
49
49
 
50
50
  // A namespaced logger so a module's log lines are attributable + consistent.
51
51
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -132,7 +132,7 @@ test('the explore page mirrors the pack, var for var', () => {
132
132
 
133
133
  test('the provisioning onboarding pages resolve their accent pair from branding', () => {
134
134
  const source = fs.readFileSync(
135
- path.join(REPO_ROOT, 'modules/provisioning/routes/provisioning.js'), 'utf8');
135
+ path.join(REPO_ROOT, 'modules/provisioning/routes/callback-page.js'), 'utf8');
136
136
  assert.match(source, /--sun:\$\{/, '--sun interpolates a resolved value, never a literal');
137
137
  assert.match(source, /--sun600:\$\{/, '--sun600 interpolates a resolved value, never a literal');
138
138
  const block = source.match(/const ACCENT =[\s\S]*?\}\)\(\);/);