@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
|
@@ -539,7 +539,7 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
539
539
|
<td class="right num" id="selfBuilders">—</td>
|
|
540
540
|
<td class="colTags" id="selfTags">—</td>
|
|
541
541
|
<td class="right colStatus"><span class="chip calm"><span class="cdot"></span>—</span></td>
|
|
542
|
-
<td class="colGo"><a class="rowGo" href="/">Open →</a></td>
|
|
542
|
+
<td class="colGo"><a class="rowGo" href="/">Open →</a><a class="rowHall" id="selfHall" href="{{buildersOrigin}}" aria-label="Builders' hall" title="Builders' hall" hidden><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M2.8 7.4 10 3.5l7.2 3.9"/><path d="M5.2 8.8v5.6M10 8.8v5.6M14.8 8.8v5.6"/><path d="M3.4 16.3h13.2"/></svg></a></td>
|
|
543
543
|
</tr>
|
|
544
544
|
<!--apex-only:end-->
|
|
545
545
|
</tbody>
|
|
@@ -563,20 +563,30 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
563
563
|
<div class="cardScrim" id="cardScrim" hidden>
|
|
564
564
|
<div class="projCard" id="projCard" role="dialog" aria-modal="true" aria-labelledby="cardName">
|
|
565
565
|
<button type="button" class="cardX" id="cardClose" aria-label="Close">✕</button>
|
|
566
|
+
<!-- The poster (task 1003301, the v2 pick): every region a DIRECT child of
|
|
567
|
+
the card, on purpose. The C6 card test slices the card non-greedily up
|
|
568
|
+
to the first adjacent pair of closing wrapper tags, and a nested
|
|
569
|
+
wrapper would silently shorten what that test guards. So the banner is
|
|
570
|
+
a bare img, the figures and the faces are paragraphs, the facts a
|
|
571
|
+
definition list. Each region degrades by hiding: no art, no banner;
|
|
572
|
+
no faces, no row; one figure null, the other stands alone; both null,
|
|
573
|
+
no band; a fact with no answer hides label and value together,
|
|
574
|
+
because "Owner: dash" on the most authoritative-looking element of
|
|
575
|
+
the page reads as broken, not honest (task 1002778). Everything null
|
|
576
|
+
is the card that shipped before this one: disc, name, tagline,
|
|
577
|
+
relationship, the address, one door. -->
|
|
578
|
+
<img class="cardArt" id="cardArt" alt="" hidden>
|
|
579
|
+
<p class="cardFigs" id="cardFigs" hidden>
|
|
580
|
+
<span class="cardCall a" id="cardBuildersFig" hidden><svg class="lead" viewBox="0 0 68 26" preserveAspectRatio="none" aria-hidden="true"><line x1="0" y1="25" x2="67" y2="1" stroke="currentColor" stroke-width="1" vector-effect="non-scaling-stroke"/></svg><span class="fig" id="cardBuilders"></span><span class="cap">builders</span></span>
|
|
581
|
+
<span class="cardCall b" id="cardShippedFig" hidden><svg class="lead" viewBox="0 0 68 26" preserveAspectRatio="none" aria-hidden="true"><line x1="0" y1="25" x2="67" y2="1" stroke="currentColor" stroke-width="1" vector-effect="non-scaling-stroke"/></svg><span class="fig" id="cardShipped"></span><span class="cap">shipped in 30 days</span></span>
|
|
582
|
+
</p>
|
|
566
583
|
<span class="cardDot" id="cardDot"></span>
|
|
567
584
|
<h3 id="cardName"></h3>
|
|
568
585
|
<p class="cardTag" id="cardTag"></p>
|
|
569
586
|
<p class="cardRel" id="cardRel" hidden></p>
|
|
570
|
-
|
|
571
|
-
purpose: the C6 card test slices the card non-greedily up to the
|
|
572
|
-
first adjacent pair of closing wrapper tags, and a nested wrapper
|
|
573
|
-
would silently shorten what that test guards. A fact with no answer
|
|
574
|
-
hides, label and value together, because "Owner: dash" on the most
|
|
575
|
-
authoritative-looking element of the page reads as broken, not
|
|
576
|
-
honest (task 1002778). -->
|
|
587
|
+
<p class="cardFaces" id="cardFaces" hidden></p>
|
|
577
588
|
<dl class="cardMeta" id="cardMeta" hidden>
|
|
578
589
|
<dt id="cardTypeL" hidden>Type</dt><dd id="cardType" hidden></dd>
|
|
579
|
-
<dt id="cardBuildersL" hidden>Builders</dt><dd id="cardBuilders" hidden></dd>
|
|
580
590
|
<dt id="cardRunsL" hidden>Runs</dt><dd id="cardRuns" hidden></dd>
|
|
581
591
|
<dt id="cardOwnerL" hidden>Owner</dt><dd id="cardOwner" hidden></dd>
|
|
582
592
|
<dt id="cardListedL" hidden>Listed</dt><dd id="cardListed" hidden></dd>
|
|
@@ -1492,7 +1502,7 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
1492
1502
|
: '<span class="chip calm"><span class="cdot"></span>—</span>') + '</td>' +
|
|
1493
1503
|
'<td class="colGo">' + (safeHttpUrl(p.origin)
|
|
1494
1504
|
? '<a class="rowGo" href="' + esc(safeHttpUrl(p.origin)) + '" target="_blank" rel="noopener">Open →</a>'
|
|
1495
|
-
: '') + '</td>' +
|
|
1505
|
+
: '') + hallGlyphHtml(p, rel) + '</td>' +
|
|
1496
1506
|
'</tr>';
|
|
1497
1507
|
}).join(''));
|
|
1498
1508
|
}
|
|
@@ -1577,6 +1587,9 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
1577
1587
|
if (row) {
|
|
1578
1588
|
row.hidden = !selfShown;
|
|
1579
1589
|
$('selfPill').innerHTML = badge ? ' <span class="pill">' + badge + '</span>' : '';
|
|
1590
|
+
/* the hall glyph: member-gated (the badge says so) and address-gated */
|
|
1591
|
+
var sh = $('selfHall');
|
|
1592
|
+
if (sh) sh.hidden = !badge || !hallUrlOf(SELF_PROJECT);
|
|
1580
1593
|
}
|
|
1581
1594
|
|
|
1582
1595
|
var shown = list.length + (selfShown ? 1 : 0);
|
|
@@ -1639,32 +1652,111 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
1639
1652
|
return !!value;
|
|
1640
1653
|
}
|
|
1641
1654
|
|
|
1655
|
+
/* Runs, on the poster, is the project's ADDRESS: where it answers. The table
|
|
1656
|
+
keeps the module list in its Runs column (the v2 pick, direction §7). */
|
|
1657
|
+
function runsOf(p) {
|
|
1658
|
+
return p.isSelf ? hostOf(location.host) : hostOf(p.origin);
|
|
1659
|
+
}
|
|
1660
|
+
/* shipped in the last 30 days, "N+" when the count sat at the feed's row cap;
|
|
1661
|
+
null is "we could not ask" and hides, 0 is a real answer and renders */
|
|
1662
|
+
function shippedFigure(p) {
|
|
1663
|
+
if (p.shipped_30d == null) return '';
|
|
1664
|
+
return String(p.shipped_30d) + (p.shipped_30d_capped ? '+' : '');
|
|
1665
|
+
}
|
|
1666
|
+
/* A figure either has an answer or takes no space: the callout hides whole,
|
|
1667
|
+
leader and caption with it, so the other figure stands alone. */
|
|
1668
|
+
function cardFig(id, value) {
|
|
1669
|
+
$(id).textContent = value || '';
|
|
1670
|
+
$(id + 'Fig').hidden = !value;
|
|
1671
|
+
return !!value;
|
|
1672
|
+
}
|
|
1673
|
+
/* The faces: up to five avatars from the project's own leaderboard, then +N
|
|
1674
|
+
for the builders beyond them. Third-party URLs, scheme-checked before they
|
|
1675
|
+
become a src. No faces, no row. */
|
|
1676
|
+
var FACES_MAX = 5;
|
|
1677
|
+
function cardFaces(p) {
|
|
1678
|
+
var faces = Array.isArray(p.faces) ? p.faces.map(safeHttpUrl).filter(Boolean).slice(0, FACES_MAX) : [];
|
|
1679
|
+
var more = p.builders != null && p.builders > faces.length ? p.builders - faces.length : 0;
|
|
1680
|
+
$('cardFaces').innerHTML = faces.map(function (u) {
|
|
1681
|
+
return '<img src="' + esc(u) + '" alt="" loading="lazy">';
|
|
1682
|
+
}).join('') + (more ? '<span class="facesMore">+' + more + '</span>' : '');
|
|
1683
|
+
$('cardFaces').hidden = !faces.length;
|
|
1684
|
+
}
|
|
1685
|
+
/* The banner. has-art switches the whole layout (the disc over the banner's
|
|
1686
|
+
edge, the figures as callouts on it); without art the card is the card
|
|
1687
|
+
that shipped before this one. A broken image demotes to no-art, so the
|
|
1688
|
+
most authoritative element of the page never shows a broken glyph. */
|
|
1689
|
+
function cardArt(url) {
|
|
1690
|
+
var img = $('cardArt');
|
|
1691
|
+
if (url) img.src = url; else img.removeAttribute('src');
|
|
1692
|
+
img.hidden = !url;
|
|
1693
|
+
$('projCard').classList.toggle('has-art', !!url);
|
|
1694
|
+
}
|
|
1695
|
+
$('cardArt').addEventListener('error', function () { cardArt(''); });
|
|
1696
|
+
/* EXACTLY ONE hall-URL derivation (task 1003257, owner decision a). The hub's
|
|
1697
|
+
own hall comes from the DOM: the server-stamped anchor in its own table row
|
|
1698
|
+
(dropped whole by the stamp when a pack names no hall; never assembled in
|
|
1699
|
+
script). A project's comes from the hall_origin its feed row carries. Both
|
|
1700
|
+
go through safeHttpUrl, and both count only where the hall is a different
|
|
1701
|
+
host from the site: a door that opens onto the room you are in is a dead door. */
|
|
1702
|
+
function hallUrlOf(p) {
|
|
1703
|
+
var hall, site;
|
|
1704
|
+
if (p.isSelf) {
|
|
1705
|
+
var a = $('selfHall');
|
|
1706
|
+
hall = a ? safeHttpUrl(a.getAttribute('href')) : '';
|
|
1707
|
+
site = location.host;
|
|
1708
|
+
} else {
|
|
1709
|
+
hall = safeHttpUrl(p.hall_origin);
|
|
1710
|
+
site = p.origin;
|
|
1711
|
+
}
|
|
1712
|
+
return hall && hostOf(hall) !== hostOf(site) ? hall : '';
|
|
1713
|
+
}
|
|
1714
|
+
var HALL_GLYPH = '<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M2.8 7.4 10 3.5l7.2 3.9"/><path d="M5.2 8.8v5.6M10 8.8v5.6M14.8 8.8v5.6"/><path d="M3.4 16.3h13.2"/></svg>';
|
|
1715
|
+
function isMember(rel) { return rel === 'owner' || rel === 'builder'; }
|
|
1716
|
+
/* the row's hall glyph: a member's door, only where the hall is elsewhere */
|
|
1717
|
+
function hallGlyphHtml(p, rel) {
|
|
1718
|
+
var hall = isMember(rel) ? hallUrlOf(p) : '';
|
|
1719
|
+
return hall
|
|
1720
|
+
? '<a class="rowHall" href="' + esc(hall) + '" target="_blank" rel="noopener" aria-label="Builders\' hall" title="Builders\' hall">' + HALL_GLYPH + '</a>'
|
|
1721
|
+
: '';
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1642
1724
|
function openCard(p, rel, returnTo) {
|
|
1643
1725
|
var href = p.isSelf ? '/' : safeHttpUrl(p.origin);
|
|
1726
|
+
/* the art: catalog text an Archon typed, scheme-checked server-side and
|
|
1727
|
+
again here before it becomes a src */
|
|
1728
|
+
cardArt(p.isSelf ? '' : safeHttpUrl(p.art_url));
|
|
1644
1729
|
$('cardDot').style.setProperty('--acc', p.isSelf ? 'var(--accent)' : accentOf(p));
|
|
1645
1730
|
$('cardName').textContent = p.name || '';
|
|
1646
1731
|
$('cardTag').textContent = p.tagline || '';
|
|
1647
1732
|
var label = rel === 'owner' ? 'yours' : rel === 'builder' ? 'you build here' : '';
|
|
1648
1733
|
$('cardRel').textContent = label;
|
|
1649
1734
|
$('cardRel').hidden = !label;
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1735
|
+
cardFaces(p);
|
|
1736
|
+
/* the two figures: builders (0 is a real answer, "nobody has joined yet";
|
|
1737
|
+
null is "we could not ask" and hides) and shipped in the last 30 days */
|
|
1738
|
+
var figs = cardFig('cardBuilders', p.builders == null ? '' : String(p.builders));
|
|
1739
|
+
figs = cardFig('cardShipped', shippedFigure(p)) || figs;
|
|
1740
|
+
$('cardFigs').hidden = !figs;
|
|
1653
1741
|
var any = cardFact('cardType', typeLabel(p.type));
|
|
1654
|
-
any = cardFact('
|
|
1655
|
-
any = cardFact('cardRuns', Array.isArray(p.tags) ? tagsHtml(p.tags) : '', true) || any;
|
|
1742
|
+
any = cardFact('cardRuns', runsOf(p)) || any;
|
|
1656
1743
|
/* when the rel chip already says "yours", naming the viewer as Owner is
|
|
1657
1744
|
noise — the fact answers strangers, not the person it describes */
|
|
1658
1745
|
any = cardFact('cardOwner', rel === 'owner' ? '' : ownerHtml(p), true) || any;
|
|
1659
1746
|
any = cardFact('cardListed', p.created_at ? listedDate(p.created_at) : '') || any;
|
|
1660
1747
|
$('cardMeta').hidden = !any;
|
|
1661
1748
|
/* the platform's door is same-tab: / is this site, and a new tab for the
|
|
1662
|
-
page you are already on is a bug, not a courtesy
|
|
1663
|
-
|
|
1664
|
-
|
|
1749
|
+
page you are already on is a bug, not a courtesy. The hall door beside it
|
|
1750
|
+
is member-gated AND address-gated: a member's entry, only where the hall
|
|
1751
|
+
is somewhere other than the site the first door already opens. */
|
|
1752
|
+
var hall = isMember(rel) ? hallUrlOf(p) : '';
|
|
1753
|
+
$('cardActions').innerHTML = (href
|
|
1754
|
+
? '<a class="cardOpen" id="cardOpen" href="' + esc(href) + '"' +
|
|
1665
1755
|
(p.isSelf ? '' : ' target="_blank" rel="noopener"') + '>' +
|
|
1666
1756
|
(p.isSelf ? 'Home →' : 'Open site →') + '</a>'
|
|
1667
|
-
: ''
|
|
1757
|
+
: '') + (hall
|
|
1758
|
+
? '<a class="cardHall" id="cardHall" href="' + esc(hall) + '" target="_blank" rel="noopener">' + HALL_GLYPH + 'Builders\' hall</a>'
|
|
1759
|
+
: '');
|
|
1668
1760
|
closeMenus();
|
|
1669
1761
|
cardReturn = returnTo || null;
|
|
1670
1762
|
$('cardScrim').hidden = false;
|
|
@@ -1864,6 +1956,7 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
1864
1956
|
builder count and its owner — the archon row — by the same rule the
|
|
1865
1957
|
server-side enrichment applies to every other project */
|
|
1866
1958
|
SELF_PROJECT.builders = real.length;
|
|
1959
|
+
SELF_PROJECT.faces = real.map(function (b) { return safeHttpUrl(b.avatar_url); }).filter(Boolean).slice(0, FACES_MAX);
|
|
1867
1960
|
var arch = real.filter(function (b) { return b.rank === 'archon'; })[0];
|
|
1868
1961
|
if (arch && arch.github_login) {
|
|
1869
1962
|
SELF_PROJECT.owner = String(arch.github_login);
|
|
@@ -1872,6 +1965,21 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
1872
1965
|
})
|
|
1873
1966
|
.catch(function () { /* unreachable → the dash already in the markup stands */ });
|
|
1874
1967
|
}
|
|
1968
|
+
/* the platform's shipped-in-30-days figure, by the rule the server applies
|
|
1969
|
+
to every other project (project-stats.js): rows inside the window from
|
|
1970
|
+
shipped_at, "N+" when the feed's row cap (200 with ?days honoured) was full */
|
|
1971
|
+
function loadSelfShipped() {
|
|
1972
|
+
return fetch(API + '/public/recent-shipped?days=30', { credentials: 'same-origin' })
|
|
1973
|
+
.then(function (res) { return res.ok ? res.json() : null; })
|
|
1974
|
+
.then(function (d) {
|
|
1975
|
+
if (!d || !Array.isArray(d.tasks)) return;
|
|
1976
|
+
var since = Date.now() - 30 * 24 * 60 * 60 * 1000;
|
|
1977
|
+
var n = d.tasks.filter(function (t) { return Date.parse(t && t.shipped_at) >= since; }).length;
|
|
1978
|
+
SELF_PROJECT.shipped_30d = n;
|
|
1979
|
+
SELF_PROJECT.shipped_30d_capped = d.tasks.length >= 200 && n === d.tasks.length;
|
|
1980
|
+
})
|
|
1981
|
+
.catch(function () { /* unknown stays unknown: the figure hides */ });
|
|
1982
|
+
}
|
|
1875
1983
|
|
|
1876
1984
|
function loadCatalog() {
|
|
1877
1985
|
fetch(API + '/projects/featured', { credentials: 'same-origin' })
|
|
@@ -5610,7 +5718,8 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
5610
5718
|
signed-out stranger lands on and it must not depend on identity */
|
|
5611
5719
|
.then(loadCatalog, loadCatalog)
|
|
5612
5720
|
/* the platform's own count is public too, so it loads either way */
|
|
5613
|
-
.then(loadSelfStats, loadSelfStats)
|
|
5721
|
+
.then(loadSelfStats, loadSelfStats)
|
|
5722
|
+
.then(loadSelfShipped, loadSelfShipped);
|
|
5614
5723
|
})();
|
|
5615
5724
|
</script>
|
|
5616
5725
|
</body>
|
|
@@ -51,6 +51,34 @@
|
|
|
51
51
|
{ "name": "Escape closes the card when no band is open", "continue": true,
|
|
52
52
|
"steps": [["press", "Escape"], ["wait", 150]],
|
|
53
53
|
"expect": { "dialog": false } },
|
|
54
|
+
{ "name": "the poster (task 1003301): art, five faces and +N, two figures on the art, the address, a member's hall door", "continue": true,
|
|
55
|
+
"steps": [["dispatchClick", "#orbField a.worldOrb[data-origin=\"https://amazonprimea.com\"]"], ["wait", 900]],
|
|
56
|
+
"expect": { "dialog": true, "hasClass": [["#projCard", "has-art"]],
|
|
57
|
+
"visible": ["#cardArt", "#cardFaces", "#cardBuildersFig", "#cardShippedFig", "#cardOpen", "#cardHall"],
|
|
58
|
+
"count": [["#cardFaces img", 5], ["#projCard > div", 1]],
|
|
59
|
+
"text": [["#cardBuilders", "11"], ["#cardShipped", "0"], ["#cardRel", "you build here"], ["#cardRuns", "amazonprimea.com"], ["#cardFaces .facesMore", "+6"], ["#cardHall", "Builders' hall"]],
|
|
60
|
+
"href": [["#cardHall", "https://builders.amazonprimea.com/"], ["#cardOpen", "https://amazonprimea.com/"]] } },
|
|
61
|
+
{ "name": "the ladder: a project that answered nothing is the card that shipped before (disc, name, tagline, address, listed, one door)", "continue": true,
|
|
62
|
+
"steps": [["press", "Escape"], ["wait", 150], ["dispatchClick", "#orbField a.worldOrb[data-origin=\"https://demo.cloudbongos.com\"]"], ["wait", 500]],
|
|
63
|
+
"expect": { "dialog": true, "hasClass": [["#projCard", "has-art", false]],
|
|
64
|
+
"hidden": ["#cardArt", "#cardFaces", "#cardFigs", "#cardBuildersFig", "#cardShippedFig", "#cardHall", "#cardRel", "#cardTypeL", "#cardOwnerL"],
|
|
65
|
+
"visible": ["#cardDot", "#cardName", "#cardTag", "#cardRuns", "#cardListed", "#cardOpen"],
|
|
66
|
+
"text": [["#cardRuns", "demo.cloudbongos.com"]] } },
|
|
67
|
+
{ "name": "the hall door is address-gated: a member of a project whose hall is its own site gets no door; the capped figure reads N+", "continue": true,
|
|
68
|
+
"steps": [["press", "Escape"], ["wait", 150], ["dispatchClick", "#orbField a.worldOrb[data-origin=\"https://emersonian-circles.cloudbongos.com\"]"], ["wait", 500]],
|
|
69
|
+
"expect": { "dialog": true, "hidden": ["#cardArt", "#cardFaces", "#cardHall"], "visible": ["#cardFigs", "#cardBuildersFig", "#cardShippedFig", "#cardOpen"],
|
|
70
|
+
"text": [["#cardRel", "you build here"], ["#cardShipped", "30+"], ["#cardBuilders", "1"]] } },
|
|
71
|
+
{ "name": "the platform's own card: figures and faces by the same rule, its hall door from the stamped DOM link", "continue": true,
|
|
72
|
+
"steps": [["press", "Escape"], ["wait", 150], ["dispatchClick", "#selfOrb"], ["wait", 500]],
|
|
73
|
+
"expect": { "dialog": true, "hidden": ["#cardArt"], "visible": ["#cardBuildersFig", "#cardShippedFig", "#cardFaces", "#cardOpen", "#cardHall"],
|
|
74
|
+
"text": [["#cardRel", "yours"]], "href": [["#cardOpen", "/"]] } },
|
|
75
|
+
{ "name": "the table: the hall glyph rides the door cell on the one member row whose hall is elsewhere, and on the platform's row", "continue": true,
|
|
76
|
+
"steps": [["press", "Escape"], ["wait", 150], ["click", "#projTrig"], ["wait", 120], ["click", "a[data-sub=\"table\"]"], ["wait", 300]],
|
|
77
|
+
"expect": { "dialog": false, "visible": ["#selfHall"], "count": [["#mapRows tr[data-generated] .rowHall", 1], ["#mapRows tr[data-generated] td", 18]],
|
|
78
|
+
"href": [["#mapRows tr[data-generated] .rowHall", "https://builders.amazonprimea.com/"]] } },
|
|
79
|
+
{ "name": "back to the sky for the rest of the sequence", "continue": true,
|
|
80
|
+
"steps": [["click", "#projTrig"], ["wait", 120], ["click", "a[data-sub=\"sky\"]"], ["wait", 300]],
|
|
81
|
+
"expect": { "hidden": ["#sub-table-body"] } },
|
|
54
82
|
{ "name": "a solo Shift tap focuses the search on the map", "continue": true,
|
|
55
83
|
"steps": [["clickAt", 700, 600], ["press", "Shift"], ["wait", 100]],
|
|
56
84
|
"expect": { "activeId": "mapQ" } },
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"_": "The hub's states for modules/ui-design/kit/render.js (task 1003320): name → url + actions + expectations. `auth` picks the signed-in stub. Rendered dark + light at 1440/390/320 and audited against the platform floors.",
|
|
2
|
+
"_": "The hub's states for modules/ui-design/kit/render.js (task 1003320): name → url + actions + expectations. `auth` picks the signed-in stub. Rendered dark + light at 1440/390/320 and audited against the platform floors. The card states (task 1003301) walk the poster's ladder: the full poster, the artless two-up band, the all-null floor, the platform's own card.",
|
|
3
3
|
"page": "/projects",
|
|
4
4
|
"surface": "public-landing",
|
|
5
5
|
"modeQuery": "mode",
|
|
@@ -14,12 +14,15 @@
|
|
|
14
14
|
"in-map-kbd": { "auth": true, "actions": [["focus", "#projTrig"], ["press", "Enter"], ["wait", 350], ["press", "ArrowDown"], ["wait", 120]], "expect": { "expanded": true, "activeText": "Table" } },
|
|
15
15
|
"in-map-search": { "auth": true, "actions": [["clickIfVisible", "#qToggle"], ["wait", 200], ["fill", "#mapQ", "emer"], ["wait", 300]], "expect": { "value": [["#mapQ", "emer"]] } },
|
|
16
16
|
"in-map-mine-on": { "auth": true, "actions": [["check", "#onlyMine"], ["wait", 300]], "expect": { "checked": "#onlyMine" } },
|
|
17
|
-
"in-table": { "auth": true, "actions": [["click", "#projTrig"], ["wait", 250], ["click", "a[data-sub=\"table\"]"], ["wait", 400]], "expect": { "visible": ["#sub-table-body", ".tblMast", ".mastObj"], "closed": "#menuProjects" } },
|
|
17
|
+
"in-table": { "auth": true, "actions": [["click", "#projTrig"], ["wait", 250], ["click", "a[data-sub=\"table\"]"], ["wait", 400]], "expect": { "visible": ["#sub-table-body", ".tblMast", ".mastObj", "#selfHall"], "closed": "#menuProjects", "count": [["#mapRows tr[data-generated] .rowHall", 1]] } },
|
|
18
18
|
"in-mine": { "auth": true, "url": "/projects?view=mine", "expect": { "hidden": ["#qWrap"] } },
|
|
19
19
|
"in-new": { "auth": true, "url": "/projects?view=new", "expect": { "hidden": ["#qWrap"] } },
|
|
20
20
|
"out-new": { "auth": false, "url": "/projects?view=new", "expect": { "hidden": ["#qWrap"] } },
|
|
21
21
|
"out-map-projects": { "auth": false, "actions": [["click", "#projTrig"], ["wait", 350]], "expect": { "expanded": true, "hidden": ["#menuProjects [data-nav=\"mine\"]"] } },
|
|
22
|
-
"card": { "auth": true, "actions": [["dispatchClick", "#orbField a.worldOrb
|
|
22
|
+
"card": { "auth": true, "actions": [["dispatchClick", "#orbField a.worldOrb[data-origin=\"https://amazonprimea.com\"]"], ["wait", 900]], "expect": { "dialog": true, "hasClass": [["#projCard", "has-art"]], "visible": ["#cardArt", "#cardFaces", "#cardBuildersFig", "#cardShippedFig", "#cardOpen", "#cardHall"], "text": [["#cardBuilders", "11"], ["#cardShipped", "0"], ["#cardRel", "you build here"], ["#cardRuns", "amazonprimea.com"], ["#cardFaces .facesMore", "+6"]], "count": [["#cardFaces img", 5]], "href": [["#cardHall", "https://builders.amazonprimea.com/"]] } },
|
|
23
|
+
"card-noart": { "auth": true, "actions": [["dispatchClick", "#orbField a.worldOrb[data-origin=\"https://emersonian-circles.cloudbongos.com\"]"], ["wait", 600]], "expect": { "dialog": true, "hasClass": [["#projCard", "has-art", false]], "hidden": ["#cardArt", "#cardFaces", "#cardHall"], "visible": ["#cardBuildersFig", "#cardShippedFig", "#cardOpen"], "text": [["#cardShipped", "30+"], ["#cardRel", "you build here"]] } },
|
|
24
|
+
"card-floor": { "auth": true, "actions": [["dispatchClick", "#orbField a.worldOrb[data-origin=\"https://demo.cloudbongos.com\"]"], ["wait", 600]], "expect": { "dialog": true, "hidden": ["#cardArt", "#cardFaces", "#cardFigs", "#cardHall", "#cardRel", "#cardTypeL", "#cardOwnerL"], "visible": ["#cardOpen", "#cardRuns", "#cardListed"] } },
|
|
25
|
+
"card-self": { "auth": true, "actions": [["dispatchClick", "#selfOrb"], ["wait", 600]], "expect": { "dialog": true, "hidden": ["#cardArt"], "visible": ["#cardBuildersFig", "#cardShippedFig", "#cardFaces", "#cardOpen", "#cardHall"], "text": [["#cardRel", "yours"]] } }
|
|
23
26
|
},
|
|
24
27
|
"reducedMotion": { "auth": true, "actions": [["click", "#projTrig"], ["wait", 100]] }
|
|
25
28
|
}
|
package/src/module-api.js
CHANGED
|
@@ -1244,8 +1244,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
1244
1244
|
// landed since 1.19.400 with no explicit bump. run 33205015717. (task 1002620)
|
|
1245
1245
|
// 1.19.402 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1246
1246
|
// landed since 1.19.401 with no explicit bump. run 33206226800. (task 1002620)
|
|
1247
|
+
// 1.19.403 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1248
|
+
// landed since 1.19.402 with no explicit bump. run 33208825538. (task 1002620)
|
|
1247
1249
|
// ---------------------------------------------------------------------------
|
|
1248
|
-
const CORE_VERSION = '1.19.
|
|
1250
|
+
const CORE_VERSION = '1.19.403'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
1249
1251
|
|
|
1250
1252
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
1251
1253
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -176,6 +176,8 @@ const PAIRS = [
|
|
|
176
176
|
// shipped a fired-down ember where #ffffff measured 4.61:1 — that is history now,
|
|
177
177
|
// and any prose below still naming those values is history too.)
|
|
178
178
|
['.btn label on its fill', 'var(--accent-on)', 'var(--accent)', 4.5],
|
|
179
|
+
// the poster card's door lifts to --accent-lift on hover (task 1003301)
|
|
180
|
+
['.cardOpen:hover label on its lift', 'var(--accent-on)', 'var(--accent-lift)', 4.5],
|
|
179
181
|
['.btn.ghost:hover label', 'var(--accent-deep)', CARD, 4.5],
|
|
180
182
|
['.btn.danger label', 'var(--err)', CARD, 4.5],
|
|
181
183
|
// Code chips, pills, the now-step number, the hover inks: --accent-deep is
|
|
@@ -205,8 +207,9 @@ const PAIRS = [
|
|
|
205
207
|
['.chip.live label', 'var(--ok)', 'var(--ok-soft)', 4.5],
|
|
206
208
|
['.chip.maybe label', 'var(--warn)', 'var(--warn-soft)', 4.5],
|
|
207
209
|
['.chip.down/.bad label', 'var(--err)', 'var(--err-soft)', 4.5],
|
|
208
|
-
// .chip.busy + the
|
|
209
|
-
//
|
|
210
|
+
// .chip.busy + the builder-badge family: accent-deep ink on the accent-soft
|
|
211
|
+
// tint, both modes (.tbl .pill and .cardRel are mono labels in ink-faint since
|
|
212
|
+
// tasks 1003332 / 1003301 — a fact is not pressable, so the accent left them)
|
|
210
213
|
['.chip.busy label', 'var(--accent-deep)', 'var(--accent-soft)', 4.5],
|
|
211
214
|
// lifecycle strip STATE INDICATORS (task 1002751): non-text at the WCAG
|
|
212
215
|
// 1.4.11 bar of 3.0. --sea-ink derives brighter in dark exactly the way
|
package/tests/project_stats.mjs
CHANGED
|
@@ -27,7 +27,8 @@ beforeEach(() => { stats._reset(); });
|
|
|
27
27
|
function json(body) {
|
|
28
28
|
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(body) });
|
|
29
29
|
}
|
|
30
|
-
// route a fake instance: { leaderboard: [...], enabled: {...}
|
|
30
|
+
// route a fake instance: { leaderboard: [...], enabled: {...}, tasks: [...],
|
|
31
|
+
// days: 30|undefined (whether the remote honoured ?days=), index: {...} }
|
|
31
32
|
function stub(map) {
|
|
32
33
|
globalThis.fetch = (url) => {
|
|
33
34
|
const u = new URL(url);
|
|
@@ -39,9 +40,17 @@ function stub(map) {
|
|
|
39
40
|
if (u.pathname.endsWith('/modules')) {
|
|
40
41
|
return site.enabled ? json({ enabled: site.enabled }) : Promise.reject(new Error('down'));
|
|
41
42
|
}
|
|
43
|
+
if (u.pathname.endsWith('/recent-shipped')) {
|
|
44
|
+
return site.tasks ? json({ tasks: site.tasks, days: site.days }) : Promise.reject(new Error('down'));
|
|
45
|
+
}
|
|
46
|
+
if (u.pathname === '/api/bongos') {
|
|
47
|
+
return site.index ? json(site.index) : Promise.reject(new Error('404'));
|
|
48
|
+
}
|
|
42
49
|
return Promise.reject(new Error('404'));
|
|
43
50
|
};
|
|
44
51
|
}
|
|
52
|
+
const DAY = 24 * 60 * 60 * 1000;
|
|
53
|
+
function shipped(daysAgo) { return { shipped_at: new Date(Date.now() - daysAgo * DAY).toISOString() }; }
|
|
45
54
|
|
|
46
55
|
test('system accounts are excluded — they are machines, not builders', async () => {
|
|
47
56
|
// measured live: raw leaderboards read 13/3/3 against real 11/1/1
|
|
@@ -208,7 +217,7 @@ test('concurrent requests for the same origin collapse into one fetch', async ()
|
|
|
208
217
|
stats.getProjectStats(['https://8.8.8.8']),
|
|
209
218
|
stats.getProjectStats(['https://8.8.8.8']),
|
|
210
219
|
]);
|
|
211
|
-
assert.equal(calls,
|
|
220
|
+
assert.equal(calls, 4, 'four feeds for one origin, not twelve for three callers');
|
|
212
221
|
});
|
|
213
222
|
|
|
214
223
|
test('a non-http origin is refused rather than fetched', async () => {
|
|
@@ -274,3 +283,120 @@ test('an origin named __proto__ cannot retarget the result prototype', async ()
|
|
|
274
283
|
assert.equal(Object.getPrototypeOf(out), null, 'the result map is null-prototype');
|
|
275
284
|
assert.equal(({}).builders, undefined, 'Object.prototype is untouched');
|
|
276
285
|
});
|
|
286
|
+
|
|
287
|
+
/* ── The poster card's fields (task 1003301) ────────────────────────────────
|
|
288
|
+
Faces, shipped-in-30-days and the hall address ride the SAME allSettled as
|
|
289
|
+
builders and tags, degrade to null exactly like them, and share the one
|
|
290
|
+
frozen null shape — a key that went missing would ship as undefined, which
|
|
291
|
+
JSON.stringify drops, which silently changes the wire. ── */
|
|
292
|
+
|
|
293
|
+
const KEYS = ['builders', 'tags', 'owner', 'owner_avatar', 'faces', 'shipped_30d', 'shipped_30d_capped', 'hall_origin'];
|
|
294
|
+
|
|
295
|
+
test('one frozen null shape carries every key, and both fallbacks are that shape', async () => {
|
|
296
|
+
assert.ok(Object.isFrozen(stats.NULL_STATS));
|
|
297
|
+
assert.deepEqual(Object.keys(stats.NULL_STATS).sort(), KEYS.slice().sort());
|
|
298
|
+
const failed = await stats.getProjectStats(['file:///etc/passwd']); // the cached-failure literal
|
|
299
|
+
assert.equal(failed['file:///etc/passwd'], stats.NULL_STATS);
|
|
300
|
+
stub({});
|
|
301
|
+
const unreached = await stats.getProjectStats(['https://9.9.9.9']); // the read fallback
|
|
302
|
+
for (const k of KEYS) assert.equal(unreached['https://9.9.9.9'][k], null, `${k} is null, never undefined`);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test('faces are the first five people with an http avatar, in the feed\'s order; machines and bad URLs are skipped', async () => {
|
|
306
|
+
const av = (n) => `https://avatars.example.com/${n}.png`;
|
|
307
|
+
stub({
|
|
308
|
+
'https://8.8.8.8': {
|
|
309
|
+
leaderboard: [
|
|
310
|
+
{ github_login: 'a', avatar_url: av('a') }, { github_login: 'bfg-system', avatar_url: av('bot') },
|
|
311
|
+
{ github_login: 'b', avatar_url: 'javascript:alert(1)' }, { github_login: 'c', avatar_url: av('c') },
|
|
312
|
+
{ github_login: 'd', avatar_url: av('d') }, { github_login: 'e', avatar_url: av('e') },
|
|
313
|
+
{ github_login: 'f', avatar_url: av('f') }, { github_login: 'g', avatar_url: av('g') },
|
|
314
|
+
],
|
|
315
|
+
enabled: {},
|
|
316
|
+
},
|
|
317
|
+
});
|
|
318
|
+
const out = await stats.getProjectStats(['https://8.8.8.8']);
|
|
319
|
+
assert.deepEqual(out['https://8.8.8.8'].faces, ['a', 'c', 'd', 'e', 'f'].map(av));
|
|
320
|
+
assert.equal(out['https://8.8.8.8'].builders, 7, 'the +N the card shows is counted against builders');
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test('a leaderboard with no avatars yields an empty faces list — asked, none — not null', async () => {
|
|
324
|
+
stub({ 'https://8.8.8.8': { leaderboard: [{ github_login: 'a' }], enabled: {} } });
|
|
325
|
+
const out = await stats.getProjectStats(['https://8.8.8.8']);
|
|
326
|
+
assert.deepEqual(out['https://8.8.8.8'].faces, []);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test('shipped_30d counts only rows inside the window, from shipped_at, whatever the remote returned', async () => {
|
|
330
|
+
stub({
|
|
331
|
+
'https://8.8.8.8': {
|
|
332
|
+
leaderboard: [], enabled: {},
|
|
333
|
+
tasks: [shipped(1), shipped(29), shipped(31), shipped(400), { shipped_at: null }, {}],
|
|
334
|
+
},
|
|
335
|
+
});
|
|
336
|
+
const out = await stats.getProjectStats(['https://8.8.8.8']);
|
|
337
|
+
assert.equal(out['https://8.8.8.8'].shipped_30d, 2);
|
|
338
|
+
assert.equal(out['https://8.8.8.8'].shipped_30d_capped, false);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test('shipped_30d is capped ("N+") when the feed\'s row cap is full and every row is inside the window', async () => {
|
|
342
|
+
const full = (n) => Array.from({ length: n }, (_, i) => shipped(i % 20));
|
|
343
|
+
stub({
|
|
344
|
+
'https://1.1.1.1': { leaderboard: [], enabled: {}, tasks: full(200), days: 30 }, // ?days honoured: cap 200
|
|
345
|
+
'https://8.8.8.8': { leaderboard: [], enabled: {}, tasks: full(30) }, // older core, no days: cap 30
|
|
346
|
+
'https://9.9.9.9': { leaderboard: [], enabled: {}, tasks: full(199), days: 30 }, // under the cap
|
|
347
|
+
});
|
|
348
|
+
const out = await stats.getProjectStats(['https://1.1.1.1', 'https://8.8.8.8', 'https://9.9.9.9']);
|
|
349
|
+
assert.deepEqual([out['https://1.1.1.1'].shipped_30d, out['https://1.1.1.1'].shipped_30d_capped], [200, true]);
|
|
350
|
+
assert.deepEqual([out['https://8.8.8.8'].shipped_30d, out['https://8.8.8.8'].shipped_30d_capped], [30, true]);
|
|
351
|
+
assert.deepEqual([out['https://9.9.9.9'].shipped_30d, out['https://9.9.9.9'].shipped_30d_capped], [199, false]);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
test('the shipped feed is asked for the window explicitly', async () => {
|
|
355
|
+
const urls = [];
|
|
356
|
+
globalThis.fetch = (url) => { urls.push(String(url)); return Promise.reject(new Error('down')); };
|
|
357
|
+
await stats.getProjectStats(['https://8.8.8.8']);
|
|
358
|
+
assert.ok(urls.includes(`https://8.8.8.8/api/bongos/public/recent-shipped?days=${stats.SHIPPED_WINDOW_DAYS}`));
|
|
359
|
+
assert.ok(urls.includes('https://8.8.8.8/api/bongos'), 'the discovery index answers the hall address');
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test('hall_origin is the discovery index\'s hall only where it is a different host from the site', async () => {
|
|
363
|
+
stub({
|
|
364
|
+
'https://1.1.1.1': { leaderboard: [], enabled: {}, index: { hall: 'https://builders.example.com' } },
|
|
365
|
+
'https://8.8.8.8': { leaderboard: [], enabled: {}, index: { hall: 'https://8.8.8.8' } }, // single host
|
|
366
|
+
'https://9.9.9.9': { leaderboard: [], enabled: {}, index: { hall: 'javascript:alert(1)' } },
|
|
367
|
+
'https://4.4.4.4': { leaderboard: [], enabled: {} }, // no index (older core)
|
|
368
|
+
});
|
|
369
|
+
const out = await stats.getProjectStats(['https://1.1.1.1', 'https://8.8.8.8', 'https://9.9.9.9', 'https://4.4.4.4']);
|
|
370
|
+
assert.equal(out['https://1.1.1.1'].hall_origin, 'https://builders.example.com');
|
|
371
|
+
assert.equal(out['https://8.8.8.8'].hall_origin, null, 'a hall at <site>/builders is the site\'s own door');
|
|
372
|
+
assert.equal(out['https://9.9.9.9'].hall_origin, null);
|
|
373
|
+
assert.equal(out['https://4.4.4.4'].hall_origin, null);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test('a dead shipped feed leaves the other quarters standing', async () => {
|
|
377
|
+
stub({ 'https://8.8.8.8': { leaderboard: [{ github_login: 'a' }], enabled: { game: true }, index: { hall: 'https://h.example.com' } } });
|
|
378
|
+
const out = await stats.getProjectStats(['https://8.8.8.8']);
|
|
379
|
+
assert.equal(out['https://8.8.8.8'].shipped_30d, null, 'the dead quarter is unknown');
|
|
380
|
+
assert.equal(out['https://8.8.8.8'].shipped_30d_capped, null);
|
|
381
|
+
assert.equal(out['https://8.8.8.8'].builders, 1);
|
|
382
|
+
assert.deepEqual(out['https://8.8.8.8'].tags, ['game']);
|
|
383
|
+
assert.equal(out['https://8.8.8.8'].hall_origin, 'https://h.example.com');
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test('the origin\'s host is resolved once per sweep, not once per feed', async () => {
|
|
387
|
+
const src = await import('node:fs').then((fs) =>
|
|
388
|
+
fs.readFileSync(new URL('../modules/platform-identity/project-stats.js', import.meta.url), 'utf8'));
|
|
389
|
+
assert.match(src, /await assertPublicHost\(siteUrl\(origin\)\.hostname\);/, 'the guard runs in fetchStats, before the allSettled');
|
|
390
|
+
const getJson = (src.match(/async function getJson\(url\)\s*\{([\s\S]*?)\n\}/) || [, ''])[1];
|
|
391
|
+
assert.ok(getJson, 'getJson() must exist');
|
|
392
|
+
assert.doesNotMatch(getJson, /assertPublicHost/, 'no per-feed lookup');
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
test('httpUrl accepts only http(s) strings and caps them', () => {
|
|
396
|
+
assert.equal(stats.httpUrl('https://a.example/x.png'), 'https://a.example/x.png');
|
|
397
|
+
assert.equal(stats.httpUrl('http://a.example/x.png'), 'http://a.example/x.png');
|
|
398
|
+
assert.equal(stats.httpUrl('javascript:alert(1)'), null);
|
|
399
|
+
assert.equal(stats.httpUrl('//a.example/x.png'), null);
|
|
400
|
+
assert.equal(stats.httpUrl(42), null);
|
|
401
|
+
assert.equal(stats.httpUrl('https://a.example/' + 'x'.repeat(400)).length, 300);
|
|
402
|
+
});
|