@bongos/core 1.19.113 → 1.19.114

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 CHANGED
@@ -2,11 +2,11 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.113",
6
- "core_contract": "1.19.113",
7
- "source_commit": "4f04a88c84292c94616306d95392485737ed1737",
5
+ "core_version": "1.19.114",
6
+ "core_contract": "1.19.114",
7
+ "source_commit": "0de67d2652248283befaf1c2ee7ecbf83fa50346",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-14T15:48:22.422Z",
9
+ "built_at": "2026-08-14T15:52:55.624Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 302,
@@ -17,7 +17,7 @@
17
17
  "gate": "passed"
18
18
  },
19
19
  "file_count": 1559,
20
- "tree_sha256": "52d268079196d1b135c13eab10c51c87c9b1424cad1079ec595591824b0a0daf",
20
+ "tree_sha256": "f530c12a47c766a901cbfd537e9aa4cdaa95663cfda270bbc25c8575fee0bede",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -4152,12 +4152,12 @@
4152
4152
  {
4153
4153
  "path": "package-lock.json",
4154
4154
  "mode": "0000644",
4155
- "sha256": "2c8ecaac1dcae43c85fa2b642e470908f69dfdd6190e0986e06e120220ebe6d2"
4155
+ "sha256": "de51e915b8cf14b11d3cc68cc6106a2baed1bf351da2c94be74ff65b42c2ac09"
4156
4156
  },
4157
4157
  {
4158
4158
  "path": "package.json",
4159
4159
  "mode": "0000644",
4160
- "sha256": "4a7a150717c7a3336a9f72ed5ce773a229850b90cce8314de632067305371394"
4160
+ "sha256": "2fdb7573bd5efafc25771e96ce4a3fc790a8d5e83483dac1c3d3bcd3f1b513fe"
4161
4161
  },
4162
4162
  {
4163
4163
  "path": "public-docs/index.html",
@@ -4397,7 +4397,7 @@
4397
4397
  {
4398
4398
  "path": "public-landing/assets/cosmos.css",
4399
4399
  "mode": "0000644",
4400
- "sha256": "4b549b012f7e736ee81e54ed35a65c96acf9cd6a223c3e9fac369d9328426a88"
4400
+ "sha256": "ad327f42773674cc3a6deb3f65e0f9da89fd1c4c47947c513a958a0b8d37ff5f"
4401
4401
  },
4402
4402
  {
4403
4403
  "path": "public-landing/buddha.png",
@@ -4417,7 +4417,7 @@
4417
4417
  {
4418
4418
  "path": "public-landing/projects.html",
4419
4419
  "mode": "0000644",
4420
- "sha256": "264e6f7cb1273b24208b3063bec53f544d86f9174a4bd854a57c784722427583"
4420
+ "sha256": "1a58ca91bed5e4658599b1290465ee83b5845b7465376e13d19bd79ab6ac3dc1"
4421
4421
  },
4422
4422
  {
4423
4423
  "path": "public-landing/world/assets/clouds/cloud-island-large.png",
@@ -5732,7 +5732,7 @@
5732
5732
  {
5733
5733
  "path": "src/module-api.js",
5734
5734
  "mode": "0000644",
5735
- "sha256": "c19adbd125efe46fba720abdecef683027e607275543b65e971630304d686b75"
5735
+ "sha256": "766abd484728dd8ceb692aa3a4e5a1c4e68f9767503af3982ba69bc6deabe0c6"
5736
5736
  },
5737
5737
  {
5738
5738
  "path": "src/module-loader/catalog.js",
@@ -7257,7 +7257,7 @@
7257
7257
  {
7258
7258
  "path": "tests/projects_hub.mjs",
7259
7259
  "mode": "0000644",
7260
- "sha256": "5b23c369288ebdfd4a35ad76faa5ac5fbf9ff7b55de2a283b184977ba5513abe"
7260
+ "sha256": "dc915f8d09f97eb4a359bf4627a4e4dddb77daf2cd438084bae1b4abbe04963d"
7261
7261
  },
7262
7262
  {
7263
7263
  "path": "tests/provision.mjs",
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.113",
3
+ "version": "1.19.114",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.113",
9
+ "version": "1.19.114",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.113",
3
+ "version": "1.19.114",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -577,6 +577,28 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
577
577
  .tbl .colName{width:auto;}
578
578
  @media (max-width:780px){.tbl .colTags{display:none;}}
579
579
 
580
+ /* ── the living sky (task 1002762): camera, spotlight, hover card ──
581
+ The camera is ONE transform on .skyCam; orbs carry only their own physics
582
+ delta. transform-origin 0 0 is load-bearing — the pan/zoom algebra assumes
583
+ the layer scales about its origin. */
584
+ .field .skyCam{position:absolute;inset:0;transform-origin:0 0;will-change:transform;}
585
+ .fieldWrap{cursor:grab;touch-action:pan-y pinch-zoom;user-select:none;-webkit-user-select:none;}
586
+ .fieldWrap.panning,.fieldWrap.panning .worldOrb{cursor:grabbing;}
587
+ .worldOrb{transition:opacity .25s ease;}
588
+ /* a dimmed orb is backdrop — no pointer, no focus (the JS drops it from the
589
+ tab order); at .25 opacity its label could not carry interaction honestly */
590
+ .worldOrb.dim{opacity:.25;filter:saturate(.35);pointer-events:none;}
591
+ /* the spotlight glow is an OUTLINE + drop-shadow, never box-shadow: the owned
592
+ ring already lives on the disc's box-shadow, and a same-specificity
593
+ box-shadow here would silently delete it by source order */
594
+ .worldOrb.match .disc{outline:3px solid color-mix(in srgb,var(--acc,var(--accent)) 55%,transparent);
595
+ outline-offset:3px;filter:drop-shadow(0 0 16px color-mix(in srgb,var(--acc,var(--accent)) 60%,transparent));}
596
+ .field .hoverCard{position:absolute;z-index:6;pointer-events:none;display:flex;flex-direction:column;gap:2px;
597
+ background:var(--bg-card);border:1.5px solid var(--line);border-radius:12px;box-shadow:var(--shadow-card);
598
+ padding:9px 13px;max-width:240px;}
599
+ .field .hoverCard .hn{font-family:var(--font-display);font-weight:700;font-size:13.5px;color:var(--ink);white-space:nowrap;}
600
+ .field .hoverCard .ht{font-size:12px;color:var(--ink-faint);font-weight:600;}
601
+
580
602
  /* ── the project card ──
581
603
  An overlay INSIDE the map, not a destination: opening a project must not cost
582
604
  you the sky you were reading (direction §9). It sits above .shell (z-index 40)
@@ -147,6 +147,13 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
147
147
  <div class="field" id="orbField">
148
148
  <div class="ground"></div>
149
149
  <div class="stars" id="mapStars" aria-hidden="true"></div>
150
+ <!-- The camera (task 1002762). Pan and zoom are ONE transform on this
151
+ layer; orbs write only their own physics delta, so the two compose
152
+ for free and the ground, stars, empty state and hintbar stay put.
153
+ It opens BEFORE the apex sentinel: stripApexOnly() deletes whole
154
+ sentinel spans, and a tag opened inside one would leave the child
155
+ instance an unbalanced tree. -->
156
+ <div class="skyCam" id="skyCam">
150
157
  <!-- The platform's own orb. Static markup, not JS-built, precisely so the
151
158
  apex-only sentinels can remove it: a child instance has no "this
152
159
  platform" to point at, and stripApexOnly() is a string transform that
@@ -158,12 +165,17 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
158
165
  <span class="sub">this platform</span>
159
166
  </a>
160
167
  <!--apex-only:end-->
168
+ </div>
161
169
  <div class="fieldEmpty">
162
170
  <p id="emptyMsg">You don’t own or build on anything here yet.</p>
163
171
  <span class="sub">Turn the filter off to see what everyone else is building — or start your own.</span>
164
172
  <a class="btn small" href="?view=new" data-nav-to="new">Start a project</a>
165
173
  </div>
166
- <div class="hintbar"><span><kbd>click a project</kbd> open it</span></div>
174
+ <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>
175
+ <!-- The hover card: a glance, not a dialog. Outside the camera so it
176
+ never scales, aria-hidden because the orb itself is the accessible
177
+ link, and chip-free — the orb's own chip is the ONE liveness site. -->
178
+ <div class="hoverCard" id="hoverCard" hidden aria-hidden="true"></div>
167
179
  </div>
168
180
  </div>
169
181
 
@@ -474,6 +486,12 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
474
486
  if (v === 'mine' && me) refreshList();
475
487
  /* owned chips refresh on re-entry, never on a timer — C8 keeps the map quiet */
476
488
  if (v === 'map' && me) loadOwnedInstances().then(applyMapFilter);
489
+ /* the living sky runs only on stage (C8) — start and stop with the view;
490
+ anchors that went unreadable while the map was hidden re-adopt first */
491
+ if (v === 'map') {
492
+ if (skyNeedsRebuild) rebuildSkyBodies();
493
+ startSky();
494
+ } else { stopSky(); hideHover(); }
477
495
  /* focus the name field only on a deliberate move into the view — never on
478
496
  boot, where it would steal focus from whatever the visitor arrived at */
479
497
  if (v === 'new' && focus) $('newName').focus();
@@ -651,16 +669,24 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
651
669
  return hay.toLowerCase().indexOf(q) !== -1;
652
670
  }
653
671
 
654
- function renderOrbs(list) {
655
- var field = $('orbField');
672
+ function renderOrbs(list, dimmed) {
673
+ var layer = $('skyCam');
656
674
  /* replace only the generated orbs — the platform's own orb is static
657
675
  apex-only markup and must survive every re-render */
658
- field.querySelectorAll('.worldOrb[data-generated]').forEach(function (n) { n.remove(); });
659
- var hint = field.querySelector('.hintbar');
660
- list.forEach(function (p, i) {
676
+ layer.querySelectorAll('.worldOrb[data-generated]').forEach(function (n) { n.remove(); });
677
+ /* the spotlight (task 1002762): while a query is live the sky DIMS what
678
+ does not match instead of removing it — the cluster keeps its shape and
679
+ the matches glow. The table still filters, and the count counts matches. */
680
+ var spot = !!(dimmed && dimmed.length);
681
+ (spot ? list.concat(dimmed) : list).forEach(function (p, i) {
661
682
  var slot = ORB_SLOTS[i % ORB_SLOTS.length];
683
+ var isDim = spot && i >= list.length;
662
684
  var a = document.createElement('a');
663
- a.className = 'worldOrb';
685
+ a.className = 'worldOrb' + (spot ? (isDim ? ' dim' : ' match') : '');
686
+ /* a dimmed orb is BACKDROP: at .25 opacity its label cannot carry
687
+ interaction honestly, so it leaves the tab order and the pointer
688
+ (pointer-events:none in css) — the matches and the table do the work */
689
+ if (isDim) { a.setAttribute('aria-hidden', 'true'); a.tabIndex = -1; }
664
690
  a.setAttribute('data-generated', '');
665
691
  var rel = relOf(p);
666
692
  a.setAttribute('data-rel', rel);
@@ -695,7 +721,7 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
695
721
  project can never look alive in the sky and unreachable in the list
696
722
  (C6: two views of one truth) */
697
723
  (inst ? chipFor(inst) : '');
698
- field.insertBefore(a, hint);
724
+ layer.appendChild(a);
699
725
  });
700
726
  }
701
727
 
@@ -766,6 +792,11 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
766
792
  var list = browse.filter(function (p) {
767
793
  return matchesMapQuery(p, q) && (!onlyMine || relOf(p) !== 'none');
768
794
  });
795
+ /* what the query rules OUT still exists — the sky dims it (spotlight);
796
+ the "only mine" switch is a filter, not a search, and stays a removal */
797
+ var dimmedOrbs = q ? browse.filter(function (p) {
798
+ return !matchesMapQuery(p, q) && (!onlyMine || relOf(p) !== 'none');
799
+ }) : [];
769
800
  list.sort(function (x, y) {
770
801
  if (mapSort.key === 'name') return String(x.name || '').localeCompare(String(y.name || '')) * mapSort.dir;
771
802
  /* unknown (null) sorts to the BOTTOM in both directions — it is not a low
@@ -779,7 +810,7 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
779
810
  }
780
811
  return 0;
781
812
  });
782
- renderOrbs(list);
813
+ renderOrbs(list, dimmedOrbs);
783
814
  renderRows(list);
784
815
 
785
816
  /* The platform's own entry, in BOTH views from one predicate — it used to
@@ -788,11 +819,19 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
788
819
  It is somebody's: see selfRel(). It still answers the search box, and it
789
820
  still vanishes for a signed-out visitor, who owns nothing here. */
790
821
  var rel = selfRel();
791
- var selfShown = (rel !== 'none' || !onlyMine) && matchesMapQuery(SELF_PROJECT, q);
822
+ var selfPass = (rel !== 'none' || !onlyMine);
823
+ var selfShown = selfPass && matchesMapQuery(SELF_PROJECT, q);
792
824
  var badge = rel === 'owner' ? 'yours' : rel === 'builder' ? 'you build here' : '';
793
825
  var orb = $('selfOrb'), row = $('selfRow');
794
826
  if (orb) {
795
- orb.hidden = !selfShown;
827
+ /* in the sky a query DIMS the platform like everyone else; the "only
828
+ mine" filter still removes it for a signed-out visitor. Dimmed = pure
829
+ backdrop, same as a generated dim: no focus, no pointer. */
830
+ var selfDim = selfPass && !selfShown;
831
+ orb.hidden = !selfPass;
832
+ orb.classList.toggle('dim', selfDim);
833
+ if (selfDim) { orb.setAttribute('aria-hidden', 'true'); orb.tabIndex = -1; }
834
+ else { orb.removeAttribute('aria-hidden'); orb.removeAttribute('tabindex'); }
796
835
  orb.setAttribute('data-rel', rel);
797
836
  var b = orb.querySelector('.badge');
798
837
  if (badge && !b) { b = document.createElement('span'); b.className = 'badge'; orb.insertBefore(b, orb.firstChild); }
@@ -804,11 +843,18 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
804
843
  }
805
844
 
806
845
  var shown = list.length + (selfShown ? 1 : 0);
807
- $('orbField').classList.toggle('isEmpty', shown === 0 && (onlyMine || !!q));
846
+ /* a sky full of DIMMED orbs is not empty — the message would paint over
847
+ them and eat their pointer events; it shows only when nothing renders */
848
+ $('orbField').classList.toggle('isEmpty', shown === 0 && !dimmedOrbs.length && (onlyMine || !!q));
808
849
  $('emptyMsg').textContent = onlyMine
809
850
  ? 'You don’t own or build on anything here yet.'
810
851
  : 'Nothing here matches that.';
811
852
  $('mapCount').textContent = shown + (shown === 1 ? ' project' : ' projects');
853
+
854
+ /* every re-render replaces the orb nodes — the sim re-adopts them,
855
+ carrying position + velocity for orbs that persisted (see the sky) */
856
+ rebuildSkyBodies();
857
+ startSky();
812
858
  }
813
859
 
814
860
  /* ══ THE PROJECT CARD ══════════════════════════════════════════════════════
@@ -904,6 +950,9 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
904
950
  $('orbField').addEventListener('click', function (ev) {
905
951
  var a = ev.target.closest && ev.target.closest('.worldOrb');
906
952
  if (!a || !isPlainClick(ev)) return;
953
+ /* a drag that ended on an orb is a pan, not a click (task 1002762) —
954
+ and the link underneath must not fire either */
955
+ if (skyPanned) { ev.preventDefault(); return; }
907
956
  ev.preventDefault();
908
957
  if (a.id === 'selfOrb') return openCard(SELF_PROJECT, selfRel(), a);
909
958
  /* an owned orb's job is MANAGE (direction §5) — before the card path */
@@ -1049,11 +1098,439 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
1049
1098
  });
1050
1099
  });
1051
1100
 
1101
+ /* ══ THE LIVING SKY (task 1002762) ═════════════════════════════════════════
1102
+ The retired gate's cluster, rewritten for the hub. Physics is the gate's
1103
+ verified core (core-v1.19.93 space.js: softened Newtonian pairs, fixed-dt
1104
+ kick-drift-kick leapfrog, soft collisions, a KE band thermostat, per-step
1105
+ drift removal — stable over a 60-min headless run), re-tuned for this
1106
+ roster: the containment well's radius derives from the actual spread
1107
+ instead of the gate's 520px constant, and mass comes from disc size.
1108
+
1109
+ Layers: the CAMERA is one transform on #skyCam (pan + zoom-toward-cursor,
1110
+ the gate's exact algebra); each orb writes only its own physics delta over
1111
+ its slot anchor, so the two compose for free. The loop is C8-guarded —
1112
+ it stops DEAD off the map view, on the table sub-view, in a hidden tab —
1113
+ and never starts under prefers-reduced-motion (C9): reduced and no-JS
1114
+ both get the mock's static field. Zero requests anywhere in the loop. ══ */
1115
+
1116
+ var SKY_GRAV = {
1117
+ R_REF: 50, /* mass = (radius / R_REF)^3 — size IS gravity */
1118
+ G: 5200, /* px^3 / mass·s^2 */
1119
+ SOFTEN: 48, /* Plummer softening ~ a typical orb radius here */
1120
+ SWIRL: 0.09, /* initial solid-body swirl (rad/s) */
1121
+ SWIRL_JITTER: 0.15,
1122
+ REST: 0.92, /* near-elastic contacts (rare) */
1123
+ CONTAIN_K: 6.0, /* inward accel per px beyond the well */
1124
+ MAXV: 260, /* last-resort speed cap (px/s) */
1125
+ THERMO_LO: 0.55, THERMO_HI: 1.9, THERMO_MAX: 0.01,
1126
+ PHYS_HZ: 120
1127
+ };
1128
+ var skyBodies = [], keTarget = 0, containR = 460, containAspect = 1;
1129
+ var skyView = { x: 0, y: 0, z: 1 };
1130
+ var SKY_ZMIN = 0.55, SKY_ZMAX = 2.4;
1131
+ var SKY_DEADZONE = 5; /* px of movement before a press becomes a pan */
1132
+ var skyPanned = false; /* consumed by the delegated click handler */
1133
+ var skyPtr = { x: 0, y: 0 };
1134
+
1135
+ function skyBarycenter() {
1136
+ var M = 0, x = 0, y = 0;
1137
+ for (var i = 0; i < skyBodies.length; i++) { var b = skyBodies[i]; M += b.m; x += b.m * b.x; y += b.m * b.y; }
1138
+ return M > 0 ? { x: x / M, y: y / M } : { x: 0, y: 0 };
1139
+ }
1140
+ function skyKE() {
1141
+ var e = 0;
1142
+ for (var i = 0; i < skyBodies.length; i++) { var b = skyBodies[i]; e += 0.5 * b.m * (b.vx * b.vx + b.vy * b.vy); }
1143
+ return e;
1144
+ }
1145
+ function killSkyDrift() {
1146
+ var M = 0, px = 0, py = 0;
1147
+ for (var i = 0; i < skyBodies.length; i++) { var b = skyBodies[i]; M += b.m; px += b.m * b.vx; py += b.m * b.vy; }
1148
+ if (M <= 0) return;
1149
+ var dvx = px / M, dvy = py / M;
1150
+ for (var j = 0; j < skyBodies.length; j++) { skyBodies[j].vx -= dvx; skyBodies[j].vy -= dvy; }
1151
+ }
1152
+ function computeSkyForces(c) {
1153
+ var n = skyBodies.length, soft2 = SKY_GRAV.SOFTEN * SKY_GRAV.SOFTEN, i, j;
1154
+ for (i = 0; i < n; i++) { skyBodies[i].fx = 0; skyBodies[i].fy = 0; }
1155
+ for (i = 0; i < n; i++) {
1156
+ for (j = i + 1; j < n; j++) {
1157
+ var a = skyBodies[i], b = skyBodies[j];
1158
+ var dx = b.x - a.x, dy = b.y - a.y;
1159
+ var d2 = dx * dx + dy * dy + soft2;
1160
+ var f = SKY_GRAV.G / (d2 * Math.sqrt(d2));
1161
+ a.fx += f * b.m * dx; a.fy += f * b.m * dy;
1162
+ b.fx -= f * a.m * dx; b.fy -= f * a.m * dy;
1163
+ }
1164
+ }
1165
+ /* soft containment well — a gentle inward pull only beyond containR.
1166
+ ELLIPTICAL, matched to the field: the gate's circular well lived in an
1167
+ unbounded camera world, but this field clips, and it is much wider than
1168
+ it is tall — a circular well sized to the x-spread would let vertical
1169
+ orbits sail past the clipped edge. y-distance is scaled by the aspect,
1170
+ so the well's shape is the field's shape. */
1171
+ var a2 = containAspect * containAspect;
1172
+ for (i = 0; i < n; i++) {
1173
+ var it = skyBodies[i], ex = it.x - c.x, ey = it.y - c.y;
1174
+ var d = Math.hypot(ex, ey * containAspect);
1175
+ if (d > containR && d > 1e-6) {
1176
+ var k = -SKY_GRAV.CONTAIN_K * (d - containR) / d;
1177
+ it.fx += k * ex; it.fy += k * ey * a2;
1178
+ }
1179
+ }
1180
+ }
1181
+ function resolveSkyCollisions() {
1182
+ for (var i = 0; i < skyBodies.length; i++) {
1183
+ for (var j = i + 1; j < skyBodies.length; j++) {
1184
+ var a = skyBodies[i], b = skyBodies[j];
1185
+ var dx = b.x - a.x, dy = (b.y + b.discOff) - (a.y + a.discOff);
1186
+ var dist = Math.hypot(dx, dy) || 1e-6, min = a.r + b.r;
1187
+ if (dist >= min) continue;
1188
+ var nx = dx / dist, ny = dy / dist;
1189
+ var ima = 1 / a.m, imb = 1 / b.m;
1190
+ var corr = (min - dist) / (ima + imb);
1191
+ a.x -= nx * corr * ima; a.y -= ny * corr * ima;
1192
+ b.x += nx * corr * imb; b.y += ny * corr * imb;
1193
+ var vn = (b.vx - a.vx) * nx + (b.vy - a.vy) * ny;
1194
+ if (vn < 0) {
1195
+ var imp = -(1 + SKY_GRAV.REST) * vn / (ima + imb);
1196
+ a.vx -= nx * imp * ima; a.vy -= ny * imp * ima;
1197
+ b.vx += nx * imp * imb; b.vy += ny * imp * imb;
1198
+ }
1199
+ }
1200
+ }
1201
+ }
1202
+ function skyThermostat() {
1203
+ var lo = keTarget * SKY_GRAV.THERMO_LO, hi = keTarget * SKY_GRAV.THERMO_HI, ke = skyKE();
1204
+ if (ke < 1e-6) return;
1205
+ var target = ke < lo ? lo : (ke > hi ? hi : 0);
1206
+ if (!target) return; /* inside the band: pure physics */
1207
+ var f = Math.sqrt(target / ke);
1208
+ f = Math.max(1 - SKY_GRAV.THERMO_MAX, Math.min(1 + SKY_GRAV.THERMO_MAX, f));
1209
+ for (var i = 0; i < skyBodies.length; i++) { skyBodies[i].vx *= f; skyBodies[i].vy *= f; }
1210
+ }
1211
+ function clampSkyVelocities() {
1212
+ for (var i = 0; i < skyBodies.length; i++) {
1213
+ var b = skyBodies[i], v = Math.hypot(b.vx, b.vy);
1214
+ if (v > SKY_GRAV.MAXV) { var s = SKY_GRAV.MAXV / v; b.vx *= s; b.vy *= s; }
1215
+ }
1216
+ }
1217
+ function physStep(dt) {
1218
+ var i, it, c = skyBarycenter();
1219
+ for (i = 0; i < skyBodies.length; i++) { it = skyBodies[i]; it.px = it.x; it.py = it.y; }
1220
+ computeSkyForces(c); /* leapfrog kick-drift-kick — the order is load-bearing */
1221
+ for (i = 0; i < skyBodies.length; i++) { it = skyBodies[i]; it.vx += 0.5 * it.fx * dt; it.vy += 0.5 * it.fy * dt; }
1222
+ for (i = 0; i < skyBodies.length; i++) { it = skyBodies[i]; it.x += it.vx * dt; it.y += it.vy * dt; }
1223
+ computeSkyForces(c);
1224
+ for (i = 0; i < skyBodies.length; i++) { it = skyBodies[i]; it.vx += 0.5 * it.fx * dt; it.vy += 0.5 * it.fy * dt; }
1225
+ resolveSkyCollisions();
1226
+ skyThermostat();
1227
+ killSkyDrift();
1228
+ clampSkyVelocities();
1229
+ }
1230
+
1231
+ /* bodies re-adopt the DOM after every render: an orb that persisted keeps
1232
+ its position AND momentum across filter keystrokes (visual position is
1233
+ x/y itself — the anchor is only the subtraction base for the transform) */
1234
+ var skyNeedsRebuild = false;
1235
+ function rebuildSkyBodies() {
1236
+ var layer = $('skyCam'), fieldEl = $('orbField');
1237
+ if (!layer) { skyBodies = []; return; }
1238
+ /* display:none zeroes offsetLeft/Top AND clientWidth: anchoring to zeros
1239
+ paints every orb at slot + physics position on return, degrades the
1240
+ elliptical well to circular, and breaks the camera clamp (review
1241
+ CRITICAL — reachable from the Table view's own sort/search controls).
1242
+ Defer instead: the fresh orb nodes sit at their CSS slots — the static
1243
+ mock — until the sky is back on stage and the anchors are real. */
1244
+ if (!fieldEl || !fieldEl.clientWidth) { skyNeedsRebuild = true; return; }
1245
+ skyNeedsRebuild = false;
1246
+ var prev = {};
1247
+ skyBodies.forEach(function (b) { prev[b.key] = b; });
1248
+ var next = [];
1249
+ layer.querySelectorAll('.worldOrb').forEach(function (el) {
1250
+ if (el.hidden) return;
1251
+ var d = parseFloat(getComputedStyle(el).getPropertyValue('--d')) || 60;
1252
+ var r = d / 2;
1253
+ /* identity must survive re-sorts: an origin-less orb keys on its NAME,
1254
+ never its list index (an index key hands its momentum to whichever
1255
+ orb lands on that index after the next sort) */
1256
+ var nmEl = el.querySelector('.nm');
1257
+ var key = el.id === 'selfOrb' ? 'self'
1258
+ : (el.getAttribute('data-origin') || 'nm:' + (nmEl ? nmEl.textContent : String(next.length)));
1259
+ var ax = el.offsetLeft, ay = el.offsetTop;
1260
+ /* the body's anchor is the label-stack CENTER, but the disc — the thing
1261
+ that must not interpenetrate — sits above it; collisions measure from
1262
+ the disc's own center */
1263
+ var disc = el.querySelector('.disc');
1264
+ var discOff = disc ? (disc.offsetTop + r) - el.offsetHeight / 2 : 0;
1265
+ /* reduced motion IS the mock: no carry-over, every orb sits exactly on
1266
+ its slot (C9) — carrying frozen positions across a re-sort would
1267
+ leave zero-motion orbs parked off their own anchors forever */
1268
+ var old = REDUCED ? null : prev[key];
1269
+ next.push({
1270
+ key: key, el: el, r: r, ax: ax, ay: ay, discOff: discOff,
1271
+ m: Math.pow(r / SKY_GRAV.R_REF, 3),
1272
+ x: old ? old.x : ax, y: old ? old.y : ay,
1273
+ px: old ? old.x : ax, py: old ? old.y : ay,
1274
+ vx: old ? old.vx : 0, vy: old ? old.vy : 0,
1275
+ fx: 0, fy: 0, seeded: !!old
1276
+ });
1277
+ });
1278
+ skyBodies = next;
1279
+ /* new arrivals join the field swirl at their own position */
1280
+ var c = skyBarycenter();
1281
+ var jit = SKY_GRAV.SWIRL * 60 * SKY_GRAV.SWIRL_JITTER;
1282
+ skyBodies.forEach(function (b) {
1283
+ if (b.seeded) return;
1284
+ var dx = b.x - c.x, dy = b.y - c.y;
1285
+ b.vx = -dy * SKY_GRAV.SWIRL + (Math.random() - 0.5) * 2 * jit;
1286
+ b.vy = dx * SKY_GRAV.SWIRL + (Math.random() - 0.5) * 2 * jit;
1287
+ });
1288
+ killSkyDrift();
1289
+ keTarget = skyKE();
1290
+ /* the well hugs the roster: sized from the real spread in ASPECT-SCALED
1291
+ space (the gate's 520px assumed its own scatter; ours is the slot
1292
+ layout in THIS viewport, which is wide and short) */
1293
+ var fieldEl = $('orbField');
1294
+ containAspect = fieldEl
1295
+ ? Math.min(3, Math.max(1, fieldEl.clientWidth / Math.max(1, fieldEl.clientHeight)))
1296
+ : 1;
1297
+ var maxD = 0;
1298
+ skyBodies.forEach(function (b) {
1299
+ maxD = Math.max(maxD, Math.hypot(b.x - c.x, (b.y - c.y) * containAspect));
1300
+ });
1301
+ containR = Math.max(380, maxD * 1.15);
1302
+ /* the camera may still be aimed at a cluster that no longer exists */
1303
+ clampSkyCam();
1304
+ applySkyCam();
1305
+ renderSky(1);
1306
+ }
1307
+
1308
+ function renderSky(alpha) {
1309
+ var dpr = window.devicePixelRatio || 1;
1310
+ for (var k = 0; k < skyBodies.length; k++) {
1311
+ var b = skyBodies[k];
1312
+ var ix = b.px + (b.x - b.px) * alpha, iy = b.py + (b.y - b.py) * alpha;
1313
+ /* snap the offset to the device-pixel grid: motion stays smooth but
1314
+ glyphs rasterize identically each frame, so labels never shimmer */
1315
+ var tx = Math.round((ix - b.ax) * dpr) / dpr, ty = Math.round((iy - b.ay) * dpr) / dpr;
1316
+ b.el.style.transform = 'translate(-50%,-50%) translate(' + tx.toFixed(2) + 'px,' + ty.toFixed(2) + 'px)';
1317
+ }
1318
+ followHover();
1319
+ }
1320
+
1321
+ /* the loop: fixed 120Hz physics under an accumulator, interpolated render.
1322
+ The 250ms frame clamp + 8-substep guard + drop-on-overrun keep a slow
1323
+ machine in slow motion rather than a spiral. */
1324
+ var SKY_DT = 1000 / SKY_GRAV.PHYS_HZ, skyAcc = 0, skyLast = 0, skyRaf = 0;
1325
+ function skyActive() {
1326
+ return !REDUCED && !document.hidden && currentView === 'map' &&
1327
+ !$('sub-sky-body').hidden && skyBodies.length > 1;
1328
+ }
1329
+ function skyTick(now) {
1330
+ skyRaf = 0;
1331
+ if (!skyActive()) return;
1332
+ var ft = now - skyLast; skyLast = now; if (ft > 250) ft = 250;
1333
+ skyAcc += ft;
1334
+ var guard = 0;
1335
+ while (skyAcc >= SKY_DT && guard < 8) { physStep(SKY_DT / 1000); skyAcc -= SKY_DT; guard++; }
1336
+ if (guard >= 8) skyAcc = 0;
1337
+ renderSky(Math.max(0, Math.min(1, skyAcc / SKY_DT)));
1338
+ skyRaf = requestAnimationFrame(skyTick);
1339
+ }
1340
+ function startSky() {
1341
+ if (skyRaf || !skyActive()) return;
1342
+ skyLast = performance.now(); skyAcc = 0;
1343
+ skyRaf = requestAnimationFrame(skyTick);
1344
+ }
1345
+ function stopSky() { if (skyRaf) { cancelAnimationFrame(skyRaf); skyRaf = 0; } }
1346
+ document.addEventListener('visibilitychange', function () { if (!document.hidden) startSky(); });
1347
+ window.addEventListener('resize', function () {
1348
+ clearTimeout(skyResizeT);
1349
+ skyResizeT = setTimeout(function () {
1350
+ /* percent anchors moved with the field — re-seed rather than let stale
1351
+ pixel anchors shear the cluster */
1352
+ skyBodies = [];
1353
+ rebuildSkyBodies();
1354
+ startSky();
1355
+ }, 200);
1356
+ });
1357
+ var skyResizeT = 0;
1358
+
1359
+ /* ── the camera: the gate's exact pan/zoom algebra on one layer ── */
1360
+ function applySkyCam() {
1361
+ var el = $('skyCam');
1362
+ if (el) el.style.transform = 'translate(' + skyView.x.toFixed(2) + 'px,' + skyView.y.toFixed(2) + 'px) scale(' + skyView.z.toFixed(4) + ')';
1363
+ }
1364
+ function clampSkyCam() {
1365
+ var wrap = $('sub-sky-body');
1366
+ if (!wrap || !skyBodies.length) return;
1367
+ var W = wrap.clientWidth, H = wrap.clientHeight, m = 160;
1368
+ var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
1369
+ skyBodies.forEach(function (b) {
1370
+ if (b.x < minX) minX = b.x; if (b.x > maxX) maxX = b.x;
1371
+ if (b.y < minY) minY = b.y; if (b.y > maxY) maxY = b.y;
1372
+ });
1373
+ var sxMin = skyView.x + minX * skyView.z, sxMax = skyView.x + maxX * skyView.z;
1374
+ var syMin = skyView.y + minY * skyView.z, syMax = skyView.y + maxY * skyView.z;
1375
+ if (sxMax < m) skyView.x += m - sxMax; else if (sxMin > W - m) skyView.x -= sxMin - (W - m);
1376
+ if (syMax < m) skyView.y += m - syMax; else if (syMin > H - m) skyView.y -= syMin - (H - m);
1377
+ }
1378
+ (function skyPanZoom() {
1379
+ var wrap = $('sub-sky-body');
1380
+ if (!wrap) return;
1381
+ var down = false, moved = false, middle = false, sx = 0, sy = 0, startX = 0, startY = 0;
1382
+ var panId = null; /* the ONE pointer that owns the drag — a second finger
1383
+ (pinch, a stray touch) must never hijack the camera */
1384
+ wrap.addEventListener('mousedown', function (e) { if (e.button === 1) e.preventDefault(); });
1385
+ wrap.addEventListener('pointerdown', function (e) {
1386
+ if (down) return;
1387
+ if (e.button !== 0 && e.button !== 1) return;
1388
+ /* middle-press on an orb belongs to the browser (open in a new tab);
1389
+ middle on empty sky is pan-anywhere, like the gate */
1390
+ middle = e.button === 1 && !(e.target.closest && e.target.closest('.worldOrb'));
1391
+ if (e.button === 1 && !middle) return;
1392
+ down = true; moved = false; skyPanned = false; panId = e.pointerId;
1393
+ sx = e.clientX; sy = e.clientY; startX = skyView.x; startY = skyView.y;
1394
+ if (middle) { moved = true; skyPanned = true; wrap.classList.add('panning'); hideHover(); e.preventDefault(); }
1395
+ });
1396
+ window.addEventListener('pointermove', function (e) {
1397
+ skyPtr.x = e.clientX; skyPtr.y = e.clientY;
1398
+ if (!down || (panId !== null && e.pointerId !== panId)) return;
1399
+ if (!moved && Math.hypot(e.clientX - sx, e.clientY - sy) < SKY_DEADZONE) return;
1400
+ if (!moved) { moved = true; skyPanned = true; wrap.classList.add('panning'); hideHover(); }
1401
+ skyView.x = startX + (e.clientX - sx);
1402
+ skyView.y = startY + (e.clientY - sy);
1403
+ clampSkyCam(); applySkyCam();
1404
+ });
1405
+ function endPan(e) {
1406
+ if (!down || (panId !== null && e && e.pointerId !== undefined && e.pointerId !== panId)) return;
1407
+ down = false; middle = false; panId = null; wrap.classList.remove('panning');
1408
+ /* the click this pan suppresses fires synchronously after pointerup —
1409
+ clear the flag a tick later, so a keyboard Enter is never eaten */
1410
+ setTimeout(function () { skyPanned = false; }, 0);
1411
+ }
1412
+ window.addEventListener('pointerup', endPan);
1413
+ window.addEventListener('pointercancel', endPan);
1414
+ wrap.addEventListener('wheel', function (e) {
1415
+ var d = e.deltaY;
1416
+ /* line-mode wheels (Firefox) report ~3 lines, not ~100 px */
1417
+ if (e.deltaMode === 1) d *= 16; else if (e.deltaMode === 2) d *= 300;
1418
+ var factor = Math.exp(-d * 0.0016);
1419
+ var z = Math.max(SKY_ZMIN, Math.min(SKY_ZMAX, skyView.z * factor));
1420
+ /* at the clamp the wheel would do nothing — hand it back to the page
1421
+ instead of trapping scroll over the sky */
1422
+ if (z === skyView.z) return;
1423
+ e.preventDefault();
1424
+ var rect = wrap.getBoundingClientRect();
1425
+ var cx = e.clientX - rect.left, cy = e.clientY - rect.top;
1426
+ /* un-project the cursor at the old zoom, re-pin it at the new — the
1427
+ world point under the wheel never moves */
1428
+ var vx = (cx - skyView.x) / skyView.z, vy = (cy - skyView.y) / skyView.z;
1429
+ skyView.z = z;
1430
+ skyView.x = cx - vx * z;
1431
+ skyView.y = cy - vy * z;
1432
+ clampSkyCam(); applySkyCam(); hideHover();
1433
+ }, { passive: false });
1434
+ })();
1435
+
1436
+ /* ── the hover card: a glance under the cursor, never a second chip ── */
1437
+ var hoverOrb = null, hoverFrames = 0;
1438
+ function showHover(a) {
1439
+ if (a === hoverOrb) return;
1440
+ hoverOrb = a;
1441
+ var card = $('hoverCard');
1442
+ if (!card) return;
1443
+ card.textContent = '';
1444
+ var nm = document.createElement('span');
1445
+ nm.className = 'hn';
1446
+ var n = a.querySelector('.nm');
1447
+ nm.textContent = n ? n.textContent : '';
1448
+ card.appendChild(nm);
1449
+ var tag = a.title || (a.querySelector('.sub') ? a.querySelector('.sub').textContent : '');
1450
+ if (tag && tag !== nm.textContent) {
1451
+ var ht = document.createElement('span');
1452
+ ht.className = 'ht';
1453
+ ht.textContent = tag;
1454
+ card.appendChild(ht);
1455
+ }
1456
+ card.hidden = false;
1457
+ placeHover();
1458
+ }
1459
+ function hideHover() {
1460
+ hoverOrb = null;
1461
+ var c = $('hoverCard');
1462
+ if (c) c.hidden = true;
1463
+ }
1464
+ function placeHover() {
1465
+ if (!hoverOrb) return;
1466
+ var card = $('hoverCard'), field = $('orbField');
1467
+ var fr = field.getBoundingClientRect(), or = hoverOrb.getBoundingClientRect();
1468
+ var x = or.left + or.width / 2 - fr.left, y = or.top - fr.top;
1469
+ card.style.left = Math.max(8, Math.min(fr.width - card.offsetWidth - 8, x - card.offsetWidth / 2)) + 'px';
1470
+ var top = y - card.offsetHeight - 10;
1471
+ card.style.top = (top < 8 ? y + or.height + 10 : top) + 'px';
1472
+ }
1473
+ function followHover() {
1474
+ if (!hoverOrb) return;
1475
+ placeHover();
1476
+ /* the sim can slide an orb out from under a stationary cursor and the
1477
+ browser never fires the leave — re-check what is under the pointer
1478
+ every few frames (a gate lesson, not optional polish) */
1479
+ if (++hoverFrames % 4 === 0) {
1480
+ var under = document.elementFromPoint(skyPtr.x, skyPtr.y);
1481
+ var overOrb = under && under.closest ? under.closest('.worldOrb') : null;
1482
+ if (overOrb !== hoverOrb) hideHover();
1483
+ }
1484
+ }
1485
+ $('orbField').addEventListener('pointerover', function (ev) {
1486
+ /* keep the pointer sample fresh from here too: a hover can arrive before
1487
+ any move (a touch tap, a page-load hover) and the frame check would
1488
+ otherwise measure a stale point and hide the card it just showed */
1489
+ skyPtr.x = ev.clientX; skyPtr.y = ev.clientY;
1490
+ var a = ev.target.closest && ev.target.closest('.worldOrb');
1491
+ if (a && !$('sub-sky-body').classList.contains('panning')) showHover(a);
1492
+ });
1493
+ $('orbField').addEventListener('pointerout', function (ev) {
1494
+ if (!hoverOrb) return;
1495
+ var to = ev.relatedTarget;
1496
+ if (!to || !(to.closest && to.closest('.worldOrb') === hoverOrb)) hideHover();
1497
+ });
1498
+
1499
+ /* a tapped Shift lights the search — the gate's spotlight key, kept: a tap
1500
+ with no other key while held focuses the query box in map view */
1501
+ (function shiftSpot() {
1502
+ var solo = false;
1503
+ document.addEventListener('keydown', function (e) {
1504
+ if (e.key === 'Shift') { solo = !e.repeat; return; }
1505
+ solo = false;
1506
+ });
1507
+ /* Shift used as a MOUSE modifier is not a tap: a shift-click or
1508
+ shift-drag between the keydown and keyup must not light the search */
1509
+ document.addEventListener('pointerdown', function () { solo = false; });
1510
+ document.addEventListener('wheel', function () { solo = false; }, { passive: true });
1511
+ document.addEventListener('keyup', function (e) {
1512
+ if (e.key !== 'Shift' || !solo) return;
1513
+ solo = false;
1514
+ /* never steal focus out of the open project card (it is aria-modal) */
1515
+ if (currentView !== 'map' || !$('cardScrim').hidden) return;
1516
+ var t = document.activeElement;
1517
+ if (t && /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName)) return;
1518
+ var q = $('mapQ');
1519
+ if (q) q.focus();
1520
+ });
1521
+ })();
1522
+
1052
1523
  function setSub(which) {
1053
1524
  $('sub-sky').setAttribute('aria-pressed', String(which === 'sky'));
1054
1525
  $('sub-table').setAttribute('aria-pressed', String(which === 'table'));
1055
1526
  $('sub-sky-body').hidden = which !== 'sky';
1056
1527
  $('sub-table-body').hidden = which !== 'table';
1528
+ /* the sim runs only while the sky is on stage (C8); anchors that were
1529
+ unreadable while hidden are re-adopted before the first frame */
1530
+ if (which === 'sky') {
1531
+ if (skyNeedsRebuild) rebuildSkyBodies();
1532
+ startSky();
1533
+ } else { stopSky(); hideHover(); }
1057
1534
  }
1058
1535
  $('sub-sky').addEventListener('click', function () { setSub('sky'); });
1059
1536
  $('sub-table').addEventListener('click', function () { setSub('table'); });
package/src/module-api.js CHANGED
@@ -653,8 +653,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
653
653
  // landed since 1.19.111 with no explicit bump. run 31814245230. (task 1002620)
654
654
  // 1.19.113 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
655
655
  // landed since 1.19.112 with no explicit bump. run 31816239246. (task 1002620)
656
+ // 1.19.114 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
657
+ // landed since 1.19.113 with no explicit bump. run 31816599678. (task 1002620)
656
658
  // ---------------------------------------------------------------------------
657
- const CORE_VERSION = '1.19.113'; // CI auto-patch carrier (ADR 0161); see changelog above
659
+ const CORE_VERSION = '1.19.114'; // CI auto-patch carrier (ADR 0161); see changelog above
658
660
 
659
661
  // A namespaced logger so a module's log lines are attributable + consistent.
660
662
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -702,3 +702,72 @@ test('the chip-freshness tick spends no requests — it re-renders what it has (
702
702
  assert.match(fn, /document\.hidden/, 'and does nothing in a hidden tab');
703
703
  assert.doesNotMatch(fn, /fetch\(/, 'zero requests — the re-fetch belongs to entry and tab return only');
704
704
  });
705
+
706
+ /* ── The living sky (task 1002762) ──────────────────────────────────────────
707
+ The retired gate's n-body cluster + pan/zoom/hover/spotlight, rewritten for
708
+ the hub: one camera layer, physics in ~110 guarded lines, and the mock's
709
+ static field as the reduced-motion and no-JS state. These pin the C8/C9
710
+ contract and the structural topology the camera depends on. ── */
711
+
712
+ test('the sky camera is one layer inside the field, outside the sentinels', () => {
713
+ const stack = openAncestorsAt(HUB, 'id="skyCam"');
714
+ assert.ok(stack.includes('view-map') && stack.includes('orbField'),
715
+ 'the camera lives in the orb field. open: ' + stack.join(' > '));
716
+ assert.ok(!stack.includes('sub-table-body'), 'and never inside the table');
717
+ const selfStack = openAncestorsAt(HUB, 'id="selfOrb"');
718
+ assert.ok(selfStack.includes('skyCam'), 'the platform orb pans and zooms with everyone else');
719
+ const camAt = HUB.indexOf('id="skyCam"');
720
+ const selfAt = HUB.indexOf('id="selfOrb"');
721
+ const sentinelAt = HUB.lastIndexOf('<!--apex-only:start-->', selfAt);
722
+ assert.ok(camAt !== -1 && camAt < sentinelAt && sentinelAt < selfAt,
723
+ 'the camera opens BEFORE the orb sentinel — stripApexOnly() must never eat its open tag');
724
+ const hoverStack = openAncestorsAt(HUB, 'id="hoverCard"');
725
+ assert.ok(hoverStack.includes('orbField') && !hoverStack.includes('skyCam'),
726
+ 'the hover card sits outside the camera so it never scales or pans');
727
+ });
728
+
729
+ test('the sim stops dead when the sky is not on stage (C8, C9)', () => {
730
+ const active = (SCRIPT.match(/function skyActive\(\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
731
+ assert.ok(active, 'skyActive() must exist');
732
+ for (const guard of ['REDUCED', 'document.hidden', "currentView === 'map'", 'sub-sky-body']) {
733
+ assert.ok(active.includes(guard), `skyActive must check ${guard}`);
734
+ }
735
+ const tick = (SCRIPT.match(/function skyTick\(now\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
736
+ assert.ok(tick, 'skyTick() must exist');
737
+ assert.match(tick, /if \(!skyActive\(\)\) return/, 'a tick on a hidden sky ends the loop, not just the work');
738
+ assert.doesNotMatch(tick, /fetch\(/, 'the sky spends no requests (C6+C8)');
739
+ const step = (SCRIPT.match(/function physStep\(dt\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
740
+ assert.ok(step, 'physStep() must exist');
741
+ assert.doesNotMatch(step, /fetch\(/);
742
+ });
743
+
744
+ test('a drag is a pan and a click is a click — the two never blur', () => {
745
+ assert.match(SCRIPT, /var SKY_DEADZONE = 5/, 'the 5px Euclidean deadzone from the gate');
746
+ // mutation-found gap: a bare /skyPanned/ match was satisfied by the flag's
747
+ // declaration alone — pin the CONSUME site and the deferred clear instead
748
+ assert.match(SCRIPT, /if \(skyPanned\) \{ ev\.preventDefault\(\); return; \}/,
749
+ 'the delegated click handler consumes the pan flag AND still cancels the link');
750
+ assert.match(SCRIPT, /setTimeout\(function \(\) \{ skyPanned = false; \}, 0\)/,
751
+ 'the flag clears a tick after the pan ends, so a keyboard Enter is never eaten');
752
+ assert.match(SCRIPT, /pointercancel/, 'an interrupted touch must not strand the grab state');
753
+ assert.match(SCRIPT, /panId/, 'one pointer owns the drag — a second finger cannot hijack it');
754
+ });
755
+
756
+ test('anchors are never read from a hidden field', () => {
757
+ // review CRITICAL: offsetLeft/Top and clientWidth all read 0 under
758
+ // [hidden]{display:none} — anchoring to zeros paints every orb at
759
+ // slot + physics position when the sky returns. The rebuild must defer.
760
+ const fn = (SCRIPT.match(/function rebuildSkyBodies\(\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
761
+ assert.ok(fn, 'rebuildSkyBodies() must exist');
762
+ assert.match(fn, /clientWidth\) \{ skyNeedsRebuild = true; return; \}/,
763
+ 'an unlaid-out field defers the rebuild instead of anchoring to zeros');
764
+ assert.match(SCRIPT, /if \(skyNeedsRebuild\) rebuildSkyBodies\(\);/,
765
+ 'the deferred rebuild is consumed when the sky comes back on stage');
766
+ });
767
+
768
+ test('zoom heads for the cursor and survives line-mode wheels', () => {
769
+ assert.match(SCRIPT, /deltaMode/, 'Firefox line-mode deltas must be normalized');
770
+ assert.match(SCRIPT, /Math\.exp\(/, 'exponential zoom — symmetric in and out');
771
+ assert.ok(COSMOS.includes('transform-origin:0 0'),
772
+ 'the camera algebra assumes the layer scales about its origin');
773
+ });