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
@@ -10,7 +10,7 @@ import {
10
10
  newGame, advance, step, movePaddle, nudge, launch, fire, tiles, remaining, powers, setOptions,
11
11
  COLS, ROWS, LIVES, CAPSULE_LETTER, layoutFor,
12
12
  } from './arkanoid.js';
13
- import { loadSettings, setSetting, blockanoidOptions } from './settings.js';
13
+ import { loadSettings, setSetting, blockanoidOptions, nextSky, SKY_LABELS } from './settings.js';
14
14
  import * as sound from './tetsound.js';
15
15
 
16
16
  const SCORES_KEY = 'blockyard.blockanoid.scores';
@@ -130,9 +130,20 @@ function drawScores(highlightAt = null) {
130
130
  if (t.innerHTML !== html) t.innerHTML = html;
131
131
  }
132
132
 
133
- const SWITCHES = [['baStars', 'stars'], ['baGalaxy', 'galaxy'], ['baNeon', 'neon'], ['baSfx', 'sfx']];
133
+ const SWITCHES = [['baNeon', 'neon'], ['baSfx', 'sfx']];
134
+ /** The sky button says which sky it is on, and lights up while there is one. */
135
+ function skyButton(id, sky) {
136
+ const b = el(id);
137
+ if (!b) return;
138
+ b.textContent = `\u2600 ${SKY_LABELS[sky] ?? sky}`;
139
+ b.classList.toggle('on', sky !== 'none');
140
+ b.setAttribute('aria-pressed', sky !== 'none' ? 'true' : 'false');
141
+ }
134
142
  function drawSwitches() {
135
143
  const b = blockanoidOptions(loadSettings());
144
+ // ONE SKY BUTTON (docs/PLAN-SKIES.md): Galaxy, Earth or none, round the ring, in place of the old
145
+ // star field and galaxy pair -- the same setting the Sky tab's table shows for this board
146
+ skyButton('baSkySw', b.sky.sky);
136
147
  for (const [id, key] of SWITCHES) {
137
148
  const btn = el(id);
138
149
  if (!btn) continue;
@@ -146,7 +157,6 @@ function flip(key) {
146
157
  setSetting(loadSettings(), `blockanoid.${key}`, !cur);
147
158
  sound.unlock();
148
159
  drawSwitches();
149
- if (key === 'stars' || key === 'galaxy') drawSky();
150
160
  // capsules and minions change the RULES, so a flip must reach the game IN PLAY, not just the
151
161
  // next one -- otherwise the switch does nothing until you lose, which reads as a broken control
152
162
  if (G.game) { const b = blockanoidOptions(loadSettings()); setOptions(G.game, { capsules: b.capsules, enemies: b.enemies }); }
@@ -159,10 +169,7 @@ function drawSky() {
159
169
  const b = blockanoidOptions(loadSettings());
160
170
  board3d(sky, [], {
161
171
  ...SKY,
162
- stars: b.stars, galaxy: b.stars && b.galaxy, galaxyAt: b.galaxyAt,
163
- starDensity: b.starDensity, starBrightness: b.starBrightness,
164
- nebulae: b.nebulae, galaxies: b.galaxies, dust: b.dust, clusters: b.clusters,
165
- starColours: b.starColours, starGlints: b.starGlints,
172
+ ...b.sky, // which sky this board chose, and what it is made of (settings.js skyFor)
166
173
  });
167
174
  }
168
175
 
@@ -327,6 +334,7 @@ function bind() {
327
334
  });
328
335
  court?.addEventListener('contextmenu', (e) => { if (G.running) e.preventDefault(); });
329
336
  for (const [id, key] of SWITCHES) el(id)?.addEventListener('click', () => flip(key));
337
+ el('baSkySw')?.addEventListener('click', () => { const s = loadSettings(); setSetting(s, 'blockanoid.sky', nextSky(blockanoidOptions(s).sky.sky)); sound.unlock(); drawSwitches(); drawSky(); draw(); });
330
338
  }
331
339
 
332
340
  export function renderBlockanoid(s, state, h) {
@@ -12,7 +12,7 @@ import {
12
12
  newGame, advance, step, movePaddle, nudge, launch, tiles, remaining,
13
13
  COLS, ROWS, LIVES,
14
14
  } from './breakout.js';
15
- import { loadSettings, setSetting, blockoutOptions } from './settings.js';
15
+ import { loadSettings, setSetting, blockoutOptions, nextSky, SKY_LABELS } from './settings.js';
16
16
  import * as sound from './tetsound.js';
17
17
 
18
18
  const SCORES_KEY = 'blockyard.blockout.scores';
@@ -145,9 +145,20 @@ function drawScores(highlightAt = null) {
145
145
  if (t.innerHTML !== html) t.innerHTML = html;
146
146
  }
147
147
 
148
- const SWITCHES = [['boStars', 'stars'], ['boGalaxy', 'galaxy'], ['boNeon', 'neon'], ['boSfx', 'sfx']];
148
+ const SWITCHES = [['boNeon', 'neon'], ['boSfx', 'sfx']];
149
+ /** The sky button says which sky it is on, and lights up while there is one. */
150
+ function skyButton(id, sky) {
151
+ const b = el(id);
152
+ if (!b) return;
153
+ b.textContent = `\u2600 ${SKY_LABELS[sky] ?? sky}`;
154
+ b.classList.toggle('on', sky !== 'none');
155
+ b.setAttribute('aria-pressed', sky !== 'none' ? 'true' : 'false');
156
+ }
149
157
  function drawSwitches() {
150
158
  const b = blockoutOptions(loadSettings());
159
+ // ONE SKY BUTTON (docs/PLAN-SKIES.md): Galaxy, Earth or none, round the ring, in place of the old
160
+ // star field and galaxy pair -- the same setting the Sky tab's table shows for this board
161
+ skyButton('boSkySw', b.sky.sky);
151
162
  for (const [id, key] of SWITCHES) {
152
163
  const btn = el(id);
153
164
  if (!btn) continue;
@@ -161,7 +172,6 @@ function flip(key) {
161
172
  setSetting(loadSettings(), `blockout.${key}`, !cur);
162
173
  sound.unlock();
163
174
  drawSwitches();
164
- if (key === 'stars' || key === 'galaxy') drawSky();
165
175
  G.dirty = true;
166
176
  draw();
167
177
  }
@@ -172,10 +182,7 @@ function drawSky() {
172
182
  const b = blockoutOptions(loadSettings());
173
183
  board3d(sky, [], {
174
184
  ...SKY,
175
- stars: b.stars, galaxy: b.stars && b.galaxy, galaxyAt: b.galaxyAt,
176
- starDensity: b.starDensity, starBrightness: b.starBrightness,
177
- nebulae: b.nebulae, galaxies: b.galaxies, dust: b.dust, clusters: b.clusters,
178
- starColours: b.starColours, starGlints: b.starGlints,
185
+ ...b.sky, // which sky this board chose, and what it is made of (settings.js skyFor)
179
186
  });
180
187
  }
181
188
 
@@ -329,6 +336,7 @@ function bind() {
329
336
  court?.addEventListener('pointermove', onPointer);
330
337
  court?.addEventListener('pointerdown', (e) => { onPointer(e); serve(); });
331
338
  for (const [id, key] of SWITCHES) el(id)?.addEventListener('click', () => flip(key));
339
+ el('boSkySw')?.addEventListener('click', () => { const s = loadSettings(); setSetting(s, 'blockout.sky', nextSky(blockoutOptions(s).sky.sky)); sound.unlock(); drawSwitches(); drawSky(); draw(); });
332
340
  }
333
341
 
334
342
  export function renderBlockout(s, state, h) {
@@ -852,21 +852,50 @@ const flipOf = (o) => (o.flipY === false ? -1 : 1);
852
852
  // settings"). Each lamp is a direction over the board for the dome's slope shading (grid x,
853
853
  // screen-up rows, up) and a direction across the screen for the side faces (x right, y down);
854
854
  // `overhead` is straight above, so no slope is in shade and every side takes the same light.
855
+ // SIX PLACEMENTS AT THREE HEIGHTS (operator, 2026-09-16: "add more camera light angles ... Low
856
+ // Middle and High light placement locations, top left, top right, bottom left, bottom right,
857
+ // viewer, direct overhead"). A placement is a direction across the board (x right, y screen-up);
858
+ // the height is the lamp's elevation over it: low 25°, middle 45°, high 65°. `overhead` ignores
859
+ // the height. The old names (upper-left, upper-right) still answer, as top-left and top-right at
860
+ // the middle height; `front` is the lamp at the viewer. (Not 'viewer': `light: 'viewer'` is the
861
+ // Markets board's own flat, viewer-lit finish -- viewerLit below -- and a placement of that name
862
+ // turned the whole block board flat-shaded, which the operator saw at once on 2026-09-16.)
863
+ export const LIGHT_PLACES = Object.freeze({
864
+ 'overhead': [0, 0], 'top-left': [-1, 1], 'top-right': [1, 1], 'bottom-left': [-1, -1], 'bottom-right': [1, -1], 'front': [0, -1],
865
+ });
866
+ export const LIGHT_HEIGHTS = Object.freeze({ low: 25, middle: 45, high: 65 });
867
+ const LIGHT_ALIASES = Object.freeze({ 'upper-left': 'top-left', 'upper-right': 'top-right' });
868
+ export function lampFor(place, height = 'middle') {
869
+ const p = LIGHT_PLACES[place] ?? LIGHT_PLACES['top-left'];
870
+ if (!p[0] && !p[1]) return { L: [0, 0, 1], side: [0, 0] };
871
+ const el = ((LIGHT_HEIGHTS[height] ?? 45) * Math.PI) / 180;
872
+ const n = Math.hypot(p[0], p[1]) || 1;
873
+ const ax = p[0] / n, ay = p[1] / n;
874
+ // the side faces take the lamp's direction across the screen (y down), weaker the higher it hangs
875
+ const k = 0.9 * Math.cos(el) / Math.cos((45 * Math.PI) / 180);
876
+ return { L: [ax * Math.cos(el), ay * Math.cos(el), Math.sin(el)], side: [ax * k, -ay * k] };
877
+ }
855
878
  export const LIGHTS = Object.freeze({
856
- 'overhead': { L: [0, 0, 1], side: [0, 0] },
857
- 'upper-left': { L: [-0.55, 0.55, 0.63], side: [-0.7071, -0.7071] },
858
- 'upper-right': { L: [0.55, 0.55, 0.63], side: [0.7071, -0.7071] },
859
- 'front': { L: [0, -0.55, 0.63], side: [0, 0.9] },
879
+ 'overhead': lampFor('overhead'),
880
+ 'top-left': lampFor('top-left'), 'top-right': lampFor('top-right'), 'bottom-left': lampFor('bottom-left'), 'bottom-right': lampFor('bottom-right'), 'front': lampFor('front'),
881
+ 'upper-left': lampFor('top-left'), 'upper-right': lampFor('top-right'),
860
882
  });
861
- export const LIGHT_DEFAULT = 'upper-left';
883
+ export const LIGHT_DEFAULT = 'top-left';
884
+ // the middle of the shipped brightness range: `lightGain` stretches the faces about this point
885
+ const LIT_MID = 0.58;
862
886
  const NEON_HEX = /^#[0-9a-f]{6}$/i;
863
887
  export function lightOf(o = {}) {
864
- return LIGHTS[o.light] ? o.light : o.overheadLight === true ? 'overhead' : LIGHT_DEFAULT;
888
+ const name = LIGHT_ALIASES[o.light] ?? o.light;
889
+ return LIGHT_PLACES[name] ? name : o.overheadLight === true ? 'overhead' : LIGHT_DEFAULT;
890
+ }
891
+ /** The lamp a board asks for: its placement at its height. */
892
+ export function lampOf(o = {}) {
893
+ return lampFor(lightOf(o), LIGHT_HEIGHTS[o.lightHeight] ? o.lightHeight : 'middle');
865
894
  }
866
895
  export function domeLight(t, o = {}) {
867
896
  const { dome = 0, gridW = 0, gridH = 0 } = o;
868
897
  if (!dome || !gridW || !gridH) return 1;
869
- const lamp = LIGHTS[lightOf(o)];
898
+ const lamp = lampOf(o);
870
899
  // straight above: the slope shades nothing -- the bottom rows, which lean away from a corner
871
900
  // lamp and sat at the 0.6 floor, read as bright as the middle
872
901
  if (lamp.L[0] === 0 && lamp.L[1] === 0) return 1;
@@ -912,8 +941,20 @@ export const FX_NONE = Object.freeze({ glow: 0, outline: 0, lift: 0, color: null
912
941
  // just outside one side of the board and leaving past the other
913
942
  export function fxFront(fx) {
914
943
  const qs = [0, fx.gridW * fx.dx, fx.gridH * fx.dy, fx.gridW * fx.dx + fx.gridH * fx.dy];
915
- const lo = Math.min(...qs) - 4, hi = Math.max(...qs) + 4;
916
- return lo + fx.u * (hi - lo);
944
+ // THE MARGIN IS THE PANEL'S, NOT A CONSTANT (operator, 2026-09-15: "It needs to cleanly travel on
945
+ // and off screen"). Four units clears the BOARD, and the panel reaches much further than that --
946
+ // measured 21.8 units beyond a 96-wide board -- so a front that started four units out began its
947
+ // sweep in plain view and ended it there. render3d measures the real overhang from the viewRect
948
+ // and puts it on `fx.margin`; four is the fallback for a caller with no panel (tests).
949
+ const m = fx.margin ?? 4;
950
+ const lo = Math.min(...qs) - m, hi = Math.max(...qs) + m;
951
+ // A SEARCHLIGHT PANS (operator, 2026-09-15: "the scanner needs to be more like a search light,
952
+ // slowly panning back and forth"). A single linear pass is a scanner; sweeping out and back is a
953
+ // light being AIMED, which is what reads as something looking for things. `pan` is set for the
954
+ // top-down board only -- on the price chart the camera is low and one pass along the hours is
955
+ // the right motion.
956
+ const t = fx.pan ? 1 - Math.abs(((fx.u * 2) % 2) - 1) : fx.u;
957
+ return lo + t * (hi - lo);
917
958
  }
918
959
 
919
960
  // a deterministic 0..1 from an integer: where a firework bursts, which cube flares. Hashed, not
@@ -957,8 +998,26 @@ export function fxAt(t, fx) {
957
998
  return { glow: A * w, outline: 0.8 * A * w, lift: 0, color: [255, 255, 255] };
958
999
  }
959
1000
  case 'scan': {
960
- const w = g((along() - fxFront(fx)) / 1.6);
961
- return { glow: 0.8 * A * w, outline: 0.5 * A * w, lift: 0, color: [120, 220, 255] };
1001
+ // the beam's own width -- ten units, so a whole swath of blocks is lit as it passes
1002
+ // (2026-09-15: "Reach more blocks with the scanner effect")
1003
+ // THE LIGHT IS THE POINT (operator, 2026-09-15: "We really need to make it much more obvious
1004
+ // somehow that the blocks are being illuminated"). The swath tracks the beam's own half-width
1005
+ // (fx.beamR, set with the cone) so light and geometry cannot disagree, and it hits harder:
1006
+ // a near-white glow, a bright outline and real lift, so a struck cube stands up out of the
1007
+ // board rather than merely brightening.
1008
+ const w = g((along() - fxFront(fx)) / (fx.beamR ?? 5.5));
1009
+ return { glow: Math.min(1, 1.6 * A * w), outline: Math.min(1, 1.2 * A * w), lift: 1.4 * A * w, color: [225, 248, 255] };
1010
+ }
1011
+ case 'xray': {
1012
+ // X-RAY (2026-09-15, operator: "Add xray as additional choosable effect"): a front sweeps the
1013
+ // board and behind it every tile goes x-ray -- its body dims to glass and its edges and a
1014
+ // raster across its faces light up (buildScene's xray treatment) -- then develops back to
1015
+ // solid over a few units, the front itself a bright edge
1016
+ const d = fxFront(fx) - along(); // > 0 once the front has passed
1017
+ const edge = g(d / 1.2);
1018
+ const back = d > 0 && d < 9 ? (d < 5 ? 1 : 1 - (d - 5) / 4) : 0;
1019
+ const xray = Math.max(edge, back) * A;
1020
+ return xray > 0.02 ? { glow: 0.5 * A * edge, outline: A * Math.max(edge, 0.8 * back), lift: 0, color: [190, 240, 255], xray } : FX_NONE;
962
1021
  }
963
1022
  case 'ball': {
964
1023
  // "illuminating everything it comes near": brightest right under the ball, gone ~5 units off
@@ -997,30 +1056,41 @@ export function fxAt(t, fx) {
997
1056
  return { glow: hot * A * w, outline: 0.8 * hot * A * w, lift: (inward ? 0.4 : 3.2) * A * w,
998
1057
  color: inward ? [150, 190, 255] : [255, 255, 245] };
999
1058
  }
1000
- case 'firework': {
1001
- // three bursts, each at its own moment and place, each a ring that expands and dies
1002
- let glow = 0, outline = 0, lift = 0, col = [255, 200, 120];
1003
- for (let i = 0; i < 3; i++) {
1004
- const t0 = 0.05 + 0.26 * i, life = 0.45;
1005
- const v = (fx.u - t0) / life;
1006
- if (!(v > 0 && v < 1)) continue;
1007
- const bx = fxHash(fx.seed + i * 31 + 1) * fx.gridW, by = fxHash(fx.seed + i * 31 + 2) * fx.gridH;
1008
- const r = 9 * Math.pow(v, 0.55);
1009
- const w = g((Math.hypot(cx - bx, cy - by) - r) / 1.4) * (1 - v);
1010
- if (w > glow) {
1011
- glow = w; outline = 0.9 * w; lift = 1.8 * w;
1012
- col = [[255, 170, 110], [140, 220, 255], [220, 160, 255]][i];
1013
- }
1014
- }
1015
- return glow > 0.02 ? { glow: A * glow, outline: A * outline, lift: A * lift, color: col } : FX_NONE;
1016
- }
1059
+ // 'firework' lights through fxNow's heads now (details3d fireworkShells): one head per live
1060
+ // shell, in the shell's colour -- see the heads branch below
1017
1061
  case 'flare': {
1018
1062
  // one cube goes supernova and lights its neighbourhood -- the same hashed cube every replay
1019
1063
  const fxp = fxHash(fx.seed + 7) * fx.gridW, fyp = fxHash(fx.seed + 8) * fx.gridH;
1020
1064
  const bell = Math.sin(Math.PI * fx.u);
1021
1065
  const d = Math.hypot(cx - fxp, cy - fyp);
1022
- const w = g(d / (1.2 + 7 * bell)) * bell;
1023
- return w > 0.02 ? { glow: A * w, outline: 0.8 * A * w, lift: 1.4 * A * w, color: [255, 245, 205] } : FX_NONE;
1066
+ // A SUPERNOVA'S LIGHT (2026-09-15, drawn in details3d drawSupernova): on a thin board -- the
1067
+ // price board, eight deep -- the light reaches three times as far, so the chart is lit, and
1068
+ // it cools with the remnant, gold to red to violet, after the white of the blast
1069
+ const thin = fx.gridH <= 12;
1070
+ const w = g(d / (1.2 + (thin ? 22 : 7) * bell)) * bell;
1071
+ const cool = Math.max(0, Math.min(1, (fx.u - 0.2) / 0.8));
1072
+ // white at the blast, then blue, then violet, as the cloud goes (details3d drawSupernova)
1073
+ const col = cool < 0.3 ? [Math.round(255 - 105 * cool / 0.3), Math.round(255 - 60 * cool / 0.3), 255] : [Math.round(150 + 25 * (cool - 0.3) / 0.7), Math.round(195 - 85 * (cool - 0.3) / 0.7), 255];
1074
+ // THE SHOCKWAVE THROWS WHAT IT CROSSES (operator, 2026-09-15: "Consider perturbing all the
1075
+ // candles that are affected by the shockwave"): the ring drawn in drawSupernova (4.5 shell
1076
+ // radii, out over u 0.14-0.6) runs through the tiles here too -- a tile it reaches is thrown
1077
+ // up hard and lit white, and shakes itself out for a while after the front has passed
1078
+ let lift = 1.4 * A * w, glow = A * w, outline = 0.8 * A * w, colour = col;
1079
+ if (fx.u >= 0.14) {
1080
+ const fr = Math.min(1, (fx.u - 0.14) / 0.46), ring = (thin ? 27 : 31) * (1 - Math.pow(1 - fr, 2.2));
1081
+ const front = g((d - ring) / 1.8) * (1 - fr * 0.6);
1082
+ const passed = ring - d; // > 0 once the front has gone by
1083
+ // the shaking starts as the front APPROACHES, five units out, not once it has passed
1084
+ // (operator: "trigger their shaking sooner in the blast radius")
1085
+ const shake = passed > -5 && fx.u < 0.75 ? Math.max(0, Math.sin(fx.u * 70 + jitterOf(t.txid, 'sn' + fx.seed) * 6.28)) * Math.exp(-Math.max(0, passed) / 12) * (1 - (fx.u - 0.14) / 0.61) : 0;
1086
+ if (front > 0.02 || shake > 0.02) {
1087
+ lift = Math.max(lift, 0.5 * A * front + 0.12 * A * shake); // a tenth of the first cut (operator: "affects the candles too much", "half as much still", "we really need to turn down the block shaking")
1088
+ glow = Math.max(glow, A * front + 0.4 * A * shake);
1089
+ outline = Math.max(outline, A * front);
1090
+ if (front > w) colour = [255, 255, 255];
1091
+ }
1092
+ }
1093
+ return glow > 0.02 || lift > 0.05 ? { glow: Math.min(1, glow), outline: Math.min(1, outline), lift, color: colour } : FX_NONE;
1024
1094
  }
1025
1095
  case 'wave': {
1026
1096
  // a swell rolling across the board: the cubes rise and fall with it, several crests at once
@@ -1125,6 +1195,9 @@ export function fxAt(t, fx) {
1125
1195
  const col = [Math.round(140 + 115 * ph), Math.round(90 + 140 * (1 - ph)), Math.round(190 + 60 * ph)];
1126
1196
  return { glow: 0.85 * A * w, outline: 0.2 * A * w, lift: 0, color: col };
1127
1197
  }
1198
+ case 'pulse': // the price line's surge lights the candles under its head (details3d fxNow)
1199
+ case 'firework': // each shell lights its surroundings in its colour (details3d fxNow)
1200
+ case 'blackhole': // the hole's head shrinks the candles it reaches toward nothing (scale) and lights the rest orange
1128
1201
  case 'lightcycle':
1129
1202
  case 'packets':
1130
1203
  case 'centipede':
@@ -1151,10 +1224,29 @@ export function fxAt(t, fx) {
1151
1224
  // the very fade that meant it was working. Measured: 0 cubes hidden over a whole run.
1152
1225
  // So the physical effect uses PROXIMITY alone and the visual effect keeps using w.
1153
1226
  const reach = g(Math.hypot(ddx, ddy) / Math.max(0.2, hd.r ?? 0.8));
1227
+ // `release` (1 unless the head says otherwise): the head letting go of everything it
1228
+ // holds, evenly -- the pull and the scale fade by it, so a tile under the head glides
1229
+ // home over the head's whole release rather than when its reach finally passes it
1230
+ const rel = hd.release ?? 1;
1154
1231
  best = {
1155
1232
  glow: 0.85 * w, outline: 0.8 * w, lift: (hd.lift ?? 0) * w, color: hd.color,
1156
1233
  hide: (hd.hide ?? 0) > 0 && reach > 0.45 ? 1 : 0,
1157
- scale: hd.scale == null ? 1 : 1 - (1 - hd.scale) * reach,
1234
+ scale: hd.scale == null ? 1 : 1 - (1 - hd.scale) * reach * rel,
1235
+ // `pull`: how hard the head drags this tile toward itself (the black hole; buildScene
1236
+ // shears the faces toward the head's screen point by it)
1237
+ pull: (hd.pull ?? 0) * reach * rel, pullAt: hd.pull ? { x: hd.x, y: hd.y, z: hd.z ?? 0 } : null,
1238
+ // the pull at the head's full size (rPeak): the orbit is laid out against this, so it
1239
+ // does not unwind as the head shrinks; the current pull only says how far along the
1240
+ // line from home to that orbit the tile sits
1241
+ pullPeak: (hd.pull ?? 0) * (hd.rPeak ? g(Math.hypot(ddx, ddy) / Math.max(0.2, hd.rPeak)) : reach),
1242
+ // `lean`: how much the corners lean and stretch toward the head on top of the glide
1243
+ // (1 on the price board; 0 on the block board, where the cubes are lifted out whole)
1244
+ lean: hd.lean ?? 1,
1245
+ // `shrink`: scale the footprint with the height, so the cube stays a cube as it goes
1246
+ shrink: hd.shrink ? 1 : 0,
1247
+ // `orbitR`: the head's reach in grid units -- buildScene lays the block board's orbits
1248
+ // out at a fixed distance round the hole from it, rather than part-way home
1249
+ orbitR: hd.r ?? 0,
1158
1250
  };
1159
1251
  }
1160
1252
  }
@@ -1208,7 +1300,7 @@ export function buildScene(tiles, o = {}) {
1208
1300
  // among equals the one further from the vanishing point first (its sides
1209
1301
  // lean outward, away from its nearer neighbours).
1210
1302
  const vx0 = o.vanishX ?? 0, vy0 = o.vanishY ?? 0;
1211
- const ordered = o.oblique && o.order === 'diagonal' ? diagonalOrder(tiles) : o.oblique ? obliqueOrder(tiles, o) : tiles.map((t) => (growth.has(t) ? { ...t, boost: growth.get(t) } : t))
1303
+ const ordered = o.order === 'given' ? tiles.slice() : o.oblique && o.order === 'diagonal' ? diagonalOrder(tiles) : o.oblique ? obliqueOrder(tiles, o) : tiles.map((t) => (growth.has(t) ? { ...t, boost: growth.get(t) } : t))
1212
1304
  .map((t) => { const c = project(t.x + t.s / 2, t.y + t.s / 2, 0, o); return { t, top: (t.z ?? 0) + (t.floor ?? 0) + cubeHeight(t), d: Math.hypot(c.x - vx0, c.y - vy0) }; })
1213
1305
  // Under the oblique camera a cube reaches STRAIGHT UP from its footprint (measured: height
1214
1306
  // moves screen y only, grid x moves screen x only), so it can only cover blocks at smaller
@@ -1220,6 +1312,12 @@ export function buildScene(tiles, o = {}) {
1220
1312
  const ground = [];
1221
1313
  const air = [];
1222
1314
  const shadows = [];
1315
+ // IN THE AIR, PAINTED LAST (operator, 2026-09-15, of the black hole on the block board: "There
1316
+ // are too many collision errors happening"): a cube pulled off the board toward the hole was
1317
+ // still painted in the board's own order, so it cut through the neighbours it was passing over.
1318
+ // Its faces are moved to the end of the frame instead, the least-pulled first, so the ones
1319
+ // nearest the hole are on top of everything
1320
+ const pulled = new Map();
1223
1321
  let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
1224
1322
  const note = (p) => {
1225
1323
  if (p.x < minX) minX = p.x;
@@ -1257,7 +1355,10 @@ export function buildScene(tiles, o = {}) {
1257
1355
  })).filter((e) => e.k > 0.01) : [];
1258
1356
  // the lamp's direction across the screen, for the side faces (LIGHTS); under the overhead
1259
1357
  // lamp every side takes the same light, a shade under the top
1260
- const lampSide = LIGHTS[lightOf(o)].side;
1358
+ const lampSide = lampOf(o).side;
1359
+ // the lamp's hardness and how bright the tops sit under it (see the side loop below)
1360
+ const gain = Number.isFinite(o.lightGain) ? Math.max(0.2, Math.min(3, o.lightGain)) : 1;
1361
+ const topLit = Number.isFinite(o.topLight) ? Math.max(0.1, Math.min(1.4, o.topLight)) : 0.8;
1261
1362
  for (const t of ordered) {
1262
1363
  const fxv = (t.z ?? 0) > 0.02 ? FX_NONE : fxAt(t, o.fx);
1263
1364
  // GONE FOR THE DURATION, not deleted: a hidden cube is skipped this frame and drawn again the
@@ -1266,10 +1367,70 @@ export function buildScene(tiles, o = {}) {
1266
1367
  // A SHORTER CUBE, the same way: `tall` on a copy. A collapse or a dig is a height override,
1267
1368
  // never an edit to the tile the board was built from.
1268
1369
  const sc = fxv.scale == null ? 1 : fxv.scale;
1269
- const shaped = sc < 0.999 ? { ...t, tall: Math.max(0.04, cubeHeight(t) * sc) } : t;
1370
+ let shaped = sc < 0.999 ? { ...t, tall: Math.max(0.04, cubeHeight(t) * sc) } : t;
1371
+ // a WHOLE smaller cube, not a plate, where the head asks for it (the black hole over the block
1372
+ // board): the footprint shrinks about its centre with the height
1373
+ if (sc < 0.999 && fxv.shrink) { const s2 = Math.max(0.08, t.s * Math.max(0.08, sc)); shaped = { ...shaped, x: t.x + (t.s - s2) / 2, y: t.y + (t.s - s2) / 2, s: s2 }; }
1270
1374
  // NEON is a flat cube: no facets, no crown -- solid faces and the tubes on their edges
1271
1375
  const f = tileFaces(fxv.lift > 0.001 ? { ...shaped, fxz: fxv.lift } : shaped, o, t.s >= facetMin && o.neon !== true);
1272
- const a = t.alpha ?? 1;
1376
+ // SPAGHETTIFIED (operator, 2026-09-15: "the candles need to stretch and bend toward the hole
1377
+ // before they shrink"): a tile within a pulling head's reach has its faces sheared toward the
1378
+ // head's point on screen -- the top corners dragged hard, the base corners a little -- so it
1379
+ // leans and lengthens toward the hole before the scale takes it. Each corner object is moved
1380
+ // once, however many faces share it.
1381
+ if (fxv.pull > 0.001 && fxv.pullAt) {
1382
+ pulled.set(String(t.txid), fxv.pull);
1383
+ // ...AND ORBITS (operator, 2026-09-15: "The chart bars need to cleanly orbit the disc"): the
1384
+ // tile is swept round the hole along the disk's flow as it is drawn in -- its centre swings
1385
+ // about the hole by an angle that grows with the pull and the clock, on a radius that
1386
+ // shrinks with the pull, flattened toward the disk's plane -- and its corners lean and
1387
+ // stretch toward the hole on top of that
1388
+ const hp = project(fxv.pullAt.x, fxv.pullAt.y, fxv.pullAt.z, o);
1389
+ // EVERY CORNER THE CUBE HAS, the bevel's inner quad included -- it was left at home while
1390
+ // the top and sides moved, so a bevelled cube on the block board drew as a slab stretched
1391
+ // from its old place to its new one (operator, 2026-09-15: "too much bad shearing"); the
1392
+ // crown's points are projected later through f.P, which is shifted the same way below
1393
+ const pts = [...f.top, ...f.sides.flatMap((sd) => sd.points), ...(f.inset ?? [])];
1394
+ const uniq = [...new Set(pts)];
1395
+ const cx0 = uniq.reduce((a, p) => a + p.x, 0) / uniq.length, cy0 = uniq.reduce((a, p) => a + p.y, 0) / uniq.length;
1396
+ // THE ORBIT IS LAID OUT AGAINST THE HOLE'S FULL SIZE, and the tile sits somewhere on the
1397
+ // straight line between its home and that orbit by the CURRENT pull (operator, 2026-09-15:
1398
+ // "Instead of the bars reversing into place, can they just reorient themselves into their
1399
+ // original positions when the blackhole starts to disappear?"). While the hole grows the
1400
+ // tile glides out to its orbit and turns with it; while it shrinks the pull falls and the
1401
+ // tile glides straight home -- the orbit never unwinds.
1402
+ // STEADILY (operator, 2026-09-15: "The bars don't cleanly leave or return ... They sorta get
1403
+ // ripped off, and snapped back into place"): the capture and the release are eased with a
1404
+ // smoothstep, and the orbit is KEPLERIAN -- its angular speed goes with the square of the
1405
+ // pull, so a bar far out barely turns and one close in whips round -- where before every
1406
+ // bar's target swept a wide circle at one speed and a bar just being caught was yanked after
1407
+ // it. The angle's starting offset is small for the same reason.
1408
+ const peak = Math.max(fxv.pullPeak ?? fxv.pull, 0.001), kl = Math.min(1, fxv.pull / peak), k0 = kl * kl * (3 - 2 * kl);
1409
+ const dx = cx0 - hp.x, dy = cy0 - hp.y, r0 = Math.hypot(dx, dy) || 1, a0 = Math.atan2(dy, dx);
1410
+ // HALF THE SPEED (operator, 2026-09-15: "The bars sping around much much too quickly. Try to
1411
+ // calm it down by at least half to start" -- then "slow the spin ... by another half. Want it
1412
+ // smoother and calmer"): 0.00035 a millisecond at full pull, a quarter of the 0.0014 it was
1413
+ const swing = peak * peak * (o.now ?? 0) * 0.00035 + 0.5 * peak; // clockwise on screen, Keplerian
1414
+ // ROUND THE HOLE, on the block board (operator, 2026-09-15: "the blocks don't look right"):
1415
+ // part-way home from a hole twenty units up left the cubes hanging in the middle distance;
1416
+ // each is carried instead to its own ring round the hole, one and a half to three reaches
1417
+ // out in screen pixels, and glides straight back from it as the pull falls
1418
+ const ring = fxv.lean === 0 && fxv.orbitR > 0 && o.unit ? fxv.orbitR * o.unit * (1.5 + 1.5 * fxHash(Math.round(t.x * 31 + t.y * 7 + 3))) : null;
1419
+ const r1 = ring != null ? r0 + (ring - r0) * peak : r0 * (1 - 0.55 * peak), a1 = a0 + swing;
1420
+ const cx1 = hp.x + Math.cos(a1) * r1, cy1 = hp.y + Math.sin(a1) * r1 * (1 - 0.6 * peak); // flattened toward the disk
1421
+ const sx = (cx1 - cx0) * k0, sy = (cy1 - cy0) * k0;
1422
+ const tops = new Set([...f.top, ...f.sides.flatMap((sd) => [sd.points[0], sd.points[1]])]);
1423
+ for (const p of uniq) {
1424
+ p.x += sx; p.y += sy; // carried toward, and round, the orbit
1425
+ const k = (tops.has(p) ? 0.55 : 0.2) * peak * k0 * (fxv.lean ?? 1); // and leaning in (not on the block board: lean 0)
1426
+ p.x += (hp.x - p.x) * k; p.y += (hp.y - p.y) * k;
1427
+ }
1428
+ const P0 = f.P, kc = 0.55 * peak * k0 * (fxv.lean ?? 1);
1429
+ f.P = (x, y, z) => { const p = P0(x, y, z); const q = { x: p.x + sx, y: p.y + sy }; q.x += (hp.x - q.x) * kc; q.y += (hp.y - q.y) * kc; return q; };
1430
+ }
1431
+ // an x-rayed tile's body is glass: most of its alpha goes, and its edges come back below
1432
+ const xray = fxv.xray ?? 0;
1433
+ const a = (t.alpha ?? 1) * (1 - 0.72 * xray);
1273
1434
  const airborne = (t.z ?? 0) > 0.02;
1274
1435
  const out = airborne && !o.oblique ? air : ground;
1275
1436
  // A tile that just locked flashes brighter for a moment -- the Tetris
@@ -1388,11 +1549,19 @@ export function buildScene(tiles, o = {}) {
1388
1549
  // brighter, one turned away falls into shadow
1389
1550
  const d = side.nx * lampSide[0] + side.ny * lampSide[1];
1390
1551
  // from the viewer: a face pointing down the screen (toward the camera) takes the most light
1391
- const k = viewerLit ? 0.6 + 0.42 * Math.max(0, side.ny) + 0.3 * Math.max(0, side.nx) - 0.16 * Math.max(0, -side.nx) : 0.34 + 0.26 * (d + 1);
1552
+ const k0 = viewerLit ? 0.6 + 0.42 * Math.max(0, side.ny) + 0.3 * Math.max(0, side.nx) - 0.16 * Math.max(0, -side.nx) : 0.34 + 0.26 * (d + 1);
1553
+ // HOW HARD THE LAMP IS (operator, 2026-09-16: "we need better lighting on the front of the
1554
+ // blocks. Still looks too washed out and not illuminated well enough"). The shipped range put
1555
+ // every face between 0.34 and 0.86 of its colour with the top at 0.8: a narrow band, and a
1556
+ // board of it reads as tinted rather than lit. `lightGain` stretches that range about its
1557
+ // middle -- above 1 the face turned to the lamp goes brighter than its own colour and the one
1558
+ // turned away falls properly dark. 1 is exactly what every board drew before this.
1559
+ const k = Math.max(0.06, Math.min(1.4, LIT_MID + (k0 - LIT_MID) * gain));
1392
1560
  out.push({ txid: t.txid, face: 'side', key: side.key, points: side.points, fill: shade(c, k * lit, a), ...(viewerLit ? { stroke: lift(c, 0.45, round3(0.5 * a)) } : {}) });
1393
1561
  side.points.forEach(note);
1394
1562
  }
1395
- out.push({ txid: t.txid, face: 'top', points: f.top, fill: shade(c, (viewerLit ? 1.02 : 0.8) * lit, a), stroke: viewerLit ? lift(c, 0.55, round3(0.6 * a)) : `rgba(0,0,0,${round3(seam * a)})` });
1563
+ const topK = viewerLit ? 1.02 : Math.max(0.06, Math.min(1.4, LIT_MID + (topLit - LIT_MID) * gain));
1564
+ out.push({ txid: t.txid, face: 'top', points: f.top, fill: shade(c, topK * lit, a), stroke: viewerLit ? lift(c, 0.55, round3(0.6 * a)) : `rgba(0,0,0,${round3(seam * a)})` });
1396
1565
  f.top.forEach(note);
1397
1566
 
1398
1567
  if (f.innerG && !viewerLit) {
@@ -1582,6 +1751,23 @@ export function buildScene(tiles, o = {}) {
1582
1751
  // a tint at the top's weight (0.5) was there in the pixels and nowhere to the eye (measured:
1583
1752
  // the scan's peak moved a candle's face by a 0.26 cyan wash). At 0.85 it reads as struck.
1584
1753
  if (pulse > 0.03 && o.axes) for (const side of f.sides) out.push({ txid: t.txid, face: 'glow', points: side.points, fill: `rgba(${fxv.color.join(',')},${round3(0.85 * pulse * a)})` });
1754
+ // THE X-RAY TREATMENT: bright edges round every face, and a raster of thin lines across the top
1755
+ // and the sides, so the tile reads as a wireframe scan of itself
1756
+ if (xray > 0.02) {
1757
+ const xc = `rgba(200,245,255,${round3(0.95 * xray)})`;
1758
+ out.push({ txid: t.txid, face: 'outline', points: f.top, fill: 'rgba(0,0,0,0)', stroke: xc, lw: 1.2, always: true });
1759
+ for (const side of f.sides) out.push({ txid: t.txid, face: 'outline', points: side.points, fill: 'rgba(0,0,0,0)', stroke: xc, lw: 1.2, always: true });
1760
+ const raster = (poly) => {
1761
+ const [p0, p1, p2, p3] = poly; // p0->p1 and p3->p2 are the edges the lines run between
1762
+ for (let i = 1; i <= 3; i++) {
1763
+ const u = i / 4;
1764
+ const a0 = { x: p0.x + (p3.x - p0.x) * u, y: p0.y + (p3.y - p0.y) * u }, b0 = { x: p1.x + (p2.x - p1.x) * u, y: p1.y + (p2.y - p1.y) * u };
1765
+ out.push({ txid: t.txid, face: 'outline', points: [a0, b0], fill: 'rgba(0,0,0,0)', stroke: `rgba(160,230,255,${round3(0.45 * xray)})`, lw: 0.8, always: true });
1766
+ }
1767
+ };
1768
+ raster(f.top);
1769
+ for (const side of f.sides) raster(side.points);
1770
+ }
1585
1771
  if (fxv.outline > 0.03) {
1586
1772
  const col = fxv.color.join(',');
1587
1773
  out.push({ txid: t.txid, face: 'outline', points: f.top, fill: 'rgba(0,0,0,0)', stroke: `rgba(${col},${round3(0.95 * fxv.outline * a)})`, lw: 1 + 4 * fxv.outline });
@@ -1630,7 +1816,13 @@ export function buildScene(tiles, o = {}) {
1630
1816
  if (o.oblique && !viewerLit && zt < 1.5) shadows.push(...restingShadowOps(t, o, 1 - zt / 1.5));
1631
1817
  }
1632
1818
  }
1633
- const ops = o.oblique ? [...shadows, ...ground] : [...ground, ...shadows, ...air];
1819
+ let ops = o.oblique ? [...shadows, ...ground] : [...ground, ...shadows, ...air];
1820
+ if (pulled.size) {
1821
+ const rest = [], lifted = [];
1822
+ ops.forEach((op, i) => (pulled.has(String(op.txid)) ? lifted : rest).push({ op, i }));
1823
+ lifted.sort((p, q) => (pulled.get(String(p.op.txid)) - pulled.get(String(q.op.txid))) || (p.i - q.i));
1824
+ ops = [...rest.map((e) => e.op), ...lifted.map((e) => e.op)];
1825
+ }
1634
1826
  return { ops, bounds: ops.length ? { minX, maxX, minY, maxY } : null, count: ordered.length };
1635
1827
  }
1636
1828
 
@@ -12,12 +12,11 @@
12
12
  // old chart and calling it current is the same sin as inventing a number, so the
13
13
  // pill says how old it is.
14
14
  const M = { top: 8, right: 10, bottom: 18, left: 46 };
15
- const COL = {
16
- grid: '#20252d', axis: '#2c323c', text: '#6a7484', textDim: '#4d5665',
17
- accent: '#f7931a', ok: '#2ecc8f', warn: '#f0b429', bad: '#ef5a5a',
18
- info: '#58a6ff', purple: '#a78bfa', cyan: '#4dd0e1', pink: '#f06ba7',
19
- };
20
- const PALETTE = [COL.accent, COL.info, COL.ok, COL.purple, COL.cyan, COL.pink, COL.warn, COL.bad];
15
+ // THE COLOURS COME FROM THE THEME (theme.js, 2026-09-16): COL is the theme module's live object,
16
+ // refilled when the operator picks a theme, and the chrome colours below read INK the same way --
17
+ // a chart painted from literals of the dark look was unreadable on a light card.
18
+ import { COL, INK } from './theme.js';
19
+ const palette = () => [COL.accent, COL.info, COL.ok, COL.purple, COL.cyan, COL.pink, COL.warn, COL.bad];
21
20
 
22
21
  function prep(canvas) {
23
22
  const dpr = Math.min(window.devicePixelRatio || 1, 2);
@@ -64,7 +63,7 @@ export function resetCanvas(canvas) {
64
63
  export function empty(canvas, msg = 'no data from the node yet') {
65
64
  if (canvas.__hasData) { markStale(canvas, msg); return; }
66
65
  const { ctx, w, h } = prep(canvas);
67
- ctx.fillStyle = '#4d5665';
66
+ ctx.fillStyle = INK.textDim;
68
67
  ctx.textAlign = 'center';
69
68
  ctx.fillText(msg, w / 2, h / 2);
70
69
  ctx.textAlign = 'left';
@@ -83,8 +82,8 @@ export function markStale(canvas, msg = 'no fresh samples') {
83
82
  const tw = ctx.measureText(label).width;
84
83
  const pw = tw + 20;
85
84
  const x = Math.max(4, w - pw - 6);
86
- ctx.fillStyle = '#12151a';
87
- ctx.strokeStyle = '#3d4552';
85
+ ctx.fillStyle = INK.panel;
86
+ ctx.strokeStyle = INK.panelLine;
88
87
  ctx.lineWidth = 1;
89
88
  ctx.beginPath();
90
89
  ctx.roundRect(x, 4, pw, 16, 5);
@@ -94,7 +93,7 @@ export function markStale(canvas, msg = 'no fresh samples') {
94
93
  ctx.beginPath();
95
94
  ctx.arc(x + 9, 12, 3, 0, 7);
96
95
  ctx.fill();
97
- ctx.fillStyle = '#b9c3d1';
96
+ ctx.fillStyle = INK.label;
98
97
  ctx.textAlign = 'left';
99
98
  ctx.fillText(label, x + 15, 12);
100
99
  }
@@ -205,7 +204,8 @@ export function lineChart(canvas, series, opts = {}) {
205
204
 
206
205
  for (let si = 0; si < pts.length; si++) {
207
206
  const s = pts[si];
208
- const color = s.color ?? PALETTE[si % PALETTE.length];
207
+ const pal = palette();
208
+ const color = s.color ?? pal[si % pal.length];
209
209
  const yOf = s.axis === 'right' ? Y2 : (opts.logY ? (v) => logY(v, lo, hi, L, plotH) : Y);
210
210
  const sorted = s.points.slice().sort((a, b) => a.t - b.t);
211
211
 
@@ -318,15 +318,15 @@ function attachLineTip(canvas, opts) {
318
318
  if (!rows.length) return hideTip(canvas);
319
319
  redraw(canvas);
320
320
  const { ctx } = ctxOf(canvas);
321
- ctx.strokeStyle = '#ffffff30';
321
+ ctx.strokeStyle = INK.faintLine;
322
322
  ctx.beginPath(); ctx.moveTo(nearX, c.L.top); ctx.lineTo(nearX, c.L.top + c.plotH); ctx.stroke();
323
323
  const fmt = opts.fmtTip ?? ((v, s) => short(v));
324
324
  const boxW = 118;
325
325
  const boxH = 14 + rows.length * 12;
326
326
  const bx = Math.min(c.w - boxW - 4, nearX + 8);
327
327
  const by = c.L.top + 4;
328
- ctx.fillStyle = '#0c0e11e8';
329
- ctx.strokeStyle = '#2c323c';
328
+ ctx.fillStyle = INK.tip;
329
+ ctx.strokeStyle = INK.tipLine;
330
330
  ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 5); ctx.fill(); ctx.stroke();
331
331
  ctx.textAlign = 'left';
332
332
  ctx.fillStyle = COL.text;
@@ -335,11 +335,11 @@ function attachLineTip(canvas, opts) {
335
335
  const y = by + 20 + i * 12;
336
336
  ctx.fillStyle = row.color;
337
337
  ctx.fillRect(bx + 6, y - 3, 6, 2);
338
- ctx.fillStyle = '#c7cfdb';
338
+ ctx.fillStyle = INK.tipText;
339
339
  const label = (row.label || '').slice(0, 12);
340
340
  ctx.fillText(label, bx + 16, y - 2);
341
341
  ctx.textAlign = 'right';
342
- ctx.fillStyle = '#e8edf5';
342
+ ctx.fillStyle = INK.bright;
343
343
  ctx.fillText(fmt(row.v, row), bx + boxW - 6, y - 2);
344
344
  ctx.textAlign = 'left';
345
345
  });
@@ -512,7 +512,7 @@ export function meter(canvas, { value, max, label, danger = 0.9, fmt }) {
512
512
  const a1 = Math.PI * 2;
513
513
  ctx.lineWidth = Math.max(7, r * 0.34);
514
514
  ctx.lineCap = 'butt';
515
- ctx.strokeStyle = '#171b21';
515
+ ctx.strokeStyle = INK.panel;
516
516
  ctx.beginPath(); ctx.arc(cx, cy, r, a0, a1); ctx.stroke();
517
517
  // the region past which the node starts feerate-evicting
518
518
  ctx.strokeStyle = hexA(COL.bad, 0.5);
@@ -520,7 +520,7 @@ export function meter(canvas, { value, max, label, danger = 0.9, fmt }) {
520
520
  const col = pct >= danger ? COL.bad : pct > 0.75 ? COL.warn : COL.ok;
521
521
  ctx.strokeStyle = col;
522
522
  ctx.beginPath(); ctx.arc(cx, cy, r, a0, a0 + Math.PI * Math.min(1, pct)); ctx.stroke();
523
- ctx.fillStyle = '#e8edf5';
523
+ ctx.fillStyle = INK.bright;
524
524
  ctx.textAlign = 'center';
525
525
  ctx.font = '600 17px ui-monospace, monospace';
526
526
  ctx.fillText(`${((value / max) * 100).toFixed(1)}%`, cx, cy - 4);
@@ -556,12 +556,12 @@ export function stackedBars(canvas, rows, opts = {}) {
556
556
  ctx.fillStyle = COL.text;
557
557
  ctx.textAlign = 'right';
558
558
  ctx.fillText(String(r.label ?? '').slice(0, 20), L.left - 6, y + rowH / 2);
559
- ctx.fillStyle = '#12151a';
559
+ ctx.fillStyle = INK.panel;
560
560
  ctx.fillRect(L.left, y + 2, plotW, rowH - 5);
561
561
  const bw = (r.value / max) * plotW;
562
562
  ctx.fillStyle = r.color ?? COL.accent;
563
563
  ctx.fillRect(L.left, y + 2, Math.max(1, bw), rowH - 5);
564
- ctx.fillStyle = '#e8edf5';
564
+ ctx.fillStyle = INK.bright;
565
565
  ctx.textAlign = 'left';
566
566
  ctx.fillText(String(r.display ?? short(r.value)), L.left + Math.min(bw + 5, plotW - 40), y + rowH / 2);
567
567
  });
@@ -632,4 +632,4 @@ function hexA(hex, a) {
632
632
  }
633
633
  function clampN(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
634
634
 
635
- export { COL, PALETTE };
635
+ export { COL, palette };