blockyard 0.0.9 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +251 -1
- package/README.md +42 -23
- package/bin/blockyard.js +2 -1
- package/docs/API.md +16 -14
- package/docs/ARCHITECTURE.md +92 -5
- package/docs/CONFIGURATION.md +33 -26
- package/docs/GETTING-STARTED.md +5 -2
- package/docs/INSTALL.md +90 -33
- package/docs/MEASUREMENTS.md +147 -0
- package/docs/SECURITY.md +32 -15
- package/docs/TROUBLESHOOTING.md +35 -1
- package/docs/USER-GUIDE.md +266 -26
- package/package.json +1 -1
- package/public/404.html +1 -1
- package/public/css/app.css +306 -82
- package/public/donate-qr.png +0 -0
- package/public/index.html +295 -103
- package/public/js/agents.js +228 -51
- package/public/js/app.js +82 -8
- package/public/js/blockscene3d.js +179 -27
- package/public/js/charts.js +21 -21
- package/public/js/depthchart.js +31 -27
- package/public/js/details3d.js +1456 -71
- package/public/js/doom.js +31 -0
- package/public/js/dosaudio.js +48 -0
- package/public/js/dosgame.js +389 -0
- package/public/js/dosio.js +186 -0
- package/public/js/dospc.js +1353 -0
- package/public/js/dosworker.js +196 -0
- package/public/js/login.js +5 -0
- package/public/js/markets.js +46 -8
- package/public/js/mining.js +310 -32
- package/public/js/panels.js +14 -10
- package/public/js/pricechart.js +14 -13
- package/public/js/quake.js +20 -0
- package/public/js/settings.js +103 -21
- package/public/js/soundcard.js +459 -0
- package/public/js/theme.js +235 -0
- package/public/js/wolf3d.js +22 -0
- package/public/js/x86.js +1978 -0
- package/scripts/donate-qr.py +12 -9
- package/scripts/dos-bench.js +56 -0
- package/scripts/setup.js +34 -12
- package/scripts/shots.mjs +6 -0
- package/scripts/smoke.sh +1 -1
- package/scripts/tls.js +31 -0
- package/server/chain/index/build.js +21 -4
- package/server/collect/monitor.js +30 -1
- package/server/collect/network.js +295 -0
- package/server/config.js +46 -22
- package/server/http/api.js +49 -5
- package/server/http/games.js +77 -0
- package/server/http/server.js +8 -0
- package/server/main.js +53 -8
- package/server/tls/selfsigned.js +160 -0
- package/systemd/blockyard.service +7 -5
- package/docs/PRIVATE-LEADERBOARD.md +0 -230
- package/docs/STATE-2026-09-09.md +0 -200
package/public/js/settings.js
CHANGED
|
@@ -22,6 +22,24 @@ export const SETTINGS_KEY = 'blockyard.settings';
|
|
|
22
22
|
export const SCHEMA_VERSION = 4;
|
|
23
23
|
|
|
24
24
|
export const DEFAULTS = Object.freeze({
|
|
25
|
+
// APPEARANCE (operator, 2026-09-16: "add an appearance section in preferences to change the colors
|
|
26
|
+
// of our layout. Our current scheme should be the default appearance ... as well as options for
|
|
27
|
+
// configure custom schemes by allowing users to pick colors and save to settings so it sticks").
|
|
28
|
+
// The faces themselves live in theme.js; this holds the choice and the nine custom colours, whose
|
|
29
|
+
// defaults are the shipped dark look so that Custom starts where the page starts.
|
|
30
|
+
appearance: Object.freeze({
|
|
31
|
+
mode: 'dark', // 'light' | 'dark' | 'system' -- which face of the theme
|
|
32
|
+
theme: 'blockyard', // a preset in theme.js PRESETS, or 'custom'
|
|
33
|
+
customBg: '#0b0d10', // the page
|
|
34
|
+
customPanel: '#12151a', // cards, the header, the settings sheet
|
|
35
|
+
customText: '#dfe5ee',
|
|
36
|
+
customMuted: '#96a0b0',
|
|
37
|
+
customAccent: '#f7931a',
|
|
38
|
+
customLine: '#262c36',
|
|
39
|
+
customOk: '#2ecc8f',
|
|
40
|
+
customWarn: '#f0b429',
|
|
41
|
+
customBad: '#ef5a5a',
|
|
42
|
+
}),
|
|
25
43
|
space: Object.freeze({
|
|
26
44
|
// OFF by default (operator, 2026-09-12: "make simple cubes the default, disable shadows by
|
|
27
45
|
// default"). Shadows are the costliest single thing the board draws -- one per resting stone
|
|
@@ -123,6 +141,10 @@ export const DEFAULTS = Object.freeze({
|
|
|
123
141
|
// the grid glow. that's just terrible"). Never-show makes the switch a control nobody may use,
|
|
124
142
|
// and a control that must stay off is worse than no control: the board forces it off now.
|
|
125
143
|
markets: Object.freeze({
|
|
144
|
+
// THE SWITCH (operator, 2026-09-15: "an app wide 'Enable Market Polling' checkbox"): off out
|
|
145
|
+
// of the box, so a fresh monitor makes no outbound connection but to the node. The server
|
|
146
|
+
// reads it from the shared settings file on every market request (http/api.js).
|
|
147
|
+
polling: false,
|
|
126
148
|
stars: true,
|
|
127
149
|
effects: true, // the idle effects and the flight when the candles refresh
|
|
128
150
|
// THE TOOLBAR REMEMBERS (operator, 2026-09-12: "We need to remember the user settings for the
|
|
@@ -166,9 +188,12 @@ export const DEFAULTS = Object.freeze({
|
|
|
166
188
|
// for, and the board picks among whatever is left on (scheduleFx). Turn them all off and the
|
|
167
189
|
// board simply rests, which `idleFx` also does in one click.
|
|
168
190
|
effects: Object.freeze({
|
|
169
|
-
ripple: true, outline: true, tide: true, cascade: true, twinkle: true, scan: true,
|
|
191
|
+
ripple: true, outline: true, tide: true, cascade: true, twinkle: true, scan: true, xray: true,
|
|
170
192
|
lightcycle: true, ball: true,
|
|
171
|
-
|
|
193
|
+
// FIREWORKS ARE KEPT FOR OCCASIONS (operator, 2026-09-15: "make it disabled by default. I want
|
|
194
|
+
// to re-use that one for special occasions in the future, and don't want it in the rotation
|
|
195
|
+
// unless user selects it"): off on both boards until someone ticks it
|
|
196
|
+
shockwave: true, nova: true, firework: false, flare: true, wave: true, quake: true,
|
|
172
197
|
rain: true, sparkle: true, checker: true, radar: true, vortex: true, powerup: true, combo: true, aurora: true, plasma: true,
|
|
173
198
|
// the agents: something happening on the board, rather than a pattern over it
|
|
174
199
|
centipede: true, tractor: true, missile: true, boulderdash: true, stormball: true,
|
|
@@ -192,10 +217,14 @@ export const DEFAULTS = Object.freeze({
|
|
|
192
217
|
// should ship with ... Many of the effects don't translate over to the market chart"). Its own
|
|
193
218
|
// no-repeat window too. All on, twelve.
|
|
194
219
|
marketEffects: Object.freeze({
|
|
195
|
-
ripple: true, outline: true, tide: true, cascade: true, twinkle: true, scan: true,
|
|
220
|
+
ripple: true, outline: true, tide: true, cascade: true, twinkle: true, scan: true, xray: true,
|
|
196
221
|
pulse: true, // the surge that runs the price line
|
|
197
222
|
bulge: true, // a sphere rolls through the pipe and it swells round it
|
|
198
|
-
|
|
223
|
+
breathe: true, // the line breathes between the wire and the pulse's heat
|
|
224
|
+
saber: true, // the line ignites as a light saber
|
|
225
|
+
blackhole: true, // the chart collapses into a black hole
|
|
226
|
+
firework: false, // kept for occasions, off until ticked (see the block board's list)
|
|
227
|
+
flare: true, wave: true, stormball: true,
|
|
199
228
|
noRepeat: 12,
|
|
200
229
|
// no firstAfter here: the candle board does not land (operator, 2026-09-14: "there is no
|
|
201
230
|
// 'landing' for the markets display"); after a refresh its first effect keeps the cadence below
|
|
@@ -287,15 +316,19 @@ const FX_ROW = Object.freeze({
|
|
|
287
316
|
tide: Object.freeze({ label: 'Tide', hint: 'A swell that lifts the blocks it passes under' }),
|
|
288
317
|
cascade: Object.freeze({ label: 'Cascade', hint: 'The blocks light in feerate order, richest first' }),
|
|
289
318
|
twinkle: Object.freeze({ label: 'Twinkle', hint: 'Scattered blocks flash white, each on its own beat' }),
|
|
290
|
-
scan: Object.freeze({ label: 'Scan line', hint: 'A
|
|
319
|
+
scan: Object.freeze({ label: 'Scan line', hint: 'A curtain of light standing on the board, floor to top, sweeping across: a white core, soft cyan faces, raster rippling down it, a bar it hangs from, a glowing foot with a phosphor tail, motes in the beam' }),
|
|
320
|
+
xray: Object.freeze({ label: 'X-ray', hint: 'A front sweeps the board and everything behind it goes x-ray -- bodies to glass, edges and a raster lit -- then develops back to solid' }),
|
|
291
321
|
lightcycle: Object.freeze({ label: 'Light cycles', hint: 'Two riders from opposite edges, leaving light walls, until one crashes' }),
|
|
292
322
|
ball: Object.freeze({ label: 'Lightning ball', hint: 'A plasma ball tracing the grid, throwing bolts and a dust trail' }),
|
|
293
323
|
pulse: Object.freeze({ label: 'Energy pulse', hint: 'The surge that runs the price line on Markets, blue behind the head' }),
|
|
294
324
|
bulge: Object.freeze({ label: 'Pipe bulge', hint: 'On Markets: a glowing sphere rolls through the price line left to right, and the pipe swells around it as it passes' }),
|
|
325
|
+
breathe: Object.freeze({ label: 'Breathe', hint: 'The price line breathes: three slow swells from the plain wire to the pulse\u2019s white heat and back' }),
|
|
326
|
+
saber: Object.freeze({ label: 'Light saber', hint: 'The price line ignites from its left end as a light saber -- blue, green, red or purple -- hums, spits sparks, and retracts' }),
|
|
327
|
+
blackhole: Object.freeze({ label: 'Black hole', hint: 'A point of darkness opens on the price line and grows, then drifts slowly across the chart from one side to the other: the line bends round it, the candles nearest are swallowed into orbit, and an accretion disk of the chart\u2019s own colours spirals in round the shadow -- brighter on the side coming toward you, its far side arched over the top -- until it lets everything go' }),
|
|
295
328
|
shockwave: Object.freeze({ label: 'Shockwave', hint: 'A hard ring that throws the blocks it passes into the air' }),
|
|
296
329
|
nova: Object.freeze({ label: 'Nova', hint: 'An implosion to the middle, then a brighter blast back out' }),
|
|
297
|
-
firework: Object.freeze({ label: 'Fireworks', hint: '
|
|
298
|
-
flare: Object.freeze({ label: '
|
|
330
|
+
firework: Object.freeze({ label: 'Fireworks', hint: 'A display of up to ten shells, each at its own moment and place, with its smoke. Off by default -- kept for special occasions; tick it to put it in the rotation' }),
|
|
331
|
+
flare: Object.freeze({ label: 'Supernova', hint: 'One block goes supernova: a star swells and blows out in a white-out, a debris cloud of gas expands and cools white to blue to violet, a shockwave throws what it crosses, and a pulsar is left beating at the centre' }),
|
|
299
332
|
wave: Object.freeze({ label: 'Wave', hint: 'Several crests rolling across the board, the blocks riding them' }),
|
|
300
333
|
quake: Object.freeze({ label: 'Quake', hint: 'The board shakes, hardest at the start, and settles' }),
|
|
301
334
|
rain: Object.freeze({ label: 'Code rain', hint: 'A drop falls down every column with a white head and a green tail' }),
|
|
@@ -315,12 +348,13 @@ const FX_ROW = Object.freeze({
|
|
|
315
348
|
});
|
|
316
349
|
// where an effect reads differently on the price board, the price board's hint
|
|
317
350
|
const MARKET_HINT = Object.freeze({
|
|
351
|
+
blackhole: 'A point of darkness opens on the price line and grows, then drifts slowly along the hours from one side to the other: the line bends round it, the candles nearest are swallowed into orbit, and an accretion disk of the chart\u2019s own colours spirals in round the shadow -- brighter on the side coming toward you, its far side arched over the top -- until it lets everything go',
|
|
318
352
|
twinkle: 'Scattered candles flash white, each on its own beat',
|
|
319
|
-
pulse: 'The surge that runs the price line
|
|
353
|
+
pulse: 'The surge that runs the price line: the tube swells round the head and goes white-hot behind it',
|
|
320
354
|
bulge: 'A glowing sphere rolls through the price line left to right, and the pipe swells around it as it passes',
|
|
321
355
|
stormball: 'An electric blue sphere in a nebula flies through the chart, striking candles and charging the price line where it passes; a struck candle often throws a green arc on to another, and that one sometimes on to a third',
|
|
322
356
|
cascade: 'The candles light in order, tallest first',
|
|
323
|
-
flare: 'One candle goes supernova and
|
|
357
|
+
flare: 'One candle goes supernova: it swells white-hot, blows out in a flash and a lens flare, a shockwave rings out across the chart, plasma is flung on every side, and a ring nebula expands and cools for the rest of the run',
|
|
324
358
|
});
|
|
325
359
|
// THE WINDOW'S TOP IS THE LIST'S LENGTH (operator, 2026-09-14: "for the markets page, all we have
|
|
326
360
|
// is 12 effects, so max the slider out at max effects"): a window wider than the list is the same
|
|
@@ -349,7 +383,40 @@ export function fxCadence(g) {
|
|
|
349
383
|
return { idleEvery, idleFirst };
|
|
350
384
|
}
|
|
351
385
|
|
|
352
|
-
|
|
386
|
+
// THE TABS, IN ORDER AND IN ROWS (operator, 2026-09-16: "we really need to group these better.
|
|
387
|
+
// Diversions go at the very end"). The groups are written below in the order they were added; the
|
|
388
|
+
// sheet shows them in this order, each row of the strip labelled: the boards, their effects, the
|
|
389
|
+
// games last.
|
|
390
|
+
export const TAB_ROWS = Object.freeze([
|
|
391
|
+
Object.freeze({ label: 'Boards', groups: Object.freeze(['appearance', 'space', 'sky', 'markets']) }),
|
|
392
|
+
Object.freeze({ label: 'Effects', groups: Object.freeze(['effects', 'marketEffects']) }),
|
|
393
|
+
Object.freeze({ label: 'Diversions', groups: Object.freeze(['tetrust', 'blockout', 'blockanoid']) }),
|
|
394
|
+
]);
|
|
395
|
+
const PANEL_GROUPS = Object.freeze([
|
|
396
|
+
Object.freeze({
|
|
397
|
+
group: 'appearance',
|
|
398
|
+
title: 'Appearance',
|
|
399
|
+
note: 'The colours of the layout: the page, its cards, text, lines, accent and charts. The 3D boards are space whatever is chosen here, and the Explorer\u2019s block and transaction pages keep their own dark cards.',
|
|
400
|
+
rows: Object.freeze([
|
|
401
|
+
Object.freeze({
|
|
402
|
+
key: 'mode', label: 'Theme mode', kind: 'segment', hint: 'Light or dark, or whichever the operating system asks for (System follows it as it changes)',
|
|
403
|
+
options: Object.freeze([['light', 'Light'], ['dark', 'Dark'], ['system', 'System']]),
|
|
404
|
+
}),
|
|
405
|
+
Object.freeze({
|
|
406
|
+
key: 'theme', label: 'Theme', kind: 'cards', hint: 'Each has a light and a dark face; the mode above picks which. Custom is the nine colours below',
|
|
407
|
+
options: Object.freeze([['blockyard', 'BlockYard'], ['mono', 'Mono'], ['nous', 'Nous'], ['github', 'GitHub'], ['catppuccin', 'Catppuccin'], ['custom', 'Custom']]),
|
|
408
|
+
}),
|
|
409
|
+
Object.freeze({ key: 'customBg', label: 'Page', kind: 'colour', hint: 'The colour behind everything. Whether Custom is a light or a dark theme follows from this one: the derived shades go the other way', custom: true }),
|
|
410
|
+
Object.freeze({ key: 'customPanel', label: 'Panels', kind: 'colour', hint: 'Cards, the header, tables and the settings sheet. Raised panels and hovers are this nudged toward the text colour', custom: true }),
|
|
411
|
+
Object.freeze({ key: 'customText', label: 'Text', kind: 'colour', hint: 'Body text and figures', custom: true }),
|
|
412
|
+
Object.freeze({ key: 'customMuted', label: 'Muted text', kind: 'colour', hint: 'Labels, hints and notes. The fainter text is this halfway to the page colour', custom: true }),
|
|
413
|
+
Object.freeze({ key: 'customAccent', label: 'Accent', kind: 'colour', hint: 'Titles, the active tab, links, buttons and the primary lines of charts', custom: true }),
|
|
414
|
+
Object.freeze({ key: 'customLine', label: 'Lines', kind: 'colour', hint: 'Card borders and table rules. The softer rules are this halfway to the page colour', custom: true }),
|
|
415
|
+
Object.freeze({ key: 'customOk', label: 'Good', kind: 'colour', hint: 'Synced, fresh, a block on time', custom: true }),
|
|
416
|
+
Object.freeze({ key: 'customWarn', label: 'Warning', kind: 'colour', hint: 'Late, stale, a caveat', custom: true }),
|
|
417
|
+
Object.freeze({ key: 'customBad', label: 'Bad', kind: 'colour', hint: 'Overdue, failed, offline', custom: true }),
|
|
418
|
+
]),
|
|
419
|
+
}),
|
|
353
420
|
Object.freeze({
|
|
354
421
|
group: 'space',
|
|
355
422
|
title: 'Block space',
|
|
@@ -419,8 +486,24 @@ export const PANEL = Object.freeze([
|
|
|
419
486
|
Object.freeze({
|
|
420
487
|
group: 'markets',
|
|
421
488
|
title: 'Markets & Price',
|
|
422
|
-
note: 'The candle board on Markets and Kiosk.',
|
|
489
|
+
note: 'The exchange feed, and the candle board on Markets and Kiosk.',
|
|
423
490
|
rows: Object.freeze([
|
|
491
|
+
Object.freeze({
|
|
492
|
+
key: 'polling', label: 'Enable market polling', kind: 'toggle',
|
|
493
|
+
hint: 'Let the server ask five exchanges (Coinbase, Kraken, Bitstamp, Bitfinex, OKX) for prices, candles and order books. '
|
|
494
|
+
+ 'Off out of the box: this is the only thing the monitor ever says to anyone but your node, and until it is on, '
|
|
495
|
+
+ 'Markets and Kiosk say so and the explorer shows no dollar figures. On, the server asks only while someone is looking, '
|
|
496
|
+
+ 'and stops ten minutes after the last look. Shared by every screen of this monitor',
|
|
497
|
+
}),
|
|
498
|
+
// right under the polling switch it depends on (operator, 2026-09-15: "Move price line on Overview
|
|
499
|
+
// underneath Enable Market Polling")
|
|
500
|
+
Object.freeze({
|
|
501
|
+
key: 'overviewSummary', label: 'Price line on Overview', kind: 'toggle',
|
|
502
|
+
hint: 'Median, spread, 24 h volume and how many books reported, at the top of Overview. '
|
|
503
|
+
+ 'It needs market polling (above); with that on, leaving this on means this monitor contacts '
|
|
504
|
+
+ 'five exchanges whenever Overview is open, not only on Markets and Kiosk. '
|
|
505
|
+
+ 'Switch it off and the landing page talks to nothing but your node.',
|
|
506
|
+
}),
|
|
424
507
|
Object.freeze({ key: 'stars', label: 'Star field', kind: 'toggle', hint: 'The twinkling sky behind the candles' }),
|
|
425
508
|
Object.freeze({ key: 'effects', label: 'Board effects', kind: 'toggle', hint: 'The idle effects while the board rests (which of them is the Market effects tab) and the flight when the candles refresh. Off draws the board and leaves it alone' }),
|
|
426
509
|
Object.freeze({
|
|
@@ -431,13 +514,6 @@ export const PANEL = Object.freeze([
|
|
|
431
514
|
key: 'range', label: 'Range', kind: 'choice', hint: 'How many hours the chart covers when the page opens',
|
|
432
515
|
options: Object.freeze([['24', '24 hours'], ['48', '48 hours'], ['168', '7 days']]),
|
|
433
516
|
}),
|
|
434
|
-
Object.freeze({
|
|
435
|
-
key: 'overviewSummary', label: 'Price line on Overview', kind: 'toggle',
|
|
436
|
-
hint: 'Median, spread, 24 h volume and how many books reported, at the top of Overview. '
|
|
437
|
-
+ 'On by default: it needs the exchange feed, so leaving it on means this monitor '
|
|
438
|
-
+ 'contacts five exchanges whenever Overview is open, not only on Markets and Kiosk. '
|
|
439
|
-
+ 'Switch it off and the landing page talks to nothing but your node.',
|
|
440
|
-
}),
|
|
441
517
|
Object.freeze({
|
|
442
518
|
key: 'priceView', label: 'Price view', kind: 'choice', hint: 'Which one the Markets page draws. Only one at a time — they show the same hours, and two tall panels of it filled the screen',
|
|
443
519
|
options: Object.freeze([['2d', 'Flat chart'], ['3d', '3D candle board']]),
|
|
@@ -503,7 +579,7 @@ export const PANEL = Object.freeze([
|
|
|
503
579
|
note: 'What the Block space board may play while it rests. One is chosen at random every seven to thirteen seconds, never one played within the no-repeat window \u2014 so the more you leave on, the less often you see any one of them. The Markets board has a list of its own, on the next tab.',
|
|
504
580
|
bulk: true,
|
|
505
581
|
rows: fxRows([
|
|
506
|
-
'ripple', 'outline', 'tide', 'cascade', 'twinkle', 'scan', 'lightcycle', 'ball', 'shockwave', 'nova',
|
|
582
|
+
'ripple', 'outline', 'tide', 'cascade', 'twinkle', 'scan', 'xray', 'lightcycle', 'ball', 'shockwave', 'nova',
|
|
507
583
|
'firework', 'flare', 'wave', 'quake', 'rain', 'sparkle', 'checker', 'radar', 'vortex', 'powerup',
|
|
508
584
|
'combo', 'aurora', 'plasma', 'centipede', 'tractor', 'missile', 'boulderdash', 'stormball',
|
|
509
585
|
]),
|
|
@@ -511,10 +587,10 @@ export const PANEL = Object.freeze([
|
|
|
511
587
|
Object.freeze({
|
|
512
588
|
group: 'marketEffects',
|
|
513
589
|
title: 'Market effects',
|
|
514
|
-
note: 'What the candle board on Markets and Kiosk may play while it rests, chosen the same way as on Block space but from the
|
|
590
|
+
note: 'What the candle board on Markets and Kiosk may play while it rests, chosen the same way as on Block space but from the sixteen that translate to a chart: fronts along the hours, bursts from the candle row, the candles lit where they stand, and the price line\u2019s own pulse, bulge and ball lightning. The Board effects switch on the Markets & Price tab is the master.',
|
|
515
591
|
bulk: true,
|
|
516
592
|
rows: fxRows([
|
|
517
|
-
'ripple', 'outline', 'tide', 'cascade', 'twinkle', 'scan', 'pulse', 'bulge', 'firework', 'flare', 'wave', 'stormball',
|
|
593
|
+
'ripple', 'outline', 'tide', 'cascade', 'twinkle', 'scan', 'xray', 'pulse', 'bulge', 'breathe', 'saber', 'blackhole', 'firework', 'flare', 'wave', 'stormball',
|
|
518
594
|
], MARKET_HINT, { landing: false, top: 300 }),
|
|
519
595
|
}),
|
|
520
596
|
Object.freeze({
|
|
@@ -545,6 +621,12 @@ export const PANEL = Object.freeze([
|
|
|
545
621
|
]),
|
|
546
622
|
}),
|
|
547
623
|
]);
|
|
624
|
+
/** The groups in the sheet's order: row by row of TAB_ROWS, then anything a row forgot (a test says nothing is). */
|
|
625
|
+
export const PANEL = Object.freeze([
|
|
626
|
+
...TAB_ROWS.flatMap((row) => row.groups.map((id) => PANEL_GROUPS.find((g) => g.group === id)).filter(Boolean)),
|
|
627
|
+
...PANEL_GROUPS.filter((g) => !TAB_ROWS.some((row) => row.groups.includes(g.group))),
|
|
628
|
+
]);
|
|
629
|
+
|
|
548
630
|
|
|
549
631
|
// "group.key" -> the row that defines it. The bounds live in exactly one place now.
|
|
550
632
|
const ROWS = new Map();
|