@bongos/core 1.19.407 → 1.19.409

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.
@@ -0,0 +1,232 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <script>
6
+ (function(){try{var q=new URLSearchParams(location.search || location.hash.slice(1) || (window.__Q || '')).get('mode');var m=(q==='light'||q==='dark')?q:localStorage.getItem('cb-color-mode');if(m==='light'||m==='dark')document.documentElement.setAttribute('data-mode',m);}catch(e){}})();
7
+ </script>
8
+ <meta name="viewport" content="width=device-width,initial-scale=1">
9
+ <title>Planet templates, the hub's sky</title>
10
+ <link rel="icon" href="data:,">
11
+ <link rel="preconnect" href="https://fonts.googleapis.com">
12
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
13
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400&family=Manrope:wght@300;400;500;600&display=swap">
14
+ <link rel="stylesheet" href="../../../../public-landing/assets/cosmos.css">
15
+ <style>
16
+ body{
17
+ /* The composition round for task 1003319 (docs/design/projects-hub-direction-v2.md §11). cosmos.css
18
+ carries the hub's contract + derived tiers and the set plate; the sheet adds the other two cutouts
19
+ the sky needs (root-absolute, the world's rule: a url() in a custom property resolves against the
20
+ DOCUMENT) and the one token that flips with the mode, the object's contact shadow. */
21
+ --plate-bead-cut:url(/assets/world/r2-chrome-blob-one-cut.webp);
22
+ --plate-bongos-cut:url(/assets/world/r2-bongos-chrome-a-cut.webp);
23
+ --orb-shadow:drop-shadow(0 12px 14px color-mix(in srgb,var(--ink) 22%,transparent));
24
+ --grid-line:var(--rule-soft);
25
+ }
26
+ :root[data-mode="dark"] body{
27
+ --orb-shadow:opacity(1);
28
+ --grid-line:var(--rule-soft);
29
+ }
30
+ @media (prefers-color-scheme: dark){
31
+ :root:not([data-mode="light"]) body{
32
+ --orb-shadow:opacity(1);
33
+ --grid-line:var(--rule-soft);
34
+ }
35
+ }
36
+
37
+ /* ── the planet: one of the world's three whole objects on the landing's crop maths, in the
38
+ project's accent. The colour layer blends with the plate; the mask clips both to the cutout's
39
+ alpha, so the colour never paints the box. Shape = plate + rectangle; style = how the accent
40
+ lands (a colour blend, a hue blend, or not at all). ── */
41
+ .planet{position:relative;display:block;width:var(--d,64px);aspect-ratio:var(--tpl-ar);filter:var(--orb-shadow);isolation:isolate;}
42
+ .planet::before{content:"";position:absolute;inset:0;
43
+ background-image:linear-gradient(var(--tpl-ink),var(--tpl-ink)),var(--tpl-src);
44
+ background-repeat:no-repeat;
45
+ background-size:100% 100%,calc(var(--d,64px) * var(--tpl-zoom)) auto;
46
+ background-position:0 0,calc(var(--d,64px) * var(--tpl-px)) calc(var(--d,64px) * var(--tpl-py));
47
+ background-blend-mode:var(--tpl-blend),normal;
48
+ -webkit-mask-image:var(--tpl-src);mask-image:var(--tpl-src);
49
+ -webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;
50
+ -webkit-mask-size:calc(var(--d,64px) * var(--tpl-zoom)) auto;mask-size:calc(var(--d,64px) * var(--tpl-zoom)) auto;
51
+ -webkit-mask-position:calc(var(--d,64px) * var(--tpl-px)) calc(var(--d,64px) * var(--tpl-py));
52
+ mask-position:calc(var(--d,64px) * var(--tpl-px)) calc(var(--d,64px) * var(--tpl-py));}
53
+ /* shapes: r2-chrome-blob-one u[.14,.865] v[.172,.808]; the bongo pair whole; the set's small bead u[.810,.955] v[.556,.840] */
54
+ [data-shape="blob"]{--tpl-src:var(--plate-bead-cut);--tpl-ar:1.13994;--tpl-zoom:1.37931;--tpl-px:-.19310;--tpl-py:-.23724;}
55
+ [data-shape="pair"]{--tpl-src:var(--plate-bongos-cut);--tpl-ar:1;--tpl-zoom:1;--tpl-px:0;--tpl-py:0;}
56
+ [data-shape="bead"]{--tpl-src:var(--plate-set-cut);--tpl-ar:.91476;--tpl-zoom:6.89655;--tpl-px:-5.58621;--tpl-py:-2.14014;}
57
+ /* styles */
58
+ [data-style="tint"]{--tpl-ink:var(--acc);--tpl-blend:color;}
59
+ [data-style="wash"]{--tpl-ink:var(--acc);--tpl-blend:overlay;}
60
+ [data-style="chrome"]{--tpl-ink:transparent;--tpl-blend:normal;}
61
+ /* the candidates row (blend modes under consideration) */
62
+ [data-blend="color"]{--tpl-ink:var(--acc);--tpl-blend:color;}
63
+ [data-blend="hue"]{--tpl-ink:var(--acc);--tpl-blend:hue;}
64
+ [data-blend="soft-light"]{--tpl-ink:var(--acc);--tpl-blend:soft-light;}
65
+ [data-blend="overlay"]{--tpl-ink:var(--acc);--tpl-blend:overlay;}
66
+ [data-blend="multiply"]{--tpl-ink:var(--acc);--tpl-blend:multiply;}
67
+ [data-blend="screen"]{--tpl-ink:var(--acc);--tpl-blend:screen;}
68
+ [data-blend="none"]{--tpl-ink:transparent;--tpl-blend:normal;}
69
+
70
+ /* ── the sheet ── */
71
+ .sheet{padding:34px var(--gutter) 60px;max-width:1180px;margin:0 auto;}
72
+ .sheet h2{font-family:var(--font-display);font-weight:300;font-size:clamp(26px,3.1vw,42px);letter-spacing:-.035em;line-height:1.06;color:var(--ink);margin:0 0 6px;}
73
+ .sheet p{font-size:15.5px;line-height:1.5;color:var(--ink-soft);max-width:62ch;margin:0 0 26px;}
74
+ .rowLabel{font-family:var(--label-face);font-size:var(--label-size);letter-spacing:var(--label-space);text-transform:uppercase;color:var(--ink-faint);padding:18px 0 12px;border-top:1px solid var(--rule-soft);margin-top:10px;}
75
+ .matrix{display:grid;grid-template-columns:repeat(9,1fr);gap:18px 14px;align-items:end;}
76
+ .cell{display:flex;flex-direction:column;align-items:center;gap:12px;min-width:0;}
77
+ .cell .planet{--d:78px;}
78
+ .cell .nm{font-family:var(--label-face);font-size:var(--label-size);letter-spacing:var(--label-space);text-transform:uppercase;color:var(--ink-faint);white-space:nowrap;}
79
+ .cands{display:grid;grid-template-columns:repeat(7,1fr);gap:18px 14px;align-items:end;}
80
+ .cands .planet{--d:96px;}
81
+ @media (max-width:900px){
82
+ .matrix{grid-template-columns:repeat(3,1fr);}
83
+ .cands{grid-template-columns:repeat(4,1fr);}
84
+ .cell .planet{--d:64px;}
85
+ }
86
+
87
+ /* ── the field, both treatments: the world's grammar for the labels ── */
88
+ .mock .field{height:calc(100vh - var(--top-h) - 60px);}
89
+ .mock .worldOrb .disc{width:var(--d);height:auto;aspect-ratio:var(--tpl-ar);border-radius:0;background:none;box-shadow:none;filter:var(--orb-shadow);isolation:isolate;}
90
+ .mock .worldOrb .disc::before{content:"";position:absolute;inset:0;
91
+ background-image:linear-gradient(var(--tpl-ink),var(--tpl-ink)),var(--tpl-src);
92
+ background-repeat:no-repeat;
93
+ background-size:100% 100%,calc(var(--d) * var(--tpl-zoom)) auto;
94
+ background-position:0 0,calc(var(--d) * var(--tpl-px)) calc(var(--d) * var(--tpl-py));
95
+ background-blend-mode:var(--tpl-blend),normal;
96
+ -webkit-mask-image:var(--tpl-src);mask-image:var(--tpl-src);
97
+ -webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;
98
+ -webkit-mask-size:calc(var(--d) * var(--tpl-zoom)) auto;mask-size:calc(var(--d) * var(--tpl-zoom)) auto;
99
+ -webkit-mask-position:calc(var(--d) * var(--tpl-px)) calc(var(--d) * var(--tpl-py));
100
+ mask-position:calc(var(--d) * var(--tpl-px)) calc(var(--d) * var(--tpl-py));}
101
+ /* the relationship ring: a circle behind the object, the rail dot's grammar */
102
+ .mock .worldOrb .disc::after{content:none;border:0;}
103
+ .mock .worldOrb[data-rel="owner"] .disc::after,.mock .worldOrb[data-rel="builder"] .disc::after{content:"";position:absolute;left:50%;top:50%;
104
+ width:calc(100% + 18px);aspect-ratio:1;translate:-50% -50%;border-radius:50%;z-index:-1;}
105
+ .mock .worldOrb[data-rel="owner"] .disc::after{border:1.5px solid var(--accent);}
106
+ .mock .worldOrb[data-rel="builder"] .disc::after{border:1px solid color-mix(in srgb,var(--accent) 55%,transparent);}
107
+ .mock .worldOrb[data-rel="owner"] .disc,.mock .worldOrb[data-rel="builder"] .disc{box-shadow:none;}
108
+ .mock .worldOrb .nm{font-family:var(--font-body);font-weight:500;font-size:15px;letter-spacing:-.015em;color:var(--ink);text-shadow:var(--type-halo);}
109
+ .mock .worldOrb .sub{font-family:var(--label-face);font-size:var(--label-size);letter-spacing:var(--label-space);text-transform:uppercase;color:var(--ink-faint);text-shadow:none;}
110
+ .mock .worldOrb .badge{top:-14px;background:var(--bg);color:var(--ink-faint);border:1px solid var(--rule);box-shadow:none;
111
+ font-family:var(--label-face);font-size:var(--label-size);letter-spacing:var(--label-space);text-transform:uppercase;font-weight:400;padding:3px 10px;}
112
+ .mock .worldOrb[data-rel="owner"] .badge{background:var(--bg);color:var(--ink);border-color:var(--ink-faint);}
113
+ /* A: the void. nothing drawn — the ground is the ground */
114
+ .mock .field .ground::after{content:none;}
115
+ .mock .field .stars{display:none;}
116
+ /* B: the sheet — one hairline grid inside the camera, so it pans and zooms with the objects */
117
+ .mock .field.sheetGrid .skyCam::before{content:"";position:absolute;inset:-100%;
118
+ background-image:linear-gradient(var(--grid-line) 1px,transparent 1px),linear-gradient(90deg,var(--grid-line) 1px,transparent 1px);
119
+ background-size:92px 92px;pointer-events:none;}
120
+ .mock .hintbar{background:none;border:0;padding:0;backdrop-filter:none;font-family:var(--label-face);font-size:var(--label-size);
121
+ letter-spacing:var(--label-space);text-transform:uppercase;color:var(--ink-faint);gap:18px;}
122
+ .mock .hintbar kbd{background:none;padding:0;color:var(--ink-soft);font-family:inherit;font-size:inherit;}
123
+ .mock .foot{padding:18px var(--gutter);font-size:14px;color:var(--ink-soft);text-align:center;}
124
+
125
+ /* the state contract: one section shows */
126
+ section[data-state]{display:none;}
127
+ body[data-state="sheet"] section[data-state="sheet"],
128
+ body[data-state="void"] section[data-state="void"],
129
+ body[data-state="grid"] section[data-state="grid"]{display:block;}
130
+ .vh{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap;}
131
+ .skip{position:absolute;left:12px;top:-60px;background:var(--bg-card);color:var(--ink);padding:11px 18px;border-radius:999px;}
132
+ .skip:focus{top:12px;}
133
+ .bar{height:var(--top-h);display:flex;align-items:center;padding:0 var(--gutter);border-bottom:1px solid var(--rule);font-family:var(--font-body);font-weight:500;font-size:17px;letter-spacing:-.015em;color:var(--ink);}
134
+ </style>
135
+ </head>
136
+ <body class="mock" data-state="sheet">
137
+ <!--
138
+ Planet templates, the composition round (task 1003319, goal 1000080).
139
+ ?state=sheet the nine templates at the five accents, and the blend candidates
140
+ ?state=void field treatment A: the void — nothing drawn, the objects carry the depth
141
+ ?state=grid field treatment B: the sheet — one hairline grid inside the camera
142
+ ?mode=dark|light
143
+ -->
144
+ <a class="skip" href="#main">Skip to content</a>
145
+ <header class="bar">Cloud Bongos</header>
146
+ <main id="main">
147
+ <h1 class="vh">Planet templates</h1>
148
+
149
+ <section data-state="sheet" class="sheet">
150
+ <h2>Nine templates, five accents</h2>
151
+ <p>Each project's orb is one of the world's three whole chrome objects, cut on the landing's own crop maths, in the project's accent. Three shapes by three styles: tint (the accent replaces the chrome's colour), wash (the accent takes only its hue), chrome (the plate as rendered).</p>
152
+ <div class="rowLabel">the blend candidates, one blob, the sea accent</div>
153
+ <div class="cands">
154
+ <div class="cell"><span class="planet" data-shape="blob" data-blend="color" style="--acc:var(--sea)"></span><span class="nm">color</span></div>
155
+ <div class="cell"><span class="planet" data-shape="blob" data-blend="hue" style="--acc:var(--sea)"></span><span class="nm">hue</span></div>
156
+ <div class="cell"><span class="planet" data-shape="blob" data-blend="soft-light" style="--acc:var(--sea)"></span><span class="nm">soft-light</span></div>
157
+ <div class="cell"><span class="planet" data-shape="blob" data-blend="overlay" style="--acc:var(--sea)"></span><span class="nm">overlay</span></div>
158
+ <div class="cell"><span class="planet" data-shape="blob" data-blend="multiply" style="--acc:var(--sea)"></span><span class="nm">multiply</span></div>
159
+ <div class="cell"><span class="planet" data-shape="blob" data-blend="screen" style="--acc:var(--sea)"></span><span class="nm">screen</span></div>
160
+ <div class="cell"><span class="planet" data-shape="blob" data-blend="none" style="--acc:var(--sea)"></span><span class="nm">none</span></div>
161
+ </div>
162
+ <div id="matrix"></div>
163
+ </section>
164
+
165
+ <section data-state="void">
166
+ <div class="fieldWrap"><div class="field" id="fieldA">
167
+ <div class="ground"></div>
168
+ <div class="skyCam">
169
+ <a class="worldOrb" href="#" data-rel="owner" data-shape="pair" data-style="chrome" style="--acc:var(--accent);--d:140px;left:50%;top:44%"><span class="badge">yours</span><span class="disc"></span><span class="nm">Cloud Bongos</span><span class="sub">this platform</span></a>
170
+ <a class="worldOrb" href="#" data-rel="none" data-shape="blob" data-style="tint" style="--acc:var(--terracotta);--d:92px;left:22%;top:30%"><span class="disc"></span><span class="nm">Demo</span></a>
171
+ <a class="worldOrb" href="#" data-rel="builder" data-shape="bead" data-style="wash" style="--acc:var(--gold-soft);--d:75px;left:78%;top:66%"><span class="badge">you build here</span><span class="disc"></span><span class="nm">Emersonian Circles</span></a>
172
+ <a class="worldOrb" href="#" data-rel="builder" data-shape="pair" data-style="tint" style="--acc:var(--sea);--d:70px;left:33%;top:74%"><span class="badge">you build here</span><span class="disc"></span><span class="nm">Off the Boats</span></a>
173
+ <a class="worldOrb" href="#" data-rel="none" data-shape="bead" data-style="chrome" style="--acc:var(--sun-lit);--d:80px;left:70%;top:26%"><span class="disc"></span><span class="nm">Mercury</span></a>
174
+ </div>
175
+ <div class="hintbar"><span><kbd>drag</kbd> pan</span><span><kbd>scroll</kbd> zoom</span><span><kbd>click a project</kbd> open it</span></div>
176
+ </div></div>
177
+ </section>
178
+
179
+ <section data-state="grid">
180
+ <div class="fieldWrap"><div class="field sheetGrid" id="fieldB">
181
+ <div class="ground"></div>
182
+ <div class="skyCam">
183
+ <a class="worldOrb" href="#" data-rel="owner" data-shape="pair" data-style="chrome" style="--acc:var(--accent);--d:140px;left:50%;top:44%"><span class="badge">yours</span><span class="disc"></span><span class="nm">Cloud Bongos</span><span class="sub">this platform</span></a>
184
+ <a class="worldOrb" href="#" data-rel="none" data-shape="blob" data-style="tint" style="--acc:var(--terracotta);--d:92px;left:22%;top:30%"><span class="disc"></span><span class="nm">Demo</span></a>
185
+ <a class="worldOrb" href="#" data-rel="builder" data-shape="bead" data-style="wash" style="--acc:var(--gold-soft);--d:75px;left:78%;top:66%"><span class="badge">you build here</span><span class="disc"></span><span class="nm">Emersonian Circles</span></a>
186
+ <a class="worldOrb" href="#" data-rel="builder" data-shape="pair" data-style="tint" style="--acc:var(--sea);--d:70px;left:33%;top:74%"><span class="badge">you build here</span><span class="disc"></span><span class="nm">Off the Boats</span></a>
187
+ <a class="worldOrb" href="#" data-rel="none" data-shape="bead" data-style="chrome" style="--acc:var(--sun-lit);--d:80px;left:70%;top:26%"><span class="disc"></span><span class="nm">Mercury</span></a>
188
+ </div>
189
+ <div class="hintbar"><span><kbd>drag</kbd> pan</span><span><kbd>scroll</kbd> zoom</span><span><kbd>click a project</kbd> open it</span></div>
190
+ </div></div>
191
+ </section>
192
+ </main>
193
+ <footer class="foot">A composition sheet for task 1003319. Nothing here is served.</footer>
194
+ <script>
195
+ /* The mock's state contract: read ?state= once, apply one state (sheet | void | grid). The
196
+ matrix is built with createElement + textContent. No motion in script. */
197
+ (function () {
198
+ var state = new URLSearchParams(location.search || location.hash.slice(1) || (window.__Q || '')).get('state') || 'sheet';
199
+ if (state !== 'sheet' && state !== 'void' && state !== 'grid') state = 'sheet';
200
+ document.body.setAttribute('data-state', state);
201
+ var ACCENTS = [['sea', 'var(--sea)'], ['terracotta', 'var(--terracotta)'], ['gold-soft', 'var(--gold-soft)'], ['sun-lit', 'var(--sun-lit)'], ['accent', 'var(--accent)']];
202
+ var TEMPLATES = ['blob-tint', 'pair-wash', 'bead-chrome', 'blob-wash', 'pair-chrome', 'bead-tint', 'blob-chrome', 'pair-tint', 'bead-wash'];
203
+ var host = document.getElementById('matrix');
204
+ ACCENTS.forEach(function (a) {
205
+ var label = document.createElement('div');
206
+ label.className = 'rowLabel';
207
+ label.textContent = 'the ' + a[0] + ' accent';
208
+ host.appendChild(label);
209
+ var row = document.createElement('div');
210
+ row.className = 'matrix';
211
+ TEMPLATES.forEach(function (t) {
212
+ var parts = t.split('-');
213
+ var cell = document.createElement('div');
214
+ cell.className = 'cell';
215
+ var p = document.createElement('span');
216
+ p.className = 'planet';
217
+ p.setAttribute('data-shape', parts[0]);
218
+ p.setAttribute('data-style', parts[1]);
219
+ p.style.setProperty('--acc', a[1]);
220
+ var n = document.createElement('span');
221
+ n.className = 'nm';
222
+ n.textContent = t;
223
+ cell.appendChild(p);
224
+ cell.appendChild(n);
225
+ row.appendChild(cell);
226
+ });
227
+ host.appendChild(row);
228
+ });
229
+ })();
230
+ </script>
231
+ </body>
232
+ </html>
@@ -160,3 +160,13 @@ Three mastheads, rendered dark and light at 1440 / 390 / 320 (ALL CLEAN) and mea
160
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
161
 
162
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.
163
+
164
+ ## 11. The planets and the field (task 1003319)
165
+
166
+ **Date:** 2026-08-28 · the owner's ask at the pick (§7): "templates for the planets themselves … they look like the blocks from our landing page mock with different colors, shapes, and styles", and "improve the look of the map a bit more as well".
167
+
168
+ **The round.** One composition sheet, `docs/design/mocks/hub/planets.html`, rendered through the kit's stub in both modes at 1440 and 390 (`?state=sheet`: the nine templates at the five accents plus a row of blend candidates; `?state=void` and `?state=grid`: the two field treatments with the real orbs; `check-mock.js` ALL RULES PASS). The templates are the landing's three whole chrome objects on the landing's own crop maths (`.o1/.o2/.o3`; the rectangles are now pinned equal across the two sheets), in the project's accent: **shape** blob / pair / bead by **style** tint / wash / chrome. The blend row decided the styles: `color` for tint (the accent's hue and chroma over the chrome's light — unmistakably the colour, still unmistakably chrome), `overlay` for wash (the chrome lit by the accent with its iridescence kept; `hue` and `soft-light` were too faint to read as a second style, `multiply` and `screen` lost the material), the plate itself for chrome. The field: **the void** (A) over **the sheet** (B, one hairline grid inside the camera so it pans and zooms with the objects). A is DESIGN.md read literally — no drawn atmosphere, the depth is the objects' — and is what was built; B stays on the sheet as the alternative should the owner want a ground reference under the camera.
169
+
170
+ **What was built.** In `cosmos.css` the orb's disc is a masked colour layer over a cutout (`.disc::before`: the plate as both background and mask, `background-blend-mode` by style), sized `--d` by the object's own aspect; `[data-shape]` carries the three crops, `[data-style]` the three blends; the relationship ring is a circle behind the object (a mask clips a box-shadow away), the search spotlight an accent drop-shadow on the disc's filter after the contact shadow (dark's `--orb-shadow` is `opacity(1)`, a no-op, so the two compose). The starfield, its 3.2s twinkle, the 74px gravity grid and `--star-display` are gone; the labels, the badge, the hint and the hover card moved onto the world's type (hairline pill, label face, no accent on a fact, no shadow). In `projects.html` `ORB_TEMPLATES` walks beside `ORB_ACCENTS` in `assignAccents` (consecutive positions change shape and style; nine against five repeat every forty-five projects), `templateOf()` prefers a curated name, an owned-only entry walks by its slug, the orb carries `data-template` / `data-shape` / `data-style`, the physics measures the disc's own height, and the slot widths grew a quarter (a cutout's silhouette reads smaller than a filled disc of the same box). The platform's own orb is the pair in chrome. On the catalog side migration `<redacted>` adds the nullable `template` column with its nine-value CHECK, `upsertProject` carries it COALESCE-preserved, `POST /projects` validates it against `PROJECT_TEMPLATES`, and the public `/projects/featured` projection returns it. The kit's fixture gives Example a curated `bead-wash` and the `in-map` state reads it back off the orb.
171
+
172
+ **Not done, on purpose:** no owner-facing picker — a template is set through the curation route today, the way `featured` is; whether a project's owner chooses their own planet from the manage view is the owner's call. The manage view's small `.orb` spheres keep their old look (the scope named the sky's `a.worldOrb` only).
@@ -0,0 +1,26 @@
1
+ -- platform_identity_012_project_template.sql — a project's planet template (task 1003319).
2
+ --
3
+ -- The hub's sky renders every project as one of the landing's chrome objects in the
4
+ -- project's accent (DESIGN.md "Project orbs" / "Planet templates"): a SHAPE (blob /
5
+ -- pair / bead — the world's three whole objects) and a STYLE (tint / wash / chrome —
6
+ -- how the accent lands on the chrome). Nullable on purpose: null means "not chosen",
7
+ -- and the hub assigns one deterministically from the project's canonical position —
8
+ -- the same walk that assigns its accent — so no row is back-filled with a guess and
9
+ -- a curated choice survives every re-render. An Archon sets it through POST /projects.
10
+ --
11
+ -- Additive + platform_identity_-namespaced (ADR 0083 §5): a new nullable column plus
12
+ -- a CHECK that only constrains non-null values. Idempotent: safe to re-run.
13
+
14
+ BEGIN;
15
+
16
+ ALTER TABLE platform_identity_projects
17
+ ADD COLUMN IF NOT EXISTS template text;
18
+
19
+ ALTER TABLE platform_identity_projects DROP CONSTRAINT IF EXISTS platform_identity_projects_template_check;
20
+ ALTER TABLE platform_identity_projects ADD CONSTRAINT platform_identity_projects_template_check
21
+ CHECK (template IS NULL OR template IN (
22
+ 'blob-tint', 'pair-wash', 'bead-chrome',
23
+ 'blob-wash', 'pair-chrome', 'bead-tint',
24
+ 'blob-chrome', 'pair-tint', 'bead-wash'));
25
+
26
+ COMMIT;
@@ -0,0 +1,40 @@
1
+ -- platform_identity_012_recent_ships.sql — the hub's "recent works" store
2
+ -- (BV1 goal 1000045 P07, task 1002803; profile-page spec "The page spec" row 4 +
3
+ -- ADR 0171 D7).
4
+ --
5
+ -- WHY A COLUMN AND NOT A TABLE. The hub has no `tasks` table for other projects,
6
+ -- so a builder's last few shipped works can only reach it as data the project
7
+ -- PUSHES. The existing pull pattern (modules/platform-identity/project-stats.js)
8
+ -- fans out to each project's public API — fine for a builder count on a catalog
9
+ -- page, wrong once per profile view, and useless for a CLI-only project that
10
+ -- serves no public API at all. The push already exists: POST /sso/activity/rollup
11
+ -- upserts exactly one row per (client_id, github_id), refreshed on every federated
12
+ -- sign-in AND (task 1002803) on every ship. A capped list of at most 3 works
13
+ -- belongs ON that row: it is written and read as one unit, it is never queried
14
+ -- across rows, and a child table would add a join and a delete-orphan path to
15
+ -- store nine values.
16
+ --
17
+ -- WHAT IS IN IT. At most 3 objects, each `{ id, title, shipped_at, url }` —
18
+ -- sanitized in upsertActivity BEFORE the write (the rank/disciplines precedent,
19
+ -- ADR 0171 D7: a reporting instance is third-party self-report and is never
20
+ -- trusted to respect its own cap). `url` may only point back into the reporting
21
+ -- client's own registered origin, so a ship link on a public portfolio can never
22
+ -- be turned into a phishing hop. `'[]'` — never NULL — so every reader gets a
23
+ -- list without a coalesce.
24
+ --
25
+ -- DESCRIPTIVE ANALYTICS ONLY (ADR 0016 / 0141): a ship link is a label, never an
26
+ -- access grant. Reading it confers nothing on the instance it points at.
27
+ --
28
+ -- Additive + `platform_identity_`-namespaced (ADR 0083 §5). Forward-safe: ADD
29
+ -- COLUMN with a DEFAULT, so the previous code release still runs against this
30
+ -- schema (it simply never writes the column). Idempotent.
31
+
32
+ BEGIN;
33
+
34
+ ALTER TABLE platform_identity_builder_project_activity
35
+ ADD COLUMN IF NOT EXISTS recent_ships jsonb NOT NULL DEFAULT '[]'::jsonb;
36
+
37
+ COMMENT ON COLUMN platform_identity_builder_project_activity.recent_ships IS
38
+ 'Up to 3 recently shipped works this project reported for this builder, pushed on POST /sso/activity/rollup (task 1002803): [{id, title, shipped_at, url}]. Third-party self-report — capped, type-checked and same-origin-checked in upsertActivity, never trusted as written. Surfaced only on a project the catalog names publicly (getRollupForBuilder), so a non-public project''s work titles stay counted-but-unnamed like its name does.';
39
+
40
+ COMMIT;
@@ -233,6 +233,12 @@ async function getClientByOrigin(origin, { pool = defaultPool } = {}) {
233
233
  // provisioning's copy on its own instances table).
234
234
  const PROJECT_TYPES = ['game', 'research', 'business', 'non-profit', 'not-sure'];
235
235
 
236
+ // The sky's planet templates (task 1003319): shape (blob / pair / bead, the world's three
237
+ // whole chrome objects) × style (how the accent lands: tint / wash / chrome). A CATALOG field
238
+ // curated through POST /projects; null = the hub's deterministic walk (the accent's), whose
239
+ // order this list IS. Pinned by the DB CHECK (migration 012); mirrored by ORB_TEMPLATES.
240
+ const PROJECT_TEMPLATES = ['blob-tint', 'pair-wash', 'bead-chrome', 'blob-wash', 'pair-chrome', 'bead-tint', 'blob-chrome', 'pair-tint', 'bead-wash'];
241
+
236
242
  // The stored JOIN DOOR (ADR 0182 D1, task 1002323). Three values, and ALL THREE ARE
237
243
  // VISIBLE: `private` is visible-but-closed (the `goals.visibility` meaning, ADR 0112),
238
244
  // `stealth` publishes EXISTENCE without IDENTITY. Mirrored by the CHECK in migration 011.
@@ -284,14 +290,16 @@ function publishableSql(alias) {
284
290
  // report from the authenticated instance (R08, task 1002327). Leaving the column out of
285
291
  // the upsert is what stops a client re-registration from silently resetting an owner's
286
292
  // door back to the 'public' default.
287
- async function upsertProject({ origin, name, tagline, description, artUrl, ownerGithubId, type }, { pool = defaultPool } = {}) {
293
+ async function upsertProject({ origin, name, tagline, description, artUrl, ownerGithubId, type, template }, { pool = defaultPool } = {}) {
288
294
  const norm = String(origin || '').replace(/\/+$/, '');
289
295
  if (!norm) throw new Error('project origin required');
290
296
  // off-vocabulary → null (the DB CHECK is the backstop; a declaration is never invented)
291
297
  const declaredType = PROJECT_TYPES.includes(type) ? type : null;
298
+ // the same rule for the planet template: an unknown name is not a choice
299
+ const declaredTemplate = PROJECT_TEMPLATES.includes(template) ? template : null;
292
300
  const { rows } = await pool.query(
293
- `INSERT INTO platform_identity_projects (origin, name, tagline, description, art_url, owner_github_id, type)
294
- VALUES ($1, $2, $3, $4, $5, $6, $7)
301
+ `INSERT INTO platform_identity_projects (origin, name, tagline, description, art_url, owner_github_id, type, template)
302
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
295
303
  ON CONFLICT (origin) DO UPDATE SET
296
304
  name = EXCLUDED.name,
297
305
  tagline = COALESCE(EXCLUDED.tagline, platform_identity_projects.tagline),
@@ -299,9 +307,10 @@ async function upsertProject({ origin, name, tagline, description, artUrl, owner
299
307
  art_url = COALESCE(EXCLUDED.art_url, platform_identity_projects.art_url),
300
308
  owner_github_id = COALESCE(EXCLUDED.owner_github_id, platform_identity_projects.owner_github_id),
301
309
  type = COALESCE(EXCLUDED.type, platform_identity_projects.type),
310
+ template = COALESCE(EXCLUDED.template, platform_identity_projects.template),
302
311
  updated_at = now()
303
- RETURNING id, origin, name, tagline, description, art_url, owner_github_id, type, status, featured, visibility`,
304
- [norm, name || norm, tagline ?? null, description ?? null, artUrl ?? null, ownerGithubId ?? null, declaredType],
312
+ RETURNING id, origin, name, tagline, description, art_url, owner_github_id, type, template, status, featured, visibility`,
313
+ [norm, name || norm, tagline ?? null, description ?? null, artUrl ?? null, ownerGithubId ?? null, declaredType, declaredTemplate],
305
314
  );
306
315
  return rows[0];
307
316
  }
@@ -311,7 +320,7 @@ async function upsertProject({ origin, name, tagline, description, artUrl, owner
311
320
  // it here rather than leaving each caller to re-derive the gate.
312
321
  async function getProject(id, { pool = defaultPool } = {}) {
313
322
  const { rows } = await pool.query(
314
- `SELECT id, origin, name, tagline, description, art_url, owner_github_id, type, status, featured, visibility
323
+ `SELECT id, origin, name, tagline, description, art_url, owner_github_id, type, template, status, featured, visibility
315
324
  FROM platform_identity_projects WHERE id = $1`,
316
325
  [id],
317
326
  );
@@ -327,7 +336,7 @@ async function getProject(id, { pool = defaultPool } = {}) {
327
336
  // it, so as a public "last updated" it would lie.
328
337
  async function listFeaturedProjects({ pool = defaultPool } = {}) {
329
338
  const { rows } = await pool.query(
330
- `SELECT origin, name, tagline, art_url, type, created_at
339
+ `SELECT origin, name, tagline, art_url, type, template, created_at
331
340
  FROM platform_identity_projects
332
341
  WHERE featured = true AND status = 'active'
333
342
  ORDER BY updated_at DESC`,
@@ -341,7 +350,7 @@ async function listFeaturedProjects({ pool = defaultPool } = {}) {
341
350
  // field so a curator can see WHY a row is dark matter instead of guessing (ADR 0182 D4).
342
351
  async function listAllProjects({ pool = defaultPool } = {}) {
343
352
  const { rows } = await pool.query(
344
- `SELECT id, origin, name, tagline, description, art_url, owner_github_id, type, status,
353
+ `SELECT id, origin, name, tagline, description, art_url, owner_github_id, type, template, status,
345
354
  featured, visibility, updated_at,
346
355
  ${publishableSql()} AS publishable
347
356
  FROM platform_identity_projects
@@ -523,25 +532,96 @@ async function setClientStatus(clientId, status, { pool = defaultPool } = {}) {
523
532
  const MAX_ROLLUP_DISCIPLINES = 8;
524
533
  const MAX_DISCIPLINE_LENGTH = 32;
525
534
 
535
+ // "Recent works" caps (task 1002803). The hub has no tasks table for other
536
+ // projects, so a builder's last few shipped works arrive on the rollup push
537
+ // instead of being fanned out for at read time. THREE PER PROJECT, hard-capped
538
+ // HERE and not only at the sender: the cap is a property of what the hub will
539
+ // store, and a reporting instance is third-party self-report that may be old,
540
+ // buggy or hostile.
541
+ const MAX_RECENT_SHIPS = 3;
542
+ const MAX_SHIP_TITLE_LENGTH = 200;
543
+ const MAX_SHIP_URL_LENGTH = 500;
544
+ // How far into a pushed array the hub will even LOOK. Validating first and
545
+ // capping second is what stops a few malformed entries from crowding the good
546
+ // ones out of the three slots; bounding the scan is what stops an unbounded
547
+ // array from buying unbounded work with one request.
548
+ const MAX_RECENT_SHIPS_SCANNED = 20;
549
+
550
+ // May this ship link be stored? A ship link is the ONLY free-text URL on a public
551
+ // builder portfolio, which makes it the one place a client-secret holder could
552
+ // plant a phishing hop under someone else's name. So it must point AT OR BELOW
553
+ // the instance that reported it: the client's own REGISTERED origin host, or a
554
+ // subdomain of it (an instance's hall may live at builders.<domain> while its
555
+ // registered origin is <domain>). Anything else — another site, a non-http
556
+ // scheme, embedded credentials — stores no link rather than a wrong one.
557
+ //
558
+ // DOWNWARD ONLY, deliberately. The mirror rule ("the link host is an ANCESTOR of
559
+ // the registered origin") looks symmetric and is not: on a shared multi-tenant
560
+ // apex it would let a client registered at foo.github.io publish links on bare
561
+ // github.io, which it does not control. Nothing needs the upward direction — a
562
+ // registered origin is the instance's own public origin — so it is not offered.
563
+ function shipUrlWithinClientOrigin(raw, clientOrigin) {
564
+ if (typeof raw !== 'string' || !raw || raw.length > MAX_SHIP_URL_LENGTH) return null;
565
+ let u, o;
566
+ try { u = new URL(raw); o = new URL(String(clientOrigin || '')); } catch { return null; }
567
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
568
+ if (u.username || u.password) return null;
569
+ const host = u.hostname.toLowerCase(), registered = o.hostname.toLowerCase();
570
+ if (host !== registered && !host.endsWith(`.${registered}`)) return null;
571
+ return raw;
572
+ }
573
+
574
+ // Shape and bound the pushed recent_ships[] before it is stored (the
575
+ // upsertActivity rank/disciplines precedent, ADR 0171 D7 — validation lives next
576
+ // to the data where no route can forget it). Returns AT MOST MAX_RECENT_SHIPS
577
+ // entries of exactly `{ id, title, shipped_at, url }`; a row with no usable id or
578
+ // title is dropped entirely rather than stored half-empty, and a bad date or an
579
+ // off-origin link becomes null on an otherwise good row. Always an array, never
580
+ // null — every reader gets a list without a coalesce.
581
+ function sanitizeRecentShips(raw, clientOrigin) {
582
+ return (Array.isArray(raw) ? raw : [])
583
+ .slice(0, MAX_RECENT_SHIPS_SCANNED)
584
+ .filter((s) => s && typeof s === 'object' && !Array.isArray(s))
585
+ .map((s) => {
586
+ const id = Number(s.id);
587
+ const at = typeof s.shipped_at === 'string' ? Date.parse(s.shipped_at) : NaN;
588
+ return {
589
+ id: Number.isSafeInteger(id) && id > 0 ? id : null,
590
+ title: typeof s.title === 'string' && s.title.trim() ? s.title.slice(0, MAX_SHIP_TITLE_LENGTH) : null,
591
+ shipped_at: Number.isFinite(at) ? new Date(at).toISOString() : null,
592
+ url: shipUrlWithinClientOrigin(s.url, clientOrigin),
593
+ };
594
+ })
595
+ .filter((s) => s.id !== null && s.title !== null)
596
+ .slice(0, MAX_RECENT_SHIPS);
597
+ }
598
+
526
599
  // Upsert one project's rollup for one builder (keyed by client_id + github_id).
527
600
  // Descriptive analytics — never authority. Refreshes last_active on each report.
528
- async function upsertActivity({ clientId, githubId, credits = 0, tasksShipped = 0, karma = 0, rank = null, disciplines = [] }, { pool = defaultPool } = {}) {
601
+ // `clientOrigin` is the AUTHENTICATED client's registered origin (never a
602
+ // caller-named one) and exists only to bound the ship links — see
603
+ // shipUrlWithinClientOrigin.
604
+ async function upsertActivity({ clientId, githubId, credits = 0, tasksShipped = 0, karma = 0, rank = null, disciplines = [], recentShips = [], clientOrigin = null }, { pool = defaultPool } = {}) {
529
605
  // api.LIVE_RANK_LADDER is a published doorway export (src/module-api.js, a
530
606
  // lazy getter over db-kernel's canonical four-rank ladder).
531
607
  const safeRank = api.LIVE_RANK_LADDER.includes(rank) ? rank : null;
532
608
  const disc = (Array.isArray(disciplines) ? disciplines : [])
533
609
  .filter((d) => typeof d === 'string' && d.length > 0 && d.length <= MAX_DISCIPLINE_LENGTH)
534
610
  .slice(0, MAX_ROLLUP_DISCIPLINES);
611
+ const ships = sanitizeRecentShips(recentShips, clientOrigin);
535
612
  const { rows } = await pool.query(
536
613
  `INSERT INTO platform_identity_builder_project_activity
537
- (client_id, github_id, credits, tasks_shipped, karma, rank_held, disciplines, last_active, updated_at)
538
- VALUES ($1, $2, $3, $4, $5, $6, $7, now(), now())
614
+ (client_id, github_id, credits, tasks_shipped, karma, rank_held, disciplines, recent_ships, last_active, updated_at)
615
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, now(), now())
539
616
  ON CONFLICT (client_id, github_id) DO UPDATE SET
540
617
  credits = EXCLUDED.credits, tasks_shipped = EXCLUDED.tasks_shipped, karma = EXCLUDED.karma,
541
618
  rank_held = EXCLUDED.rank_held, disciplines = EXCLUDED.disciplines,
619
+ recent_ships = EXCLUDED.recent_ships,
542
620
  last_active = now(), updated_at = now()
543
621
  RETURNING id, client_id, github_id`,
544
- [clientId, githubId, credits | 0, tasksShipped | 0, karma | 0, safeRank, disc],
622
+ // jsonb is bound as a JSON STRING: node-postgres serializes a JS array as a
623
+ // Postgres ARRAY literal, which the jsonb column would reject.
624
+ [clientId, githubId, credits | 0, tasksShipped | 0, karma | 0, safeRank, disc, JSON.stringify(ships)],
545
625
  );
546
626
  return rows[0];
547
627
  }
@@ -644,7 +724,7 @@ async function getRollupForBuilder(githubId, { pool = defaultPool } = {}) {
644
724
  );
645
725
  const agg = aggRows[0];
646
726
  const { rows: projRows } = await pool.query(
647
- `SELECT p.name, p.origin, a.rank_held,
727
+ `SELECT p.name, p.origin, a.rank_held, a.recent_ships,
648
728
  (p.id IS NOT NULL AND p.featured AND p.status = 'active') AS is_public
649
729
  FROM platform_identity_builder_project_activity a
650
730
  LEFT JOIN platform_identity_sso_clients c ON c.client_id = a.client_id
@@ -653,9 +733,19 @@ async function getRollupForBuilder(githubId, { pool = defaultPool } = {}) {
653
733
  ORDER BY a.last_active DESC`,
654
734
  [githubId],
655
735
  );
736
+ // recent_ships rides the project row it belongs to (task 1002803) — which is
737
+ // also what keeps it honest: a work title is at least as identifying as the
738
+ // project name it came from, so it may only appear where the name already
739
+ // may. A non-public project stays counted in more_projects and its work
740
+ // titles never leave the hub, exactly as its name doesn't.
656
741
  const named = projRows
657
742
  .filter((r) => r.is_public)
658
- .map((r) => ({ name: r.name, origin: r.origin, rank_held: r.rank_held }));
743
+ .map((r) => ({
744
+ name: r.name,
745
+ origin: r.origin,
746
+ rank_held: r.rank_held,
747
+ recent_ships: Array.isArray(r.recent_ships) ? r.recent_ships : [],
748
+ }));
659
749
  return {
660
750
  totals: {
661
751
  projects: agg.projects,
@@ -1180,6 +1270,7 @@ module.exports = {
1180
1270
  publishableSql,
1181
1271
  upsertProject,
1182
1272
  PROJECT_TYPES,
1273
+ PROJECT_TEMPLATES,
1183
1274
  getProject,
1184
1275
  listFeaturedProjects,
1185
1276
  listAllProjects,
@@ -48,6 +48,9 @@ module.exports = function projectsRoutes() {
48
48
  description: { type: 'string', maxLength: 2000 },
49
49
  art_url: { type: 'string', maxLength: 2048 },
50
50
  type: { type: 'string', enum: [...pi.PROJECT_TYPES] },
51
+ // The planet template (task 1003319): the shape and style the sky cuts this
52
+ // project's orb in. Curated here, never self-reported; absent means the hub's walk.
53
+ template: { type: 'string', enum: [...pi.PROJECT_TEMPLATES] },
51
54
  })) return;
52
55
  try {
53
56
  const project = await pi.upsertProject({
@@ -57,6 +60,7 @@ module.exports = function projectsRoutes() {
57
60
  description: req.body.description,
58
61
  artUrl: req.body.art_url,
59
62
  type: req.body.type,
63
+ template: req.body.template,
60
64
  }, { pool });
61
65
  res.status(201).json({ project });
62
66
  } catch (err) {
@@ -426,6 +426,11 @@ module.exports = function ssoRoutes() {
426
426
  karma: { type: 'number' },
427
427
  rank: { type: 'string', maxLength: 32 },
428
428
  disciplines: {},
429
+ // "Recent works" (task 1002803): up to 3 of this builder's latest shipped
430
+ // works on THIS project — [{ id, title, shipped_at, url }]. Left untyped
431
+ // here, like disciplines: the shape is checked and hard-capped in
432
+ // upsertActivity, next to the data, so no route can forget it.
433
+ recent_ships: {},
429
434
  })) return;
430
435
  const b = req.body || {};
431
436
  try {
@@ -456,6 +461,11 @@ module.exports = function ssoRoutes() {
456
461
  karma: b.karma,
457
462
  rank: b.rank || null,
458
463
  disciplines: Array.isArray(b.disciplines) ? b.disciplines : [],
464
+ recentShips: b.recent_ships,
465
+ // The AUTHENTICATED client's registered origin — the bound every pushed
466
+ // ship link must fall inside (task 1002803). Taken from the client row,
467
+ // never from the body, for the same reason client_id is.
468
+ clientOrigin: client.origin,
459
469
  }, { pool });
460
470
  res.json({ ok: true });
461
471
  } catch (err) {