@bongos/core 1.19.402 → 1.19.403
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.
- package/.bongos-core.json +24 -24
- package/docs/copy-inventory.md +141 -138
- package/docs/copy-registry.json +205 -176
- package/docs/design/projects-hub-direction-v2.md +16 -0
- package/modules/platform-identity/project-stats.js +123 -38
- package/modules/platform-identity/routes/my-projects.js +13 -0
- package/modules/platform-identity/routes/projects.js +7 -4
- package/modules/ui-design/kit/fixtures/featured.json +1 -1
- package/modules/ui-design/kit/fixtures/my-projects.json +1 -1
- package/modules/ui-design/kit/serve.js +14 -3
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/assets/cosmos.css +124 -25
- package/public-landing/projects.html +129 -20
- package/public-landing/projects.probes.json +28 -0
- package/public-landing/projects.states.json +6 -3
- package/src/module-api.js +3 -1
- package/tests/cosmos_token_contrast.mjs +5 -2
- package/tests/project_stats.mjs +128 -2
- package/tests/projects_hub.mjs +106 -5
|
@@ -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
|
|
2
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
185
|
-
|
|
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 ||
|
|
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 = {
|
|
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
|
-
|
|
129
|
-
|
|
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":
|
|
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.
|
|
3
|
+
"version": "1.19.403",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.403",
|
|
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.
|
|
3
|
+
"version": "1.19.403",
|
|
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
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
.projCard
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
.projCard .
|
|
915
|
-
|
|
916
|
-
.projCard .
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
.projCard .
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
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 .
|
|
925
|
-
@keyframes cardIn{from{opacity:0;transform:translateY(6px)
|
|
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
|