@bongos/core 1.19.402 → 1.19.404

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.
@@ -56,6 +56,8 @@ The world round's light landing rehoused the chrome in a black specimen panel an
56
56
 
57
57
  What the pick settles: the settled constraint (dark-first with a real, tested light mode) stands for the landing; the light cutouts (`r2-chrome-blobs-set-cut.png`, `r2-chrome-blob-one-cut.png`, `r2-bongos-chrome-a-cut.png`) are production material for the light mode, so the build's asset set is the three black plates plus the three cutouts. v1 and v2 stay on disk as built and verified; what they leave behind for the build: v1's pointer parallax as an optional second layer of the drift, v2's beat rail down the margin of the live ledger (the reviewer's strongest below-the-fold block of the round) as the ledger proof's alternative form, and v2's mode-invariant black stage as the pattern for any surface that must keep black in light.
58
58
 
59
+ **2026-08-28: the owner's review of the shipped hero (task 1003333).** Three corrections to the pick, each overriding this record and `DESIGN.md` where they conflicted: the doors sit in the blob's lower half (stage 73% dark / 75% light, from 53 / 60), not at its centre; the three leader-line figures leave the landing (they read as "random lines" there; the poster card on `/projects` keeps the grammar, task 1003301); and the small bead's orbit is replaced by the big plates themselves drifting like a lava lamp (v1's "drift" of §3, built: `drift-form` 74s, `drift-orb` 58s, transform only, dead under reduced motion; the pointer parallax stays out, no motion in JavaScript). The bead left the hero and survives as the third project orb below the fold. The two feeds that fed only the figures (`/public/cost-summary`, `/public/leaderboard`) leave the page with them.
60
+
59
61
  ## 7. Shot list: real imagery still owed
60
62
 
61
63
  No version left a placeholder slot: every image on the three pages is a real plate (the round-2 chrome set, the single blob, the two bongo pairs, the two new blob cutouts) or the real Verro Labs medallion, and the proof is the ledger rendered as page content, so no hall or hub capture is used. What the build will still owe:
@@ -144,3 +144,19 @@ Three mastheads, rendered dark and light at 1440 / 390 / 320 (ALL CLEAN) and mea
144
144
  - Task 1003301 (the poster card): the ledger grammar above is the card's facts strip, too.
145
145
  - Task 1003319 (the planets): the masthead object rides the same crop maths the orbs will take.
146
146
  - The mine view and the wizard are the next views to take a masthead in the picked treatment.
147
+
148
+ ## 10. The poster card is built (task 1003301)
149
+
150
+ **Date:** 2026-08-28 · the v2 card (§7) built into `public-landing/projects.html` + `cosmos.css`, rendered through the kit before and after (`node modules/ui-design/kit/render.js --page /projects`, both modes, 1440 / 390 / 320) — ALL CLEAN; the probes green including seven new card probes.
151
+
152
+ **What was built, region by region (every one a direct child of the card, the C6 slice rule):** the 5:2 banner as a bare `<img>` (`object-fit: cover`, top corners on the card's radius; a broken image demotes to the no-art layout through one error listener); the two figures as leader-line callouts ON the art from 561px (one feathered patch of the card ground per callout — a mask, not a shadow — set 44px in from the card's edges so the feather is never clipped flat on the paper card) and as the two-up band under 561 or with no art; the accent disc surviving, half over the banner's lower edge, ringed 4px in the card ground; the name at 32px/300, the tagline, the relationship as a mono `--label-face` label (no accent: a fact, not a control); up to five 28px faces at −8px overlap ringed in the card ground, `+N` counted against `builders`; the facts as the 1003332 ledger (mono label left, tabular value right, one `rule-soft` hairline per row); one accent door (`open site →`, 48px, lowercase) and the hall entry beside it as an underlined `accent-deep` text link with the hall glyph. `.projCard` is `min(560px, 100%)`, one `rule` hairline, no shadow, `container-type: inline-size` so the disc and the callouts measure the card.
153
+
154
+ **The degradation ladder, as shipped:** no art → no banner, the disc where it always was; no faces → the row absent; one figure null → the other stands alone; both null → the band hidden; a fact with no answer hides label and value together; everything null → disc, name, tagline, **the address**, listed, one door. The address is the one difference from the ladder as written in task 1003258: `Runs` on the poster is the project's address (§7), which the catalog always knows, so the floor card carries a Runs row the tags-based card did not. The table keeps the module list in its Runs column.
155
+
156
+ **The feed (§6 closed):** `modules/platform-identity/project-stats.js` reads four feeds per origin inside the one `Promise.allSettled` — the leaderboard now also yields `faces`; `/public/recent-shipped?days=30` yields `shipped_30d` + `shipped_30d_capped` (200 rows with `?days` honoured, 30 on an older core: the "N+" rule); and the project's API discovery index (`GET /api/bongos`, ADR 0114) yields `hall_origin`. **A decision made on the way:** task 1003301 said the hall address would be "resolved from the project's provisioning record" — no such column exists (provisioning writes `BUILDERS_ORIGIN = PUBLIC_ORIGIN` into the instance's own `web.env` and never round-trips it), and the record could only ever say "same host". The discovery index is the project answering for itself, on the same basis as builders and tags, and it is null where the hall is the site's own host (the address gate) or where the project does not say. One frozen `NULL_STATS` carries every key. The platform's own card answers by the same rule client-side (its leaderboard for faces, its own `recent-shipped?days=30` for the figure), and its hall comes from the DOM: the server-stamped `#selfHall` anchor in its table row, dropped whole by the stamp when a pack names no hall.
157
+
158
+ **The hall entry, three surfaces → two:** the card's door and the table row's glyph (riding the existing door cell, so the six-cell parity holds), both **member-gated AND address-gated** through one derivation (`hallUrlOf`, through `safeHttpUrl`). The mine-view card is untouched (task 1003301's scope; goal 1000059 R7 adds its manage door). The platform's own row carries the glyph only for a signed-in member.
159
+
160
+ **Not done, on purpose:** the platform card has no banner — the mock used the landing's social card as "the platform's art in the only sense it has one", but that image carries the landing's own headline and reads as a screenshot inside the card; the kit fixture uses a text-free plate instead. Whether the platform should carry a real `art_url` is the owner's call.
161
+
162
+ **Owner notes received during the build (filed as task 1003333, the landing hero):** the two doors sit too high and central; the three hero figures with their leader lines leave the landing (they fit the projects page — this card keeps the grammar); the large chrome plates should drift slowly like a lava lamp in place of the small bubble.
@@ -1,5 +1,6 @@
1
- // modules/platform-identity/project-stats.js — real Builders + Tags for the hub
2
- // catalog, read from each project's OWN public API (task 1002771).
1
+ // modules/platform-identity/project-stats.js — real Builders + Tags (task 1002771)
2
+ // and, since task 1003301, the poster card's faces, shipped-in-30-days figure and
3
+ // hall address for the hub catalog, all read from each project's OWN public API.
3
4
  //
4
5
  // WHY THE DATA IS NOT HERE. The apex catalog stores four fields (origin, name,
5
6
  // tagline, art_url) and nothing else; the membership and activity tables that
@@ -13,6 +14,10 @@
13
14
  // /api/bongos/public/* surface, and the hub's server can read it instance-to-
14
15
  // instance with no CORS involved. The project itself is the authority on its own
15
16
  // builder count, which is a better source than anything the apex could infer.
17
+ // The same holds for its hall address: no hub table stores one (provisioning
18
+ // collapses every origin to the domain), but the instance's API discovery index
19
+ // (GET /api/bongos, ADR 0114) advertises `hall` — the project answering for
20
+ // itself, on the same basis as builders and tags.
16
21
  //
17
22
  // SCALE, recorded so it is not rediscovered: this PULL model is right for a
18
23
  // handful and fine for a few dozen behind the cache. It does not scale to
@@ -30,6 +35,23 @@ const TTL_MS = 10 * 60 * 1000; // owner's choice, 2026-08-13
30
35
  const FETCH_TIMEOUT_MS = 2500; // per request
31
36
  const COLD_DEADLINE_MS = 3000; // total wait a COLD cache may cost a page load
32
37
 
38
+ // The card's figure window and its faces row (task 1003301). The remote
39
+ // recent-shipped feed is row-capped: 200 when it honours `?days=`, 30 when an
40
+ // older core ignores the parameter — the cap is where a count becomes "N+".
41
+ const SHIPPED_WINDOW_DAYS = 30;
42
+ const SHIPPED_CAP_WINDOWED = 200;
43
+ const SHIPPED_CAP_LEGACY = 30;
44
+ const FACES_MAX = 5;
45
+ const URL_MAX = 300;
46
+
47
+ // ONE null shape for every way a project can fail to answer (the refresh catch
48
+ // AND the read fallback). A key missing here ships as undefined, which
49
+ // JSON.stringify drops, which silently changes the wire shape (task 1003258).
50
+ const NULL_STATS = Object.freeze({
51
+ builders: null, tags: null, owner: null, owner_avatar: null,
52
+ faces: null, shipped_30d: null, shipped_30d_capped: null, hall_origin: null,
53
+ });
54
+
33
55
  const _cache = new Map(); // origin → { at, stats }
34
56
  const _inflight = new Map(); // origin → Promise (collapses concurrent refreshes)
35
57
 
@@ -58,6 +80,14 @@ function isSystemAccount(login) {
58
80
  return /-system$/.test(String(login || ''));
59
81
  }
60
82
 
83
+ // httpUrl — a third-party string becomes a URL only when it is http(s), and only
84
+ // up to a length cap; anything else is null. Every avatar, face, art and hall
85
+ // address the hub relays passes here (the client scheme-checks again before a
86
+ // value becomes a src or an href).
87
+ function httpUrl(raw, max = URL_MAX) {
88
+ return typeof raw === 'string' && /^https?:\/\//.test(raw) ? raw.slice(0, max) : null;
89
+ }
90
+
61
91
  // ── SSRF GUARD ──
62
92
  // This is the one place the server makes an outbound request to an address that
63
93
  // came out of the database, so it is the one place that has to assume the address
@@ -109,14 +139,23 @@ async function assertPublicHost(hostname) {
109
139
  }
110
140
  }
111
141
 
112
- function publicUrl(origin, route) {
142
+ // siteUrl — the origin as a URL, http(s) only. Throws on anything else, which is
143
+ // the one path that reaches the cached-failure literal synchronously.
144
+ function siteUrl(origin) {
113
145
  const u = new URL(origin);
114
146
  if (u.protocol !== 'https:' && u.protocol !== 'http:') throw new Error('bad scheme');
115
- return new URL(`/api/bongos/public/${route}`, u).href;
147
+ return u;
148
+ }
149
+ function apiUrl(origin, route) {
150
+ return new URL(route, siteUrl(origin)).href;
151
+ }
152
+ function publicUrl(origin, route) {
153
+ return apiUrl(origin, `/api/bongos/public/${route}`);
116
154
  }
117
155
 
156
+ // The guard has already run for this sweep's host (fetchStats resolves each
157
+ // origin ONCE, not once per feed); this only fetches.
118
158
  async function getJson(url) {
119
- await assertPublicHost(new URL(url).hostname);
120
159
  const res = await fetch(url, {
121
160
  signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
122
161
  headers: { accept: 'application/json' },
@@ -126,48 +165,92 @@ async function getJson(url) {
126
165
  return res.json();
127
166
  }
128
167
 
129
- // One project's stats. Either half may fail on its own — a project whose
168
+ // leaderboardStats — builders, owner and faces from the project's OWN leaderboard.
169
+ // The archon row IS the instance's owner — the project's own authority answering
170
+ // for itself, from a payload already in hand (ADR 0170). The catalog's
171
+ // owner_github_id ("advisory attribution (not an authority)") stays unread.
172
+ // Caveat: the leaderboard is credits-ordered and page-capped, so a zero-credit
173
+ // archon on a very large instance can fall off it — that renders as no owner
174
+ // shown, never as a wrong one. The payload is third-party: accept only a
175
+ // plausible login string (GitHub caps logins at 39 chars; 64 leaves headroom
176
+ // without hosting a novel) and only http(s) avatar URLs.
177
+ function leaderboardStats(rows) {
178
+ const people = rows.filter((b) => !isSystemAccount(b?.github_login));
179
+ const arch = people.find((b) => b?.rank === 'archon');
180
+ const login = arch?.github_login;
181
+ const owner = typeof login === 'string' && login ? login.slice(0, 64) : null;
182
+ return {
183
+ builders: people.length,
184
+ owner,
185
+ owner_avatar: owner ? httpUrl(arch?.avatar_url) : null,
186
+ // the faces row: the first five people with a usable avatar, in the feed's
187
+ // credits order; the card counts the rest against `builders` as +N
188
+ faces: people.map((b) => httpUrl(b?.avatar_url)).filter(Boolean).slice(0, FACES_MAX),
189
+ };
190
+ }
191
+
192
+ // shippedStats — how many tasks the project shipped in the last 30 days, and
193
+ // whether that count sat at the feed's row cap (the card renders "N+" then). The
194
+ // window is applied here from shipped_at even when the remote honoured `?days=`,
195
+ // so an older core that ignores the parameter still yields a true number.
196
+ function shippedStats(body) {
197
+ const rows = Array.isArray(body?.tasks) ? body.tasks : [];
198
+ const since = Date.now() - SHIPPED_WINDOW_DAYS * 24 * 60 * 60 * 1000;
199
+ const inWindow = rows.filter((t) => {
200
+ const ts = Date.parse(t?.shipped_at);
201
+ return Number.isFinite(ts) && ts >= since;
202
+ }).length;
203
+ const cap = body?.days === SHIPPED_WINDOW_DAYS ? SHIPPED_CAP_WINDOWED : SHIPPED_CAP_LEGACY;
204
+ return { shipped_30d: inWindow, shipped_30d_capped: rows.length >= cap && inWindow === rows.length };
205
+ }
206
+
207
+ // hallOrigin — the project's builders' hall address when it is genuinely a
208
+ // different host from the site; null on a single-host instance (the hall is at
209
+ // <site>/builders, where the site's own door already leads) and null when the
210
+ // project does not say. Task 1003257, owner decision (a): a door only where it
211
+ // opens somewhere else.
212
+ function hallOrigin(index, origin) {
213
+ const hall = httpUrl(index?.hall);
214
+ if (!hall) return null;
215
+ try {
216
+ return new URL(hall).host.toLowerCase() === siteUrl(origin).host.toLowerCase() ? null : hall;
217
+ } catch {
218
+ return null;
219
+ }
220
+ }
221
+
222
+ // One project's stats. Every quarter may fail on its own — a project whose
130
223
  // leaderboard is down can still report its modules — so they settle independently
131
- // and a missing half is null (an em dash) rather than a discarded pair.
224
+ // inside ONE allSettled (sequential awaits would turn the per-request ceiling into
225
+ // a serial cost that blows the cold-load deadline), and a missing quarter is null
226
+ // rather than a discarded whole.
132
227
  async function fetchStats(origin) {
133
- const [lb, mods] = await Promise.allSettled([
228
+ await assertPublicHost(siteUrl(origin).hostname); // once per sweep, not once per feed
229
+ const [lb, mods, ships, index] = await Promise.allSettled([
134
230
  getJson(publicUrl(origin, 'leaderboard')),
135
231
  getJson(publicUrl(origin, 'modules')),
232
+ getJson(publicUrl(origin, `recent-shipped?days=${SHIPPED_WINDOW_DAYS}`)),
233
+ getJson(apiUrl(origin, '/api/bongos')),
136
234
  ]);
137
235
 
138
- let builders = null;
139
- let owner = null;
140
- let ownerAvatar = null;
236
+ const stats = { ...NULL_STATS };
141
237
  if (lb.status === 'fulfilled' && Array.isArray(lb.value?.leaderboard)) {
142
- const people = lb.value.leaderboard.filter((b) => !isSystemAccount(b?.github_login));
143
- builders = people.length;
144
- // The archon row IS the instance's owner — the project's own authority
145
- // answering for itself, from a payload already in hand (ADR 0170). The
146
- // catalog's owner_github_id ("advisory attribution (not an authority)")
147
- // stays unread. Caveat: the leaderboard is credits-ordered and page-capped,
148
- // so a zero-credit archon on a very large instance can fall off it — that
149
- // renders as no owner shown, never as a wrong one.
150
- // The payload is third-party: accept only a plausible login string (GitHub
151
- // caps logins at 39 chars; 64 leaves headroom without hosting a novel),
152
- // and only an http(s) avatar URL — the client scheme-checks again before
153
- // it becomes an <img>.
154
- const arch = people.find((b) => b?.rank === 'archon');
155
- const login = arch?.github_login;
156
- owner = typeof login === 'string' && login ? login.slice(0, 64) : null;
157
- const av = arch?.avatar_url;
158
- ownerAvatar = owner && typeof av === 'string' && /^https?:\/\//.test(av) ? av.slice(0, 300) : null;
238
+ Object.assign(stats, leaderboardStats(lb.value.leaderboard));
159
239
  }
160
-
161
- let tags = null;
162
240
  if (mods.status === 'fulfilled' && mods.value?.enabled && typeof mods.value.enabled === 'object') {
163
241
  const core = coreModules();
164
- tags = Object.entries(mods.value.enabled)
242
+ stats.tags = Object.entries(mods.value.enabled)
165
243
  .filter(([key, on]) => on === true && !core.has(key))
166
244
  .map(([key]) => key)
167
245
  .sort();
168
246
  }
169
-
170
- return { builders, tags, owner, owner_avatar: ownerAvatar };
247
+ if (ships.status === 'fulfilled' && Array.isArray(ships.value?.tasks)) {
248
+ Object.assign(stats, shippedStats(ships.value));
249
+ }
250
+ if (index.status === 'fulfilled') {
251
+ stats.hall_origin = hallOrigin(index.value, origin);
252
+ }
253
+ return stats;
171
254
  }
172
255
 
173
256
  function refresh(origin) {
@@ -181,9 +264,8 @@ function refresh(origin) {
181
264
  // Remember the FAILURE for a full TTL too. Without this a permanently
182
265
  // unreachable project is re-fetched on every request and pays the timeout
183
266
  // every time — the exact way one dead project slows the whole page.
184
- const stats = { builders: null, tags: null, owner: null, owner_avatar: null };
185
- _cache.set(origin, { at: Date.now(), stats });
186
- return stats;
267
+ _cache.set(origin, { at: Date.now(), stats: NULL_STATS });
268
+ return NULL_STATS;
187
269
  })
188
270
  .finally(() => _inflight.delete(origin));
189
271
  _inflight.set(origin, p);
@@ -215,11 +297,14 @@ async function getProjectStats(origins = []) {
215
297
  // null-prototype: an origin literally equal to '__proto__' would otherwise
216
298
  // retarget the object's prototype instead of adding a key
217
299
  const out = Object.create(null);
218
- for (const origin of origins) out[origin] = _cache.get(origin)?.stats || { builders: null, tags: null, owner: null, owner_avatar: null };
300
+ for (const origin of origins) out[origin] = _cache.get(origin)?.stats || NULL_STATS;
219
301
  return out;
220
302
  }
221
303
 
222
304
  // tests
223
305
  function _reset() { _cache.clear(); _inflight.clear(); _coreModules = null; }
224
306
 
225
- module.exports = { getProjectStats, isSystemAccount, coreModules, blockedReason, assertPublicHost, _reset, TTL_MS };
307
+ module.exports = {
308
+ getProjectStats, isSystemAccount, coreModules, blockedReason, assertPublicHost, httpUrl,
309
+ NULL_STATS, SHIPPED_WINDOW_DAYS, FACES_MAX, _reset, TTL_MS,
310
+ };
@@ -15,6 +15,7 @@
15
15
  const express = require('express');
16
16
  const api = require('../../../src/module-api');
17
17
  const pi = require('../platform-identity');
18
+ const projectStats = require('../project-stats');
18
19
 
19
20
  const { pool, requireBuilder, validateOrRespond, userAgent } = api;
20
21
 
@@ -87,6 +88,17 @@ module.exports = function myProjectsRoutes() {
87
88
  router.get('/my-projects', requireBuilder, async (req, res) => {
88
89
  try {
89
90
  const rows = await pi.listMembershipsForAccount(req.builder.github_id, { pool });
91
+ // hall_origin per membership (task 1003301): the project's own hall
92
+ // address where it differs from its site, null otherwise — read from the
93
+ // project's API discovery index through the same cached enrichment the
94
+ // catalog uses (no hub table stores it). Best-effort: a project that does
95
+ // not answer gets null, never a failed listing.
96
+ let stats = {};
97
+ try {
98
+ stats = await projectStats.getProjectStats(rows.map((m) => m.project_origin).filter(Boolean));
99
+ } catch (err) {
100
+ api.logger('platform-identity').error(`my-projects hall enrichment failed (non-fatal): ${err && err.message}`);
101
+ }
90
102
  const owned = [];
91
103
  const building = [];
92
104
  for (const m of rows) {
@@ -94,6 +106,7 @@ module.exports = function myProjectsRoutes() {
94
106
  client_id: m.client_id,
95
107
  name: m.project_name || m.project_origin || m.client_id,
96
108
  origin: m.project_origin || null,
109
+ hall_origin: (m.project_origin && stats[m.project_origin]?.hall_origin) || null,
97
110
  membership_kind: m.membership_kind,
98
111
  joined_at: m.joined_at,
99
112
  last_active: m.last_active,
@@ -122,13 +122,16 @@ module.exports = function projectsRoutes() {
122
122
  } catch (err) {
123
123
  console.error('[platform-identity] project stats enrichment failed (non-fatal):', err.message);
124
124
  }
125
+ // The enrichment's keys (builders, tags, owner, owner_avatar, faces,
126
+ // shipped_30d, shipped_30d_capped, hall_origin) never collide with the
127
+ // catalog's; a project that did not answer carries the ONE shared null
128
+ // shape, so the wire never drops a key. art_url is catalog text an Archon
129
+ // typed — scheme-checked here, and again on the client, before it is a src.
125
130
  res.json({
126
131
  projects: projects.map((p) => ({
127
132
  ...p,
128
- builders: stats[p.origin]?.builders ?? null,
129
- tags: stats[p.origin]?.tags ?? null,
130
- owner: stats[p.origin]?.owner ?? null,
131
- owner_avatar: stats[p.origin]?.owner_avatar ?? null,
133
+ art_url: projectStats.httpUrl(p.art_url, 2048),
134
+ ...(stats[p.origin] || projectStats.NULL_STATS),
132
135
  })),
133
136
  });
134
137
  } catch (err) {
@@ -1 +1 @@
1
- {"projects":[{"origin":"https://amazonprimea.com","name":"Off the Boats","tagline":"A singular shop, and a broader videogame in a persistent ancient Greek world that collides with modern day commerce & products.","art_url":null,"type":"game","created_at":"2026-07-15T22:12:57.732Z","builders":11,"tags":["art-pipeline","character-anim","demo","game"],"owner":"lars589","owner_avatar":"https://avatars.githubusercontent.com/u/283020391?v=4"},{"origin":"https://demo.cloudbongos.com","name":"Demo","tagline":"A live demo instance on Cloud Bongos.","art_url":null,"type":null,"created_at":"2026-07-14T19:16:38.160Z","builders":1,"tags":[],"owner":"lars589","owner_avatar":"https://avatars.githubusercontent.com/u/283020391?v=4"},{"origin":"https://emersonian-circles.cloudbongos.com","name":"Emersonian Circles","tagline":"An independent project built on Cloud Bongos.","art_url":null,"type":null,"created_at":"2026-07-25T02:21:55.641Z","builders":1,"tags":[],"owner":"lars589","owner_avatar":"https://avatars.githubusercontent.com/u/283020391?v=4"}]}
1
+ {"projects":[{"origin":"https://amazonprimea.com","name":"Off the Boats","tagline":"A singular shop, and a broader videogame in a persistent ancient Greek world that collides with modern day commerce & products.","art_url":"/assets/world/r2-chrome-blob-one.jpg","type":"game","created_at":"2026-07-15T22:12:57.732Z","builders":11,"tags":["art-pipeline","character-anim","demo","game"],"owner":"lars589","owner_avatar":"https://avatars.githubusercontent.com/u/283020391?v=4","faces":["https://avatars.githubusercontent.com/u/283020391?v=4","https://avatars.githubusercontent.com/u/64553469?v=4","https://avatars.githubusercontent.com/u/290184380?v=4","https://avatars.githubusercontent.com/u/295990394?v=4","https://avatars.githubusercontent.com/u/35337514?v=4"],"shipped_30d":0,"shipped_30d_capped":false,"hall_origin":"https://builders.amazonprimea.com"},{"origin":"https://demo.cloudbongos.com","name":"Demo","tagline":"A live demo instance on Cloud Bongos.","art_url":null,"type":null,"created_at":"2026-07-14T19:16:38.160Z","builders":null,"tags":null,"owner":null,"owner_avatar":null,"faces":null,"shipped_30d":null,"shipped_30d_capped":null,"hall_origin":null},{"origin":"https://emersonian-circles.cloudbongos.com","name":"Emersonian Circles","tagline":"An independent project built on Cloud Bongos.","art_url":null,"type":null,"created_at":"2026-07-25T02:21:55.641Z","builders":1,"tags":[],"owner":"lars589","owner_avatar":"https://avatars.githubusercontent.com/u/283020391?v=4","faces":[],"shipped_30d":30,"shipped_30d_capped":true,"hall_origin":null}]}
@@ -1 +1 @@
1
- {"owned":[],"building":[]}
1
+ {"owned":[],"building":[{"client_id":"otb","name":"Off the Boats","origin":"https://amazonprimea.com","hall_origin":"https://builders.amazonprimea.com","membership_kind":"member","joined_at":"2026-07-20T10:00:00.000Z","last_active":"2026-08-27T18:00:00.000Z"},{"client_id":"emersonian","name":"Emersonian Circles","origin":"https://emersonian-circles.cloudbongos.com","hall_origin":null,"membership_kind":"member","joined_at":"2026-07-26T10:00:00.000Z","last_active":"2026-08-20T18:00:00.000Z"}]}
@@ -99,12 +99,23 @@ const AUTHED = { '/my-projects': 'my-projects' };
99
99
  const UNAUTH = { error: { code: 'unauthenticated', message: 'unauthenticated' } };
100
100
 
101
101
  function json(res, status, body) { res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(body)); }
102
- function fixture(name, res) {
102
+ function fixture(name, res, origin) {
103
103
  if (FAIL.has(name)) return json(res, 500, { error: { code: 'stub_failure', message: `FAIL=${name}` } });
104
104
  const file = path.join(FIXTURES, name + '.json');
105
105
  if (!fs.existsSync(file)) return json(res, 404, { error: { code: 'no_fixture', message: name } });
106
106
  res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' });
107
- res.end(fs.readFileSync(file));
107
+ res.end(name === 'featured' ? JSON.stringify(featured(file, origin)) : fs.readFileSync(file));
108
+ }
109
+ // A project's art in the featured fixture may be root-relative (the surface's
110
+ // own /assets/…, served by this stub) — the page accepts only an http(s) art
111
+ // URL, so it is absolutised to this origin per request, as the builder's
112
+ // avatar is (see me()).
113
+ function featured(file, origin) {
114
+ const body = JSON.parse(fs.readFileSync(file, 'utf8'));
115
+ for (const p of body.projects || []) {
116
+ if (typeof p.art_url === 'string' && p.art_url.startsWith('/')) p.art_url = origin + p.art_url;
117
+ }
118
+ return body;
108
119
  }
109
120
  function proxy(req, res) {
110
121
  const origin = (liveOrigin === '1' ? '' : liveOrigin).replace(/\/$/, '');
@@ -142,7 +153,7 @@ const server = http.createServer((req, res) => {
142
153
  const apiMatch = p.match(/^\/api\/(gds|bongos)(\/.*)$/);
143
154
  if (apiMatch) {
144
155
  const rest = apiMatch[2];
145
- if (FEEDS[rest]) return LIVE ? proxy(req, res) : fixture(FEEDS[rest], res);
156
+ if (FEEDS[rest]) return LIVE ? proxy(req, res) : fixture(FEEDS[rest], res, origin);
146
157
  if (rest === '/me') return AUTH ? json(res, 200, me(origin)) : json(res, 401, UNAUTH);
147
158
  if (AUTHED[rest]) return AUTH ? fixture(AUTHED[rest], res) : json(res, 401, UNAUTH);
148
159
  if (rest === '/provisioning/instances') return AUTH ? json(res, 200, { instances: [] }) : json(res, 401, UNAUTH);
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.402",
3
+ "version": "1.19.404",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.402",
9
+ "version": "1.19.404",
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.402",
3
+ "version": "1.19.404",
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",
@@ -250,6 +250,7 @@
250
250
  --radius:16px; --radius-sm:10px; --radius-pill:999px;
251
251
  --top-h:64px;
252
252
  --gutter:clamp(16px,4vw,34px);
253
+ --leader-len:68px; /* the poster card's callout leader (task 1003301) */
253
254
  /* the world's object for the hub's view masthead (task 1003332): the apex
254
255
  shares the landing's plates because it is the same world (DESIGN.md
255
256
  "Materials"); the hub takes the alpha CUTOUT in both modes — the view
@@ -897,32 +898,130 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
897
898
  dark, which paints a white sheet over the page instead of dimming it. */
898
899
  .cardScrim{position:fixed;inset:0;z-index:60;display:flex;align-items:center;justify-content:center;
899
900
  padding:24px;background:rgba(0,0,0,.55);backdrop-filter:blur(3px);}
900
- .projCard{position:relative;width:min(420px,100%);background:var(--bg-card);border:1.5px solid var(--line);
901
- border-radius:var(--r-card,20px);box-shadow:var(--shadow-card);padding:30px 28px 26px;text-align:center;}
902
- .projCard .cardDot{display:block;width:54px;height:54px;border-radius:50%;margin:0 auto 16px;
903
- background:var(--acc,var(--accent));box-shadow:0 0 0 8px color-mix(in srgb,var(--acc,var(--accent)) 18%,transparent);}
904
- .projCard h3{font-family:var(--font-display);font-weight:700;font-size:21px;color:var(--ink);margin-bottom:6px;}
905
- .projCard .cardTag{font-size:14px;color:var(--ink-faint);font-weight:600;line-height:1.5;margin-bottom:14px;}
906
- .projCard .cardRel{display:inline-block;font-family:var(--font-mono);font-size:11px;padding:3px 10px;
907
- border-radius:999px;background:var(--accent-soft);color:var(--accent-deep);margin-bottom:18px;}
908
- /* the facts strip (task 1002778): a label/value grid, centered as a block but
909
- read left-aligned. A hidden pair leaves no gap — the grid row collapses. */
910
- .projCard .cardMeta{display:grid;grid-template-columns:auto auto;justify-content:center;
911
- gap:6px 14px;margin-bottom:18px;text-align:left;}
912
- .projCard .cardMeta dt{font-family:var(--font-mono);font-size:11px;letter-spacing:.04em;
913
- text-transform:uppercase;color:var(--ink-faint);align-self:center;}
914
- .projCard .cardMeta dd{font-size:13.5px;color:var(--ink-soft);font-weight:600;}
915
- .projCard .cardMeta .ownerAv{width:16px;height:16px;border-radius:50%;vertical-align:-3px;margin-right:6px;}
916
- .projCard .cardMeta .tags{display:flex;gap:6px;flex-wrap:wrap;}
917
- .projCard .cardMeta .tags span{font-family:var(--font-mono);font-size:10.5px;padding:2px 8px;
918
- border-radius:999px;border:1px solid var(--line);color:var(--ink-faint);white-space:nowrap;}
919
- .projCard .cardActions{display:flex;gap:10px;justify-content:center;flex-wrap:wrap;}
920
- .projCard .cardX{position:absolute;top:10px;right:12px;width:30px;height:30px;border-radius:50%;
921
- border:none;background:none;cursor:pointer;font-size:14px;color:var(--ink-faint);line-height:1;}
922
- .projCard .cardX:hover{background:var(--accent-soft);color:var(--accent-deep);}
901
+ /* THE POSTER (task 1003301; the v2 pick, direction §7): a specimen sheet in the
902
+ world, not a panel. min(560px,100%) on bg-card closed by one rule hairline,
903
+ no shadow (the world has no shadow vocabulary for UI). A flex column ordered
904
+ by `order`, so every region stays a flat child of the card whatever the
905
+ layout does with it (the C6 slice rule in tests/projects_hub.mjs).
906
+ container-type gives the disc and the callouts the card's own width (cqw),
907
+ so the banner's 5:2 edge is a number the disc can sit on. Six regions, top
908
+ to bottom: the banner, the two figures (callouts ON the art from 561px,
909
+ the two-up band otherwise and with no art), the accent disc, name / tagline
910
+ / relationship, the faces, the facts as a mono hairline ledger, the doors. */
911
+ .projCard{position:relative;container-type:inline-size;display:flex;flex-direction:column;
912
+ width:min(560px,100%);max-height:calc(100vh - 48px);overflow:auto;text-align:left;
913
+ background:var(--bg-card);border:1px solid var(--rule);border-radius:var(--radius);box-shadow:none;padding:0 0 26px;}
914
+ .projCard:not(.has-art){padding-top:26px;}
915
+ .projCard .cardArt{order:0;display:block;width:100%;aspect-ratio:5/2;object-fit:cover;
916
+ border-radius:var(--radius) var(--radius) 0 0;}
917
+ .projCard .cardX{position:absolute;top:12px;right:12px;z-index:4;display:inline-flex;align-items:center;
918
+ justify-content:center;width:30px;height:30px;border:0;border-radius:var(--radius-pill);
919
+ background:var(--glass-strong);color:var(--ink);cursor:pointer;font-size:14px;line-height:1;}
920
+ .projCard .cardX:hover{background:var(--glass-edge);}
921
+ /* on art the close control takes the void pill: the one legal literal is black with alpha */
922
+ .projCard.has-art .cardX{background:var(--void-pill);color:var(--void-ink);}
923
+ /* THE DISC SURVIVES: it is how a project is recognised in the sky (task 1003258).
924
+ With art it half-overlaps the banner's lower edge, ringed in the card ground
925
+ so it sits ON the card; without art it renders where it always did. */
926
+ .projCard .cardDot{order:1;align-self:flex-start;display:block;width:54px;height:54px;border-radius:50%;
927
+ margin:0 0 16px 26px;background:var(--acc,var(--accent));box-shadow:none;}
928
+ .projCard.has-art .cardDot{position:absolute;z-index:3;left:24px;top:calc(100cqw / 2.5 - 27px);margin:0;
929
+ border:4px solid var(--bg-card);}
930
+ .projCard h3{order:3;margin:0;padding:0 26px;font-family:var(--font-display);font-size:32px;font-weight:300;
931
+ line-height:1.08;letter-spacing:-.03em;color:var(--ink);}
932
+ .projCard.has-art h3{margin-top:44px;}
933
+ .projCard .cardTag{order:4;margin:9px 0 0;padding:0 26px;font-size:14.5px;font-weight:400;line-height:1.5;
934
+ color:var(--ink-soft);max-width:46ch;}
935
+ /* the relationship is a mono label, not a pill: a fact about you, not a control */
936
+ .projCard .cardRel{order:5;display:block;margin:14px 0 0;padding:0 26px;background:none;border-radius:0;
937
+ font-family:var(--label-face);font-size:var(--label-size);letter-spacing:var(--label-space);
938
+ text-transform:uppercase;color:var(--ink-faint);}
939
+ .projCard .cardFaces{order:6;display:flex;align-items:center;margin:18px 0 0;padding:0 26px;}
940
+ .projCard .cardFaces img{width:28px;height:28px;border-radius:50%;object-fit:cover;
941
+ border:2px solid var(--bg-card);margin-right:-8px;}
942
+ .projCard .facesMore{margin-left:16px;font-family:var(--font-mono);font-size:11.5px;color:var(--ink-faint);
943
+ font-variant-numeric:tabular-nums;}
944
+ /* the two figures in the world's callout grammar (DESIGN.md "Leader-line
945
+ callouts"): a 1px diagonal leader, a 300-weight tabular figure, a caption */
946
+ .projCard .cardFigs{order:7;display:flex;gap:38px;margin:20px 0 0;padding:0 26px;}
947
+ .projCard .cardCall{display:block;}
948
+ .projCard .lead{display:none;width:var(--leader-len);height:26px;margin-bottom:10px;overflow:visible;
949
+ color:color-mix(in srgb,var(--ink) 78%,transparent);}
950
+ .projCard .fig{display:block;position:relative;width:fit-content;font-family:var(--font-display);
951
+ font-size:clamp(21px,5cqw,28px);font-weight:300;letter-spacing:-.025em;line-height:1;
952
+ font-variant-numeric:tabular-nums;color:var(--ink);text-shadow:var(--type-halo);}
953
+ .projCard .cap{display:block;position:relative;width:fit-content;margin-top:7px;font-size:13px;
954
+ color:var(--ink-soft);text-shadow:var(--type-halo);}
955
+ @media (min-width:561px){
956
+ .projCard.has-art .cardFigs{position:absolute;z-index:2;left:0;right:0;top:0;height:calc(100cqw / 2.5);
957
+ margin:0;padding:0;display:block;pointer-events:none;}
958
+ .projCard.has-art .cardCall{position:absolute;width:max-content;max-width:42%;}
959
+ /* 44px in, not the 26px gutter: the patch reaches 44px past the glyph box,
960
+ and a patch that meets the card's edge is clipped flat there */
961
+ .projCard.has-art .cardCall.a{left:44px;top:14px;}
962
+ .projCard.has-art .cardCall.b{right:44px;top:calc(100cqw / 2.5 - 112px);text-align:right;}
963
+ .projCard.has-art .cardCall.b .fig,.projCard.has-art .cardCall.b .cap{margin-left:auto;}
964
+ .projCard.has-art .cardCall .lead{display:block;}
965
+ .projCard.has-art .cardCall.b .lead{transform:scaleX(-1);margin-left:auto;}
966
+ /* each callout carries ONE feathered patch of the CARD ground under its
967
+ figure and caption together: solid to 10px past every edge of the glyph
968
+ boxes (no column of the text ever sits on the fade), then a 34px feather
969
+ to nothing, so the patch reads as haze in the art and never as a slab of
970
+ paint on it. The feather is a mask, not a shadow. It starts under the
971
+ figure, never behind the leader. */
972
+ .projCard.has-art .cardCall::before{content:"";position:absolute;left:-44px;right:-44px;top:18px;bottom:-24px;
973
+ z-index:-1;pointer-events:none;background:var(--bg-card);border-radius:44px/30px;filter:blur(3px);
974
+ -webkit-mask-image:linear-gradient(90deg,transparent,var(--ink) 34px,var(--ink) calc(100% - 34px),transparent),
975
+ linear-gradient(180deg,transparent,var(--ink) 10px,var(--ink) calc(100% - 12px),transparent);
976
+ -webkit-mask-composite:source-in;
977
+ mask-image:linear-gradient(90deg,transparent,var(--ink) 34px,var(--ink) calc(100% - 34px),transparent),
978
+ linear-gradient(180deg,transparent,var(--ink) 10px,var(--ink) calc(100% - 12px),transparent);
979
+ mask-composite:intersect;}
980
+ }
981
+ /* the facts as the ledger (task 1003332's grammar): mono label left, value
982
+ right and tabular, ONE rule-soft hairline per row on both cells so the rule
983
+ never steps at the column boundary. A hidden pair leaves no gap. */
984
+ .projCard .cardMeta{order:8;display:grid;grid-template-columns:auto 1fr;justify-content:normal;gap:0;
985
+ margin:22px 0 0;padding:0 26px;text-align:left;}
986
+ .projCard .cardMeta dt{font-family:var(--label-face);font-size:var(--label-size);letter-spacing:var(--label-space);
987
+ text-transform:uppercase;color:var(--ink-faint);padding:11px 20px 11px 0;border-top:1px solid var(--rule-soft);
988
+ line-height:19px;}
989
+ .projCard .cardMeta dd{margin:0;padding:11px 0;text-align:right;font-size:14px;font-weight:400;color:var(--ink);
990
+ border-top:1px solid var(--rule-soft);font-variant-numeric:tabular-nums;}
991
+ .projCard .cardMeta .ownerAv{display:inline-block;width:16px;height:16px;border-radius:50%;object-fit:cover;
992
+ vertical-align:-3px;margin-right:6px;}
993
+ /* ONE accent door (the One Warm Thing Rule), the hall entry beside it as an
994
+ underlined accent-deep text link (the footer-link grammar) */
995
+ .projCard .cardActions{order:9;display:flex;align-items:center;justify-content:flex-start;gap:22px;flex-wrap:wrap;
996
+ margin:24px 0 0;padding:0 26px;}
997
+ .projCard .cardOpen{display:inline-flex;align-items:center;justify-content:center;min-height:48px;padding:0 30px;
998
+ border-radius:var(--radius-pill);background:var(--accent);color:var(--accent-on);font-family:var(--font-display);
999
+ font-size:16px;font-weight:500;text-transform:lowercase;text-decoration:none;
1000
+ transition:background .2s cubic-bezier(.2,.7,.3,1);}
1001
+ .projCard .cardOpen:hover{background:var(--accent-lift);color:var(--accent-on);}
1002
+ .projCard .cardHall{display:inline-flex;align-items:center;gap:8px;min-height:24px;color:var(--accent-deep);
1003
+ font-size:15px;text-transform:lowercase;text-decoration:underline;text-underline-offset:4px;
1004
+ text-decoration-color:color-mix(in srgb,var(--accent) 55%,transparent);
1005
+ transition:text-decoration-color .2s cubic-bezier(.2,.7,.3,1);}
1006
+ .projCard .cardHall:hover{text-decoration-color:var(--accent-deep);}
1007
+ .cardHall svg,.rowHall svg{width:17px;height:17px;fill:none;stroke:currentColor;stroke-width:1.5;
1008
+ stroke-linecap:round;stroke-linejoin:round;}
1009
+ /* the table row's hall glyph: beside Open, a member's door, only where the hall is elsewhere */
1010
+ .rowHall{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;margin-left:10px;
1011
+ vertical-align:middle;color:var(--accent-deep);border-radius:var(--radius-pill);}
1012
+ .rowHall:hover{background:var(--glass);}
1013
+ .tbl .colGo{white-space:nowrap;}
1014
+ @media (max-width:640px){
1015
+ .projCard h3{font-size:26px;padding:0 20px;}
1016
+ .projCard .cardTag,.projCard .cardRel,.projCard .cardFaces,.projCard .cardFigs,.projCard .cardMeta,
1017
+ .projCard .cardActions{padding-left:20px;padding-right:20px;}
1018
+ .projCard .cardDot{margin-left:20px;}
1019
+ .projCard.has-art .cardDot{left:18px;}
1020
+ .projCard .cardActions{gap:16px;}
1021
+ }
923
1022
  @media (prefers-reduced-motion:no-preference){
924
- .cardScrim:not([hidden]) .projCard{animation:cardIn .16s ease-out;}
925
- @keyframes cardIn{from{opacity:0;transform:translateY(6px) scale(.98);}to{opacity:1;transform:none;}}
1023
+ .cardScrim:not([hidden]) .projCard{animation:cardIn 260ms cubic-bezier(.16,1,.3,1) both;}
1024
+ @keyframes cardIn{from{opacity:0;transform:translateY(6px);}to{opacity:1;transform:none;}}
926
1025
  }
927
1026
 
928
1027
  /* ── your projects: search + a state filter whose pills carry live counts, so