blockyard 0.1.0 → 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 (71) hide show
  1. package/CHANGELOG.md +108 -0
  2. package/README.md +13 -11
  3. package/SECURITY.md +2 -2
  4. package/docs/API.md +1 -1
  5. package/docs/ARCHITECTURE.md +36 -5
  6. package/docs/CONFIGURATION.md +6 -4
  7. package/docs/DEFECTS.md +4 -1
  8. package/docs/GETTING-STARTED.md +14 -7
  9. package/docs/INSTALL.md +7 -4
  10. package/docs/PLAN-SCORCHED-YARD.md +456 -0
  11. package/docs/PLAN-SKIES.md +142 -0
  12. package/docs/SECURITY-AUDIT-2026-09-16.md +647 -0
  13. package/docs/SECURITY.md +26 -7
  14. package/docs/TROUBLESHOOTING.md +10 -5
  15. package/docs/USER-GUIDE.md +239 -9
  16. package/package.json +4 -2
  17. package/public/css/app.css +87 -0
  18. package/public/index.html +58 -6
  19. package/public/js/app.js +60 -19
  20. package/public/js/blockanoid.js +15 -7
  21. package/public/js/blockout.js +15 -7
  22. package/public/js/blockscene3d.js +51 -11
  23. package/public/js/depthchart.js +1 -1
  24. package/public/js/details3d.js +25 -2
  25. package/public/js/explorer.js +7 -1
  26. package/public/js/livingsky.js +494 -0
  27. package/public/js/login.js +3 -2
  28. package/public/js/mining.js +4 -4
  29. package/public/js/panels.js +27 -18
  30. package/public/js/safenext.js +14 -0
  31. package/public/js/scorched.js +1051 -0
  32. package/public/js/scorchedai.js +227 -0
  33. package/public/js/scorchedair.js +286 -0
  34. package/public/js/scorchedfx.js +376 -0
  35. package/public/js/scorchedshop.js +105 -0
  36. package/public/js/scorchedwind.js +69 -0
  37. package/public/js/scorchedyard.js +1338 -0
  38. package/public/js/settings.js +266 -80
  39. package/public/js/tetrust.js +15 -6
  40. package/public/js/tetsound.js +35 -5
  41. package/scripts/check.js +46 -0
  42. package/scripts/index-build.js +9 -2
  43. package/scripts/pool-map.js +152 -36
  44. package/scripts/setup.js +108 -10
  45. package/scripts/shots.mjs +21 -0
  46. package/scripts/smoke.sh +6 -5
  47. package/scripts/ui.js +4 -2
  48. package/server/auth/sessions.js +33 -13
  49. package/server/chain/blockfile.js +64 -5
  50. package/server/chain/index/build.js +432 -56
  51. package/server/chain/index/heights.js +29 -3
  52. package/server/chain/index/live.js +13 -7
  53. package/server/chain/index/rows.js +6 -1
  54. package/server/chain/index/store.js +28 -5
  55. package/server/chain/index/worker.js +23 -11
  56. package/server/collect/logparse.js +65 -18
  57. package/server/collect/markets.js +76 -7
  58. package/server/collect/mining.js +32 -0
  59. package/server/collect/monitor.js +24 -11
  60. package/server/collect/network.js +19 -9
  61. package/server/config.js +7 -0
  62. package/server/http/api.js +70 -13
  63. package/server/http/server.js +22 -5
  64. package/server/http/sse.js +53 -7
  65. package/server/main.js +13 -3
  66. package/server/rpc/allowlist.js +26 -0
  67. package/server/rpc/client.js +30 -2
  68. package/server/store/audit.js +6 -1
  69. package/server/store/history.js +19 -3
  70. package/server/store/ledger.js +15 -4
  71. package/systemd/blockyard.service +34 -3
@@ -0,0 +1,494 @@
1
+ // THE LIVING SKY (operator, 2026-09-16: "suggest a new sky background type we can add to
2
+ // blockyard. I would like for it to be something realistic with blue skies, clouds, the sun,
3
+ // change of day, night with the moon coming out, just something fucking beautiful"; then "do it").
4
+ //
5
+ // A second sky type beside the star field: a real day, drawn from the machine's clock, over every
6
+ // board that has a sky. The sun climbs and sets, the dome goes through night, dawn, day and dusk
7
+ // by the sun's altitude, clouds drift lit from the sun's side, the moon rises at its real phase
8
+ // and the star field (the one details3d already draws) comes out as the sun goes down.
9
+ //
10
+ // It obeys the canvas rules the rest of the renderer lives by (test/viewer-canvas-rules.test.js):
11
+ // no gradients, no globalAlpha, no composite modes, no shadowBlur. The dome is ONE huge soft disc
12
+ // centred far below the horizon, so its rings become gently curved horizontal bands -- which is
13
+ // how a sky dome looks -- and it is cached on an offscreen canvas that is repainted only when the
14
+ // colours move. Clouds are clusters of soft discs, the way the supernova's gas is built. The sun
15
+ // and the moon are discs with halos. Rain is lines. Nothing here needs a dependency.
16
+ //
17
+ // `drawLivingSky` takes the two helpers it needs from details3d as arguments (softStops, and a
18
+ // drawStars bound to the canvas) rather than importing them, so the two files do not import each
19
+ // other. Everything else here is pure, and the tests hold the astronomy to known dates.
20
+
21
+ // ------------------------------------------------------------------- the clock
22
+ /**
23
+ * The hour the sky is drawn at, 0..24, by the clock setting: 'real' (this machine's local time),
24
+ * 'cycle' (a whole day every 24 minutes), 'fixed' (an hour to admire). The date is today's for
25
+ * the moon's phase whatever the clock says.
26
+ */
27
+ export function skyTime(opts = {}, nowMs = Date.now()) {
28
+ const d = new Date(nowMs);
29
+ const mode = opts.skyClock ?? 'real';
30
+ let hour;
31
+ if (mode === 'cycle') hour = ((nowMs / 60000) % 24 + 24) % 24; // a minute an hour
32
+ else if (mode === 'fixed') hour = Math.max(0, Math.min(24, Number(opts.skyHour) || 0)) % 24;
33
+ else hour = d.getHours() + d.getMinutes() / 60 + d.getSeconds() / 3600;
34
+ const start = new Date(d.getFullYear(), 0, 0);
35
+ const dayOfYear = Math.floor((d - start) / 86400000);
36
+ // under the cycle a day is 24 minutes, and the moon keeps its calendar in THOSE days: a lunar
37
+ // month goes by in about twelve hours of the clock on the wall, so the phases can be watched
38
+ const cycleDays = mode === 'cycle' ? Math.floor(nowMs / (24 * 60000)) : 0;
39
+ return { hour, dayOfYear, date: d, mode, cycleDays };
40
+ }
41
+
42
+ // ------------------------------------------------------------------- the sun
43
+ const RAD = Math.PI / 180;
44
+ /**
45
+ * Where the sun is: altitude in degrees and its progress across the sky, 0 at rising and 1 at
46
+ * setting. With no latitude, a symmetric day from six to six that reaches 62° at noon. With one,
47
+ * the standard formula: declination from the day of the year, the hour angle from the clock
48
+ * (the machine's local hour is taken as solar time), altitude from both.
49
+ */
50
+ export function sunPosition(hour, dayOfYear = 172, lat = null) {
51
+ if (lat == null || !Number.isFinite(lat)) {
52
+ const alt = 62 * Math.sin(((hour - 6) / 12) * Math.PI);
53
+ return { alt, frac: (hour - 6) / 12, rise: 6, set: 18 };
54
+ }
55
+ const phi = Math.max(-89, Math.min(89, lat)) * RAD;
56
+ const decl = 23.44 * RAD * Math.sin(((284 + dayOfYear) / 365) * Math.PI * 2);
57
+ const H = (hour - 12) * 15 * RAD;
58
+ const alt = Math.asin(Math.sin(phi) * Math.sin(decl) + Math.cos(phi) * Math.cos(decl) * Math.cos(H)) / RAD;
59
+ const c = -Math.tan(phi) * Math.tan(decl);
60
+ const half = c <= -1 ? 12 : c >= 1 ? 0 : Math.acos(c) / RAD / 15; // hours from noon to the horizon
61
+ const rise = 12 - half, set = 12 + half;
62
+ const frac = half > 0 ? (hour - rise) / (2 * half) : (hour - 12) / 12 + 0.5;
63
+ return { alt, frac, rise, set };
64
+ }
65
+
66
+ /**
67
+ * The sun's place on a canvas: left to right over the day, up by altitude; below the horizon at
68
+ * night. `horizon` is where the horizon IS, as a share of the panel's height from the top -- 1 is
69
+ * the bottom edge, which is right for a board with nothing in front of the sky; a board whose land
70
+ * fills the lower third passes about 0.6, so the sun sets behind the hills and not under them
71
+ * (operator, 2026-09-16: "so sun and moon any more?" -- at dusk the disc had dropped behind the
72
+ * terrain long before the sky called it set).
73
+ */
74
+ export function sunScreen(sun, pw, ph, maxAlt = 62, horizon = 1) {
75
+ const H = ph * Math.max(0.2, Math.min(1, horizon));
76
+ const x = pw * (0.06 + 0.88 * Math.max(-0.25, Math.min(1.25, sun.frac)));
77
+ const k = Math.max(-0.6, Math.min(1, sun.alt / maxAlt));
78
+ const y = H - (0.10 + 0.80 * k) * H;
79
+ return { x, y };
80
+ }
81
+
82
+ // ------------------------------------------------------------------- the moon
83
+ const SYNODIC = 29.530588853; // days
84
+ const NEW_MOON = Date.UTC(2000, 0, 6, 18, 14); // a known new moon
85
+ /** The moon's phase for a date: 0 new, 0.5 full, 1 new again; the lit fraction; waxing or not. */
86
+ export function moonPhase(date = new Date()) {
87
+ const days = (date.getTime() - NEW_MOON) / 86400000;
88
+ const p = ((days / SYNODIC) % 1 + 1) % 1;
89
+ return { p, lit: (1 - Math.cos(p * Math.PI * 2)) / 2, waxing: p < 0.5 };
90
+ }
91
+ /** The moon rides the sun's track a share of a day behind: opposite the sun when full, beside it when new. */
92
+ export function moonPosition(hour, phase, dayOfYear = 172, lat = null) {
93
+ const h = ((hour - 24 * phase.p) % 24 + 24) % 24;
94
+ return sunPosition(h, dayOfYear, lat);
95
+ }
96
+
97
+ // ------------------------------------------------------------------- the dome's colours
98
+ // keyframes by the sun's altitude: [alt, zenith, horizon, glow, glow strength]
99
+ export const KEYFRAMES = Object.freeze([
100
+ [-90, [3, 5, 16], [8, 11, 28], [20, 24, 60], 0],
101
+ [-18, [3, 5, 16], [8, 11, 28], [20, 24, 60], 0],
102
+ [-12, [6, 9, 26], [16, 20, 48], [50, 40, 80], 0.15],
103
+ [-6, [12, 20, 54], [56, 46, 84], [150, 80, 90], 0.45],
104
+ [-3, [26, 46, 108], [150, 90, 90], [240, 130, 70], 0.8],
105
+ [0, [52, 92, 168], [232, 140, 84], [255, 160, 70], 1],
106
+ [4, [82, 132, 206], [246, 186, 128], [255, 200, 120], 0.7],
107
+ [12, [66, 134, 224], [196, 220, 242], [255, 235, 200], 0.25],
108
+ [30, [44, 112, 222], [200, 224, 244], [255, 250, 235], 0.08],
109
+ [90, [30, 92, 210], [206, 228, 246], [255, 255, 245], 0],
110
+ ]);
111
+ const lerp = (a, b, k) => a + (b - a) * k;
112
+ const mix3 = (a, b, k) => [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
113
+ const rgb = (c, a = 1) => `rgba(${Math.round(c[0])},${Math.round(c[1])},${Math.round(c[2])},${a})`;
114
+
115
+ /** The dome's colours at a sun altitude: zenith, horizon, the glow at the sun's side, and how strong. */
116
+ export function skyColours(alt) {
117
+ const a = Math.max(-90, Math.min(90, alt));
118
+ let i = 0;
119
+ while (i < KEYFRAMES.length - 2 && KEYFRAMES[i + 1][0] <= a) i++;
120
+ const [a0, z0, h0, g0, s0] = KEYFRAMES[i], [a1, z1, h1, g1, s1] = KEYFRAMES[i + 1];
121
+ const k = a1 > a0 ? Math.max(0, Math.min(1, (a - a0) / (a1 - a0))) : 0;
122
+ return { zenith: mix3(z0, z1, k), horizon: mix3(h0, h1, k), glow: mix3(g0, g1, k), glowStrength: lerp(s0, s1, k), dayness: Math.max(0, Math.min(1, (a + 6) / 18)) };
123
+ }
124
+ /** How much of the star field shows: all of it below -12°, none above -1°. */
125
+ export const starVisibility = (alt) => Math.max(0, Math.min(1, (-1 - alt) / 11));
126
+
127
+ // ------------------------------------------------------------------- clouds
128
+ const hash = (n) => { const x = Math.sin(n * 12.9898 + 78.233) * 43758.5453; return x - Math.floor(x); };
129
+ /**
130
+ * A seeded field of cumulus: each a cluster of puffs at a height band, with its own drift. The
131
+ * same seed always gives the same sky, so a screenshot and a test agree.
132
+ */
133
+ export function cloudField(seed = 3, count = 8) {
134
+ const out = [];
135
+ for (let i = 0; i < count; i++) {
136
+ const h = (k) => hash(seed * 97 + i * 13 + k);
137
+ const puffs = [];
138
+ const n = 4 + Math.floor(h(1) * 5);
139
+ for (let j = 0; j < n; j++) puffs.push({ dx: (j / (n - 1) - 0.5) * 1.6 + (h(10 + j) - 0.5) * 0.3, dy: -(0.35 * Math.sin((j / (n - 1)) * Math.PI)) - h(20 + j) * 0.15, r: 0.32 + h(30 + j) * 0.3 });
140
+ out.push({ u: h(2), v: 0.10 + h(3) * 0.42, s: 0.55 + h(4) * 0.9, speed: 0.004 + h(5) * 0.006, puffs });
141
+ }
142
+ return out;
143
+ }
144
+ /** Long thin cirrus streaks, high up. */
145
+ export function cirrusField(seed = 5, count = 4) {
146
+ const out = [];
147
+ for (let i = 0; i < count; i++) {
148
+ const h = (k) => hash(seed * 131 + i * 17 + k);
149
+ out.push({ u: h(1), v: 0.05 + h(2) * 0.2, len: 0.18 + h(3) * 0.25, tilt: (h(4) - 0.5) * 0.12, speed: 0.0015 + h(5) * 0.002, thick: 0.006 + h(6) * 0.008 });
150
+ }
151
+ return out;
152
+ }
153
+
154
+ export const WEATHER = Object.freeze({
155
+ clear: { cover: 0.12, clouds: 3, grey: 0, rain: false, storm: false },
156
+ scattered: { cover: 0.4, clouds: 8, grey: 0.08, rain: false, storm: false },
157
+ overcast: { cover: 0.85, clouds: 16, grey: 0.35, rain: false, storm: false },
158
+ storm: { cover: 1, clouds: 18, grey: 0.6, rain: true, storm: true },
159
+ });
160
+
161
+ // ------------------------------------------------------------------- drawing
162
+ const CACHE = new WeakMap(); // canvas -> { dome, key, w, h }
163
+
164
+ function domeLayer(ctx, pw, ph, key, paint) {
165
+ const cv = ctx.canvas;
166
+ if (!cv || typeof ctx.drawImage !== 'function' || typeof document === 'undefined' || !document.createElement) return false;
167
+ let c = CACHE.get(cv);
168
+ if (!c || c.w !== pw || c.h !== ph) {
169
+ const canvas = document.createElement('canvas'); canvas.width = pw; canvas.height = ph;
170
+ const c2 = canvas.getContext?.('2d');
171
+ if (!c2) return false;
172
+ c = { canvas, ctx: c2, key: null, w: pw, h: ph };
173
+ CACHE.set(cv, c);
174
+ }
175
+ if (c.key !== key) { c.ctx.setTransform(1, 0, 0, 1, 0, 0); c.ctx.clearRect(0, 0, pw, ph); paint(c.ctx); c.key = key; }
176
+ ctx.drawImage(c.canvas, 0, 0);
177
+ return true;
178
+ }
179
+
180
+ function paintDome(ctx, pw, ph, cols) {
181
+ // OPAQUE DISCS FROM THE OUTSIDE IN. The dome is one circle centred well below the bottom edge,
182
+ // so its rings curve gently like a sky's; each disc is painted whole in the colour of its own
183
+ // height, largest (the zenith) first, so what is left showing between two radii is the colour
184
+ // of the smaller -- the horizon's at the bottom, the zenith's at the top. Not softStops: that
185
+ // helper composes translucent discs for a bright centre fading OUTWARD, and a sky is the other
186
+ // way round (the first cut came out one flat zenith blue for exactly that reason).
187
+ const cy = ph * 2.4, R = cy;
188
+ const tH = (cy - ph) / R; // the horizon's radius, as a share of R
189
+ // the zenith colour under everything: the corners above the dome's crown are the top of the
190
+ // sky, and a first cut that left them unpainted showed the board's black as a dark arc
191
+ ctx.fillStyle = rgb(cols.zenith);
192
+ ctx.fillRect(0, 0, pw, ph);
193
+ const N = 140;
194
+ for (let i = 0; i <= N; i++) {
195
+ const t = 1 - (i / N) * (1 - tH) - (i === N ? 0.002 : 0);
196
+ const k = Math.pow(Math.max(0, (t - tH) / (1 - tH)), 0.75); // 1 at the zenith, 0 at the horizon; the haze holds the low sky
197
+ ctx.fillStyle = rgb(mix3(cols.horizon, cols.zenith, k));
198
+ ctx.beginPath(); ctx.arc(pw / 2, cy, Math.max(1, R * t), 0, Math.PI * 2); ctx.fill();
199
+ }
200
+ // the ground line: the horizon colour fills what the smallest disc leaves at the very bottom
201
+ ctx.fillStyle = rgb(cols.horizon);
202
+ ctx.fillRect(0, ph - 2, pw, 2);
203
+ }
204
+
205
+ function paintTwilight(ctx, pw, ph, sunX, cols, softStops) {
206
+ if (cols.glowStrength <= 0.02) return;
207
+ const R = pw * 0.62;
208
+ softStops(ctx, sunX, ph + ph * 0.12, R, [
209
+ [0, rgb(cols.glow, 0.62 * cols.glowStrength)], [0.35, rgb(cols.glow, 0.32 * cols.glowStrength)], [1, rgb(cols.glow, 0)],
210
+ ], 90);
211
+ }
212
+
213
+ // The sun in two passes: its GLOW is painted before the clouds, so they are lit from behind, and
214
+ // its DISC after them, so it is never lost behind one (operator, 2026-09-16: at noon it was a small
215
+ // pale spot under a cloud). And it is half again as big as the first cut: a sun a fiftieth of the
216
+ // panel high is a star, not a sun.
217
+ function paintSun(ctx, pw, ph, s, sun, cols, softStops, pass = 'both') {
218
+ if (sun.alt < -8) return;
219
+ const k = Math.max(0, Math.min(1, sun.alt / 30));
220
+ // THE SUN IS NOT WHITE (operator, 2026-09-16: "The Sun is white?!"). A white disc is what a
221
+ // camera makes of it; the eye, and every painting of a sky, sees gold. Deep orange on the
222
+ // horizon, warm gold overhead -- never past a warm cream, even at noon.
223
+ const c = mix3([255, 120, 60], [255, 212, 116], k);
224
+ const r = ph * 0.032 * (1 + 0.7 * (1 - k));
225
+ const glow = 0.3 + 0.35 * (1 - k);
226
+ const vis = sun.alt < 0 ? Math.max(0, 1 + sun.alt / 8) : 1;
227
+ if (pass !== 'disc') softStops(ctx, s.x, s.y, ph * 0.5, [[0, rgb(c, glow * 0.7 * vis)], [0.08, rgb(c, glow * 0.45 * vis)], [0.3, rgb(c, glow * 0.12 * vis)], [1, rgb(c, 0)]], 90);
228
+ if (pass === 'glow') return;
229
+ // THE HALO AND THE EDGE (operator, 2026-09-16, with a screenshot: "still seeing some banding
230
+ // here. Can we add a glow effect around the sun?"). The disc's soft edge was twelve rings, which
231
+ // on a big panel is a step of several pixels each -- the rings in the picture. Ring counts are
232
+ // left to softStops now, which lays one per pixel or so. And a halo in front of the clouds: a
233
+ // wide, faint corona and a tighter bright one, so the sun sits in its own light rather than
234
+ // being a coin on the sky.
235
+ softStops(ctx, s.x, s.y, r * 4.2, [[0, rgb(c, 0.5 * vis)], [0.22, rgb(c, 0.28 * vis)], [0.55, rgb(c, 0.09 * vis)], [1, rgb(c, 0)]]);
236
+ softStops(ctx, s.x, s.y, r * 2.1, [[0, rgb([255, 236, 190], 0.6 * vis)], [0.5, rgb(c, 0.3 * vis)], [1, rgb(c, 0)]]);
237
+ // the disc, with a soft edge
238
+ softStops(ctx, s.x, s.y, r * 1.35, [[0, rgb(c, vis)], [0.7, rgb(c, vis)], [1, rgb(c, 0)]]);
239
+ softStops(ctx, s.x, s.y, r * 0.9, [[0, rgb([255, 246, 214], 0.92 * k * vis)], [1, rgb([255, 246, 214], 0)]]);
240
+ }
241
+
242
+ function paintMoon(ctx, pw, ph, m, moon, phase, night, cols, softStops) {
243
+ if (moon.alt < -3 || phase.lit < 0.03) return;
244
+ const r = ph * 0.046;
245
+ // faint by day (a daytime moon is there, but it is not a lamp), and a thin crescent by day is invisible
246
+ const show = Math.max(0, Math.min(1, (moon.alt + 3) / 6)) * (0.12 + 0.88 * night);
247
+ if (show <= 0.02 || (night < 0.3 && phase.lit < 0.25)) return;
248
+ // THE HALO (operator, 2026-09-16: "Can we give the moon a glow effect as well?"): the same two
249
+ // layers the sun has, in moonlight silver, and stronger the fuller the moon -- a full moon lights
250
+ // the sky round it; a crescent barely does
251
+ if (night > 0.3) {
252
+ const g = night * show * (0.35 + 0.65 * Math.max(0, Math.min(1, phase.lit)));
253
+ const silver = [222, 230, 248];
254
+ softStops(ctx, m.x, m.y, r * 7, [[0, rgb(silver, 0.42 * g)], [0.2, rgb(silver, 0.22 * g)], [0.5, rgb(silver, 0.08 * g)], [1, rgb(silver, 0)]]);
255
+ softStops(ctx, m.x, m.y, r * 2.6, [[0, rgb([236, 240, 255], 0.6 * g)], [0.5, rgb(silver, 0.28 * g)], [1, rgb(silver, 0)]]);
256
+ }
257
+ // ONLY THE LIT PART IS PAINTED. The first cut drew a full disc and then a disc of sky colour over
258
+ // the dark side; once the moon moved in front of the clouds and the stars, that sky-coloured
259
+ // disc was a hole punched in them. The lit shape is a polygon: the outer half-circle on the lit
260
+ // side, and the terminator -- a half-ellipse whose width is the phase -- back along the other.
261
+ const L = Math.max(0.03, Math.min(1, phase.lit));
262
+ const side = phase.waxing ? 1 : -1; // waxing: lit on the right
263
+ const a = r * (2 * L - 1); // the terminator's half-width: negative bulges into the lit side
264
+ const N = 28;
265
+ ctx.fillStyle = rgb([236, 238, 244], show);
266
+ ctx.beginPath();
267
+ for (let i = 0; i <= N; i++) { // the limb, top to bottom on the lit side
268
+ const th = -Math.PI / 2 + (i / N) * Math.PI;
269
+ const px = m.x + side * Math.cos(th) * r, py = m.y + Math.sin(th) * r;
270
+ if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
271
+ }
272
+ for (let i = N; i >= 0; i--) { // the terminator, bottom to top
273
+ const th = -Math.PI / 2 + (i / N) * Math.PI;
274
+ ctx.lineTo(m.x + side * Math.cos(th) * a, m.y + Math.sin(th) * r);
275
+ }
276
+ ctx.closePath();
277
+ ctx.fill();
278
+ // the seas, faint, only when enough of the face is lit for them to sit inside it
279
+ if (L > 0.6) {
280
+ ctx.fillStyle = rgb([196, 200, 212], show * 0.6);
281
+ for (const [dx, dy, rr] of [[0.3 * side, -0.2, 0.28], [0.45 * side, 0.2, 0.16], [0.2 * side, 0.45, 0.14]]) { ctx.beginPath(); ctx.arc(m.x + dx * r, m.y + dy * r, rr * r, 0, Math.PI * 2); ctx.fill(); }
282
+ }
283
+ }
284
+
285
+ function paintClouds(ctx, pw, ph, now, opts, sun, s, cols, weather, softStops) {
286
+ const cover = Number.isFinite(opts.skyCover) ? Math.max(0, Math.min(1, opts.skyCover)) : weather.cover;
287
+ if (cover <= 0.01) return;
288
+ const count = Math.max(1, Math.round(2 + cover * 16));
289
+ const clouds = cloudField(3, count);
290
+ const grey = weather.grey + Math.max(0, cover - 0.6) * 0.5;
291
+ const day = cols.dayness;
292
+ const sunk = Math.max(0, Math.min(1, sun.alt / 25));
293
+ // lit from the sun's side by day, warm at the horizon, silver by moonlight at night
294
+ const litBase = mix3(mix3([70, 80, 110], [250, 252, 255], day), [255, 205, 150], (1 - sunk) * day * 0.7);
295
+ const lit = mix3(litBase, [120, 125, 140], grey);
296
+ const shade = mix3(lit, mix3([20, 26, 48], [110, 120, 140], day), 0.45 + grey * 0.3);
297
+ const t = now / 1000;
298
+ const wind = (opts.skyWind ?? 1);
299
+ for (let i = 0; i < clouds.length; i++) {
300
+ const c = clouds[i];
301
+ const u = ((c.u + t * c.speed * wind * 0.5) % 1.3 + 1.3) % 1.3 - 0.15;
302
+ const cx = u * pw, cy = c.v * ph;
303
+ const S = ph * 0.11 * c.s;
304
+ const a = 0.55 + 0.4 * cover;
305
+ const toSun = s.x > cx ? 1 : -1;
306
+ for (const p of c.puffs) {
307
+ const px = cx + p.dx * S, py = cy + p.dy * S, r = p.r * S;
308
+ softStops(ctx, px, py + r * 0.35, r * 1.15, [[0, rgb(shade, a * 0.9)], [0.6, rgb(shade, a * 0.6)], [1, rgb(shade, 0)]], 24);
309
+ softStops(ctx, px, py, r, [[0, rgb(lit, a)], [0.55, rgb(lit, a * 0.75)], [1, rgb(lit, 0)]], 24);
310
+ // the bright rim on the sun's side
311
+ if (day > 0.2) softStops(ctx, px + toSun * r * 0.25, py - r * 0.25, r * 0.55, [[0, rgb([255, 255, 255], a * 0.35 * day)], [1, rgb([255, 255, 255], 0)]], 16);
312
+ }
313
+ }
314
+ // cirrus, high and slow, by day -- AS GAS, NOT AS A CHAIN OF DISCS (operator, 2026-09-16: "The
315
+ // banding on that faint cloud strip looks awful. Can we replace with plasma gasses instead?").
316
+ // The first cut laid hard-edged flat discs fourteen pixels apart along each wisp, and where
317
+ // they overlapped the outlines stacked into a row of rings. Each wisp is now a scatter of
318
+ // soft-edged puffs -- softStops, the same way the cumulus is built -- jittered off the line,
319
+ // of varying size, each drawn twice with a small offset along the wisp so it reads stretched
320
+ // in the direction it drifts. No puff has an edge, so nothing bands.
321
+ if (day > 0.3 && !weather.storm) {
322
+ const wisps = cirrusField(5, 3 + Math.round(cover * 3));
323
+ const white = [255, 255, 255];
324
+ for (const w of wisps) {
325
+ const u = ((w.u + t * w.speed * wind) % 1.4 + 1.4) % 1.4 - 0.2;
326
+ const x0 = u * pw, y0 = w.v * ph;
327
+ const n = Math.max(6, Math.round(w.len * pw / 34));
328
+ const dx = w.len * pw / n, dy = w.tilt * ph / n;
329
+ for (let k = 0; k <= n; k++) {
330
+ const f = k / n;
331
+ const j = (q) => hash(w.u * 977 + k * 31 + q);
332
+ const x = x0 + f * w.len * pw + (j(1) - 0.5) * dx * 0.9;
333
+ const y = y0 + f * w.tilt * ph + Math.sin(f * Math.PI * 2 + w.u * 9) * ph * 0.006 + (j(2) - 0.5) * w.thick * ph * 2.5;
334
+ const r = w.thick * ph * (0.45 + Math.sin(f * Math.PI)) * (2.6 + 1.6 * j(3));
335
+ const a = 0.028 * day * (0.6 + 0.8 * j(4));
336
+ const stops = [[0, rgb(white, a)], [0.4, rgb(white, a * 0.55)], [1, rgb(white, 0)]];
337
+ softStops(ctx, x, y, r, stops, 12);
338
+ softStops(ctx, x + dx * 0.45, y + dy * 0.45, r * 0.85, stops, 12);
339
+ }
340
+ }
341
+ }
342
+ }
343
+
344
+ function paintRays(ctx, pw, ph, s, sun, cols) {
345
+ if (sun.alt < 1 || sun.alt > 28) return;
346
+ const a = 0.045 * (1 - sun.alt / 28) * cols.dayness;
347
+ if (a <= 0.004) return;
348
+ ctx.fillStyle = rgb(cols.glow, a);
349
+ for (let i = 0; i < 7; i++) {
350
+ const ang = -Math.PI / 2 + (i - 3) * 0.22 + Math.sin(i * 1.7) * 0.05;
351
+ const w = 0.05 + (i % 3) * 0.02;
352
+ const L = ph * 1.6;
353
+ ctx.beginPath();
354
+ ctx.moveTo(s.x, s.y);
355
+ ctx.lineTo(s.x + Math.cos(ang - w) * L, s.y - Math.sin(ang - w) * L);
356
+ ctx.lineTo(s.x + Math.cos(ang + w) * L, s.y - Math.sin(ang + w) * L);
357
+ ctx.closePath(); ctx.fill();
358
+ }
359
+ }
360
+
361
+ function paintRain(ctx, pw, ph, now, dpr) {
362
+ const n = Math.round((pw * ph) / 3500);
363
+ const fall = (now / 1000) * ph * 1.4;
364
+ ctx.strokeStyle = 'rgba(200,212,232,0.28)';
365
+ ctx.lineWidth = Math.max(1, dpr * 0.8);
366
+ ctx.beginPath();
367
+ for (let i = 0; i < n; i++) {
368
+ const x = ((hash(i * 7 + 1) * pw + (now / 1000) * 30 * (0.5 + hash(i * 3 + 9))) % (pw + 40)) - 20;
369
+ const y = ((hash(i * 11 + 2) * ph + fall * (0.7 + hash(i * 5 + 4) * 0.6)) % (ph + 30)) - 15;
370
+ ctx.moveTo(x, y); ctx.lineTo(x - 3, y + 14 * dpr);
371
+ }
372
+ ctx.stroke();
373
+ }
374
+
375
+ /** A bolt: midpoint displacement from a cloud base to the ground, seeded, with a branch. */
376
+ export function boltPoints(seed, x0, y0, x1, y1, depth = 5) {
377
+ let pts = [{ x: x0, y: y0 }, { x: x1, y: y1 }];
378
+ let k = 0;
379
+ for (let d = 0; d < depth; d++) {
380
+ const next = [pts[0]];
381
+ for (let i = 1; i < pts.length; i++) {
382
+ const a = pts[i - 1], b = pts[i];
383
+ const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2;
384
+ const len = Math.hypot(b.x - a.x, b.y - a.y);
385
+ const off = (hash(seed * 31 + k++) - 0.5) * len * 0.5;
386
+ next.push({ x: mx + off, y: my + (hash(seed * 37 + k++) - 0.5) * len * 0.12 }, b);
387
+ }
388
+ pts = next;
389
+ }
390
+ return pts;
391
+ }
392
+
393
+ function paintLightning(ctx, pw, ph, now, dpr) {
394
+ const slot = Math.floor(now / 5000);
395
+ if (hash(slot * 3 + 0.5) > 0.35) return; // most slots are quiet
396
+ const at = slot * 5000 + hash(slot * 5 + 0.25) * 4000;
397
+ const age = now - at;
398
+ if (age < 0 || age > 220) return;
399
+ const k = 1 - age / 220;
400
+ ctx.fillStyle = `rgba(220,228,255,${(0.28 * k).toFixed(3)})`;
401
+ ctx.fillRect(0, 0, pw, ph);
402
+ const x0 = pw * (0.15 + hash(slot * 7 + 0.75) * 0.7);
403
+ const pts = boltPoints(slot, x0, ph * 0.2, x0 + (hash(slot * 9 + 0.1) - 0.5) * pw * 0.2, ph * 0.98);
404
+ for (const [w, a] of [[9, 0.18], [4, 0.45], [1.6, 0.95]]) {
405
+ ctx.strokeStyle = `rgba(236,240,255,${(a * k).toFixed(3)})`;
406
+ ctx.lineWidth = w * dpr;
407
+ ctx.beginPath();
408
+ pts.forEach((p, i) => (i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y)));
409
+ ctx.stroke();
410
+ }
411
+ }
412
+
413
+ function paintRainbow(ctx, pw, ph, s, sun, dpr) {
414
+ if (sun.alt < 4 || sun.alt > 38) return;
415
+ const k = 1 - Math.abs(sun.alt - 15) / 25;
416
+ const a = 0.16 * Math.max(0, k);
417
+ if (a <= 0.01) return;
418
+ const cx = pw - s.x, cy = ph + ph * 0.55;
419
+ const R = ph * 1.05;
420
+ const bands = [[255, 70, 60], [255, 150, 50], [250, 230, 70], [90, 210, 90], [70, 150, 255], [90, 80, 220], [150, 70, 200]];
421
+ ctx.lineWidth = Math.max(2, ph * 0.011);
422
+ bands.forEach((c, i) => {
423
+ ctx.strokeStyle = rgb(c, a);
424
+ ctx.beginPath(); ctx.arc(cx, cy, R - i * ph * 0.011, Math.PI, Math.PI * 2); ctx.stroke();
425
+ });
426
+ }
427
+
428
+ function paintShootingStar(ctx, pw, ph, now, night, dpr) {
429
+ if (night < 0.5) return;
430
+ const slot = Math.floor(now / 14000);
431
+ if (hash(slot * 13 + 0.35) > 0.5) return;
432
+ const at = slot * 14000 + hash(slot * 17 + 0.65) * 12000;
433
+ const age = now - at;
434
+ if (age < 0 || age > 800) return;
435
+ const f = age / 800;
436
+ const x0 = pw * (0.1 + hash(slot * 19 + 0.2) * 0.8), y0 = ph * (0.05 + hash(slot * 23 + 0.4) * 0.35);
437
+ const dx = pw * 0.18 * (hash(slot * 29 + 0.8) > 0.5 ? 1 : -1), dy = ph * 0.12;
438
+ const hx = x0 + dx * f, hy = y0 + dy * f;
439
+ const tail = Math.min(1, f * 3) * 0.7;
440
+ const a = (1 - f) * night;
441
+ ctx.strokeStyle = `rgba(255,255,255,${(a * 0.8).toFixed(3)})`;
442
+ ctx.lineWidth = 1.2 * dpr;
443
+ ctx.beginPath(); ctx.moveTo(hx - dx * tail, hy - dy * tail); ctx.lineTo(hx, hy); ctx.stroke();
444
+ ctx.fillStyle = `rgba(255,255,255,${a.toFixed(3)})`;
445
+ ctx.beginPath(); ctx.arc(hx, hy, 1.4 * dpr, 0, Math.PI * 2); ctx.fill();
446
+ }
447
+
448
+ /**
449
+ * The whole sky at `now` (an animation clock, ms) onto `ctx`, `pw` x `ph` device pixels.
450
+ * `helpers.softStops(ctx, x, y, r, stops, N)` and `helpers.drawStars(opts)` come from details3d.
451
+ * Returns what it drew, for a test: the sun's altitude, whether the sun, the moon and the stars
452
+ * showed, the weather.
453
+ */
454
+ export function drawLivingSky(ctx, pw, ph, dpr, now, opts, helpers) {
455
+ const { softStops, drawStars } = helpers;
456
+ const t = skyTime(opts, opts.skyNowMs ?? Date.now());
457
+ const lat = Number.isFinite(opts.skyLat) ? opts.skyLat : null;
458
+ const sun = sunPosition(t.hour, t.dayOfYear, lat);
459
+ const cols = skyColours(sun.alt);
460
+ const night = starVisibility(sun.alt);
461
+ const weather = WEATHER[opts.skyWeather] ?? WEATHER.scattered;
462
+ const horizon = Number.isFinite(opts.skyHorizon) ? opts.skyHorizon : 1;
463
+ const s = sunScreen(sun, pw, ph, lat == null ? 62 : 90, horizon);
464
+ // the dome, cached until the sun has moved half a degree
465
+ const key = `${pw}x${ph}|${Math.round(sun.alt * 2) / 2}`;
466
+ const painted = domeLayer(ctx, pw, ph, key, (c2) => paintDome(c2, pw, ph, cols));
467
+ if (!painted) paintDome(ctx, pw, ph, cols);
468
+ // the stars, fading in as the sun goes down (the star field details3d already keeps)
469
+ if (night > 0.01 && drawStars) drawStars({ ...opts, stars: true, starBrightness: (Number.isFinite(opts.starBrightness) ? opts.starBrightness : 1) * night });
470
+ paintTwilight(ctx, pw, ph, s.x, cols, softStops);
471
+ if (opts.skyShooting !== false) paintShootingStar(ctx, pw, ph, now, night, dpr);
472
+ // the real calendar's phase (operator: "Is it ever a full moon? Does it go through phases?" --
473
+ // yes, on the real dates: new, crescent, quarter, gibbous, full and back over 29.5 days); under
474
+ // the cycle clock the calendar runs a day per 24-minute day, so the month passes in twelve hours
475
+ const phase = moonPhase(t.mode === 'cycle' ? new Date(t.date.getTime() + t.cycleDays * 86400000) : t.date);
476
+ // THE MOON EVERY NIGHT (opts.skyMoon 'night', the shipped choice): on the real track a young
477
+ // crescent sets an hour or two after the sun and a night has no moon in it at all, which is
478
+ // astronomy and not what anyone looking at a night sky for pleasure expects. 'night' puts it on
479
+ // the sun's own track twelve hours behind -- highest at midnight -- and never thinner than a
480
+ // fat crescent; 'real' is the real thing, for the astronomer.
481
+ const nightly = opts.skyMoon !== 'real';
482
+ const moon = nightly ? sunPosition(((t.hour + 12) % 24 + 24) % 24, t.dayOfYear, lat) : moonPosition(t.hour, phase, t.dayOfYear, lat);
483
+ const shown = nightly ? { ...phase, lit: Math.max(phase.lit, 0.4) } : phase;
484
+ const m = sunScreen(moon, pw, ph, lat == null ? 62 : 90, horizon);
485
+ paintSun(ctx, pw, ph, s, sun, cols, softStops, 'glow');
486
+ if (opts.skyRays !== false) paintRays(ctx, pw, ph, s, sun, cols);
487
+ paintClouds(ctx, pw, ph, now, opts, sun, s, cols, weather, softStops);
488
+ // the two discs over the clouds: a sun or a moon lost behind one is a sky with neither
489
+ paintMoon(ctx, pw, ph, m, moon, shown, night, cols, softStops);
490
+ paintSun(ctx, pw, ph, s, sun, cols, softStops, 'disc');
491
+ if (opts.skyRainbow && opts.skyWeather === 'scattered') paintRainbow(ctx, pw, ph, s, sun, dpr);
492
+ if (weather.rain) { paintRain(ctx, pw, ph, now, dpr); if (weather.storm) paintLightning(ctx, pw, ph, now, dpr); }
493
+ return { hour: t.hour, alt: sun.alt, sun: sun.alt >= -8, moon: moon.alt >= -3 && shown.lit >= 0.03, stars: night, weather: opts.skyWeather ?? 'scattered', phase: phase.p, horizon };
494
+ }
@@ -4,6 +4,7 @@
4
4
  // the theme the browser last saw (settings.js keeps a copy under blockyard.settings), so a light
5
5
  // theme's sign-in page is light too; a first visit gets the shipped look
6
6
  import { followTheme } from './theme.js';
7
+ import { safeNext } from './safenext.js';
7
8
  followTheme();
8
9
 
9
10
  const err = document.getElementById('err');
@@ -76,8 +77,7 @@ form.addEventListener('submit', async (ev) => {
76
77
  // X-CSRF-Token header -- reading it is the thing a cross-origin page cannot
77
78
  // do, which is what makes the check mean anything.
78
79
  void /(?:^|;\s*)blockyard_csrf=/.test(document.cookie);
79
- const back = new URLSearchParams(location.search).get('next');
80
- window.location.replace(back && back.startsWith('/') ? back : '/');
80
+ window.location.replace(safeNext(new URLSearchParams(location.search).get('next'), location.origin));
81
81
  } catch (e) {
82
82
  show(`cannot reach the server: ${e.message}`);
83
83
  btn.disabled = false;
@@ -86,3 +86,4 @@ form.addEventListener('submit', async (ev) => {
86
86
  });
87
87
 
88
88
  document.getElementById('u').focus();
89
+
@@ -959,10 +959,10 @@ export function nextHud(nb, { meter = '', fresh = null, mempool = null } = {}, f
959
959
  const mins = fresh && Number.isFinite(fresh.seconds) ? Math.floor(fresh.seconds / 60) : null;
960
960
  const ageSec = nb.at ? Math.max(0, Math.round((Date.now() - nb.at) / 1000)) : null;
961
961
  const ec = nb.economy ?? null;
962
- const kv = (k, v) => `<dt>${k}</dt><dd>${v}</dd>`;
963
- return `${head(`<span class="hudk">#${nb.height ?? '?'}</span><span class="hudclock ${lvl}" title="time since the last block: how long this block has been accumulating${fresh?.basis === 'block time' ? ' (from the block timestamp)' : ''}">${mins != null ? `${mins}m` : '–'}</span>`)}
962
+ const kv = (k, v) => `<dt>${fmt.esc(k)}</dt><dd>${fmt.esc(v)}</dd>`; // text only (audit 2026-09-16, L2)
963
+ return `${head(`<span class="hudk">#${fmt.esc(nb.height ?? '?')}</span><span class="hudclock ${lvl}" title="time since the last block: how long this block has been accumulating${fresh?.basis === 'block time' ? ' (from the block timestamp)' : ''}">${mins != null ? `${mins}m` : '–'}</span>`)}
964
964
  ${meter}
965
- <div class="hudbig">${pct.toFixed(1)}<small>% full</small>${ec?.marginal?.rate != null ? `<span class="chip hot">marginal ~${ec.marginal.rate}/vB</span>` : '<span class="chip">not full</span>'}</div>
965
+ <div class="hudbig">${pct.toFixed(1)}<small>% full</small>${ec?.marginal?.rate != null ? `<span class="chip hot">marginal ~${fmt.esc(ec.marginal.rate)}/vB</span>` : '<span class="chip">not full</span>'}</div>
966
966
  <dl class="hudkv">
967
967
  ${kv('transactions', nb.txCount != null ? fmt.num(nb.txCount) : '–')}
968
968
  ${kv('fees', btc(nb.totalFeesSat))}
@@ -983,7 +983,7 @@ export function tipHud(tipHeight, row, prev, { avgGapSec = null, now = Date.now(
983
983
  const w = who(row);
984
984
  const f = blockFacts(row);
985
985
  const gap = prev?.time && row.time ? row.time - prev.time : null;
986
- const kv = (k, v) => `<dt>${k}</dt><dd>${v}</dd>`;
986
+ const kv = (k, v) => `<dt>${fmt.esc(k)}</dt><dd>${fmt.esc(v)}</dd>`; // text only (audit 2026-09-16, L2)
987
987
  return `${head}
988
988
  <div class="hudpool"><span class="bdot" data-pool="${w.idx}"></span><b data-pool-fg="${w.idx}">${fmt.esc(trunc(w.name, 22))}</b><span class="faint">${agoText(row.at ?? row.seenAt, now) ? `mined ${agoText(row.at ?? row.seenAt, now)}` : ''}</span></div>
989
989
  <div class="hudbar" title="${f.capPct != null ? `${f.capPct.toFixed(1)}% of the 4,000,000 WU cap` : 'weight unknown'}"><span data-w="${f.capPct == null ? 0 : f.capPct.toFixed(1)}" data-pool="${w.idx}"></span></div>
@@ -111,8 +111,8 @@ export function renderChain(s, state, h) {
111
111
  // hundreds of blocks a second is not a hashrate), and the reason travels with it
112
112
  // so the dash is an explanation rather than a mystery.
113
113
  ['network hash', s.hashrateEstEh != null ? fmt.eh(s.hashrateEstEh)
114
- : `<span title="${fmt.esc(s.hashrateNote ?? '')}">–</span>`],
115
- ...(s.hashrateEstEh == null && s.hashrateNote ? [['hash rate note', `<span class="tiny faint">${fmt.esc(s.hashrateNote)}</span>`]] : []),
114
+ : raw(`<span title="${fmt.esc(s.hashrateNote ?? '')}">–</span>`)],
115
+ ...(s.hashrateEstEh == null && s.hashrateNote ? [['hash rate note', raw(`<span class="tiny faint">${fmt.esc(s.hashrateNote)}</span>`)]] : []),
116
116
  ['avg interval', s.avgBlockGapSec != null ? fmt.ageSec(s.avgBlockGapSec) : '–'],
117
117
  ['reorgs seen', String(s.blocks?.reorgs ?? 0)],
118
118
  ]));
@@ -521,7 +521,7 @@ export function renderNode(s, state, h) {
521
521
  const lh = t.health ?? {};
522
522
  h.setText('ndTail', kv([
523
523
  ['file', t.source === 'disabled'
524
- ? '<span class="faint">disabled — running on RPC only</span>'
524
+ ? raw('<span class="faint">disabled — running on RPC only</span>')
525
525
  : (t.file ? fmt.hash(t.file, 16) : 'not configured')],
526
526
  ['exists', t.exists == null ? '–' : String(t.exists)],
527
527
  ['size', t.size != null ? fmt.bytes(t.size, 1) : '–'],
@@ -536,10 +536,10 @@ export function renderNode(s, state, h) {
536
536
  // The two figures that separate "this node is quiet" from "we are tailing the
537
537
  // wrong file". A tail that stops moving is invisible in every other panel.
538
538
  ['lines matched', lh.ratio != null
539
- ? `${Math.round(lh.ratio * 100)}%${lh.lines ? ` <span class="faint tiny">(${fmt.num(lh.parsed)}/${fmt.num(lh.lines)} last window)</span>` : ''}`
540
- : '<span class="faint">not checked yet</span>'],
539
+ ? raw(`${Math.round(lh.ratio * 100)}%${lh.lines ? ` <span class="faint tiny">(${fmt.num(lh.parsed)}/${fmt.num(lh.lines)} last window)</span>` : ''}`)
540
+ : raw('<span class="faint">not checked yet</span>')],
541
541
  ['last new bytes', lh.lastGrowthAt
542
- ? `${fmt.ago(lh.lastGrowthAt)} <span class="faint tiny">(warns after ${lh.staleAfterMs ? Math.round(lh.staleAfterMs / 60000) : '?'} min)</span>`
542
+ ? raw(`${fmt.esc(fmt.ago(lh.lastGrowthAt))} <span class="faint tiny">(warns after ${lh.staleAfterMs ? Math.round(lh.staleAfterMs / 60000) : '?'} min)</span>`)
543
543
  : '–'],
544
544
  ]));
545
545
 
@@ -826,8 +826,17 @@ function drawFromSeries(h, id, points, color, fmtY, extra = {}) {
826
826
  });
827
827
  }
828
828
 
829
- function kv(pairs) {
830
- return pairs.map(([k, v]) => `<dt>${String(k)}</dt><dd>${String(v)}</dd>`).join('');
829
+ // ESCAPED BY DEFAULT (audit 2026-09-16, L2/L3). kv() used to write every value as markup, and some
830
+ // values are the node's own strings passed straight through -- `chain`, `pruned`, the RPC endpoint,
831
+ // the cookie path. A hostile or misconfigured node returned `chain` with an <img> appended and it
832
+ // became an element on the Chain page (the CSP stopped the script; the element was still there).
833
+ // Now a value is text unless it is wrapped in raw(), which says at the call site "this is markup
834
+ // this file built, with its own dynamic parts already escaped".
835
+ const RAW = Symbol('raw');
836
+ export const raw = (html) => ({ [RAW]: String(html) });
837
+ export function kv(pairs) {
838
+ const cell = (v) => (v && typeof v === 'object' && RAW in v ? v[RAW] : F().esc(v));
839
+ return pairs.map(([k, v]) => `<dt>${F().esc(k)}</dt><dd>${cell(v)}</dd>`).join('');
831
840
  }
832
841
 
833
842
  function median(a) {
@@ -888,8 +897,8 @@ export function initChainDrill(h) {
888
897
  return;
889
898
  }
890
899
  const rows = [
891
- ['txid', `<span class="mono tiny select-all">${fmt.esc(d.txid ?? txid)}</span> <a class="xlink" href="#explorer/tx/${encodeURIComponent(d.txid ?? txid)}">open in the explorer ›</a>`],
892
- ['in', d.inMempool ? '<span class="warn">mempool (unconfirmed)</span>' : `block ${fmt.esc(d.blockHash ?? '?')}`],
900
+ ['txid', raw(`<span class="mono tiny select-all">${fmt.esc(d.txid ?? txid)}</span> <a class="xlink" href="#explorer/tx/${encodeURIComponent(d.txid ?? txid)}">open in the explorer ›</a>`)],
901
+ ['in', d.inMempool ? raw('<span class="warn">mempool (unconfirmed)</span>') : `block ${d.blockHash ?? '?'}`],
893
902
  ['confirmations', d.confirmations == null ? '– (in the pool)' : fmt.num(d.confirmations)],
894
903
  ['size / vsize / weight', [d.size, d.vsize, d.weight].map((v) => (v == null ? '–' : fmt.num(v))).join(' / ')],
895
904
  ['locktime', d.locktime ?? 0],
@@ -898,8 +907,8 @@ export function initChainDrill(h) {
898
907
  ['total out', d.totalOutSat != null ? `${fmt.num(d.totalOutSat)} sat` : '–'],
899
908
  ];
900
909
  const io = (rows2, label) => `<div class="note tiny mt-6"><b>${label}</b></div><table class="t"><tbody>${rows2}</tbody></table>`;
901
- const inRows = (d.inputs ?? []).map((v) => `<tr><td class=\"mono tiny w\">${fmt.esc(String(v.txid ?? 'coinbase').slice(0, 16))}…:${v.vout ?? '–'}</td><td class=\"tiny faint\">${fmt.esc(v.scriptSigType ?? '')} ${fmt.esc(v.scriptSigAsm ?? '')}</td></tr>`).join('');
902
- const outRows = (d.outputs ?? []).map((v) => `<tr><td class=\"r faint\">${v.n ?? '–'}</td><td class=\"mono tiny\">${fmt.esc(String(v.address ?? v.scriptPubKeyType ?? '–'))}</td><td class=\"r\">${v.value == null ? '–' : fmt.num(v.value)}</td></tr>`).join('');
910
+ const inRows = (d.inputs ?? []).map((v) => `<tr><td class=\"mono tiny w\">${fmt.esc(String(v.txid ?? 'coinbase').slice(0, 16))}…:${fmt.esc(v.vout ?? '–')}</td><td class=\"tiny faint\">${fmt.esc(v.scriptSigType ?? '')} ${fmt.esc(v.scriptSigAsm ?? '')}</td></tr>`).join('');
911
+ const outRows = (d.outputs ?? []).map((v) => `<tr><td class=\"r faint\">${fmt.esc(v.n ?? '–')}</td><td class=\"mono tiny\">${fmt.esc(String(v.address ?? v.scriptPubKeyType ?? '–'))}</td><td class=\"r\">${v.value == null ? '–' : fmt.num(v.value)}</td></tr>`).join('');
903
912
  box.innerHTML = `<div class=\"drill\">
904
913
  <dl class=\"kv\">${kv(rows)}</dl>
905
914
  ${inRows ? io(inRows, `inputs (${d.inputsTotal})`) : ''}
@@ -936,20 +945,20 @@ export function initChainDrill(h) {
936
945
  const b = d.header ?? {};
937
946
  const st = d.stats ?? {};
938
947
  if (hdr()) hdr().innerHTML = kv([
939
- ['height', b.height == null ? '–' : `<a class="xlink" href="#explorer/block/${b.height}">${fmt.num(b.height)}</a> <span class="tiny faint">open in the explorer</span>`],
940
- ['hash', `<span class=\"mono tiny select-all\">${fmt.esc(String(b.hash ?? '').slice(0, 24))}…</span>`],
941
- ['confirmations', b.confirmations == null ? '–' : `${fmt.num(b.confirmations)}${b.confirmations === 0 ? ' <span class=\"bad\">(not on the best chain)</span>' : ''}`],
948
+ ['height', b.height == null ? '–' : raw(`<a class="xlink" href="#explorer/block/${encodeURIComponent(Number(b.height))}">${fmt.esc(fmt.num(b.height))}</a> <span class="tiny faint">open in the explorer</span>`)],
949
+ ['hash', raw(`<span class=\"mono tiny select-all\">${fmt.esc(String(b.hash ?? '').slice(0, 24))}…</span>`)],
950
+ ['confirmations', b.confirmations == null ? '–' : raw(`${fmt.esc(fmt.num(b.confirmations))}${b.confirmations === 0 ? ' <span class=\"bad\">(not on the best chain)</span>' : ''}`)],
942
951
  ['time', b.time ? `${new Date(b.time * 1000).toISOString().replace('T', ' ').slice(0, 19)}Z` : '–'],
943
952
  ['age', b.time ? fmt.ago(b.time * 1000) : '–'],
944
953
  ['size / weight', `${b.size == null ? '–' : fmt.bytes(b.size, 0)} / ${b.weight == null ? '–' : fmt.num(b.weight)} WU`],
945
954
  ['transactions', fmt.num(b.nTx ?? b.txCount ?? 0)],
946
- ['fees', st.totalfee == null ? (d.statsError ? '<span class=\"warn\">getblockstats failed</span>' : '–') : `${fmt.num(st.totalfee)} sat`],
955
+ ['fees', st.totalfee == null ? (d.statsError ? raw('<span class=\"warn\">getblockstats failed</span>') : '–') : `${fmt.num(st.totalfee)} sat`],
947
956
  ['median fee', st.medianfee == null ? '–' : `${fmt.num(st.medianfee)} sat`],
948
957
  ['fee rate p10/50/90', (st.feerate_percentiles ?? []).length ? st.feerate_percentiles.map((v) => Number(v).toFixed(1)).join(' / ') : '–'],
949
958
  ['subsidy', st.subsidy == null ? '–' : `${fmt.num(st.subsidy)} sat`],
950
959
  ['utxo delta', st.utxo_increase == null ? '–' : fmt.num(st.utxo_increase)],
951
- ['prev / next', `<span class=\"mono tiny\">${fmt.esc(String(b.previousblockhash ?? '–').slice(0, 10))}… / ${fmt.esc(String(b.nextblockhash ?? 'none').slice(0, 10))}…</span>`],
952
- ['merkle root', `<span class=\"mono tiny\">${fmt.esc(String(b.merkleRoot ?? '–').slice(0, 16))}…</span>`],
960
+ ['prev / next', raw(`<span class=\"mono tiny\">${fmt.esc(String(b.previousblockhash ?? '–').slice(0, 10))}… / ${fmt.esc(String(b.nextblockhash ?? 'none').slice(0, 10))}…</span>`)],
961
+ ['merkle root', raw(`<span class=\"mono tiny\">${fmt.esc(String(b.merkleRoot ?? '–').slice(0, 16))}…</span>`)],
953
962
  ]);
954
963
  const ids = d.txids ?? [];
955
964
  if (list()) {