blockyard 0.0.9 → 0.1.1

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.
Files changed (97) hide show
  1. package/CHANGELOG.md +359 -1
  2. package/README.md +48 -27
  3. package/SECURITY.md +2 -2
  4. package/bin/blockyard.js +2 -1
  5. package/docs/API.md +17 -15
  6. package/docs/ARCHITECTURE.md +128 -10
  7. package/docs/CONFIGURATION.md +39 -30
  8. package/docs/DEFECTS.md +4 -1
  9. package/docs/GETTING-STARTED.md +18 -8
  10. package/docs/INSTALL.md +97 -37
  11. package/docs/MEASUREMENTS.md +147 -0
  12. package/docs/PLAN-SCORCHED-YARD.md +456 -0
  13. package/docs/PLAN-SKIES.md +142 -0
  14. package/docs/SECURITY-AUDIT-2026-09-16.md +647 -0
  15. package/docs/SECURITY.md +58 -22
  16. package/docs/TROUBLESHOOTING.md +44 -5
  17. package/docs/USER-GUIDE.md +505 -35
  18. package/package.json +4 -2
  19. package/public/404.html +1 -1
  20. package/public/css/app.css +393 -82
  21. package/public/donate-qr.png +0 -0
  22. package/public/index.html +353 -109
  23. package/public/js/agents.js +228 -51
  24. package/public/js/app.js +131 -16
  25. package/public/js/blockanoid.js +15 -7
  26. package/public/js/blockout.js +15 -7
  27. package/public/js/blockscene3d.js +230 -38
  28. package/public/js/charts.js +21 -21
  29. package/public/js/depthchart.js +31 -27
  30. package/public/js/details3d.js +1481 -73
  31. package/public/js/doom.js +31 -0
  32. package/public/js/dosaudio.js +48 -0
  33. package/public/js/dosgame.js +389 -0
  34. package/public/js/dosio.js +186 -0
  35. package/public/js/dospc.js +1353 -0
  36. package/public/js/dosworker.js +196 -0
  37. package/public/js/explorer.js +7 -1
  38. package/public/js/livingsky.js +494 -0
  39. package/public/js/login.js +8 -2
  40. package/public/js/markets.js +46 -8
  41. package/public/js/mining.js +314 -36
  42. package/public/js/panels.js +41 -28
  43. package/public/js/pricechart.js +14 -13
  44. package/public/js/quake.js +20 -0
  45. package/public/js/safenext.js +14 -0
  46. package/public/js/scorched.js +1051 -0
  47. package/public/js/scorchedai.js +227 -0
  48. package/public/js/scorchedair.js +286 -0
  49. package/public/js/scorchedfx.js +376 -0
  50. package/public/js/scorchedshop.js +105 -0
  51. package/public/js/scorchedwind.js +69 -0
  52. package/public/js/scorchedyard.js +1338 -0
  53. package/public/js/settings.js +368 -100
  54. package/public/js/soundcard.js +459 -0
  55. package/public/js/tetrust.js +15 -6
  56. package/public/js/tetsound.js +35 -5
  57. package/public/js/theme.js +235 -0
  58. package/public/js/wolf3d.js +22 -0
  59. package/public/js/x86.js +1978 -0
  60. package/scripts/check.js +46 -0
  61. package/scripts/donate-qr.py +12 -9
  62. package/scripts/dos-bench.js +56 -0
  63. package/scripts/index-build.js +9 -2
  64. package/scripts/pool-map.js +152 -36
  65. package/scripts/setup.js +142 -22
  66. package/scripts/shots.mjs +27 -0
  67. package/scripts/smoke.sh +7 -6
  68. package/scripts/tls.js +31 -0
  69. package/scripts/ui.js +4 -2
  70. package/server/auth/sessions.js +33 -13
  71. package/server/chain/blockfile.js +64 -5
  72. package/server/chain/index/build.js +451 -58
  73. package/server/chain/index/heights.js +29 -3
  74. package/server/chain/index/live.js +13 -7
  75. package/server/chain/index/rows.js +6 -1
  76. package/server/chain/index/store.js +28 -5
  77. package/server/chain/index/worker.js +23 -11
  78. package/server/collect/logparse.js +65 -18
  79. package/server/collect/markets.js +76 -7
  80. package/server/collect/mining.js +32 -0
  81. package/server/collect/monitor.js +54 -12
  82. package/server/collect/network.js +305 -0
  83. package/server/config.js +53 -22
  84. package/server/http/api.js +119 -18
  85. package/server/http/games.js +77 -0
  86. package/server/http/server.js +30 -5
  87. package/server/http/sse.js +53 -7
  88. package/server/main.js +66 -11
  89. package/server/rpc/allowlist.js +26 -0
  90. package/server/rpc/client.js +30 -2
  91. package/server/store/audit.js +6 -1
  92. package/server/store/history.js +19 -3
  93. package/server/store/ledger.js +15 -4
  94. package/server/tls/selfsigned.js +160 -0
  95. package/systemd/blockyard.service +41 -8
  96. package/docs/PRIVATE-LEADERBOARD.md +0 -230
  97. package/docs/STATE-2026-09-09.md +0 -200
@@ -268,6 +268,91 @@ function bloom(ctx, x, y, r, rgb, a = 1) {
268
268
  }
269
269
  }
270
270
 
271
+ /**
272
+ * THE SAUCER, drawn at a point on screen -- shared, because two effects fly the same craft
273
+ * (operator, 2026-09-15: "The top-down view in block space coming from a sphere does not look
274
+ * good. Can we re-purpose the UFO"). The scan beam used to hang from a glowing ball, which from
275
+ * directly above is just a bright blob; a craft reads as a source even top-down, because its rim
276
+ * and its dome give it an orientation a sphere does not have.
277
+ *
278
+ * `r` is in PIXELS: callers size it from view.unit, never from lw (see bloom).
279
+ */
280
+ /**
281
+ * THE SAUCER SEEN FROM ABOVE (operator, 2026-09-15: "We need a top-down rendering of the UFO for
282
+ * this scene"). The side-on craft below is right where the camera looks along the board; on the
283
+ * block board it looks down, and a dome-and-rim drawn edge-on there is just a blue smear -- which
284
+ * is what the screenshot showed.
285
+ *
286
+ * From above a saucer is concentric: hull, inner ring, canopy, and running lights spaced around
287
+ * the rim. `squash` is the camera's depth foreshortening (oblique.dy), so the circles come out as
288
+ * the same ellipses the board itself is drawn in.
289
+ */
290
+ export function saucerAbove(ctx, cx, cy, r, a = 1, squash = 1, phase = 0) {
291
+ const ell = (rr, fill) => {
292
+ ctx.fillStyle = fill;
293
+ ctx.beginPath();
294
+ for (let i = 0; i <= 28; i++) {
295
+ const th = (i / 28) * Math.PI * 2;
296
+ const x = cx + Math.cos(th) * rr, y = cy + Math.sin(th) * rr * squash;
297
+ if (i) ctx.lineTo(x, y); else ctx.moveTo(x, y);
298
+ }
299
+ ctx.closePath(); ctx.fill();
300
+ };
301
+ ell(r, `rgba(70,110,170,${(0.85 * a).toFixed(3)})`); // the hull, dark against the glow
302
+ ell(r * 0.74, `rgba(120,180,255,${(0.9 * a).toFixed(3)})`); // the lit ring
303
+ ell(r * 0.40, `rgba(205,235,255,${(0.95 * a).toFixed(3)})`); // the canopy
304
+ ell(r * 0.18, `rgba(255,255,255,${(0.95 * a).toFixed(3)})`); // its highlight
305
+ // running lights: spaced round the rim, turning slowly so it reads as a craft and not a target
306
+ for (let i = 0; i < 8; i++) {
307
+ const th = phase + (i / 8) * Math.PI * 2;
308
+ const x = cx + Math.cos(th) * r * 0.88, y = cy + Math.sin(th) * r * 0.88 * squash;
309
+ const tw = 0.55 + 0.45 * Math.sin(phase * 3 + i * 1.9);
310
+ ctx.fillStyle = `rgba(255,255,255,${(0.9 * tw * a).toFixed(3)})`;
311
+ ctx.beginPath(); ctx.arc(x, y, Math.max(0.6, r * 0.075), 0, Math.PI * 2); ctx.fill();
312
+ }
313
+ }
314
+
315
+ export function saucer(ctx, cx, cy, r, a = 1) {
316
+ const dome = [];
317
+ for (let i = 0; i <= 20; i++) { const ang = Math.PI + (i / 20) * Math.PI; dome.push({ x: cx + Math.cos(ang) * r, y: cy + Math.sin(ang) * r * 0.42 }); }
318
+ poly(ctx, dome, `rgba(120,180,255,${(0.9 * a).toFixed(3)})`);
319
+ poly(ctx, [
320
+ { x: cx - r, y: cy }, { x: cx + r, y: cy },
321
+ { x: cx + r * 0.7, y: cy + r * 0.25 }, { x: cx - r * 0.7, y: cy + r * 0.25 },
322
+ ], `rgba(200,230,255,${(0.95 * a).toFixed(3)})`);
323
+ bloom(ctx, cx, cy + r * 0.2, r * 0.8, [180, 230, 255], 0.5 * a);
324
+ }
325
+
326
+ /**
327
+ * A lens flare at a bright point: an anamorphic streak in the colour, a white core, four thin
328
+ * rays turning slowly, and a run of faint ghosts along the line toward the picture's middle.
329
+ * `f` is 1 at full and 0 gone. Gradients only, no composite modes (the canvas rules).
330
+ */
331
+ export function lensFlare(ctx, x, y, L, rgb, f, view, lw) {
332
+ if (f <= 0) return;
333
+ const c = rgb.join(',');
334
+ const lin = (x0, y0, x1, y1, stops) => {
335
+ const g = typeof ctx.createLinearGradient === 'function' ? ctx.createLinearGradient(x0, y0, x1, y1) : null;
336
+ if (!g || typeof g.addColorStop !== 'function') return stops[Math.floor(stops.length / 2)][1];
337
+ for (const [o, col] of stops) g.addColorStop(o, col);
338
+ return g;
339
+ };
340
+ const stroke = (x0, y0, x1, y1, fill, w) => { ctx.strokeStyle = fill; ctx.lineWidth = Math.max(lw * 0.8, w); ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(x1, y1); ctx.stroke(); };
341
+ stroke(x - L * 0.7, y, x + L * 0.7, y, lin(x - L * 0.7, y, x + L * 0.7, y, [[0, `rgba(${c},0)`], [0.5, `rgba(${c},${(0.25 * f).toFixed(3)})`], [1, `rgba(${c},0)`]]), L * 0.06);
342
+ stroke(x - L, y, x + L, y, lin(x - L, y, x + L, y, [[0, `rgba(${c},0)`], [0.35, `rgba(${c},${(0.35 * f).toFixed(3)})`], [0.5, `rgba(255,255,255,${(0.9 * f).toFixed(3)})`], [0.65, `rgba(${c},${(0.35 * f).toFixed(3)})`], [1, `rgba(${c},0)`]]), L * 0.018);
343
+ const spin = (view.now ?? 0) * 0.0004;
344
+ for (let k = 0; k < 4; k++) {
345
+ const ang = spin + (k / 4) * Math.PI, rl = L * 0.45;
346
+ const x0 = x - Math.cos(ang) * rl, y0 = y - Math.sin(ang) * rl, x1 = x + Math.cos(ang) * rl, y1 = y + Math.sin(ang) * rl;
347
+ stroke(x0, y0, x1, y1, lin(x0, y0, x1, y1, [[0, 'rgba(255,255,255,0)'], [0.5, `rgba(255,255,255,${(0.5 * f).toFixed(3)})`], [1, 'rgba(255,255,255,0)']]), L * 0.008);
348
+ }
349
+ const cx0 = (view.boardW ?? 0) / 2, cy0 = -(view.boardH ?? 0) / 4;
350
+ for (let k = 1; k <= 4; k++) {
351
+ const t = k * 0.3, gx = x + (cx0 - x) * t, gy = y + (cy0 - y) * t, gr = L * 0.05 * (0.6 + t), ga = 0.14 * f * (1 - k / 6);
352
+ ctx.fillStyle = `rgba(${c},${ga.toFixed(3)})`; ctx.beginPath(); ctx.arc(gx, gy, Math.max(1, gr), 0, Math.PI * 2); ctx.fill();
353
+ }
354
+ }
355
+
271
356
  /** A square standing at grid (x, y) at height z, as screen points. */
272
357
  const quad = (view, x, y, z, r) => [
273
358
  project(x - r, y - r, z, view), project(x + r, y - r, z, view),
@@ -504,16 +589,8 @@ defineAgent('tractor', {
504
589
  { x: base.x + w1 * 0.45, y: base.y }, { x: base.x - w1 * 0.45, y: base.y },
505
590
  ], `rgba(220,245,255,${(0.3 * t.beam).toFixed(3)})`);
506
591
  }
507
- // the saucer: a flattened dome with a lit rim
508
- const r = U * 1.6;
509
- const dome = [];
510
- for (let i = 0; i <= 20; i++) { const ang = Math.PI + (i / 20) * Math.PI; dome.push({ x: c.x + Math.cos(ang) * r, y: c.y + Math.sin(ang) * r * 0.42 }); }
511
- poly(ctx, dome, 'rgba(120,180,255,0.9)');
512
- poly(ctx, [
513
- { x: c.x - r, y: c.y }, { x: c.x + r, y: c.y },
514
- { x: c.x + r * 0.7, y: c.y + r * 0.25 }, { x: c.x - r * 0.7, y: c.y + r * 0.25 },
515
- ], 'rgba(200,230,255,0.95)');
516
- bloom(ctx, c.x, c.y + r * 0.2, r * 0.8, [180, 230, 255], 0.5);
592
+ // the saucer, shared with the scan beam's source (saucer() above)
593
+ saucer(ctx, c.x, c.y, U * 1.6);
517
594
  void lw;
518
595
  },
519
596
  });
@@ -721,11 +798,19 @@ defineAgent('stormball', {
721
798
  // range, so the candles it passes are beside it and the arcs strike what it comes near
722
799
  // (operator, 2026-09-14: "electrifying any elements it comes near")
723
800
  const line = st?.axes?.line;
724
- let alt = highest + 3.5;
801
+ let alt = highest + 3.5, zPath = null;
725
802
  if (line?.length > 1) {
726
- let lo = Infinity, hi = -Infinity;
727
- for (const p of line) { lo = Math.min(lo, p.z); hi = Math.max(hi, p.z); }
728
- alt = lo + (hi - lo) * (0.35 + 0.5 * rnd());
803
+ let hi = -Infinity;
804
+ for (const p of line) hi = Math.max(hi, p.z);
805
+ // UP AND DOWN THE WHOLE CHART (operator, 2026-09-15: "it's not moving up and down enough
806
+ // during transit. It should try to cover a lot of space"): the height is two sines over the
807
+ // run, the slow one swinging across most of the range, the quick one on top. The range is
808
+ // the ground to the line's high -- not the line's own low, which sits above the volume band
809
+ // (operator, later: "The lightning ball really stays in the top 2/3's of the screen. It
810
+ // needs to seek the ground level more than it presently does before rising again")
811
+ const lo = 1;
812
+ alt = (lo + hi) / 2;
813
+ zPath = { lo, hi, f: [1.2 + rnd() * 1.3, 3 + rnd() * 3], ph: [rnd() * Math.PI * 2, rnd() * Math.PI * 2] };
729
814
  }
730
815
  // across the board the long way round, far enough past both edges to start and end off-screen
731
816
  const leftToRight = rnd() < 0.5;
@@ -736,35 +821,49 @@ defineAgent('stormball', {
736
821
  // "have it fully moved off the display before you remove it"): the corona reaches 12 units
737
822
  // from the centre and the trail seven behind, and at 8 the run ended with the glow still
738
823
  // on the chart and cut off dead
739
- const margin = st?.axes?.line?.length > 1 ? 16 : Math.max(W, H) * 0.55 + 6;
824
+ const margin = st?.axes?.line?.length > 1 ? 20 : Math.max(W, H) * 0.55 + 6;
740
825
  const from = { x: leftToRight ? -margin : W + margin, y: H * (0.22 + 0.56 * rnd()) };
741
826
  const to = { x: leftToRight ? W + margin : -margin, y: H * (0.22 + 0.56 * rnd()) };
742
827
  const weave = { amp: 1.5 + 2 * rnd(), cycles: 1 + rnd() * 1.5, phase: rnd() * Math.PI * 2 };
743
- // bursts of arcs: a few a second, one to three at a time, each alive for a moment
828
+ // bursts of arcs: a few a second, one to three at a time, each alive for a moment -- half as
829
+ // often on the price board (operator, 2026-09-15: "Tone down chance of emitting lightning by
830
+ // 50%"), where the run is also slower, so per second it is quieter still
744
831
  const arcs = [];
745
- for (let u = 0.03; u < 0.97; u += 0.016 + 0.035 * rnd()) {
832
+ // ...a fifth quieter again ("Reduce ball lighting electricity chance by 20%"), then 15% more
833
+ // ("increase the ball lightning strike chance by +15%"): 2.5 / 1.15
834
+ const every = line?.length > 1 ? 2.17 : 1;
835
+ for (let u = 0.03; u < 0.97; u += (0.016 + 0.035 * rnd()) * every) {
746
836
  const n = 1 + Math.floor(rnd() * 4);
747
837
  for (let k = 0; k < n; k++) {
748
838
  const arc = { u0: u + rnd() * 0.01, life: 0.02 + 0.035 * rnd(), ang: rnd() * Math.PI * 2, reach: 3.5 + 8 * rnd(), seed: Math.floor(rnd() * 1e9) };
839
+ // one strike in five flares: a lens flare blooms where it lands (operator, 2026-09-15:
840
+ // "Consider adding the lense flare effect on occasional lightning strikes")
841
+ if (rnd() < 0.2) arc.flare = true;
749
842
  // A SECOND ARC OFF THE STRUCK BLOCK, AND SOMETIMES A THIRD (operator, 2026-09-14: "a small
750
843
  // chance for a second arc to spawn from the block and arc to a different block", then "Make
751
844
  // the secondary arcing more frequent, and have a 50% for an additional third arc"). Half
752
845
  // the arcs carry a chain -- its own direction and reach from the block the first lands on,
753
846
  // to a different block -- and half of those chain once more from the second block to a
754
847
  // third. Decided here, so a replay throws the same chains.
755
- if (rnd() < 0.5) {
848
+ // ...then a quarter fewer of each (2026-09-15: "decrease secondary and third arcing by 25%")
849
+ if (rnd() < 0.375) {
756
850
  arc.chain = { ang: rnd() * Math.PI * 2, reach: 2.5 + 6 * rnd(), seed: Math.floor(rnd() * 1e9) };
757
- if (rnd() < 0.5) arc.chain.next = { ang: rnd() * Math.PI * 2, reach: 2.5 + 6 * rnd(), seed: Math.floor(rnd() * 1e9) };
851
+ if (rnd() < 0.375) arc.chain.next = { ang: rnd() * Math.PI * 2, reach: 2.5 + 6 * rnd(), seed: Math.floor(rnd() * 1e9) };
758
852
  }
759
853
  arcs.push(arc);
760
854
  }
761
855
  }
762
- return { from, to, weave, alt, arcs, W, H, top };
856
+ return { from, to, weave, alt, zPath, arcs, W, H, top };
763
857
  },
764
858
  frame(a, u) {
765
859
  const at = (uu) => {
766
860
  const x = a.from.x + (a.to.x - a.from.x) * uu;
767
861
  const y = a.from.y + (a.to.y - a.from.y) * uu + a.weave.amp * Math.sin(uu * Math.PI * 2 * a.weave.cycles + a.weave.phase);
862
+ if (a.zPath) {
863
+ const { lo, hi, f, ph } = a.zPath;
864
+ const w = 0.5 + 0.44 * Math.sin(uu * Math.PI * 2 * f[0] + ph[0]) + 0.14 * Math.sin(uu * Math.PI * 2 * f[1] + ph[1]);
865
+ return { x, y, z: lo + (hi - lo) * Math.max(0, Math.min(1, w)) };
866
+ }
768
867
  return { x, y, z: a.alt + 0.6 * Math.sin(uu * 17) };
769
868
  };
770
869
  const ball = at(u);
@@ -790,20 +889,30 @@ defineAgent('stormball', {
790
889
  return hit;
791
890
  };
792
891
  const live = [];
793
- const heads = [{ x: ball.x, y: ball.y, color: [70, 180, 255], alpha: 0.7, r: 4.5 }];
892
+ // LIGHT EMITTERS (operator, 2026-09-15: "The lightning ball needs to illuminate anything it
893
+ // comes near with an electric hazy white effect. Need the lightning strikes to emit lights").
894
+ // `heads` is how an agent lights the board (fxAt): the ball is a wide hazy white source now,
895
+ // not a blue tint, and every strike is a source of its own where it lands
896
+ const heads = [{ x: ball.x, y: ball.y, color: [225, 240, 255], alpha: 0.95, r: 6.5 }];
794
897
  for (const arc of a.arcs) {
795
898
  const age = (u - arc.u0) / arc.life;
796
899
  if (age < 0 || age > 2.2) continue; // alive, then an afterglow on the block
797
900
  const p = at(arc.u0);
901
+ // NO ARCS FROM OFF THE BOARD (2026-09-15: "make the lightning ball completely disappear off
902
+ // the viewport before killing it. Seeing it flash out at the end"): a ball past the edge
903
+ // could still reach a candle at the edge with an arc, so the last thing seen was a bolt
904
+ // from nowhere; while it is off the board nothing is drawn from it -- not even an arc it
905
+ // threw a moment before the edge -- and the run ends with it long gone
906
+ if (a.zPath && (ball.x < -1 || ball.x > a.W + 1)) continue;
798
907
  // the block the arc strikes: the tallest cell near where it is thrown, and none if the floor is bare
799
908
  const hit = strike(p.x + Math.cos(arc.ang) * arc.reach, p.y + Math.sin(arc.ang) * arc.reach);
800
909
  if (!hit) continue;
801
- if (age <= 1) live.push({ to: hit, seed: arc.seed, strength: 1 - age * 0.6 });
910
+ if (age <= 1) live.push({ to: hit, seed: arc.seed, strength: 1 - age * 0.6, age, flare: !!arc.flare });
802
911
  // THE BLOCK IT TOUCHES IS ELECTRIFIED: a hard blue flare that flickers while the arc lives and
803
912
  // dies away after it -- lit through fxAt's heads, so only the cubes actually struck light up
804
913
  const flicker = 0.7 + 0.3 * Math.sin(u * 900 + arc.seed);
805
914
  const glow = age <= 1 ? flicker : Math.max(0, 1 - (age - 1) / 1.2) * 0.7;
806
- heads.push({ x: hit.x, y: hit.y, color: [60, 190, 255], alpha: Math.min(1, glow * 1.25), r: 1.2 });
915
+ heads.push({ x: hit.x, y: hit.y, color: [230, 245, 255], alpha: Math.min(1, glow * 1.25), r: 2.4 });
807
916
  // ...and the chain, if this arc carries one: from the struck block to another. ITS OWN
808
917
  // EVENT, not a bend in the first (2026-09-14: "I'm not seeing secondary arcs" -- they were
809
918
  // there in a third of the arc frames, drawn in the first arc's colour from the first arc's
@@ -823,7 +932,7 @@ defineAgent('stormball', {
823
932
  if (!to) break;
824
933
  if (age2 <= 1) live.push({ from, to, seed: link.seed, strength: Math.min(1, 1.2 * (1 - age2 * 0.5)), chain: hop, age: age2 });
825
934
  const glow2 = age2 <= 1 ? 0.7 + 0.3 * Math.sin(u * 900 + link.seed) : Math.max(0, 1 - (age2 - 1) / 1.2) * 0.7;
826
- heads.push({ x: to.x, y: to.y, color: [120, 255, 170], alpha: Math.min(1, glow2 * 1.25), r: 1.2 });
935
+ heads.push({ x: to.x, y: to.y, color: [200, 255, 225], alpha: Math.min(1, glow2 * 1.25), r: 2.2 });
827
936
  struck.push(to); from = to; link = link.next; hop++;
828
937
  }
829
938
  }
@@ -838,42 +947,110 @@ defineAgent('stormball', {
838
947
  // bigger than the first cut on the block board: it is the one thing there (operator: "visually
839
948
  // stunning"); half that on the price board (operator, 2026-09-14: "make the ball lightning half
840
949
  // the size it is now"), where it shares the view with the line
841
- const R = U * (view.axes?.line?.length > 1 ? 1.7 : 3.4);
842
- // a jagged bolt between two screen points, `kink` pixels of wander, re-rolled each frame
843
- const bolt = (p, q, kink, steps) => {
844
- const pts = [p];
845
- for (let i = 1; i < steps; i++) {
846
- const f = i / steps, nx = -(q.y - p.y), ny = q.x - p.x, L = Math.hypot(nx, ny) || 1;
847
- const off = (Math.random() - 0.5) * 2 * kink * Math.sin(Math.PI * f);
848
- pts.push({ x: p.x + (q.x - p.x) * f + (nx / L) * off, y: p.y + (q.y - p.y) * f + (ny / L) * off });
849
- }
850
- pts.push(q);
950
+ // ...then half again (2026-09-15: "the lightning ball is still too large. shrink it up by half again")
951
+ const R = U * (view.axes?.line?.length > 1 ? 0.85 : 3.4);
952
+ // LIGHTNING THAT LOOKS LIKE LIGHTNING (operator, 2026-09-15: "the lightning ball tendrils on
953
+ // the markets page look too big and jagged. Is there a way to thin them up a bit, or make
954
+ // substantial visual improvements to the lightning arcs?"). The first bolt was nine points
955
+ // thrown 18% of the length to either side, re-rolled every frame, in strokes sized to the grid
956
+ // unit -- on the price board, whose unit is three times the block board's, that was a fat
957
+ // zigzag flailing at 60 Hz. Three changes:
958
+ // * MIDPOINT DISPLACEMENT: the channel is split in half, the middle thrown to one side, and
959
+ // each half split again four times over with the throw halving each level -- the shape of
960
+ // a discharge, which wanders on the large scale and crackles on the small, never a zigzag;
961
+ // * BRANCHES: one or two forks leave the channel partway, thinner and shorter, each a
962
+ // smaller discharge of its own, dying out before they reach anything;
963
+ // * HELD FOR A BEAT: the shape is rolled from the arc's seed and the clock in 60 ms steps, so
964
+ // it holds for a few frames and then jumps -- a flicker, not a shiver -- and the strokes
965
+ // are sized to the ball's own radius, so the price board's half-size ball throws half-width
966
+ // bolts.
967
+ const beat = Math.floor((view.now ?? 0) / 60);
968
+ const rollFrom = (seed) => { let x = (seed ^ (beat * 2654435761)) >>> 0 || 1; return () => ((x = (Math.imul(x, 1103515245) + 12345) >>> 0) / 4294967296); };
969
+ const displace = (pts, i, j, amp, depth, rnd) => {
970
+ if (depth === 0 || j - i < 2) return;
971
+ const m = (i + j) >> 1, a = pts[i], b = pts[j];
972
+ const nx = -(b.y - a.y), ny = b.x - a.x, L = Math.hypot(nx, ny) || 1;
973
+ const off = (rnd() - 0.5) * 2 * amp * L;
974
+ pts[m] = { x: (a.x + b.x) / 2 + (nx / L) * off, y: (a.y + b.y) / 2 + (ny / L) * off };
975
+ displace(pts, i, m, amp * 0.55, depth - 1, rnd); displace(pts, m, j, amp * 0.55, depth - 1, rnd);
976
+ };
977
+ const bolt = (p, q, amp, rnd, levels = 4) => {
978
+ const n = 1 << levels, pts = new Array(n + 1); pts[0] = p; pts[n] = q;
979
+ displace(pts, 0, n, amp, levels, rnd);
851
980
  return pts;
852
981
  };
982
+ // THE HAZE: a wide, soft, white light round the ball, over the board, so what it passes reads
983
+ // as lit by it and not only tinted -- and the same, smaller, wherever an arc lands
984
+ {
985
+ const g = typeof ctx.createRadialGradient === 'function' ? ctx.createRadialGradient(c.x, c.y, 0, c.x, c.y, R * 5) : null;
986
+ if (g && typeof g.addColorStop === 'function') { g.addColorStop(0, 'rgba(235,245,255,0.30)'); g.addColorStop(0.35, 'rgba(225,240,255,0.14)'); g.addColorStop(1, 'rgba(220,235,255,0)'); ctx.fillStyle = g; }
987
+ else ctx.fillStyle = 'rgba(235,245,255,0.08)';
988
+ ctx.beginPath(); ctx.arc(c.x, c.y, R * 5, 0, Math.PI * 2); ctx.fill();
989
+ }
853
990
  // the arcs first, so the sphere sits over the roots of its own lightning
854
991
  for (const arc of s.arcs) {
855
992
  const end = project(arc.to.x, arc.to.y, arc.to.z, view);
856
993
  // a chained arc leaves the block the first one struck, not the ball
857
994
  const from = arc.from ? project(arc.from.x, arc.from.y, arc.from.z, view) : c;
858
- const main = bolt(from, end, Math.hypot(end.x - from.x, end.y - from.y) * 0.18, 9);
995
+ const rnd = rollFrom(arc.seed);
996
+ const main = bolt(from, end, 0.11, rnd);
859
997
  // a chain (hop 1 or 2 from the block) is electric green, so it reads as the block discharging, not the ball
860
998
  const [halo, body, edge] = arc.chain ? ['20,200,110', '110,255,170', '215,255,235'] : ['30,120,255', '60,170,255', '140,220,255'];
861
- const sz = arc.chain ? 0.85 : 1;
862
- line(ctx, main, `rgba(${halo},${(0.3 * arc.strength).toFixed(3)})`, Math.max(lw * 8, U * 1.1) * sz);
863
- line(ctx, main, `rgba(${body},${(0.6 * arc.strength).toFixed(3)})`, Math.max(lw * 4, U * 0.45) * sz);
864
- line(ctx, main, `rgba(${edge},${(0.95 * arc.strength).toFixed(3)})`, Math.max(lw * 2, U * 0.2) * sz);
865
- line(ctx, main, `rgba(245,252,255,${arc.strength.toFixed(3)})`, Math.max(lw, U * 0.08) * sz);
866
- // ...and a ring bursting from the block it lands on, the moment it lands
999
+ const sz = (arc.chain ? 0.85 : 1) * R;
1000
+ const stroke = (pts, col, w, a) => line(ctx, pts, `rgba(${col},${a.toFixed(3)})`, Math.max(lw, w));
1001
+ // THE FLASH (2026-09-15: "the lightning bolts still need much more visual flash and effects
1002
+ // to them"): the first fifth of an arc's life is the strike -- the halo blazes at three
1003
+ // times its weight and a white flash blooms where it lands -- then a bead of light runs the
1004
+ // channel from root to tip, sparks spray from the strike point and fall away, and the whole
1005
+ // thing settles to the steady glow. `age` is 0 at the strike and 1 at the end.
1006
+ const age = arc.age ?? (1 - arc.strength) / 0.6;
1007
+ const flash = Math.max(0, 1 - age / 0.2);
1008
+ stroke(main, halo, sz * (0.30 + 0.25 * flash), (0.28 + 0.5 * flash) * arc.strength);
1009
+ stroke(main, body, sz * 0.12, 0.6 * arc.strength);
1010
+ stroke(main, edge, sz * 0.055, 0.95 * arc.strength);
1011
+ stroke(main, '245,252,255', sz * (0.022 + 0.02 * flash), arc.strength);
1012
+ if (flash > 0) bloom(ctx, end.x, end.y, R * 1.6 * (1.2 - flash * 0.5), [255, 255, 255], 0.9 * flash);
1013
+ // the landing lights its surroundings: a hazy white pool that outlives the flash
1014
+ if (age < 0.8) bloom(ctx, end.x, end.y, R * 2.6, [230, 242, 255], 0.35 * (1 - age / 0.8) * arc.strength);
1015
+ // the occasional strike flares: an anamorphic streak through the landing, four turning
1016
+ // rays, and a run of ghosts off toward the picture's middle, over the first third of the arc
1017
+ if (arc.flare && age < 0.35) lensFlare(ctx, end.x, end.y, R * 2.2, arc.chain ? [110, 255, 170] : [140, 220, 255], Math.pow(1 - age / 0.35, 1.5), view, lw);
1018
+ // the bead: a bright knot of light running the channel in the first third of the arc's life
1019
+ const run = Math.min(1, age / 0.35), bi = Math.min(main.length - 1, Math.floor(run * (main.length - 1)));
1020
+ if (age < 0.5) bloom(ctx, main[bi].x, main[bi].y, sz * 0.22, [235, 250, 255], 0.9 * (1 - age));
1021
+ // the sparks: a dozen short lines flung from the strike point, longer and fainter as they go,
1022
+ // drifting down, gone by half the arc's life
1023
+ if (age < 0.55) {
1024
+ const srnd = rollFrom(arc.seed ^ 0x9e3779b9);
1025
+ const life = age / 0.55;
1026
+ for (let k = 0; k < 12; k++) {
1027
+ const ang = srnd() * Math.PI * 2, spd = 0.6 + srnd() * 1.4, len = sz * (0.25 + 0.2 * srnd());
1028
+ const dist = sz * 1.3 * spd * life, drop = sz * 1.2 * life * life;
1029
+ const x1 = end.x + Math.cos(ang) * dist, y1 = end.y + Math.sin(ang) * dist + drop;
1030
+ const x0 = x1 - Math.cos(ang) * len * (1 - life), y0 = y1 - Math.sin(ang) * len * (1 - life) - drop * 0.15;
1031
+ line(ctx, [{ x: x0, y: y0 }, { x: x1, y: y1 }], `rgba(${edge},${(0.9 * (1 - life) * arc.strength).toFixed(3)})`, Math.max(lw, sz * 0.02));
1032
+ }
1033
+ }
1034
+ // the branches: two or three leave the channel a third to two thirds along, at 20-45
1035
+ // degrees, a quarter to a half of the remaining length, and fade toward their tips
1036
+ const forks = 2 + (rnd() < 0.5 ? 1 : 0);
1037
+ for (let f = 0; f < forks; f++) {
1038
+ const k = Math.floor(main.length * (0.3 + 0.4 * rnd()));
1039
+ const at = main[k], dx = end.x - at.x, dy = end.y - at.y;
1040
+ const ang = (rnd() < 0.5 ? 1 : -1) * (0.35 + 0.45 * rnd()), len = 0.25 + 0.25 * rnd();
1041
+ const tip = { x: at.x + (dx * Math.cos(ang) - dy * Math.sin(ang)) * len, y: at.y + (dx * Math.sin(ang) + dy * Math.cos(ang)) * len };
1042
+ const br = bolt(at, tip, 0.14, rnd, 3);
1043
+ stroke(br, body, sz * 0.06, 0.35 * arc.strength);
1044
+ stroke(br, edge, sz * 0.028, 0.7 * arc.strength);
1045
+ stroke(br, '245,252,255', sz * 0.012, 0.8 * arc.strength);
1046
+ }
1047
+ // ...and a ring bursting from the block a chain lands on, the moment it lands
867
1048
  if (arc.chain && arc.age < 0.6) {
868
1049
  const f = arc.age / 0.6;
869
- ring(ctx, end.x, end.y, U * (0.4 + 2.2 * f), `rgba(150,255,190,${(0.9 * (1 - f)).toFixed(3)})`, Math.max(lw * 2, U * 0.12) * (1 - 0.5 * f));
870
- ring(ctx, end.x, end.y, U * (0.2 + 1.4 * f), `rgba(235,255,245,${(0.8 * (1 - f)).toFixed(3)})`, Math.max(lw, U * 0.06));
1050
+ ring(ctx, end.x, end.y, R * (0.12 + 0.65 * f), `rgba(150,255,190,${(0.9 * (1 - f)).toFixed(3)})`, Math.max(lw * 2, R * 0.035) * (1 - 0.5 * f));
1051
+ ring(ctx, end.x, end.y, R * (0.06 + 0.4 * f), `rgba(235,255,245,${(0.8 * (1 - f)).toFixed(3)})`, Math.max(lw, R * 0.018));
871
1052
  }
872
- // a fork off the main channel, and a spark where it lands
873
- const k = 3 + Math.floor(Math.random() * 4);
874
- const fork = main[k], forkEnd = { x: fork.x + (Math.random() - 0.5) * U * 3, y: fork.y + (Math.random() - 0.5) * U * 3 };
875
- line(ctx, bolt(fork, forkEnd, U * 0.5, 4), `rgba(130,215,255,${(0.6 * arc.strength).toFixed(3)})`, Math.max(lw, U * 0.08));
876
- bloom(ctx, end.x, end.y, U * 2.2, [90, 200, 255], arc.strength);
1053
+ bloom(ctx, end.x, end.y, R * 0.65, arc.chain ? [120, 255, 180] : [90, 200, 255], arc.strength);
877
1054
  }
878
1055
  // THE SPHERE IS ONE SMOOTH THING (operator, 2026-09-14: "needs a smooth gradient, not different
879
1056
  // discs around it. It needs to be visually stunning. Consider adding nebula effects"). Radial
@@ -938,9 +1115,9 @@ defineAgent('stormball', {
938
1115
  const ang = Math.random() * Math.PI * 2, r0 = R * (0.35 + 0.5 * Math.random()), r1 = R * (1.05 + 0.55 * Math.random());
939
1116
  const p = { x: c.x + Math.cos(ang) * r0, y: c.y + Math.sin(ang) * r0 };
940
1117
  const q = { x: c.x + Math.cos(ang + (Math.random() - 0.5) * 0.9) * r1, y: c.y + Math.sin(ang + (Math.random() - 0.5) * 0.9) * r1 };
941
- const pts = bolt(p, q, R * 0.22, 4);
942
- line(ctx, pts, 'rgba(90,190,255,0.55)', Math.max(lw * 2.2, U * 0.12));
943
- line(ctx, pts, 'rgba(230,248,255,0.95)', Math.max(lw, U * 0.04));
1118
+ const pts = bolt(p, q, 0.16, rollFrom(i * 7919 + 17), 3);
1119
+ line(ctx, pts, 'rgba(90,190,255,0.55)', Math.max(lw * 1.6, R * 0.035));
1120
+ line(ctx, pts, 'rgba(230,248,255,0.95)', Math.max(lw, R * 0.012));
944
1121
  }
945
1122
  },
946
1123
  });
package/public/js/app.js CHANGED
@@ -11,17 +11,46 @@ import { renderMiningOverview, renderMining, renderBlockSpace, refreshLabel } fr
11
11
  import { viewerIdle } from './details3d.js';
12
12
  import {
13
13
  loadSettings, setSetting, resetSettings, seedSettings, setSettingsPush,
14
- SETTINGS_KEY, PANEL as SETTINGS_PANEL, formatRangeValue,
15
- } from './settings.js';
14
+ SETTINGS_KEY, PANEL as SETTINGS_PANEL, formatRangeValue, SKY_BOARDS, SKY_CHOICES } from './settings.js';
15
+ // APPEARANCE (2026-09-16): the theme goes on <html> before the first paint, from the browser's copy
16
+ // of the settings, and follows every change after (followTheme); the cards' swatches are painted
17
+ // through the CSSOM because the CSP refuses a style attribute in markup
18
+ import { TAB_ROWS } from './settings.js';
19
+ import { followTheme, presetFace, resolveScheme, resolveTheme, PRESETS, BASE_KEYS } from './theme.js';
16
20
  import { renderExplorer } from './explorer.js';
17
21
  import { renderMarkets, summaryHtml as marketsSummaryHtml, REFRESH_MS as MARKETS_REFRESH_MS } from './markets.js';
18
22
  import { renderKiosk } from './kiosk.js';
19
- import { renderTetrust } from './tetrust.js';
20
- import { renderBlockout } from './blockout.js';
21
- import { renderBlockanoid } from './blockanoid.js';
23
+ // THE GAMES LOAD WHEN THEY ARE OPENED (the outside review of 2026-09-16: "Move the DOOM/Wolfenstein/
24
+ // Quake emulator and 3D games into an optional, separately loadable module ... This reduces the
25
+ // attack and bug surface of the core monitor"). They were static imports, so the Overview of a node
26
+ // monitor fetched and evaluated twenty game modules -- a quarter of the app's code -- before it drew
27
+ // a chart. Each game is now an import() made the first time its page is opened; a monitor that is
28
+ // only ever used as a monitor never runs a line of them.
29
+ const GAMES = Object.freeze({
30
+ tetrust: () => import('./tetrust.js').then((m) => m.renderTetrust),
31
+ blockout: () => import('./blockout.js').then((m) => m.renderBlockout),
32
+ blockanoid: () => import('./blockanoid.js').then((m) => m.renderBlockanoid),
33
+ scorched: () => import('./scorchedyard.js').then((m) => m.renderScorchedYard),
34
+ wolf3d: () => import('./wolf3d.js').then((m) => m.renderWolf3d),
35
+ doom: () => import('./doom.js').then((m) => m.renderDoom),
36
+ quake: () => import('./quake.js').then((m) => m.renderQuake),
37
+ });
38
+ const gameRenderers = new Map();
39
+ const gameLoads = new Map();
40
+ /** Draw a game's page, loading its module the first time; the page repaints once it has arrived. */
41
+ function renderGame(page, s) {
42
+ const draw = gameRenderers.get(page);
43
+ if (draw) { draw(s, state, helpers); return; }
44
+ if (gameLoads.has(page)) return;
45
+ gameLoads.set(page, GAMES[page]()
46
+ .then((fn) => { gameRenderers.set(page, fn); if (state.page === page) render(); })
47
+ .catch((e) => { gameLoads.delete(page); toast(`${page} could not be loaded: ${e.message}`, 'bad'); }));
48
+ }
22
49
  import { renderAbout } from './about.js';
23
50
  import { renderChain, renderMempool, renderPeers, renderNetwork, renderLogs, renderNode, renderAdmin, ensureLogsLoaded, init as initPanels, initChainDrill } from './panels.js';
24
51
 
52
+ followTheme();
53
+
25
54
  // panels.js needs the formatters but must not import them from here (circular);
26
55
  // they are injected once at module start instead.
27
56
  initPanels(F);
@@ -288,8 +317,10 @@ function renderOverview(s) {
288
317
  const stripOn = !!loadSettings().markets?.overviewSummary;
289
318
  const strip = document.getElementById('ovMkSummary');
290
319
  if (strip) {
291
- strip.hidden = !stripOn || !overviewMarkets;
292
- if (stripOn && overviewMarkets) strip.innerHTML = marketsSummaryHtml(overviewMarkets, F);
320
+ // and nothing to show while the feed is off (the default): the strip stays hidden
321
+ const have = overviewMarkets && overviewMarkets.enabled !== false;
322
+ strip.hidden = !stripOn || !have;
323
+ if (stripOn && have) strip.innerHTML = marketsSummaryHtml(overviewMarkets, F);
293
324
  }
294
325
 
295
326
  const mp = s.mempool ?? {};
@@ -516,9 +547,9 @@ export function render() {
516
547
  case 'explorer': renderExplorer(s, state, helpers); break;
517
548
  case 'markets': renderMarkets(s, state, helpers); break;
518
549
  case 'kiosk': renderKiosk(s, state, helpers); break;
519
- case 'tetrust': renderTetrust(s, state, helpers); break;
520
- case 'blockout': renderBlockout(s, state, helpers); break;
521
- case 'blockanoid': renderBlockanoid(s, state, helpers); break;
550
+ case 'tetrust': case 'blockout': case 'blockanoid': case 'scorched': case 'wolf3d': case 'doom': case 'quake':
551
+ renderGame(state.page, s);
552
+ break;
522
553
  case 'about': renderAbout(s, state, helpers); break;
523
554
  }
524
555
  }
@@ -908,7 +939,7 @@ function shortBuild(build) {
908
939
  // subroutes; they stay on the URL so every explorer page is a link.
909
940
  // The games live behind the Diversions pop-down at the end of the nav; the menu shows as the
910
941
  // active tab while one of them is open, since its own button is out of sight inside the popup.
911
- const DIVERSION_PAGES = ['tetrust', 'blockout', 'blockanoid'];
942
+ const DIVERSION_PAGES = ['tetrust', 'blockout', 'blockanoid', 'scorched', 'wolf3d', 'doom', 'quake'];
912
943
 
913
944
  function setPage(route) {
914
945
  const [page, ...rest] = String(route).split('/');
@@ -1120,8 +1151,10 @@ async function boot() {
1120
1151
  const drawSettings = () => {
1121
1152
  const s = loadSettings();
1122
1153
  if (!SETTINGS_PANEL.some((g) => g.group === cfgTab)) cfgTab = SETTINGS_PANEL[0].group;
1123
- const tabs = `<div class="cfgtabs" role="tablist">${SETTINGS_PANEL.map((g) =>
1124
- `<button type="button" class="cfgtab${g.group === cfgTab ? ' on' : ''}" role="tab" aria-selected="${g.group === cfgTab}" data-cfgtab="${g.group}">${g.title}</button>`).join('')}</div>`;
1154
+ // one row per TAB_ROWS entry, labelled, the games last (operator: "Diversions go at the very end")
1155
+ const tab = (g) => `<button type="button" class="cfgtab${g.group === cfgTab ? ' on' : ''}" role="tab" aria-selected="${g.group === cfgTab}" data-cfgtab="${g.group}">${g.title}</button>`;
1156
+ const tabs = `<div class="cfgtabs" role="tablist">${TAB_ROWS.map((row) =>
1157
+ `<div class="cfgtabrow"><span class="cfgtablbl">${row.label}</span>${row.groups.map((id) => SETTINGS_PANEL.find((g) => g.group === id)).filter(Boolean).map(tab).join('')}</div>`).join('')}</div>`;
1125
1158
  cfgBody.innerHTML = tabs + SETTINGS_PANEL.filter((g) => g.group === cfgTab).map((g) => {
1126
1159
  // ALL / NONE, on the groups that ask for it (the two effects tabs): twenty-eight switches is
1127
1160
  // a lot of clicking to answer "just show me the quiet board". This was "every row is a
@@ -1130,9 +1163,53 @@ async function boot() {
1130
1163
  const bulk = g.bulk
1131
1164
  ? `<div class="cfgbulk"><button type="button" class="btn" data-cfgall="${g.group}">all on</button><button type="button" class="btn" data-cfgnone="${g.group}">all off</button></div>`
1132
1165
  : '';
1133
- return `<div class="cfggroup"><h3>${g.title}</h3><p>${g.note}</p>${bulk}${g.rows.map((r) => {
1166
+ // THE SWITCH ROWS IN TWO COLUMNS, HINT AS THE TOOLTIP (2026-09-15, "same treatment on the
1167
+ // settings panel"): on the two effects tabs, thirty-odd switches with a line of hint each ran
1168
+ // to 2,600px of scrolling; as compact rows two across, with the hint on hover, the whole tab
1169
+ // is a screen. The sliders above them keep their hints in view.
1170
+ const compact = !!g.bulk;
1171
+ let open = false;
1172
+ const rowsHtml = g.rows.map((r) => {
1134
1173
  const v = s[g.group][r.key];
1135
1174
  const id = `cfg-${g.group}-${r.key}`;
1175
+ // THE APPEARANCE TAB'S OWN CONTROLS: the mode as a segmented row of buttons, the theme as cards
1176
+ // with a swatch strip and a line about each (the shape of every editor's appearance page),
1177
+ // and a "start Custom from this" button under the cards. The swatches' colours are set after
1178
+ // the markup lands (data-swatch), the one way the CSP lets a script colour an element.
1179
+ if (r.kind === 'segment') {
1180
+ const seg = `<span class="cfgseg" role="radiogroup" aria-label="${r.label}">${r.options.map(([val, label]) =>
1181
+ `<button type="button" class="cfgsegbtn${val === v ? ' on' : ''}" role="radio" aria-checked="${val === v}" data-cfgset="${g.group}.${r.key}" data-value="${val}">${label}</button>`).join('')}</span>`;
1182
+ return `<div class="cfgrow"><b><label>${r.label}</label></b><span>${seg}</span><i>${r.hint}</i></div>`;
1183
+ }
1184
+ // WHICH SKY, WHERE (docs/PLAN-SKIES.md): the table at the top of the Sky tab. One row per board
1185
+ // that has a sky, each a select on that board's own `sky` key -- the same setting the board's
1186
+ // tab shows, through the same data-cfg path, so a change in either place is one change.
1187
+ if (r.kind === 'skymap') {
1188
+ const rows = SKY_BOARDS.map((b) => {
1189
+ const cur = s[b.group]?.sky ?? 'galaxy';
1190
+ const sel = `<select data-cfg="${b.group}.sky" aria-label="${b.label}: sky">${SKY_CHOICES.map(([val, label]) => `<option value="${val}"${val === cur ? ' selected' : ''}>${label}</option>`).join('')}</select>`;
1191
+ return `<tr><th scope="row">${b.label}${b.note ? `<small>${b.note}</small>` : ''}</th><td>${sel}</td></tr>`;
1192
+ }).join('');
1193
+ return `<div class="cfgrow cfgskymap"><b><label>${r.label}</label></b><span><table class="skymap">${rows}</table></span><i>${r.hint}</i></div>`;
1194
+ }
1195
+ // a heading inside a group: the two skies of the Sky tab, each introducing its own rows
1196
+ if (r.kind === 'heading') {
1197
+ const close = open ? '</div>' : '';
1198
+ open = false;
1199
+ return `${close}<div class="cfghead"><b>${r.label}</b><i>${r.hint}</i></div>`;
1200
+ }
1201
+ if (r.kind === 'cards') {
1202
+ const scheme = resolveScheme(s.appearance);
1203
+ const cards = r.options.map(([val, label]) => {
1204
+ const face = val === 'custom' ? resolveTheme(s) : presetFace(val, scheme);
1205
+ const blurb = val === 'custom' ? 'Your own nine colours, below' : PRESETS[val].blurb;
1206
+ const sw = ['bg', 'panel', 'text', 'accent', 'ok', 'warn', 'bad'].map((k) => `<i data-swatch="${face[k]}" title="${k}"></i>`).join('');
1207
+ return `<button type="button" class="thcard${val === v ? ' on' : ''}" role="radio" aria-checked="${val === v}" data-cfgset="${g.group}.${r.key}" data-value="${val}" data-face-bg="${face.bg}" data-face-fg="${face.text}" data-face-line="${face.line}">`
1208
+ + `<span class="thsw">${sw}</span><b>${label}</b><small>${blurb}</small></button>`;
1209
+ }).join('');
1210
+ const from = v === 'custom' ? '' : `<button type="button" class="btn cfgcustomise" data-cfgcustomise="1" title="Copy this theme's colours into the nine pickers below and switch to Custom">customise ${PRESETS[v]?.label ?? 'this'} →</button>`;
1211
+ return `<div class="cfgrow cfgcards"><b><label>${r.label}</label></b><span>${from}</span><span class="thcards" role="radiogroup" aria-label="${r.label}">${cards}</span><i>${r.hint}</i></div>`;
1212
+ }
1136
1213
  const ctl = r.kind === 'toggle'
1137
1214
  ? `<input type="checkbox" id="${id}" data-cfg="${g.group}.${r.key}"${v ? ' checked' : ''}>`
1138
1215
  : r.kind === 'choice'
@@ -1140,9 +1217,32 @@ async function boot() {
1140
1217
  : r.kind === 'colour'
1141
1218
  ? `<input type="color" id="${id}" data-cfg="${g.group}.${r.key}" value="${v}">`
1142
1219
  : `<span class="cfgrange"><input type="range" id="${id}" data-cfg="${g.group}.${r.key}" min="${r.min}" max="${r.max}" step="${r.step}" value="${v}"><span class="val" data-val-for="${g.group}.${r.key}">${formatRangeValue(r.step, v)}</span></span>`;
1143
- return `<div class="cfgrow"><b><label for="${id}">${r.label}</label></b><span>${ctl}</span><i>${r.hint}</i></div>`;
1144
- }).join('')}</div>`;
1220
+ // (and the nine custom colours of the Appearance tab the same way: two across, hint as tooltip,
1221
+ // dimmed while another theme is chosen, so the tab is a screen)
1222
+ // A CONTROL THAT DOES NOTHING RIGHT NOW SAYS SO (operator, 2026-09-16: "It's counter
1223
+ // intuitive in the preferences to display a spiral galaxy for a night/day scene"). A row
1224
+ // whose `dimWhen` is true of the current settings is dimmed and says which sky it belongs
1225
+ // to, rather than being hidden: the setting is still there, and still remembered.
1226
+ const off = (r.custom && s.appearance.theme !== 'custom') || (typeof r.dimWhen === 'function' && r.dimWhen(s));
1227
+ const idle = off ? ' idle' : '';
1228
+ const note = off && r.dimNote ? ` <em>${r.dimNote}</em>` : '';
1229
+ if ((compact && r.kind === 'toggle') || r.custom) {
1230
+ const start = open ? '' : '<div class="cfgrows2">';
1231
+ open = true;
1232
+ return `${start}<div class="cfgrow compact${idle}" title="${String(off && r.dimNote ? `${r.dimNote} — ${r.hint}` : r.hint).replace(/"/g, '&quot;')}"><b><label for="${id}">${r.label}</label></b><span>${ctl}</span></div>`;
1233
+ }
1234
+ const close = open ? '</div>' : '';
1235
+ open = false;
1236
+ return `${close}<div class="cfgrow${idle}"><b><label for="${id}">${r.label}</label></b><span>${ctl}</span><i>${r.hint}${note}</i></div>`;
1237
+ }).join('') + (open ? '</div>' : '');
1238
+ // the Space effects tab says so while the Living sky has them off (settings.js spaceOptions)
1239
+ const held = g.group === 'effects' && s.sky.type === 'living' ? '<p class="cfgheld">Off while the sky is the Living sky (Sky tab): these play over space. Your switches are kept.</p>' : '';
1240
+ return `<div class="cfggroup"><h3>${g.title}</h3><p>${g.note}</p>${held}${bulk}${rowsHtml}</div>`;
1145
1241
  }).join('');
1242
+ for (const el of cfgBody.querySelectorAll('[data-swatch]')) el.style.setProperty('background', el.dataset.swatch);
1243
+ for (const el of cfgBody.querySelectorAll('.thcard')) {
1244
+ el.style.setProperty('--th-bg', el.dataset.faceBg); el.style.setProperty('--th-fg', el.dataset.faceFg); el.style.setProperty('--th-line', el.dataset.faceLine);
1245
+ }
1146
1246
  };
1147
1247
  const openSettings = (open) => {
1148
1248
  cfgWrap.classList.toggle('hidden', !open);
@@ -1163,6 +1263,21 @@ async function boot() {
1163
1263
  cfgBody.addEventListener('click', (e) => {
1164
1264
  const tab = e.target.closest?.('[data-cfgtab]');
1165
1265
  if (tab) { cfgTab = tab.dataset.cfgtab; drawSettings(); return; }
1266
+ // a segment button or a theme card: one value, then the tab redraws so the cards' swatches
1267
+ // show the face the new mode picks
1268
+ const pick = e.target.closest?.('[data-cfgset]');
1269
+ if (pick) { setSetting(loadSettings(), pick.dataset.cfgset, pick.dataset.value); drawSettings(); repaintSoon(); return; }
1270
+ // "customise this": the nine base colours of the theme on screen become the Custom pickers'
1271
+ // values, and Custom is selected, so the pickers start from a look rather than from the shipped one
1272
+ if (e.target.closest?.('[data-cfgcustomise]')) {
1273
+ const face = resolveTheme(loadSettings());
1274
+ let next = loadSettings();
1275
+ const keyOf = { bg: 'customBg', panel: 'customPanel', text: 'customText', muted: 'customMuted', accent: 'customAccent', line: 'customLine', ok: 'customOk', warn: 'customWarn', bad: 'customBad' };
1276
+ for (const k of BASE_KEYS) next = setSetting(next, `appearance.${keyOf[k]}`, face[k]);
1277
+ setSetting(next, 'appearance.theme', 'custom');
1278
+ drawSettings(); repaintSoon();
1279
+ return;
1280
+ }
1166
1281
  const bulk = e.target.closest?.('[data-cfgall], [data-cfgnone]');
1167
1282
  if (!bulk) return;
1168
1283
  const on = bulk.hasAttribute('data-cfgall');