@bongos/core 1.19.97 → 1.19.99
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 +13 -13
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/assets/cosmos.css +31 -0
- package/public-landing/projects.html +107 -2
- package/src/module-api.js +5 -1
- package/src/platform-server.js +47 -1
- package/tests/projects_hub.mjs +64 -1
- package/tests/public_landing_projects.mjs +59 -0
package/.bongos-core.json
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.99",
|
|
6
|
+
"core_contract": "1.19.99",
|
|
7
|
+
"source_commit": "7ab95a1bcf1751dbc3f5890c41461dcad6826506",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-13T22:
|
|
9
|
+
"built_at": "2026-08-13T22:41:45.519Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 298,
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
19
|
"file_count": 1549,
|
|
20
|
-
"tree_sha256": "
|
|
20
|
+
"tree_sha256": "37c2c9f1fb605b8e730ce3562abac5ed38699596c45f2113f0066464fb12b85f",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -4127,12 +4127,12 @@
|
|
|
4127
4127
|
{
|
|
4128
4128
|
"path": "package-lock.json",
|
|
4129
4129
|
"mode": "0000644",
|
|
4130
|
-
"sha256": "
|
|
4130
|
+
"sha256": "1df1aa5e45f918110c6adebcec118d46207c09bfadd5bf17ddc1b563cb67d8f7"
|
|
4131
4131
|
},
|
|
4132
4132
|
{
|
|
4133
4133
|
"path": "package.json",
|
|
4134
4134
|
"mode": "0000644",
|
|
4135
|
-
"sha256": "
|
|
4135
|
+
"sha256": "4cf100a9e30702e1e1bdcb42c3437e2ce4fe61795bca9c305ce08fb17c42438d"
|
|
4136
4136
|
},
|
|
4137
4137
|
{
|
|
4138
4138
|
"path": "public-docs/index.html",
|
|
@@ -4372,7 +4372,7 @@
|
|
|
4372
4372
|
{
|
|
4373
4373
|
"path": "public-landing/assets/cosmos.css",
|
|
4374
4374
|
"mode": "0000644",
|
|
4375
|
-
"sha256": "
|
|
4375
|
+
"sha256": "5f39814ab40306bc3fc5d4160119b85e2134f778dd62a7a6e6183e36a3ed193e"
|
|
4376
4376
|
},
|
|
4377
4377
|
{
|
|
4378
4378
|
"path": "public-landing/buddha.png",
|
|
@@ -4392,7 +4392,7 @@
|
|
|
4392
4392
|
{
|
|
4393
4393
|
"path": "public-landing/projects.html",
|
|
4394
4394
|
"mode": "0000644",
|
|
4395
|
-
"sha256": "
|
|
4395
|
+
"sha256": "5fd5fe20f1cb0186fd4ae69a7124a3305703952c7ac9662421793449b7ba8e26"
|
|
4396
4396
|
},
|
|
4397
4397
|
{
|
|
4398
4398
|
"path": "public-landing/world/assets/clouds/cloud-island-large.png",
|
|
@@ -5702,7 +5702,7 @@
|
|
|
5702
5702
|
{
|
|
5703
5703
|
"path": "src/module-api.js",
|
|
5704
5704
|
"mode": "0000644",
|
|
5705
|
-
"sha256": "
|
|
5705
|
+
"sha256": "896f7912e0981f27a671fb68d5cada8481f84e2cfc1025c3cb3c69eda95f7fe9"
|
|
5706
5706
|
},
|
|
5707
5707
|
{
|
|
5708
5708
|
"path": "src/module-loader/loader.js",
|
|
@@ -5737,7 +5737,7 @@
|
|
|
5737
5737
|
{
|
|
5738
5738
|
"path": "src/platform-server.js",
|
|
5739
5739
|
"mode": "0000644",
|
|
5740
|
-
"sha256": "
|
|
5740
|
+
"sha256": "6ffe23398bc452b4224e7c4aa49dbd3c4f61a61da0fd51d43bb6c5ce0bd51aa7"
|
|
5741
5741
|
},
|
|
5742
5742
|
{
|
|
5743
5743
|
"path": "src/ui/adapters/claude-design/index.js",
|
|
@@ -7212,7 +7212,7 @@
|
|
|
7212
7212
|
{
|
|
7213
7213
|
"path": "tests/projects_hub.mjs",
|
|
7214
7214
|
"mode": "0000644",
|
|
7215
|
-
"sha256": "
|
|
7215
|
+
"sha256": "9495982aa30c4e845ab879a571be1d6753e39c3f1e3b2629ae9b41feeaf471cc"
|
|
7216
7216
|
},
|
|
7217
7217
|
{
|
|
7218
7218
|
"path": "tests/provision.mjs",
|
|
@@ -7247,7 +7247,7 @@
|
|
|
7247
7247
|
{
|
|
7248
7248
|
"path": "tests/public_landing_projects.mjs",
|
|
7249
7249
|
"mode": "0000644",
|
|
7250
|
-
"sha256": "
|
|
7250
|
+
"sha256": "e03bbf258cb4b8ffd3e242ab2ddd927d1f52706347d06b539a386ca502caf1e4"
|
|
7251
7251
|
},
|
|
7252
7252
|
{
|
|
7253
7253
|
"path": "tests/public_mirror_export.mjs",
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.99",
|
|
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.99",
|
|
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.99",
|
|
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",
|
|
@@ -533,6 +533,8 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
|
|
|
533
533
|
color:var(--ink-soft);vertical-align:middle;}
|
|
534
534
|
.tbl tr:last-child td{border-bottom:none;}
|
|
535
535
|
.tbl tr:hover td{background:color-mix(in srgb,var(--accent) 5%,transparent);}
|
|
536
|
+
/* the whole row opens the project card, so it must look like it does */
|
|
537
|
+
.tbl tbody tr{cursor:pointer;}
|
|
536
538
|
.tbl .pname{display:flex;align-items:center;gap:11px;}
|
|
537
539
|
.tbl .pdot{width:11px;height:11px;border-radius:50%;background:var(--acc);flex:0 0 auto;
|
|
538
540
|
box-shadow:0 0 0 3px color-mix(in srgb,var(--acc) 22%,transparent);}
|
|
@@ -557,6 +559,35 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
|
|
|
557
559
|
.tbl .colName{width:auto;}
|
|
558
560
|
@media (max-width:780px){.tbl .colTags,.tbl .colType{display:none;}}
|
|
559
561
|
|
|
562
|
+
/* ── the project card ──
|
|
563
|
+
An overlay INSIDE the map, not a destination: opening a project must not cost
|
|
564
|
+
you the sky you were reading (direction §9). It sits above .shell (z-index 40)
|
|
565
|
+
because a modal under the page chrome is a modal you can click behind.
|
|
566
|
+
|
|
567
|
+
The scrim is a plain rgba black in BOTH modes on purpose, so it needs no dark
|
|
568
|
+
override at all — a token here would have to be ink, and ink is near-white in
|
|
569
|
+
dark, which paints a white sheet over the page instead of dimming it. */
|
|
570
|
+
.cardScrim{position:fixed;inset:0;z-index:60;display:flex;align-items:center;justify-content:center;
|
|
571
|
+
padding:24px;background:rgba(0,0,0,.55);backdrop-filter:blur(3px);}
|
|
572
|
+
.projCard{position:relative;width:min(420px,100%);background:var(--bg-card);border:1.5px solid var(--line);
|
|
573
|
+
border-radius:var(--r-card,20px);box-shadow:var(--shadow-card);padding:30px 28px 26px;text-align:center;}
|
|
574
|
+
.projCard .cardDot{display:block;width:54px;height:54px;border-radius:50%;margin:0 auto 16px;
|
|
575
|
+
background:var(--acc,var(--accent));box-shadow:0 0 0 8px color-mix(in srgb,var(--acc,var(--accent)) 18%,transparent);}
|
|
576
|
+
.projCard h3{font-family:var(--font-display);font-weight:700;font-size:21px;color:var(--ink);margin-bottom:6px;}
|
|
577
|
+
.projCard .cardTag{font-size:14px;color:var(--ink-faint);font-weight:600;line-height:1.5;margin-bottom:14px;}
|
|
578
|
+
.projCard .cardRel{display:inline-block;font-family:var(--font-mono);font-size:11px;padding:3px 10px;
|
|
579
|
+
border-radius:999px;background:var(--accent-soft);color:var(--accent-deep);margin-bottom:18px;}
|
|
580
|
+
:root[data-mode="dark"] .projCard .cardRel{color:var(--sun-lit);}
|
|
581
|
+
@media (prefers-color-scheme: dark){:root:not([data-mode="light"]) .projCard .cardRel{color:var(--sun-lit);}}
|
|
582
|
+
.projCard .cardActions{display:flex;gap:10px;justify-content:center;flex-wrap:wrap;}
|
|
583
|
+
.projCard .cardX{position:absolute;top:10px;right:12px;width:30px;height:30px;border-radius:50%;
|
|
584
|
+
border:none;background:none;cursor:pointer;font-size:14px;color:var(--ink-faint);line-height:1;}
|
|
585
|
+
.projCard .cardX:hover{background:var(--accent-soft);color:var(--accent-deep);}
|
|
586
|
+
@media (prefers-reduced-motion:no-preference){
|
|
587
|
+
.cardScrim:not([hidden]) .projCard{animation:cardIn .16s ease-out;}
|
|
588
|
+
@keyframes cardIn{from{opacity:0;transform:translateY(6px) scale(.98);}to{opacity:1;transform:none;}}
|
|
589
|
+
}
|
|
590
|
+
|
|
560
591
|
/* ── your projects: search + a state filter whose pills carry live counts, so
|
|
561
592
|
a state with nothing in it reads 0 before you click it. The filter GROUPS
|
|
562
593
|
the chip outcomes; it never relabels them — "Live" holds both `live` and
|
|
@@ -159,6 +159,22 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
|
|
|
159
159
|
</div>
|
|
160
160
|
<div class="hintbar"><span><kbd>click a project</kbd> open it</span></div>
|
|
161
161
|
</div>
|
|
162
|
+
|
|
163
|
+
<!-- The project card. An OVERLAY inside the map, not a fourth view: opening
|
|
164
|
+
a project must not cost you the sky you were reading (direction §9).
|
|
165
|
+
Real markup, never JS-built (C7) — the same reason every other view
|
|
166
|
+
ships as markup — so its copy is in the document and its ids are stable.
|
|
167
|
+
Filled by openCard(); it carries no project identity of its own. -->
|
|
168
|
+
<div class="cardScrim" id="cardScrim" hidden>
|
|
169
|
+
<div class="projCard" id="projCard" role="dialog" aria-modal="true" aria-labelledby="cardName">
|
|
170
|
+
<button type="button" class="cardX" id="cardClose" aria-label="Close">✕</button>
|
|
171
|
+
<span class="cardDot" id="cardDot"></span>
|
|
172
|
+
<h3 id="cardName"></h3>
|
|
173
|
+
<p class="cardTag" id="cardTag"></p>
|
|
174
|
+
<p class="cardRel" id="cardRel" hidden></p>
|
|
175
|
+
<div class="cardActions" id="cardActions"></div>
|
|
176
|
+
</div>
|
|
177
|
+
</div>
|
|
162
178
|
</div>
|
|
163
179
|
|
|
164
180
|
<div class="wrap viewWrap" id="sub-table-body" hidden>
|
|
@@ -512,7 +528,7 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
|
|
|
512
528
|
and the sort comparator as if a row existed. This keeps the self entry a
|
|
513
529
|
view-level concern (the owner's call, 2026-08-13) while still letting the
|
|
514
530
|
search box match it by the same predicate every other project uses. */
|
|
515
|
-
var SELF_PROJECT = { name: productName, tagline: 'this platform' };
|
|
531
|
+
var SELF_PROJECT = { name: productName, tagline: 'this platform', isSelf: true };
|
|
516
532
|
var mapSort = { key: 'name', dir: 1 };
|
|
517
533
|
|
|
518
534
|
/* Catalog origins become hrefs, so they are scheme-checked before they are
|
|
@@ -576,8 +592,13 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
|
|
|
576
592
|
a.setAttribute('data-generated', '');
|
|
577
593
|
a.setAttribute('data-rel', relOf(p));
|
|
578
594
|
var href = safeHttpUrl(p.origin);
|
|
595
|
+
/* The href STAYS, and stays honest, even though a plain click now opens
|
|
596
|
+
the card instead of following it: it is what makes the orb a real link
|
|
597
|
+
— focusable, Enter-activatable, and still ctrl/middle-clickable into a
|
|
598
|
+
new tab. The card is layered over that, not substituted for it. */
|
|
579
599
|
a.href = href || '#';
|
|
580
600
|
if (href) { a.target = '_blank'; a.rel = 'noopener'; }
|
|
601
|
+
a.setAttribute('data-origin', String(p.origin || ''));
|
|
581
602
|
a.title = p.tagline || p.name || '';
|
|
582
603
|
a.style.cssText = '--acc:' + accentOf(p) +
|
|
583
604
|
';--d:' + slot[2] + 'px;left:' + slot[0] + '%;top:' + slot[1] + '%';
|
|
@@ -602,7 +623,7 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
|
|
|
602
623
|
var rel = relOf(p);
|
|
603
624
|
var pill = rel === 'owner' ? ' <span class="pill">yours</span>'
|
|
604
625
|
: rel === 'builder' ? ' <span class="pill">you build here</span>' : '';
|
|
605
|
-
return '<tr data-generated>' +
|
|
626
|
+
return '<tr data-generated data-origin="' + esc(String(p.origin || '')) + '">' +
|
|
606
627
|
'<td class="colName"><span class="pname"><span class="pdot" style="--acc:' +
|
|
607
628
|
accentOf(p) + '"></span>' +
|
|
608
629
|
'<span><span class="ptitle">' + esc(p.name) + '</span>' + pill + '<br>' +
|
|
@@ -665,6 +686,90 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
|
|
|
665
686
|
$('mapCount').textContent = shown + (shown === 1 ? ' project' : ' projects');
|
|
666
687
|
}
|
|
667
688
|
|
|
689
|
+
/* ══ THE PROJECT CARD ══════════════════════════════════════════════════════
|
|
690
|
+
Every orb and every row opens THIS. Before, each one navigated: catalog
|
|
691
|
+
orbs into a new tab and the platform's own orb — an <a href="/"> — straight
|
|
692
|
+
back to the landing page, so clicking the project you were standing on threw
|
|
693
|
+
away the map to show you a marketing page. Direction §9 called for one
|
|
694
|
+
overlay card serving every project, self included; the owner adopted it
|
|
695
|
+
2026-08-13. The card is layered OVER the links rather than replacing them,
|
|
696
|
+
so ctrl/middle-click still opens a project's site directly.
|
|
697
|
+
|
|
698
|
+
It states only what the public catalog actually carries — name, tagline,
|
|
699
|
+
your relationship, a door — because /projects/featured selects four columns
|
|
700
|
+
and cannot answer anything richer for a catalog row (task 1002744 owns that
|
|
701
|
+
data path). A card that showed builders or liveness here would be inventing
|
|
702
|
+
both, the same C6 lie the table's em dashes exist to avoid. ══ */
|
|
703
|
+
|
|
704
|
+
var cardReturn = null; /* the element focus came from, to hand it back */
|
|
705
|
+
|
|
706
|
+
function projByOrigin(o) {
|
|
707
|
+
for (var i = 0; i < catalog.length; i++) if (String(catalog[i].origin) === o) return catalog[i];
|
|
708
|
+
return null;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function openCard(p, rel, returnTo) {
|
|
712
|
+
var href = p.isSelf ? '/' : safeHttpUrl(p.origin);
|
|
713
|
+
$('cardDot').style.setProperty('--acc', p.isSelf ? 'var(--accent)' : accentOf(p));
|
|
714
|
+
$('cardName').textContent = p.name || '';
|
|
715
|
+
$('cardTag').textContent = p.tagline || '';
|
|
716
|
+
var label = rel === 'owner' ? 'yours' : rel === 'builder' ? 'you build here' : '';
|
|
717
|
+
$('cardRel').textContent = label;
|
|
718
|
+
$('cardRel').hidden = !label;
|
|
719
|
+
/* the platform's door is same-tab: / is this site, and a new tab for the
|
|
720
|
+
page you are already on is a bug, not a courtesy */
|
|
721
|
+
$('cardActions').innerHTML = href
|
|
722
|
+
? '<a class="btn small" href="' + esc(href) + '"' +
|
|
723
|
+
(p.isSelf ? '' : ' target="_blank" rel="noopener"') + '>' +
|
|
724
|
+
(p.isSelf ? 'Home →' : 'Open site →') + '</a>'
|
|
725
|
+
: '';
|
|
726
|
+
cardReturn = returnTo || null;
|
|
727
|
+
$('cardScrim').hidden = false;
|
|
728
|
+
$('cardClose').focus();
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function closeCard() {
|
|
732
|
+
if ($('cardScrim').hidden) return;
|
|
733
|
+
$('cardScrim').hidden = true;
|
|
734
|
+
if (cardReturn && cardReturn.focus) cardReturn.focus();
|
|
735
|
+
cardReturn = null;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/* Delegated, so orbs and rows rebuilt by every filter keystroke stay wired
|
|
739
|
+
without re-binding. A modified click is left ALONE — ctrl/cmd/shift/middle
|
|
740
|
+
mean "open in a new tab" everywhere else on the web and the orb is a real
|
|
741
|
+
link, so intercepting them would break an expectation the markup makes. */
|
|
742
|
+
function isPlainClick(ev) {
|
|
743
|
+
return !(ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey || ev.button > 0);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
$('orbField').addEventListener('click', function (ev) {
|
|
747
|
+
var a = ev.target.closest && ev.target.closest('.worldOrb');
|
|
748
|
+
if (!a || !isPlainClick(ev)) return;
|
|
749
|
+
ev.preventDefault();
|
|
750
|
+
if (a.id === 'selfOrb') return openCard(SELF_PROJECT, selfRel(), a);
|
|
751
|
+
var p = projByOrigin(a.getAttribute('data-origin') || '');
|
|
752
|
+
if (p) openCard(p, relOf(p), a);
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
$('mapRows').addEventListener('click', function (ev) {
|
|
756
|
+
/* the row's own "Open →" keeps its job — the card is for the rest of the row */
|
|
757
|
+
if (ev.target.closest && ev.target.closest('a')) return;
|
|
758
|
+
var tr = ev.target.closest && ev.target.closest('tr');
|
|
759
|
+
if (!tr || !isPlainClick(ev)) return;
|
|
760
|
+
if (tr.id === 'selfRow') return openCard(SELF_PROJECT, selfRel(), null);
|
|
761
|
+
var p = projByOrigin(tr.getAttribute('data-origin') || '');
|
|
762
|
+
if (p) openCard(p, relOf(p), null);
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
$('cardClose').addEventListener('click', closeCard);
|
|
766
|
+
$('cardScrim').addEventListener('click', function (ev) {
|
|
767
|
+
if (ev.target === $('cardScrim')) closeCard(); /* backdrop only, never the card */
|
|
768
|
+
});
|
|
769
|
+
document.addEventListener('keydown', function (ev) {
|
|
770
|
+
if (ev.key === 'Escape') closeCard();
|
|
771
|
+
});
|
|
772
|
+
|
|
668
773
|
function loadCatalog() {
|
|
669
774
|
fetch(API + '/projects/featured', { credentials: 'same-origin' })
|
|
670
775
|
.then(function (res) { if (!res.ok) throw new Error('catalog'); return res.json(); })
|
package/src/module-api.js
CHANGED
|
@@ -621,8 +621,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
621
621
|
// landed since 1.19.95 with no explicit bump. run 31748829423. (task 1002620)
|
|
622
622
|
// 1.19.97 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
623
623
|
// landed since 1.19.96 with no explicit bump. run 31749223878. (task 1002620)
|
|
624
|
+
// 1.19.98 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
625
|
+
// landed since 1.19.97 with no explicit bump. run 31749841394. (task 1002620)
|
|
626
|
+
// 1.19.99 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
627
|
+
// landed since 1.19.98 with no explicit bump. run 31750941866. (task 1002620)
|
|
624
628
|
// ---------------------------------------------------------------------------
|
|
625
|
-
const CORE_VERSION = '1.19.
|
|
629
|
+
const CORE_VERSION = '1.19.99'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
626
630
|
|
|
627
631
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
628
632
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
package/src/platform-server.js
CHANGED
|
@@ -60,12 +60,58 @@ const HOST = process.env.HOST || '127.0.0.1';
|
|
|
60
60
|
// branding pack is injected once at boot (theme + window.__BRANDING__), exactly
|
|
61
61
|
// as the hall pages are, so the look + identity come from config, not hardcode.
|
|
62
62
|
const LANDING_DIR = path.join(__dirname, '..', 'public-landing');
|
|
63
|
+
// ── CONTENT-STAMPED STATIC ASSETS (task 1002766) ──
|
|
64
|
+
// The landing HTML is `cf-cache-status: DYNAMIC` and never cached, but the
|
|
65
|
+
// assets it references come back `Cache-Control: public, max-age=14400` — four
|
|
66
|
+
// hours, Cloudflare's default Browser Cache TTL, not a header this app sets. So
|
|
67
|
+
// after a deploy a RETURNING visitor gets brand-new markup styled by a
|
|
68
|
+
// stylesheet up to four hours old: markup whose components that stylesheet has
|
|
69
|
+
// never heard of. Measured live — a cached cosmos.css of 25,693 bytes with no
|
|
70
|
+
// `.shell{` against 42,610 fresh — and again when the project card shipped and
|
|
71
|
+
// rendered as an unstyled block at the foot of the page.
|
|
72
|
+
//
|
|
73
|
+
// A COLD visitor is unaffected, which is exactly what makes it easy to miss:
|
|
74
|
+
// whoever deploys usually reloads in the browser holding the stale copy.
|
|
75
|
+
//
|
|
76
|
+
// Fixed by changing the URL rather than by fighting the header: a content hash
|
|
77
|
+
// means a new core produces a new URL, so there is nothing to invalidate and no
|
|
78
|
+
// Cloudflare purge token to hold (task 1002766 weighed both). Same `?v=<sha8>`
|
|
79
|
+
// shape serve-internal.js already stamps onto the hall's own scripts.
|
|
80
|
+
//
|
|
81
|
+
// Stamped by RESOLUTION, not by a path allow-list: a URL is an asset if it
|
|
82
|
+
// actually names a file under public-landing/. Routes (`/projects`, `/create`)
|
|
83
|
+
// and API paths resolve to nothing and are left alone, so a new asset is
|
|
84
|
+
// stamped the day it is added and nobody has to remember to list it.
|
|
85
|
+
const _assetStamps = new Map();
|
|
86
|
+
function assetStamp(url) {
|
|
87
|
+
if (!_assetStamps.has(url)) {
|
|
88
|
+
let v = '';
|
|
89
|
+
try {
|
|
90
|
+
const abs = path.resolve(LANDING_DIR, '.' + url);
|
|
91
|
+
// never let a template's own text walk out of the landing dir
|
|
92
|
+
if (abs.startsWith(LANDING_DIR + path.sep)) {
|
|
93
|
+
v = require('node:crypto').createHash('sha256')
|
|
94
|
+
.update(fs.readFileSync(abs)).digest('hex').slice(0, 8);
|
|
95
|
+
}
|
|
96
|
+
} catch { v = ''; } // not a file → not an asset → no stamp
|
|
97
|
+
_assetStamps.set(url, v);
|
|
98
|
+
}
|
|
99
|
+
return _assetStamps.get(url);
|
|
100
|
+
}
|
|
101
|
+
const LOCAL_ASSET = /(href|src)="(\/[A-Za-z0-9._~/-]+\.[A-Za-z0-9]+)"/g;
|
|
102
|
+
function stampAssets(html) {
|
|
103
|
+
return String(html).replace(LOCAL_ASSET, (m, attr, url) =>
|
|
104
|
+
assetStamp(url) ? `${attr}="${url}?v=${assetStamp(url)}"` : m);
|
|
105
|
+
}
|
|
106
|
+
|
|
63
107
|
const _landingPages = new Map(); // memoized per file; '' if absent (instance ships no landing)
|
|
64
108
|
function landingPage(file) {
|
|
65
109
|
if (!_landingPages.has(file)) {
|
|
66
110
|
let html = '';
|
|
67
111
|
try {
|
|
68
|
-
|
|
112
|
+
// stamped LAST, so anything a later transform introduces is stamped too
|
|
113
|
+
html = stampAssets(injectBrandingGlobal(stripApexOnly(
|
|
114
|
+
fs.readFileSync(path.join(LANDING_DIR, file), 'utf8'))));
|
|
69
115
|
} catch { /* absent → '' */ }
|
|
70
116
|
_landingPages.set(file, html);
|
|
71
117
|
}
|
package/tests/projects_hub.mjs
CHANGED
|
@@ -238,7 +238,7 @@ test('the renderer preserves the self row exactly as it preserves the self orb',
|
|
|
238
238
|
'a wholesale innerHTML= on #mapRows destroys the static self row');
|
|
239
239
|
assert.match(SCRIPT, /querySelectorAll\('tr\[data-generated\]'\)/,
|
|
240
240
|
'generated rows must be removed selectively, like .worldOrb[data-generated]');
|
|
241
|
-
assert.match(SCRIPT, /<tr data-generated
|
|
241
|
+
assert.match(SCRIPT, /<tr data-generated[ >]/, 'generated rows must be markable');
|
|
242
242
|
});
|
|
243
243
|
|
|
244
244
|
test('"only mine" no longer hides the platform from a signed-in builder', () => {
|
|
@@ -307,3 +307,66 @@ test('the hub and the landing hero share one typing cadence', () => {
|
|
|
307
307
|
assert.ok(heroHolds.includes(n), `hold ${n}ms should match the hero's cadence (${heroHolds.join('/')})`);
|
|
308
308
|
}
|
|
309
309
|
});
|
|
310
|
+
|
|
311
|
+
/* ── The project card (task 1002770) ────────────────────────────────────────
|
|
312
|
+
Every orb used to navigate: catalog orbs into a new tab, and the platform's
|
|
313
|
+
own orb straight back to the landing page — so clicking the project you were
|
|
314
|
+
standing on threw away the map. Direction §9's overlay, owner-adopted. ── */
|
|
315
|
+
|
|
316
|
+
test('the card is real markup in the map, not a JS-built node (C7)', () => {
|
|
317
|
+
assert.match(HUB, /id="cardScrim"/);
|
|
318
|
+
assert.match(HUB, /id="projCard"/);
|
|
319
|
+
// it lives inside the map view, not as a fourth destination
|
|
320
|
+
const map = (HUB.match(/<section id="view-map">([\s\S]*?)<\/section>/) || [, ''])[1];
|
|
321
|
+
assert.match(map, /id="cardScrim"/, 'the card is an overlay INSIDE the map');
|
|
322
|
+
assert.doesNotMatch(HUB, /id="view-detail"/, 'and never a view of its own');
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test('the card is a labelled modal dialog', () => {
|
|
326
|
+
const card = (HUB.match(/<div class="projCard"[^>]*>/) || [''])[0];
|
|
327
|
+
assert.match(card, /role="dialog"/);
|
|
328
|
+
assert.match(card, /aria-modal="true"/);
|
|
329
|
+
assert.match(card, /aria-labelledby="cardName"/);
|
|
330
|
+
assert.match(HUB, /id="cardClose"[^>]*aria-label="Close"/, 'and has a labelled close control');
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test('the platform orb opens the card instead of leaving for the landing page', () => {
|
|
334
|
+
// the reported bug: <a id="selfOrb" href="/"> navigated away on a plain click
|
|
335
|
+
assert.match(SCRIPT, /a\.id === 'selfOrb'\) return openCard\(SELF_PROJECT/,
|
|
336
|
+
'a plain click on the self orb must open the card');
|
|
337
|
+
// the href stays — it is what keeps the orb a real, focusable, ctrl-clickable link
|
|
338
|
+
assert.match(HUB, /id="selfOrb" href="\/"/, 'the orb stays a real link');
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test('a modified click is left alone — the orb is still a link', () => {
|
|
342
|
+
assert.match(SCRIPT, /function isPlainClick/);
|
|
343
|
+
const fn = (SCRIPT.match(/function isPlainClick\(ev\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
|
|
344
|
+
for (const k of ['metaKey', 'ctrlKey', 'shiftKey']) {
|
|
345
|
+
assert.match(fn, new RegExp(k), `${k} must fall through to the browser`);
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test('the card closes by Escape, backdrop and the close control', () => {
|
|
350
|
+
assert.match(SCRIPT, /ev\.key === 'Escape'/);
|
|
351
|
+
assert.match(SCRIPT, /ev\.target === \$\('cardScrim'\)/, 'backdrop only — a click on the card must not close it');
|
|
352
|
+
assert.match(SCRIPT, /\$\('cardClose'\)\.addEventListener\('click', closeCard\)/);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test('focus enters the card and is handed back on close', () => {
|
|
356
|
+
assert.match(SCRIPT, /\$\('cardClose'\)\.focus\(\)/, 'focus must move into the dialog');
|
|
357
|
+
assert.match(SCRIPT, /cardReturn(\.focus)?/, 'and return to where it came from');
|
|
358
|
+
assert.match(SCRIPT, /cardReturn\.focus\(\)/);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test('the card claims nothing the public catalog cannot answer (C6)', () => {
|
|
362
|
+
const card = (HUB.match(/<div class="cardScrim"[\s\S]*?<\/div>\s*<\/div>/) || [''])[0];
|
|
363
|
+
// /projects/featured selects origin, name, tagline, art_url — and nothing else
|
|
364
|
+
for (const invented of [/Builders/i, /\blive\b/i, /uptime/i, /\btags\b/i]) {
|
|
365
|
+
assert.doesNotMatch(card, invented, `the card must not invent ${invented}`);
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test('the row keeps its own Open link working underneath the card', () => {
|
|
370
|
+
assert.match(SCRIPT, /if \(ev\.target\.closest && ev\.target\.closest\('a'\)\) return;/,
|
|
371
|
+
'a click on the row\'s own anchor must not be swallowed by the card handler');
|
|
372
|
+
});
|
|
@@ -119,3 +119,62 @@ test('create.html: the poll give-up points at /projects — "the hall has it" is
|
|
|
119
119
|
assert.match(body, /\/projects/, 'the give-up path must hand the owner the projects page');
|
|
120
120
|
assert.doesNotMatch(body, /the hall has it/, 'the false poll-stop copy must be gone');
|
|
121
121
|
});
|
|
122
|
+
|
|
123
|
+
/* ── Content-stamped assets (task 1002766) ──────────────────────────────────
|
|
124
|
+
The landing HTML is never cached but its assets come back max-age=14400 —
|
|
125
|
+
Cloudflare's default browser TTL, not ours. So a returning visitor gets new
|
|
126
|
+
markup with up to four-hour-old CSS: markup whose components that stylesheet
|
|
127
|
+
has never heard of. Twice now that has shipped a visibly broken page. The URL
|
|
128
|
+
carries the version so there is nothing to invalidate. ── */
|
|
129
|
+
|
|
130
|
+
test('the stylesheet URL carries a content stamp, so new markup cannot meet old CSS', async () => {
|
|
131
|
+
const res = await request('/projects');
|
|
132
|
+
assert.equal(res.status, 200);
|
|
133
|
+
const m = res.body.match(/href="\/assets\/cosmos\.css\?v=([0-9a-f]+)"/);
|
|
134
|
+
assert.ok(m, 'cosmos.css must be served with a ?v= content stamp');
|
|
135
|
+
assert.equal(m[1].length, 8, 'the stamp is the first 8 hex of a sha256');
|
|
136
|
+
assert.doesNotMatch(res.body, /href="\/assets\/cosmos\.css"/,
|
|
137
|
+
'no unstamped copy may survive alongside the stamped one');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('the stamp is derived from CONTENT, so it changes only when the file does', async () => {
|
|
141
|
+
const fsMod = await import('node:fs');
|
|
142
|
+
const pathMod = await import('node:path');
|
|
143
|
+
const cryptoMod = await import('node:crypto');
|
|
144
|
+
const url = await import('node:url');
|
|
145
|
+
const root = pathMod.dirname(url.fileURLToPath(import.meta.url));
|
|
146
|
+
const css = fsMod.readFileSync(pathMod.join(root, '..', 'public-landing', 'assets', 'cosmos.css'));
|
|
147
|
+
const want = cryptoMod.createHash('sha256').update(css).digest('hex').slice(0, 8);
|
|
148
|
+
const res = await request('/projects');
|
|
149
|
+
assert.ok(res.body.includes(`cosmos.css?v=${want}`),
|
|
150
|
+
'the stamp must be the hash of the bytes actually on disk');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('every landing surface is stamped, not just the one that broke', async () => {
|
|
154
|
+
for (const p of ['/', '/projects', '/create']) {
|
|
155
|
+
const res = await request(p);
|
|
156
|
+
if (res.status !== 200) continue;
|
|
157
|
+
assert.doesNotMatch(res.body, /(href|src)="\/assets\/[^"?]+"/,
|
|
158
|
+
`${p} still serves an unstamped /assets/ URL`);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('routes and API paths are never mistaken for assets', async () => {
|
|
163
|
+
// stamping resolves the URL against public-landing/ — a route names no file,
|
|
164
|
+
// so it must come back untouched rather than acquiring a meaningless ?v=
|
|
165
|
+
const res = await request('/projects');
|
|
166
|
+
assert.match(res.body, /href="\/create"/, '/create is a route, not an asset');
|
|
167
|
+
assert.doesNotMatch(res.body, /href="\/create\?v=/);
|
|
168
|
+
assert.doesNotMatch(res.body, /href="\/projects\?v=/);
|
|
169
|
+
assert.doesNotMatch(res.body, /\/api\/bongos\/[^"]*\?v=[0-9a-f]{8}/,
|
|
170
|
+
'API paths must never be stamped');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('a stamped asset still serves — the query string is not part of the filename', async () => {
|
|
174
|
+
const page = await request('/projects');
|
|
175
|
+
const stamped = (page.body.match(/\/assets\/cosmos\.css\?v=[0-9a-f]+/) || [])[0];
|
|
176
|
+
assert.ok(stamped, 'need a stamped URL to fetch');
|
|
177
|
+
const res = await request(stamped);
|
|
178
|
+
assert.equal(res.status, 200, 'express.static must ignore the ?v= when resolving the file');
|
|
179
|
+
assert.match(res.body, /\.shell\{/, 'and it must be the real stylesheet');
|
|
180
|
+
});
|