@bongos/core 1.19.93 → 1.19.94

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.
@@ -102,7 +102,6 @@ const KNOWN_FAILING = new Set([
102
102
  // diagnoses live in that task. Remove each here as it is fixed.
103
103
  // PAIRING: tests/known_failing_ratchet.mjs keeps EXECUTING each parked test
104
104
  // and fails the suite the moment one starts passing — so this set cannot rot.
105
- 'platform_boot',
106
105
  'served_paths_shipped',
107
106
  ]);
108
107
 
@@ -192,7 +192,7 @@ const MODULE_GLOBS = {
192
192
  // (moduleWebSurfaces → serve-internal). Its public read endpoints stay SHARED
193
193
  // core in routes/public.js (the hall + the off-box mirror consume them too).
194
194
  'status-ui': ['modules/status-ui/'],
195
- // The instance's public landing surface (index/world/gate/create), served by
195
+ // The instance's public landing surface (index/projects/create), served by
196
196
  // platform-server.js from the repo-root public-landing/ dir — not yet a carved
197
197
  // module, so the glob lists where the code lives TODAY (the auto-tighten
198
198
  // contract above). Its own key, not provisioning's: the front door is broader
package/src/module-api.js CHANGED
@@ -613,8 +613,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
613
613
  // landed since 1.19.91 with no explicit bump. run 31727411036. (task 1002620)
614
614
  // 1.19.93 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
615
615
  // landed since 1.19.92 with no explicit bump. run 31735192921. (task 1002620)
616
+ // 1.19.94 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
617
+ // landed since 1.19.93 with no explicit bump. run 31742308532. (task 1002620)
616
618
  // ---------------------------------------------------------------------------
617
- const CORE_VERSION = '1.19.93'; // CI auto-patch carrier (ADR 0161); see changelog above
619
+ const CORE_VERSION = '1.19.94'; // CI auto-patch carrier (ADR 0161); see changelog above
618
620
 
619
621
  // A namespaced logger so a module's log lines are attributable + consistent.
620
622
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -96,19 +96,6 @@ function stripApexOnly(html, isHub = isModuleEnabled('platform-identity')) {
96
96
  return isHub ? html : String(html).replace(APEX_ONLY, '');
97
97
  }
98
98
  function landingHtml() { return landingPage('index.html'); }
99
- // The interactive landing gate (public-landing/gate/, ported from amazonprimea
100
- // in task 1002407). When an instance ships it, it is the apex front door
101
- // (task 1002408). Unlike the placeholder landing it is a self-contained baked
102
- // design that reads no window.__BRANDING__, so it is served RAW, not injected.
103
- // Memoized: absolute path to gate/index.html, or '' when the instance ships no gate.
104
- let _gateIndexPath; // undefined = unresolved, '' = absent, string = present
105
- function gateIndexPath() {
106
- if (_gateIndexPath === undefined) {
107
- const p = path.join(LANDING_DIR, 'gate', 'index.html');
108
- _gateIndexPath = fs.existsSync(p) ? p : '';
109
- }
110
- return _gateIndexPath;
111
- }
112
99
  function isApexHost(req) {
113
100
  return !isStatusHost(req) && !isBuildersHost(req);
114
101
  }
@@ -127,38 +114,30 @@ function buildPlatformApp() {
127
114
  // The whole internal surface — shared verbatim with server.js (ADR 0062 R51).
128
115
  mountInternalSurfaces(app);
129
116
 
130
- // Apex landing. On the apex host (not status./builders.):
131
- // * if this instance ships the interactive landing gate (public-landing/gate/),
132
- // the gate is the front door — GET / 302-redirects to /gate/ (mirrors the
133
- // amazonprimea Caddy rule this replaces, task 1002408). The redirect (not a
134
- // direct serve at /) is required so the browser base URL becomes /gate/ and
135
- // the gate's relative asset paths (sprites/…, ../world, ../assets) resolve.
136
- // * otherwise GET / serves the branding-injected minimal placeholder.
137
- // Both fall through to the 404 tail when neither is present, and to the landing
138
- // asset handler below for non-/ paths (e.g. the mascot image).
117
+ // THE APEX FRONT DOOR (task 1002743). GET / serves the real landing page,
118
+ // branding-injected and apex-stripped like every other landing surface.
119
+ //
120
+ // It used to 302 to /gate/ — the interactive orb sky (task 1002408). That
121
+ // redirect is gone with the gate itself: the landing page is now the front
122
+ // door and /projects is the hub the sky became a view inside. The redirect
123
+ // could only be removed once the landing page carried real doors into the
124
+ // hub, which it does (front-door-and-hub-direction.md §3); removing it
125
+ // earlier would have left navigation worse than it was.
126
+ //
127
+ // NOTE THE INFRA HALF: on a live instance the reverse proxy may carry its own
128
+ // `redir / /gate/ 302` (ADR 0152 records the Caddy @gate block). Caddy runs
129
+ // that before the request ever reaches Node, so this change alone does not
130
+ // move a deployed apex — the operator has to drop that rule, and the
131
+ // pre-launch basic_auth it sits with, at launch.
132
+ //
133
+ // Falls through to the 404 tail when the instance ships no landing page, and
134
+ // to the landing asset handler below for non-/ paths (e.g. the mascot image).
139
135
  app.get('/', (req, res, next) => {
140
136
  if (!isApexHost(req)) return next();
141
- if (gateIndexPath()) return res.redirect(302, '/gate/');
142
137
  const html = landingHtml();
143
138
  if (!html) return next();
144
139
  res.type('html').send(html);
145
140
  });
146
- // /gate → /gate/ (so the gate's relative asset paths resolve against the dir),
147
- // and /gate/ serves the gate's own index RAW — a self-contained baked design,
148
- // so no window.__BRANDING__ injection. The gate's sub-assets (/gate/*, /world/*,
149
- // /assets/*) are served by the express.static(LANDING_DIR) handler below with no
150
- // extra rule, which also closes the amazonprimea Caddy gap where /assets/anim/idle/*
151
- // (the buddha idle frames) fell through to the app instead of the static server.
152
- // One handler for both forms — Express's non-strict routing matches /gate and
153
- // /gate/ interchangeably, so branch on the real req.path: /gate 301→ /gate/
154
- // (so relative asset paths resolve against the dir), /gate/ serves the index.
155
- app.get(['/gate', '/gate/'], (req, res, next) => {
156
- if (!isApexHost(req)) return next();
157
- const p = gateIndexPath();
158
- if (!p) return next();
159
- if (!req.path.endsWith('/')) return res.redirect(301, '/gate/');
160
- return res.type('html').sendFile(p);
161
- });
162
141
  // The branded project-creation experience (goal 35, task 2081) — a sibling
163
142
  // landing page at a clean /create URL, branding-injected like the landing.
164
143
  app.get('/create', (req, res, next) => {
@@ -167,9 +146,12 @@ function buildPlatformApp() {
167
146
  if (!html) return next();
168
147
  res.type('html').send(html);
169
148
  });
170
- // The owner's my-projects surface (goal 1000059, task 1002693) — the durable
171
- // place to find, watch, and manage one's instances after the /create tab
172
- // closes. A sibling landing page, branding-injected like /create.
149
+ // THE PROJECT HUB (task 1002743) — one surface holding every project-shaped
150
+ // action: the map of what exists (the orb sky the gate used to own, plus the
151
+ // directory table), the owner's own projects, and starting a new one. The
152
+ // view is chosen client-side from `?view=map|mine|new`; this route matches on
153
+ // path only and serves byte-identical HTML for every view, which is why all
154
+ // three ship as real markup rather than being built by script.
173
155
  app.get('/projects', (req, res, next) => {
174
156
  if (!isApexHost(req)) return next();
175
157
  const html = landingPage('projects.html');
@@ -32,16 +32,15 @@ const TESTS_DIR = path.dirname(fileURLToPath(import.meta.url));
32
32
  // ">=7 host tests" non-vacuity floor had gone stale after ADR 0107 moved the
33
33
  // host-module tests to modules/<key>/tests/; it is a regex specimen self-test
34
34
  // now, and idea 1000542 — which owned it — is superseded.)
35
- const MAX_KNOWN_FAILING = 2;
35
+ // Ratcheted down 2 → 1 by task 1002743, which unparked platform_boot. The
36
+ // diagnosis its entry asked for turned out to be the one it suspected: the apex
37
+ // really was redirecting instead of serving the landing page, and the test was
38
+ // right all along. The apex flip removed the redirect, so the test passes and
39
+ // the entry had to go in the same change — this file fails the suite the moment
40
+ // a parked test starts passing.
41
+ const MAX_KNOWN_FAILING = 1;
36
42
 
37
43
  const KNOWN_FAILING = {
38
- platform_boot: {
39
- owner: 'task 1002617 (parked in run-unit-tests KNOWN_FAILING by task 1002611)',
40
- why: 'GET / on the apex returns 302, not the 200 landing page the test expects. '
41
- + 'Possibly a REAL product bug (the apex redirecting instead of serving the '
42
- + 'landing) rather than a test problem, so it was deliberately not "fixed" by '
43
- + 'relaxing the assertion. Diagnose the redirect first.',
44
- },
45
44
  served_paths_shipped: {
46
45
  owner: 'task 1002617; collides with the ACTIVE claim "make the /docs/openapi.json endpoint self-healing"',
47
46
  why: 'Asserts serve-internal.js reads docs/api/openapi.json. That exact path is what '
@@ -209,12 +209,25 @@ test('the ripples are hidden under reduced motion — they rest at full stroke',
209
209
  });
210
210
 
211
211
  test('the self-typing eyebrow degrades to real text, not an empty span', () => {
212
- // the script only decorates; markup must already carry the word
213
- assert.match(HTML, /<span class="typed" id="typed">bongos<\/span>/);
212
+ // the script only decorates; markup must already carry the whole sentence
213
+ assert.match(HTML, /type <b>bongos<\/b> in <span class="typed" id="typed">any AI<\/span>/);
214
214
  assert.match(CODE, /matchMedia\('\(prefers-reduced-motion: reduce\)'\)/);
215
215
  assert.match(CODE, /if \(!reduce\) \{/, 'the typing loop is skipped under reduced motion');
216
216
  });
217
217
 
218
+ test('the eyebrow types the ASSISTANT, never the word you type (task 1002743)', () => {
219
+ // "bongos" is the constant — it is the word a person types — and the
220
+ // assistant is what varies. Cycling the first slot produced "type claude in
221
+ // any AI", which nobody does and which inverts the claim the line makes.
222
+ assert.doesNotMatch(HTML, /type <span class="typed"/, 'the typed span must not hold the typed-word slot');
223
+ const words = CODE.match(/words\s*=\s*\[([^\]]*)\]/);
224
+ assert.ok(words, 'the eyebrow word list must be findable');
225
+ assert.doesNotMatch(words[1], /'bongos'/, 'bongos is fixed copy, never a cycled word');
226
+ for (const ai of ['Claude', 'ChatGPT', 'Gemini']) {
227
+ assert.match(words[1], new RegExp(`'${ai}'`), `${ai} must be one of the cycled assistants`);
228
+ }
229
+ });
230
+
218
231
  test('nothing about the platform is hardcoded — the file is portable (C5)', () => {
219
232
  // the source repo link is a real, deliberate attribution; every OTHER mention
220
233
  // of an origin must be a token.
@@ -0,0 +1,205 @@
1
+ // tests/projects_hub.mjs — the /projects HUB contract (task 1002743).
2
+ //
3
+ // /projects is one surface holding three views — map (sky | table), your
4
+ // projects, and new project — under a persistent shell, selected by
5
+ // `?view=map|mine|new`. The design contract is docs/design/projects-hub-direction.md
6
+ // plus its approved mock; this file pins the parts of it a later change could
7
+ // break silently, and the numbered constraints it cites are that document's §7.
8
+ //
9
+ // Static analysis on purpose: the page's structure must be true of the FILE, not
10
+ // only of a booted server, because C7 is precisely "real markup, not JS-built".
11
+ // The served shape (branding injection, apex stripping, the id="projectsList"
12
+ // contract) is covered by tests/public_landing_projects.mjs.
13
+
14
+ import test from 'node:test';
15
+ import assert from 'node:assert/strict';
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+
20
+ const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
21
+ const HUB = fs.readFileSync(path.join(ROOT, 'public-landing', 'projects.html'), 'utf8');
22
+ const COSMOS = fs.readFileSync(path.join(ROOT, 'public-landing', 'assets', 'cosmos.css'), 'utf8');
23
+ const STYLE = (HUB.match(/<style>([\s\S]*?)<\/style>/) || [, ''])[1];
24
+ const SCRIPT = (HUB.match(/<script>([\s\S]*?)<\/script>\s*<\/body>/) || [, ''])[1];
25
+
26
+ test('all three views ship as real markup, not built by script (C7)', () => {
27
+ for (const v of ['map', 'mine', 'new']) {
28
+ assert.match(HUB, new RegExp(`id="view-${v}"`), `view-${v} must exist in the markup`);
29
+ }
30
+ // the map's two sub-views and the manage list are markup too
31
+ assert.match(HUB, /id="sub-sky-body"/);
32
+ assert.match(HUB, /id="sub-table-body"/);
33
+ assert.match(HUB, /id="projectsList"/);
34
+ // and the copy a crawler reads is in the document, not in a template string
35
+ assert.match(HUB, /Start a project/);
36
+ assert.match(HUB, /Your projects/);
37
+ });
38
+
39
+ test('the nav carries Home plus the three views, and Home is not a view', () => {
40
+ assert.match(HUB, /data-nav="home"[^>]*>Home|<a href="\/" class="navHome" data-nav="home">Home/);
41
+ for (const v of ['map', 'mine', 'new']) {
42
+ assert.match(HUB, new RegExp(`href="\\?view=${v}"\\s+data-nav="${v}"`),
43
+ `the nav must deep-link ?view=${v}`);
44
+ }
45
+ // Home navigates to the landing page; it must never take the current-view pill
46
+ assert.match(SCRIPT, /if \(a\.dataset\.nav === 'home'\) return;/,
47
+ 'the view router must leave Home as a real navigation');
48
+ assert.match(SCRIPT, /a\.dataset\.nav !== 'home' && a\.dataset\.nav === v/,
49
+ 'Home must be excluded from the aria-current pill');
50
+ });
51
+
52
+ test('?view= is the deep-link contract, read on boot and written with replaceState', () => {
53
+ assert.match(SCRIPT, /new URLSearchParams\(location\.search\)\.get\('view'\)/);
54
+ assert.match(SCRIPT, /history\.replaceState\(null, '', '\?view=' \+ v\)/);
55
+ // the landing page's CTAs have always pointed here — this is the bug they hit
56
+ const landing = fs.readFileSync(path.join(ROOT, 'public-landing', 'index.html'), 'utf8');
57
+ assert.match(landing, /href="\/projects\?view=new"/,
58
+ 'the landing CTA target must stay a view the hub knows');
59
+ });
60
+
61
+ test('no page-local :root — it would silently defeat re-branding (C1)', () => {
62
+ // injectBrandingGlobal() splices the pack's :root at the END of <head>, at
63
+ // equal specificity but later in order. A page-local one outranks it forever.
64
+ assert.doesNotMatch(STYLE, /(^|\s):root\s*\{/,
65
+ 'page-local :root defeats the branding pack — scope custom properties to their component');
66
+ });
67
+
68
+ test('the page grounds on .sky, never on body (C3)', () => {
69
+ // themeMarkup() emits body{background:<light>} after cosmos.css in BOTH modes
70
+ // and is not mode-aware, so .sky is the only mode-aware full-page paint.
71
+ assert.match(HUB, /<div class="sky"/);
72
+ assert.match(COSMOS, /\.sky\{[^}]*background:var\(--sky-bg\)/);
73
+ assert.doesNotMatch(STYLE, /body\s*\{[^}]*background/,
74
+ 'the hub must not paint its own body background');
75
+ });
76
+
77
+ test('every dark override in the page is written twice (C2)', () => {
78
+ const explicit = (STYLE.match(/:root\[data-mode="dark"\]/g) || []).length;
79
+ const media = (STYLE.match(/:root:not\(\[data-mode="light"\]\)/g) || []).length;
80
+ assert.equal(explicit, media,
81
+ 'each :root[data-mode="dark"] rule needs its prefers-color-scheme twin');
82
+ });
83
+
84
+ test('apex-only sections use balanced sentinels so a child instance loses them (C5)', () => {
85
+ const starts = (HUB.match(/<!--apex-only:start-->/g) || []).length;
86
+ const ends = (HUB.match(/<!--apex-only:end-->/g) || []).length;
87
+ assert.equal(starts, ends, 'apex-only sentinels must be balanced');
88
+ assert.ok(starts >= 2, 'the hall link and the commons claim are both apex-only');
89
+ // the platform's own orb is apex-only, and it must be MARKUP — stripApexOnly()
90
+ // is a string transform and cannot reach anything a script renders later
91
+ const apexBlocks = HUB.match(/<!--apex-only:start-->[\s\S]*?<!--apex-only:end-->/g) || [];
92
+ assert.ok(apexBlocks.some((b) => b.includes('selfOrb')),
93
+ 'the platform self-orb must sit inside the sentinels, in markup');
94
+ assert.ok(apexBlocks.some((b) => b.includes('buildersOrigin')),
95
+ 'the hall link is apex-only — a child instance has no hall');
96
+ });
97
+
98
+ test('cross-host links are server-stamped tokens, never client-filled', () => {
99
+ // clientBranding() deliberately withholds domains, so window.__BRANDING__ can
100
+ // never re-point a hall/apex href. Only the {{token}} pass can.
101
+ assert.match(HUB, /href="\{\{buildersOrigin\}\}"/);
102
+ assert.match(HUB, /<title>\{\{productName\}\}/);
103
+ });
104
+
105
+ test('the hall link is not labelled "Enter the Hall"', () => {
106
+ // tests/public_landing_projects.mjs forbids that copy on this page: the hall
107
+ // my-projects card was removed in task 1002675 and an owner must not be sent
108
+ // there to find their own projects.
109
+ assert.doesNotMatch(HUB, /Enter the Hall/i);
110
+ });
111
+
112
+ test('orb accents are tokens, never literal hexes (§6)', () => {
113
+ // ONE palette serves both the map's spheres and the manage cards, so a
114
+ // project reads the same colour in either view
115
+ const accents = (SCRIPT.match(/var ORB_ACCENTS = \[([^\]]*)\]/) || [, ''])[1];
116
+ assert.ok(accents.length, 'the orb accent list must be findable');
117
+ assert.doesNotMatch(accents, /#[0-9a-f]{3,8}/i,
118
+ 'a hardcoded hex does not follow a re-theme — derive from tokens');
119
+ for (const t of ['--sea', '--terracotta', '--accent', '--sun-lit', '--gold-soft']) {
120
+ assert.match(accents, new RegExp(`var\\(${t}\\)`), `${t} must be one of the orb accents`);
121
+ }
122
+ const glows = (SCRIPT.match(/var ORB_GLOWS = ([^;]*);/) || [, ''])[1];
123
+ assert.doesNotMatch(glows, /rgba?\(/, 'orb glows must derive from the accents, not literal rgba');
124
+ assert.equal((SCRIPT.match(/var ORB_ACCENTS/g) || []).length, 1,
125
+ 'one palette, not two — the map and the manage cards must not drift apart');
126
+ });
127
+
128
+ test('accents are assigned by canonical position, not by hashing the name', () => {
129
+ // a name hash collided: with five tokens and four projects it put three of
130
+ // them on the same ember and the sky stopped being readable
131
+ assert.match(SCRIPT, /function assignAccents/);
132
+ assert.doesNotMatch(SCRIPT, /hashIndex\(p\.name/,
133
+ 'name-hashed accents collide — assign from a canonical order');
134
+ });
135
+
136
+ test('the mark holds "bongos" and types the assistant, not the other way round', () => {
137
+ // `bongos` is the word a person types; the assistant is what varies. This is
138
+ // the same correction the landing hero needed.
139
+ assert.match(HUB, /<span id="markWord">bongos<\/span>/);
140
+ const prefixes = (SCRIPT.match(/var PREFIXES = \[([^\]]*)\]/) || [, ''])[1];
141
+ assert.ok(prefixes.length, 'the typed prefix list must be findable');
142
+ assert.doesNotMatch(prefixes, /'bongos'/, 'bongos is the constant, never a typed variant');
143
+ assert.match(prefixes, /'claude'/);
144
+ });
145
+
146
+ test('the typing and the map both respect prefers-reduced-motion (C9)', () => {
147
+ assert.match(SCRIPT, /prefers-reduced-motion: reduce/);
148
+ assert.match(SCRIPT, /if \(REDUCED \|\|/, 'the typing loop must be skipped under reduced motion');
149
+ });
150
+
151
+ test('the owner poll is view-guarded — it must not run on the map (C8)', () => {
152
+ assert.match(SCRIPT, /function shouldPoll\(\)[\s\S]*?currentView === 'mine'/,
153
+ 'the settled-card refresh must only run in the view that has cards');
154
+ });
155
+
156
+ test('catalog origins are scheme-checked before they become hrefs', () => {
157
+ // esc() neutralizes HTML metacharacters but NOT a `javascript:` URI, and this
158
+ // page drives teardown / revive / domain-attach for the signed-in owner — so
159
+ // script running in its origin is not a small thing. Same predicate the
160
+ // landing page's catalog band already uses (public-landing/index.html:544).
161
+ assert.match(SCRIPT, /function safeHttpUrl/);
162
+ assert.match(SCRIPT, /u\.protocol === 'https:' \|\| u\.protocol === 'http:'/);
163
+ // and no raw origin may reach an href
164
+ assert.doesNotMatch(SCRIPT, /a\.href = p\.origin/, 'orb hrefs must go through safeHttpUrl');
165
+ assert.doesNotMatch(SCRIPT, /href="' \+ esc\(p\.origin\)/, 'table hrefs must go through safeHttpUrl');
166
+ });
167
+
168
+ test('liveness is never claimed for a catalog row the hub did not probe (C6)', () => {
169
+ // the public feed carries origin/name/tagline/art_url and nothing else — a
170
+ // chip saying "live" here would assert more than anything verified
171
+ assert.match(SCRIPT, /chip calm/, 'an unprobed catalog row gets the calm chip');
172
+ assert.doesNotMatch(SCRIPT, /chip live[^']*'\s*\+\s*esc\(p\./,
173
+ 'no catalog row may be rendered as live');
174
+ // the three active outcomes stay three in the manage list
175
+ for (const label of ['unreachable', 'live\\?', 'live']) {
176
+ assert.match(SCRIPT, new RegExp(`'${label}'`), `the ${label} outcome must survive`);
177
+ }
178
+ });
179
+
180
+ test('the gate is retired — no route, no files, no links into it', () => {
181
+ // the FILES, not the directory: git cannot track an empty dir, so a stale
182
+ // working tree may keep the folder while the commit is clean
183
+ for (const f of ['index.html', 'space.js', 'space.css']) {
184
+ assert.equal(fs.existsSync(path.join(ROOT, 'public-landing', 'gate', f)), false,
185
+ `public-landing/gate/${f} must be gone: express.static would keep serving it`);
186
+ }
187
+ const server = fs.readFileSync(path.join(ROOT, 'src', 'platform-server.js'), 'utf8');
188
+ assert.doesNotMatch(server, /res\.redirect\(302, '\/gate\/'\)/,
189
+ 'the apex must serve the landing page, not redirect into the gate');
190
+ assert.doesNotMatch(server, /gateIndexPath/);
191
+ assert.doesNotMatch(HUB, /href="\/gate/);
192
+ });
193
+
194
+ test('/create still answers — the hub links it rather than absorbing it', () => {
195
+ // Task 1002743's Part 3 was AMENDED by the owner on 2026-08-13 to move the
196
+ // "/create redirects into the hub" clause to task 1002763, which carries the
197
+ // port that makes it safe. ?view=new is step 1 of the four-beat rail, so
198
+ // redirecting /create here before the wizard lives in the hub would let a
199
+ // visitor name a project and then go nowhere — creation would break. The
200
+ // wizard also has four id collisions with this page and a replaceState that
201
+ // strips the query string. Until 1002763 lands, /create must keep answering.
202
+ assert.ok(fs.existsSync(path.join(ROOT, 'public-landing', 'create.html')));
203
+ assert.match(SCRIPT, /location\.assign\('\/create'/);
204
+ assert.match(HUB, /href="\/create"/, 'the empty state must still point at the wizard');
205
+ });
@@ -1,137 +0,0 @@
1
- # Cloud Bongos — landing gate (interactive)
2
-
3
- `index.html` is the Cloud Bongos landing experience — a small state machine over one
4
- self-contained page (`space.css` + `space.js` for the space side; the download scene + its
5
- drag physics live in the inline `<script>`), built from **drawn ATLA-style sprites**
6
- (`sprites/*.png` — a `> cloud bongos` terminal, an enter key, a search bar, and download tiles)
7
- refracting the backdrop through SVG displacement maps.
8
-
9
- ## The flow (tasks 1721, 1722)
10
-
11
- Four body-class views: `view-entry` → `view-space` → `view-download` / `view-detail`.
12
-
13
- 1. **Entry — type `bongos`, in space** (`#entry`, `view-entry`). Every visit lands here: the
14
- `> cloud ▊` terminal floating in the **space sky** (deep-blue → sunrise gradient, drifting
15
- clouds), *not* the temple world. Typing `bongos` is the ritual on every visit; the enter key
16
- lights, and pressing enter flashes through into the circles. Driven by `space.js`
17
- (`entryKey` / `enterSite`), reusing the terminal + enter CSS.
18
- 2. **Space — the circles** (`#space` / `#space-canvas`, `view-space`). A pannable, zoomable sky
19
- of project orbs, sized by number of builders, moving as a **living gravity cluster** — every
20
- orb attracts every other by its mass (∝ size³), so the field swirls, bodies sling past one
21
- another, and a small orb is sometimes briefly caught circling a big one like a moon (task
22
- 1733; see "The gravity" below). Project data is **inlined** in `space.js` (`PROJECTS`) — Caddy
23
- 404s `*.json` on staging — so swap in a real registry by changing `PROJECTS` + `layout()`.
24
- - **Drag** empty sky to pan · **scroll** to zoom (toward the cursor).
25
- - **Hover** an orb for an info card; **click** it to dive in.
26
- - **Space bar** opens a search scoped to the orbs **currently in view** (client-side match
27
- over name/tagline/tags now; the Claude-backed answer is a dormant seam, `aiAnswer()`, per
28
- the "interaction now, AI wired next" decision).
29
- - **Click empty sky** drops a new project circle.
30
- 3. **Download — Cloud Bongos' install page** (`#land` in `.stage`, `view-download`). Clicking the
31
- Cloud Bongos orb zooms **straight** into the search + download-tiles scene over the
32
- temple-world backdrop — **no type-`bongos` gate in between** (that already happened at entry).
33
- `space.js` fires `cb:show-download`; the inline script reveals `#land` and runs its drag.
34
- 4. **Detail — other projects** (`#detail`, `view-detail`). Clicking any non-self orb opens a
35
- themed "full story — coming soon" page with a link out to its live site.
36
-
37
- **ESC** rises from the download or detail page back to the circles (`space.js` → `cb:hide-download`).
38
-
39
- ## The download scene's elastic-tether drag (task 1534)
40
-
41
- Every download sprite (search bar, tiles, wordmark) is draggable. Grab one and it resists like
42
- it's anchored; pull past the threshold and it snaps free, hovers, then drifts slowly home and
43
- re-docks, carrying a faint idle bob at rest. The physics is scoped to `.stage`, so it never
44
- grabs the entry terminal (which `space.js` owns). **This scene is separate from the space
45
- gravity below — the download interaction is unchanged by task 1733.**
46
-
47
- ## The space gravity — a living n-body cluster (task 1733)
48
-
49
- The project orbs on the **space** view (`view-space`) are a real gravitational simulation, all
50
- in `space.js`. Each orb is a body with **mass ∝ size³** (more builders → bigger → heavier →
51
- pulls harder), so the sky behaves like planets, suns and the occasional fleeting moon rather
52
- than a fixed scatter. Under the hood:
53
-
54
- - **Softened Newtonian gravity**, every pair, so a close pass *slings* instead of blowing up
55
- (no singularities).
56
- - A **fixed-timestep leapfrog integrator** (`physStep`) for accurate, energy-stable motion; the
57
- frame loop (`tick` → `render`) interpolates between steps for smoothness.
58
- - **Soft collisions** so discs bounce rather than overlap; a **soft containment well** plus a
59
- **band thermostat** keep the swirl bounded and lively *forever* — energy is free to ebb and
60
- flow inside a band, and only a runaway collapse or blow-up gets a gentle nudge back.
61
- - Net drift is removed each step, so the cluster stays centred on screen (and stays put when a
62
- visitor adds a new orb, which is flung into the swirl).
63
-
64
- Verified stable over a 60-minute headless simulation (no NaN, never off-screen, never
65
- interpenetrating). It replaced the earlier "drift around fixed anchors" motion.
66
-
67
- ### Tuning the gravity
68
-
69
- All knobs live in the `GRAV` block near the top of `space.js`:
70
-
71
- | You want… | Edit (in `GRAV`) |
72
- |--------------------------------------|---------------------------------------------------|
73
- | Livelier / faster swirl | `SWIRL` ↑ (or `THERMO_LO` ↑) |
74
- | Calmer, slower motion | `SWIRL` ↓ / `THERMO_LO` ↓ |
75
- | Tighter cluster (less panning) | `CONTAIN_R` ↓ |
76
- | Stronger pull between orbs | `G` ↑ |
77
- | Fewer hard slingshots on near-misses | `SOFTEN` ↑ |
78
- | Bouncier contacts (→ elastic) | `REST` → 1 |
79
- | Sharper size→mass contrast | lower `R_REF`, or raise the `massFor` exponent |
80
-
81
- Turn it off (freeze the orbs): comment out `initOrbits()` in `layout()` and the
82
- `requestAnimationFrame(tick)` at the bottom of the IIFE — pan, zoom, hover, dive, search and the
83
- table all keep working.
84
-
85
- > **Live:** password-locked at **staging.cloudbongos.com** (the future landing page, parked
86
- > for review). Setup + the password live in [`docs/recipes/cloudbongos-staging-gate.md`](../../../docs/recipes/cloudbongos-staging-gate.md).
87
- > Branding (logo coin / wordmark / mascot / code-glyphs) is still being finished and is
88
- > intentionally untouched here.
89
-
90
- ## Preview it locally
91
-
92
- The page links to art one folder up (`../world/assets/…`), so it must be served from the
93
- **`brand/cloud-bongos/`** folder — not from `gate/` itself, or those paths break.
94
-
95
- **Easiest (Claude Code preview):** start the `gate-preview` server in `.claude/launch.json`,
96
- then open `http://127.0.0.1:8137/gate/index.html`.
97
-
98
- **From a terminal** (repo root): `python3 -m http.server --directory brand/cloud-bongos 8137`,
99
- then open <http://127.0.0.1:8137/gate/index.html>.
100
-
101
- Use a Chromium browser (Chrome/Edge) — the live-glass refraction relies on `backdrop-filter`
102
- with an SVG displacement map. Safari ignores the displacement and falls back to a plain
103
- frost, which still looks fine.
104
-
105
- ## Tuning the drag
106
-
107
- All behaviour is in the single `<script>` at the bottom of `index.html`, which opens with a
108
- commented **TUNING** block. Common tweaks:
109
-
110
- | You want… | Edit (in the TUNING block) |
111
- |------------------------------------------|-----------------------------------|
112
- | Harder / easier to pull a pane free | `THRESHOLD` — ↑ harder, ↓ easier |
113
- | How far a tap can move before it's a drag | `DEADZONE` — px |
114
- | Freed panes wait longer before returning | `HOVER` — milliseconds |
115
- | Slower, more drifty return | `DRIFT_MAXV` — ↓ slower |
116
- | Wider, more wandering return path | `WMAG` ↑ and/or `TURN` ↓ |
117
- | More / less idle "breathing" | `BOB_X`, `BOB_Y` (px) — `0` = off |
118
-
119
- ### Which elements are draggable
120
-
121
- The drag targets **`.mark`** (the wordmark) + every **`.el`** sprite pane (terminal, enter,
122
- search, tiles). To add one, give it the `el` class (or push it into the `els` array); to
123
- exclude one, filter it out of `els`.
124
-
125
- ### Why it does not fight the other animations
126
-
127
- The drag only ever applies `transform: translate(…)` to each pane, and it owns that property
128
- exclusively — so the float/reveal animations were replaced by the drag's own idle bob, and
129
- the enter key's reveal uses opacity + blur only (no transform). The **scene** transforms
130
- (the dive's scale/translate) live on the parent `.gate`/`.land` containers, so they compose
131
- with the per-pane drag instead of clobbering it. When a scene settles, `measure()` re-runs
132
- so a freshly-revealed pane drags from the right home.
133
-
134
- ### Turn the drag off
135
-
136
- Delete the second `<script>` IIFE (the drag) — the type→dive interaction and all the art
137
- keep working untouched.