jsbeeb 1.14.0 → 1.16.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/src/disc.js CHANGED
@@ -257,10 +257,12 @@ class Sector {
257
257
  * @param {Track} track
258
258
  * @param {boolean} isMfm
259
259
  * @param {Number} idPosBitOffset
260
+ * @param {function(string): void} [warn] where to report anomalies; the console by default
260
261
  */
261
- constructor(track, isMfm, idPosBitOffset) {
262
+ constructor(track, isMfm, idPosBitOffset, warn = console.log) {
262
263
  this.track = track;
263
264
  this.isMfm = isMfm;
265
+ this._warn = warn;
264
266
  this.idPosBitOffset = idPosBitOffset;
265
267
  this.dataPosBitOffset = null;
266
268
  this.isDeleted = false;
@@ -271,7 +273,7 @@ class Sector {
271
273
  const idReader = this._readerAt(this.idPosBitOffset);
272
274
  const { data: headerData, iffyPulses } = idReader.read(6);
273
275
  if (iffyPulses) {
274
- console.log(`Iffy pulse in sector header ${this.description}`);
276
+ this._warn(`Iffy pulse in sector header ${this.description}`);
275
277
  }
276
278
  this.header = headerData;
277
279
  let crc = idReader.initialCrc;
@@ -304,7 +306,7 @@ class Sector {
304
306
  read(nextSector) {
305
307
  const pulsesPerByte = this.isMfm ? 16 : 32; // todo put in reader
306
308
  if (this.dataPosBitOffset === null) {
307
- console.log(`"Sector header without data ${this.description}"`);
309
+ this._warn(`Sector header without data ${this.description}`);
308
310
  return;
309
311
  }
310
312
 
@@ -331,7 +333,7 @@ class Sector {
331
333
  sectorSize = sectorSize >>> 1;
332
334
  } while (sectorSize >= 128);
333
335
  if (seenIffyData) {
334
- console.log(`"Iffy pulse in sector data ${this.description}"`);
336
+ this._warn(`Iffy pulse in sector data ${this.description}`);
335
337
  }
336
338
  }
337
339
 
@@ -355,6 +357,61 @@ class Sector {
355
357
  }
356
358
  }
357
359
 
360
+ /**
361
+ * A 64 bit shift register, held as a pair of unsigned 32 bit halves. BigInt would say this more
362
+ * directly but costs an order of magnitude more per bit shifted.
363
+ */
364
+ export class BitWindow64 {
365
+ constructor() {
366
+ this.hi = 0;
367
+ this.lo = 0;
368
+ }
369
+
370
+ /**
371
+ * @param {Number} bit 0 or 1, shifted into bit 0
372
+ * @returns {Number} the bit shifted out of bit 63
373
+ */
374
+ shiftIn(bit) {
375
+ const shiftedOut = this.hi >>> 31;
376
+ this.hi = ((this.hi << 1) | (this.lo >>> 31)) >>> 0;
377
+ this.lo = ((this.lo << 1) | bit) >>> 0;
378
+ return shiftedOut;
379
+ }
380
+
381
+ /**
382
+ * @returns {boolean} whether all 64 bits match the halves given
383
+ */
384
+ equals(hi, lo) {
385
+ return this.hi === hi && this.lo === lo;
386
+ }
387
+
388
+ /**
389
+ * @param {Number} nibble
390
+ * @returns {Number} the length of the run of `nibble` ending at bit 0
391
+ */
392
+ countTrailingNibbles(nibble) {
393
+ const nibblesPerHalf = 8;
394
+ let count = 0;
395
+ for (let bits = this.lo; (bits & 0xf) === nibble; bits >>>= 4) count++;
396
+ // Only a low half that matched all the way up can have a run continuing into the high half.
397
+ if (count === nibblesPerHalf) for (let bits = this.hi; (bits & 0xf) === nibble; bits >>>= 4) count++;
398
+ return count;
399
+ }
400
+ }
401
+
402
+ // What the mark detector holds at an address mark. The FM case only pins down the high half,
403
+ // the tail of the zero sync run; the low half is the marker byte itself, decoded separately.
404
+ const FmSyncHi = 0x88888888;
405
+ const MfmMarkerHi = 0xaaaa4489;
406
+ const MfmMarkerLo = 0x44894489;
407
+ // One data bit of zero, FM encoded with its clock, is the pulse nibble 0x8.
408
+ const FmZeroBitPulses = 0x8;
409
+ // FmSyncHi is eight of those, so a match has already seen this much of the sync run.
410
+ const FmSyncHiZeroBits = 8;
411
+ // Sync is counted in zero data bits, so a run no longer than two bytes' worth is short enough
412
+ // to be worth logging.
413
+ const ShortSyncZeros = 16;
414
+
358
415
  class Track {
359
416
  constructor(upper, trackNum, initialByte) {
360
417
  this.length = IbmDiscFormat.bytesPerTrack; // Default size, will be updated when track is populated
@@ -371,10 +428,11 @@ class Track {
371
428
 
372
429
  /**
373
430
  * Debug functionality to try and interpret the track.
431
+ * @param {function(string): void} [warn] where to report anomalies; the console by default
374
432
  * @returns {Sector[]}
375
433
  */
376
- findSectors() {
377
- const sectors = this.findSectorIds();
434
+ findSectors(warn = console.log) {
435
+ const sectors = this.findSectorIds(warn);
378
436
  for (let sectorIndex = 0; sectorIndex !== sectors.length; ++sectorIndex) {
379
437
  const nextSector = sectors[sectorIndex + 1]; // Will be unset for last
380
438
  sectors[sectorIndex].read(nextSector);
@@ -383,9 +441,10 @@ class Track {
383
441
  }
384
442
 
385
443
  /**
444
+ * @param {function(string): void} [warn] where to report anomalies; the console by default
386
445
  * @returns {Sector[]}
387
446
  */
388
- findSectorIds() {
447
+ findSectorIds(warn = console.log) {
389
448
  const sectors = [];
390
449
  // Pass 1: walk the track and find header and data markers.
391
450
  const bitLength = this.length * 32;
@@ -394,40 +453,30 @@ class Track {
394
453
  let doMfmMarkerByte = false;
395
454
  let isMfm = false;
396
455
  let pulses = 0;
397
- let markDetector = 0n;
398
- let markDetectorPrev = 0n;
399
- const all64b = 0xffffffffffffffffn;
400
- const top32of64b = 0xffffffff00000000n;
401
- const fmMarker = 0x8888888800000000n;
402
- const mfmMarker = 0xaaaa448944894489n;
456
+ // The mark detector is a 64 bit sliding window over the pulse stream; the bits leaving it
457
+ // spill into a second window, which the sync run length is counted from.
458
+ const markDetector = new BitWindow64();
459
+ const markDetectorPrev = new BitWindow64();
403
460
  let dataByte;
404
461
  let sector = null;
405
462
  for (let pulseIndex = 0; pulseIndex < bitLength; ++pulseIndex) {
406
463
  if ((pulseIndex & 31) === 0) pulses = this.pulses2Us[pulseIndex >>> 5];
407
- markDetectorPrev = (markDetectorPrev << 1n) & all64b;
408
- markDetectorPrev |= markDetector >> 63n;
409
- markDetector = (markDetector << 1n) & all64b;
410
- shiftRegister = (shiftRegister << 1) & 0xffffffff;
464
+ const pulseBit = pulses >>> 31;
465
+ markDetectorPrev.shiftIn(markDetector.shiftIn(pulseBit));
466
+ shiftRegister = ((shiftRegister << 1) | pulseBit) & 0xffffffff;
411
467
  numShifts++;
412
- if (pulses & 0x80000000) {
413
- markDetector |= 1n;
414
- shiftRegister |= 1;
415
- }
416
468
  pulses = (pulses << 1) & 0xffffffff;
417
- if ((markDetector & top32of64b) === fmMarker) {
418
- const { clocks, data, iffyPulses } = IbmDiscFormat._2usPulsesToFm(Number(markDetector & 0xffffffffn));
469
+ if (markDetector.hi === FmSyncHi) {
470
+ const { clocks, data, iffyPulses } = IbmDiscFormat._2usPulsesToFm(markDetector.lo);
419
471
  if (iffyPulses || clocks !== IbmDiscFormat.markClockPattern) continue;
420
472
  isMfm = false;
421
473
  doMfmMarkerByte = false;
422
- let num0s = 8;
423
- for (let bits = markDetectorPrev; (bits & 0xfn) === 0x8n; bits >>= 4n) {
424
- num0s++;
425
- }
426
- if (num0s <= 16) {
427
- console.log(`Short zeros sync ${this.description}`);
474
+ const num0s = FmSyncHiZeroBits + markDetectorPrev.countTrailingNibbles(FmZeroBitPulses);
475
+ if (num0s <= ShortSyncZeros) {
476
+ warn(`Short zeros sync ${this.description}`);
428
477
  }
429
478
  dataByte = data;
430
- } else if (markDetector === mfmMarker) {
479
+ } else if (markDetector.equals(MfmMarkerHi, MfmMarkerLo)) {
431
480
  // Next byte is MFM marker.
432
481
  isMfm = true;
433
482
  doMfmMarkerByte = true;
@@ -442,7 +491,7 @@ class Track {
442
491
  }
443
492
  switch (dataByte) {
444
493
  case IbmDiscFormat.idMarkDataPattern: {
445
- sector = new Sector(this, isMfm, pulseIndex + 1);
494
+ sector = new Sector(this, isMfm, pulseIndex + 1, warn);
446
495
  sectors.push(sector);
447
496
  shiftRegister = 0;
448
497
  numShifts = 0;
@@ -451,7 +500,7 @@ class Track {
451
500
  case IbmDiscFormat.dataMarkDataPattern:
452
501
  case IbmDiscFormat.deletedDataMarkDataPattern:
453
502
  if (!sector || sector.dataPosBitOffset) {
454
- console.log(
503
+ warn(
455
504
  `Sector data without header ${this.description}; mark bitpos ${pulseIndex}; previous good sector ${sector ? sector.description : "none"}`,
456
505
  );
457
506
  } else {
@@ -464,7 +513,7 @@ class Track {
464
513
  }
465
514
  break;
466
515
  default:
467
- console.log(`Unknown marker byte ${hexbyte(dataByte)} ${this.description}`);
516
+ warn(`Unknown marker byte ${hexbyte(dataByte)} ${this.description}`);
468
517
  }
469
518
  }
470
519
  return sectors;
@@ -580,16 +629,14 @@ export function loadSsd(disc, data, isDsd, onChange) {
580
629
  // Create a dataCopy large enough for all the sectors and tracks.
581
630
  const dataCopy = new Uint8Array(maxSize);
582
631
  dataCopy.set(data);
583
- disc.setWriteTrackCallback(
632
+ disc.addTrackWriteListener(
584
633
  /** @param {Track} trackObj */
585
634
  (side, trackNum, trackObj) => {
586
635
  const trackOffset =
587
636
  SsdFormat.sectorSize * SsdFormat.sectorsPerTrack * (trackNum * numSides + (side ? 1 : 0));
588
- for (const sector of trackObj.findSectors()) {
589
- const sectorOffset = sector.sectorNumber * SsdFormat.sectorSize;
590
- for (let x = 0; x < SsdFormat.sectorSize; ++x)
591
- dataCopy[trackOffset + sectorOffset + x] = sector.sectorData[x];
592
- }
637
+ for (const sector of trackObj.findSectors())
638
+ if (!sectorShortfall(sector, trackNum))
639
+ dataCopy.set(sector.sectorData, trackOffset + sector.sectorNumber * SsdFormat.sectorSize);
593
640
  onChange(dataCopy);
594
641
  },
595
642
  );
@@ -672,11 +719,56 @@ export function loadAdf(disc, data, isDsd) {
672
719
  return disc;
673
720
  }
674
721
 
722
+ /** Why a sector will not fit in an SSD or DSD image, or null if it will. */
723
+ function sectorShortfall(sector, trackNum) {
724
+ if (sector.hasDataCrcError || sector.hasHeaderCrcError) return "with a CRC error";
725
+ // A header whose data mark never arrives leaves the sector with nothing to write.
726
+ if (!sector.sectorData) return "with no data";
727
+ if (sector.sectorNumber >= SsdFormat.sectorsPerTrack)
728
+ return `numbered past the ${SsdFormat.sectorsPerTrack} a track holds`;
729
+ if (sector.sectorData.length !== SsdFormat.sectorSize) return `not ${SsdFormat.sectorSize} bytes`;
730
+ if (trackNum >= SsdFormat.tracksPerDisc) return `past track ${SsdFormat.tracksPerDisc}`;
731
+ return null;
732
+ }
733
+
734
+ /**
735
+ * SSD and DSD images hold sector contents and nothing else, so anything a DFS sector could not
736
+ * have held is lost. Copy protection usually shows up as one of these.
737
+ *
738
+ * @returns {string[]} what `disc` holds that an SSD or DSD cannot, worst first
739
+ * @param {Disc} disc
740
+ */
741
+ export function ssdOrDsdShortfalls(disc) {
742
+ const counts = new Map();
743
+ for (let trackNum = 0; trackNum < disc.tracksUsed; ++trackNum) {
744
+ for (const upper of disc.isDoubleSided ? [false, true] : [false]) {
745
+ for (const sector of disc.getTrack(upper, trackNum).findSectors()) {
746
+ const shortfall = sectorShortfall(sector, trackNum);
747
+ if (shortfall) counts.set(shortfall, (counts.get(shortfall) ?? 0) + 1);
748
+ }
749
+ }
750
+ }
751
+ return [...counts]
752
+ .sort(([, a], [, b]) => b - a)
753
+ .map(([shortfall, count]) => `${count} sector${count === 1 ? "" : "s"} ${shortfall}`);
754
+ }
755
+
675
756
  /**
676
757
  * @returns {Uint8Array}
677
758
  * @param {Disc} disc
759
+ * @param {object} [options]
760
+ * @param {boolean} [options.force] save what fits instead of refusing a disc that will not fit
761
+ * @throws if the disc holds anything an SSD or DSD cannot, and `force` is not set
678
762
  */
679
- export function toSsdOrDsd(disc) {
763
+ export function toSsdOrDsd(disc, { force = false } = {}) {
764
+ if (!force) {
765
+ const shortfalls = ssdOrDsdShortfalls(disc);
766
+ if (shortfalls.length)
767
+ throw new Error(
768
+ `This disc cannot be saved as SSD or DSD: it has ${shortfalls.join(", ")}. ` +
769
+ `Save it as HFE to keep everything.`,
770
+ );
771
+ }
680
772
  const numSides = disc.isDoubleSided ? 2 : 1;
681
773
  const result = new Uint8Array(
682
774
  numSides * SsdFormat.tracksPerDisc * SsdFormat.sectorsPerTrack * SsdFormat.sectorSize,
@@ -686,11 +778,8 @@ export function toSsdOrDsd(disc) {
686
778
  for (let side = 0; side < numSides; ++side) {
687
779
  const trackObj = disc.getTrack(side === 1, trackNum);
688
780
  for (const sector of trackObj.findSectors()) {
781
+ if (sectorShortfall(sector, trackNum)) continue;
689
782
  const sectorOffset = offset + sector.sectorNumber * SsdFormat.sectorSize;
690
- if (sector.hasDataCrcError || sector.hasHeaderCrcError) {
691
- console.log(`Skipping sector ${sector.description} with bad CRC`);
692
- continue;
693
- }
694
783
  for (let x = 0; x < SsdFormat.sectorSize; ++x) result[sectorOffset + x] = sector.sectorData[x];
695
784
  }
696
785
  offset += SsdFormat.sectorsPerTrack * SsdFormat.sectorSize;
@@ -722,7 +811,7 @@ export class Disc {
722
811
  this.tracksUsed = 0;
723
812
  this.isDoubleSided = false;
724
813
 
725
- this.writeTrackCallback = undefined;
814
+ this._trackWriteListeners = new Set();
726
815
  this.isWriteable = isWriteable;
727
816
 
728
817
  // Track which tracks have been written since the last snapshot.
@@ -749,8 +838,14 @@ export class Disc {
749
838
  this.initSurface(0);
750
839
  }
751
840
 
752
- setWriteTrackCallback(callback) {
753
- this.writeTrackCallback = callback;
841
+ /** @param {function(boolean, Number, Track): void} listener called once per flushed track */
842
+ addTrackWriteListener(listener) {
843
+ this._trackWriteListeners.add(listener);
844
+ }
845
+
846
+ /** @param {function(boolean, Number, Track): void} listener */
847
+ removeTrackWriteListener(listener) {
848
+ this._trackWriteListeners.delete(listener);
754
849
  }
755
850
 
756
851
  /**
@@ -860,10 +955,9 @@ export class Disc {
860
955
  this.isDirty = false;
861
956
  this.dirtySide = -1;
862
957
  this.dirtyTrack = -1;
863
- if (!this.writeTrackCallback) return;
864
958
  const trackObj = this.getTrack(dirtySide, dirtyTrack);
865
- this.writeTrackCallback(dirtySide, dirtyTrack, trackObj);
866
959
  this.setTrackUsed(dirtySide, dirtyTrack);
960
+ for (const listener of this._trackWriteListeners) listener(dirtySide, dirtyTrack, trackObj);
867
961
  }
868
962
 
869
963
  /**
package/src/dom-utils.js CHANGED
@@ -30,3 +30,19 @@ export function fadeOut(el, duration = 400) {
30
30
  if (el.style.opacity === "0") el.style.display = "none";
31
31
  }, duration);
32
32
  }
33
+
34
+ // Safari fetches the blob a task or more after the click, so the URL must outlive it.
35
+ // 40s matches FileSaver.js.
36
+ const BlobUrlLifetimeMs = 40000;
37
+
38
+ /** Save a blob to the user's downloads under the given file name. */
39
+ export function downloadBlob(blob, fileName) {
40
+ const url = URL.createObjectURL(blob);
41
+ const a = document.createElement("a");
42
+ a.href = url;
43
+ a.download = fileName;
44
+ document.body.appendChild(a);
45
+ a.click();
46
+ a.remove();
47
+ setTimeout(() => URL.revokeObjectURL(url), BlobUrlLifetimeMs);
48
+ }
package/src/econet.js CHANGED
@@ -1,6 +1,9 @@
1
1
  // Code ported from Beebem (C to .js) by Jason Robson
2
2
  // The majority of the commentary here is also from Beebem
3
3
 
4
+ // How long a four-way handshake may stall before we resend.
5
+ const RetryTimeoutSecs = 0.5;
6
+
4
7
  // Econet support classes
5
8
  class ADLC {
6
9
  constructor() {
@@ -57,10 +60,11 @@ export class ReceiveBlock {
57
60
 
58
61
  // Econet class definition
59
62
  export class Econet {
60
- constructor(stationId_) {
63
+ constructor(stationId_, cyclesPerSecond) {
61
64
  // Config parameters
62
65
  this.TIME_BETWEEN_BYTES = 128;
63
66
  this.SERVER_STATION_ID = 254;
67
+ this.retryCycles = (cyclesPerSecond * RetryTimeoutSecs) | 0;
64
68
 
65
69
  // 4-way handshake states
66
70
  this.FWH_Idle = 0;
@@ -156,7 +160,7 @@ export class Econet {
156
160
  }
157
161
 
158
162
  // Re-tries
159
- if (this.pollTotalCycles > this.wireStateEntryTimer + 1000000) {
163
+ if (this.pollTotalCycles > this.wireStateEntryTimer + this.retryCycles) {
160
164
  if (this.wireState !== this.FWH_Idle) {
161
165
  switch (this.wireState) {
162
166
  case this.FWH_RX_Scout_Received:
package/src/fake6502.js CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
  import { FakeVideo } from "./video.js";
5
5
  import { FakeSoundChip } from "./soundchip.js";
6
- import { TEST_6502, TEST_65C02, TEST_65C12, TubeModel } from "./models.js";
6
+ import { TEST_6502, TEST_65C02, TEST_65C12, tubeModelFor } from "./models.js";
7
7
  import { FakeDdNoise } from "./ddnoise.js";
8
8
  import { FakeRelayNoise } from "./relaynoise.js";
9
9
  import { Cpu6502, AtomCpu6502 } from "./6502.js";
@@ -30,7 +30,7 @@ export function fake6502(model, opts) {
30
30
  cmos: new Cmos(),
31
31
  cycleAccurate: opts.cycleAccurate,
32
32
  config: {
33
- tube: opts.tube ? TubeModel : null,
33
+ tube: opts.tube ? tubeModelFor(model) : null,
34
34
  tubeCpuMultiplier: opts.tubeCpuMultiplier,
35
35
  cpuMultiplier: opts.cpuMultiplier,
36
36
  hasTeletextAdaptor: opts.hasTeletextAdaptor,
package/src/gamepads.js CHANGED
@@ -39,16 +39,21 @@ export class GamePad {
39
39
  this.gamepadAxisMapping[3][-1] = BBC.COLON_STAR; // up
40
40
  this.gamepadAxisMapping[3][1] = BBC.SLASH; // down
41
41
  */
42
+ /**
43
+ * Maps a gamepad button or stick direction to a BBC key.
44
+ * @param {string} gamepadKey - the gamepad control, eg `FIRE2`
45
+ * @param {string} bbcKey - the BBC key to press, eg `RETURN`
46
+ * @returns {?string} a description of the problem, or null if the mapping was applied
47
+ */
42
48
  remap(gamepadKey, bbcKey) {
43
49
  // convert "1" into "K1"
44
- if ("0123456789".indexOf(bbcKey) > 0) {
50
+ if (bbcKey.length === 1 && bbcKey >= "0" && bbcKey <= "9") {
45
51
  bbcKey = "K" + bbcKey;
46
52
  }
47
53
 
48
54
  const mappedBbcKey = BBC[bbcKey];
49
55
  if (!mappedBbcKey) {
50
- console.log("unknown BBC key: " + bbcKey);
51
- return;
56
+ return `unknown BBC key "${bbcKey}".`;
52
57
  }
53
58
 
54
59
  switch (gamepadKey) {
@@ -152,8 +157,10 @@ export class GamePad {
152
157
  this.gamepadMapping[6] = mappedBbcKey;
153
158
  break;
154
159
  default:
155
- console.log("unknown gamepad key: " + gamepadKey);
160
+ return `unknown gamepad control "${gamepadKey}".`;
156
161
  }
162
+
163
+ return null;
157
164
  }
158
165
 
159
166
  update(sysvia) {
package/src/jsbeeb.css CHANGED
@@ -441,6 +441,132 @@ small {
441
441
  pointer-events: none;
442
442
  }
443
443
 
444
+ #disc-panel {
445
+ position: fixed;
446
+ top: 56px;
447
+ right: 8px;
448
+ width: min(420px, 46vw);
449
+ max-height: calc(100vh - 104px);
450
+ overflow-y: auto;
451
+ background: rgba(0, 0, 0, 0.9);
452
+ border: 1px solid #555;
453
+ border-radius: 4px;
454
+ z-index: 10;
455
+ padding: 8px;
456
+ }
457
+
458
+ .disc-header {
459
+ display: flex;
460
+ align-items: baseline;
461
+ gap: 8px;
462
+ margin-bottom: 8px;
463
+ cursor: move;
464
+ user-select: none;
465
+ /* Claim touch gestures, or a drag scrolls the page instead. */
466
+ touch-action: none;
467
+ }
468
+
469
+ .disc-controls {
470
+ display: flex;
471
+ flex-wrap: wrap;
472
+ gap: 6px;
473
+ margin-bottom: 8px;
474
+ }
475
+
476
+ .disc-header button {
477
+ cursor: pointer;
478
+ }
479
+
480
+ .disc-title {
481
+ color: #ccc;
482
+ font-size: 12px;
483
+ font-weight: bold;
484
+ flex: none;
485
+ }
486
+
487
+ /* The only item in the header with no bound on its width, so it is the one that yields. */
488
+ #disc-name {
489
+ flex: 1;
490
+ min-width: 0;
491
+ overflow: hidden;
492
+ text-overflow: ellipsis;
493
+ white-space: nowrap;
494
+ color: #888;
495
+ font-family: consolas, monospace;
496
+ font-size: 11px;
497
+ }
498
+
499
+ #disc-close {
500
+ flex: none;
501
+ margin-left: auto;
502
+ }
503
+
504
+ .disc-stack {
505
+ position: relative;
506
+ }
507
+
508
+ .disc-stack canvas {
509
+ display: block;
510
+ width: 100%;
511
+ aspect-ratio: 1;
512
+ }
513
+
514
+ #disc-overlay {
515
+ position: absolute;
516
+ inset: 0;
517
+ cursor: crosshair;
518
+ /* Claim wheel and drag gestures, or the page scrolls instead. */
519
+ touch-action: none;
520
+ }
521
+
522
+ .disc-legend {
523
+ display: flex;
524
+ align-items: center;
525
+ flex-wrap: wrap;
526
+ gap: 4px 8px;
527
+ margin-top: 8px;
528
+ color: #aaa;
529
+ font-size: 10px;
530
+ }
531
+
532
+ .disc-legend-item {
533
+ display: inline-flex;
534
+ align-items: center;
535
+ gap: 4px;
536
+ white-space: nowrap;
537
+ }
538
+
539
+ .disc-legend-grow {
540
+ flex: 1;
541
+ min-width: 150px;
542
+ }
543
+
544
+ .disc-legend-ramp {
545
+ flex: 1;
546
+ min-width: 40px;
547
+ height: 8px;
548
+ border-radius: 2px;
549
+ }
550
+
551
+ .disc-legend-swatch {
552
+ width: 12px;
553
+ height: 8px;
554
+ border-radius: 2px;
555
+ flex: none;
556
+ }
557
+
558
+ .disc-status {
559
+ margin-top: 4px;
560
+ color: #ccc;
561
+ font-family: consolas, monospace;
562
+ font-size: 11px;
563
+ /* Rewritten every frame, so the height must not depend on the content. */
564
+ min-height: 14px;
565
+ overflow: hidden;
566
+ text-overflow: ellipsis;
567
+ white-space: nowrap;
568
+ }
569
+
444
570
  div.smoothie-chart-tooltip {
445
571
  background: #444;
446
572
  padding: 1em;