@7365admin1/layer-common 3.2.1-staging.63 → 3.2.1-staging.65

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.
@@ -13,16 +13,49 @@
13
13
  The picture, when there is one to show. This is an embed of the video
14
14
  service's own player page: the stored address IS the live view, and there
15
15
  is no other transport for live web video today.
16
+
17
+ The STAGE around it is what zooms: the transform is on our own element,
18
+ so magnifying the picture needs nothing from inside that page and reaches
19
+ into no other origin.
20
+ -->
21
+ <div
22
+ v-if="camera && view.showPlayer"
23
+ class="vms-tile__stage"
24
+ :style="stageStyle"
25
+ >
26
+ <iframe
27
+ ref="frameEl"
28
+ :key="camera.host"
29
+ :src="camera.host"
30
+ class="vms-tile__frame"
31
+ :title="`Live view of ${camera.name || 'camera'}`"
32
+ allowfullscreen
33
+ referrerpolicy="no-referrer"
34
+ @load="onPlayerLoad"
35
+ @error="onPlayerError"
36
+ />
37
+ </div>
38
+
39
+ <!--
40
+ THE LAYER THAT MAKES THE TILE A TILE AGAIN.
41
+
42
+ An iframe swallows every pointer event that lands on it, so before this
43
+ existed a click on a tile that was actually showing a picture never
44
+ reached us: selecting a camera only worked on the tiles with no video in
45
+ them, which is the opposite of useful. This transparent layer sits over
46
+ the picture so a click selects, a double-click goes full screen, and —
47
+ when the server says this camera supports it — a wheel zooms and a drag
48
+ pans. The player page underneath has no controls of its own to lose.
16
49
  -->
17
- <iframe
50
+ <div
18
51
  v-if="camera && view.showPlayer"
19
- :key="camera.host"
20
- :src="camera.host"
21
- class="vms-tile__frame"
22
- :title="`Live view of ${camera.name || 'camera'}`"
23
- allowfullscreen
24
- referrerpolicy="no-referrer"
25
- @load="onPlayerLoad"
52
+ class="vms-tile__grab"
53
+ :class="{ 'vms-tile__grab--zoomed': zoom > 1 }"
54
+ @wheel="onWheel"
55
+ @pointerdown="onPointerDown"
56
+ @pointermove="onPointerMove"
57
+ @pointerup="onPointerEnd"
58
+ @pointercancel="onPointerEnd"
26
59
  />
27
60
 
28
61
  <!--
@@ -35,20 +68,60 @@
35
68
  </div>
36
69
 
37
70
  <!-- Chrome sits OVER the picture, never around it. -->
38
- <div v-if="camera" class="vms-tile__status">
71
+ <!--
72
+ THE STATUS WORD IS NEVER DROPPED, AT ANY LAYOUT.
73
+
74
+ It used to be: at nine tiles the badge became a bare 7 px dot, on the
75
+ argument that the word was unreadable at wall-scanning distance. That is
76
+ a phone's argument. Nine tiles on a desktop wall is the layout a
77
+ supervisor watches from furthest away and the one where a dying camera is
78
+ most likely to go unnoticed, so it is the last place to remove the only
79
+ words that say whether a camera is alive. The type shrinks; the fact
80
+ stays.
81
+ -->
82
+ <div v-if="camera" class="vms-tile__status" :title="view.note || undefined">
39
83
  <span class="vms-tile__dot" :class="`vms-tile__dot--${view.state}`" />
40
- <!--
41
- At nine tiles the badge is the DOT ALONE: a status word beside a 6 px
42
- dot on a small tile is unreadable at the distance a wall is scanned
43
- from, and the dot is what is being read anyway. The word returns at
44
- four tiles and one.
45
- -->
46
- <span v-if="!dense" class="vms-tile__state">{{ view.label }}</span>
84
+ <span class="vms-tile__state">{{ view.label }}</span>
47
85
  </div>
48
86
 
49
87
  <div v-if="camera" class="vms-tile__chrome">
50
88
  <!-- The NAME wins the fight for room: it is the tile's entire job. -->
51
89
  <span class="vms-tile__name">{{ camera.name || camera._id }}</span>
90
+ <!--
91
+ RULE 6: the RECORDER's state, kept apart from the badge above, which is
92
+ about the player page. At nine tiles it keeps its phrase and loses only
93
+ the timestamp — a dropped recorder is not a detail to hide on the
94
+ busiest layout.
95
+ -->
96
+ <span
97
+ v-if="healthLine"
98
+ class="vms-tile__health"
99
+ :title="healthTitle"
100
+ >
101
+ <span class="vms-tile__dot" :class="`vms-tile__dot--h-${recorder.tone}`" />
102
+ {{ healthLine }}
103
+ </span>
104
+
105
+ <!--
106
+ THE ZOOM READOUT AND THE WAY BACK.
107
+
108
+ Only drawn once the picture is actually magnified. A supervisor who has
109
+ zoomed into a corner needs one obvious control out, and needs to be
110
+ able to tell a magnified picture from a camera that has been moved —
111
+ hence the "2.0x" reading rather than a bare arrow. In the same row as
112
+ the name, so it can never sit on top of it.
113
+ -->
114
+ <span v-if="zoom > 1" class="vms-tile__zoom">
115
+ <span class="vms-tile__zoom-level">{{ zoom.toFixed(1) }}x</span>
116
+ <button
117
+ type="button"
118
+ class="vms-tile__zoom-reset"
119
+ title="Back to the whole picture"
120
+ @click.stop="resetZoom"
121
+ >
122
+ Reset
123
+ </button>
124
+ </span>
52
125
  </div>
53
126
 
54
127
  <button
@@ -73,13 +146,22 @@
73
146
  </template>
74
147
 
75
148
  <script setup lang="ts">
76
- import { computed, onBeforeUnmount, ref, watch } from "vue";
149
+ import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
77
150
 
78
151
  import {
152
+ capabilityView,
153
+ clampPan,
154
+ clampZoom,
155
+ healthView,
79
156
  LIVE_CONNECT_TIMEOUT_MS,
157
+ LIVE_FRAME_TIMEOUT_MS,
158
+ playerOrigin,
159
+ playerSignal,
80
160
  tileView,
161
+ zoomStep,
81
162
  type TPlayerState,
82
163
  type TWallCamera,
164
+ type TWallCameraHealth,
83
165
  } from "../utils/camera-wall";
84
166
 
85
167
  /**
@@ -89,16 +171,26 @@ import {
89
171
  * left here is the picture, the chrome over it, and the one thing only a
90
172
  * browser can know — whether the player page ever loaded.
91
173
  *
92
- * ## The ceiling worth knowing before "improving" the live badge
174
+ * ## The ceiling, and what the badge is now allowed to claim
93
175
  *
94
176
  * We can tell when the player PAGE loads. We cannot tell when a FRAME decodes:
95
- * the page belongs to the video service, not to us, and learning more would
96
- * mean reaching into another origin's document — which is both blocked by the
97
- * browser and would break silently the day that page changes. So **"Live" means
98
- * the player page is up**. If the service is up and the camera behind it is
99
- * dead, the operator sees that service's own empty player, exactly as they do
100
- * in the app they use today. Closing that honestly is a change on the video
101
- * service's side, not here.
177
+ * the page belongs to the video service, not to us, and reaching into another
178
+ * origin's document is blocked by the browser — correctly.
179
+ *
180
+ * That ceiling has not moved. What has changed is that the badge no longer
181
+ * pretends otherwise. A loaded page earns **"Not verified"**, not "Live"; only
182
+ * a frame the video service itself reports over `postMessage` earns "Live".
183
+ * The service sends nothing today, so every tile on this estate sits at "Not
184
+ * verified" — which is the true answer. It went in because on the live wall two
185
+ * cameras rendered a blank rectangle under a green "Live" badge, which is the
186
+ * one thing a monitoring screen must never do.
187
+ *
188
+ * The listener below is inert until the video service emits. The change needed
189
+ * there is small — jsmpeg already calls back on every decoded frame, so
190
+ * `parent.postMessage({ event: "frame" }, "*")` from its `onVideoDecode` and
191
+ * `{ event: "stalled" }` from `onStalled` is the whole of it — but it lives in
192
+ * another repository and another team's deployment, and is written up in the
193
+ * PR rather than done here.
102
194
  */
103
195
 
104
196
  const props = withDefaults(
@@ -109,23 +201,208 @@ const props = withDefaults(
109
201
  /** Browser connectivity. One dead tile and a dead browser look identical otherwise. */
110
202
  online?: boolean;
111
203
  focused?: boolean;
204
+ /** The recorder's own state, from the health sweep. Separate fact — rule 6. */
205
+ health?: TWallCameraHealth | null;
206
+ /** Recomputed by the wall on its own clock so "4 min ago" does not freeze. */
207
+ now?: number;
112
208
  }>(),
113
- { dense: false, online: true, focused: false }
209
+ { dense: false, online: true, focused: false, health: null, now: 0 }
114
210
  );
115
211
 
116
212
  defineEmits<{ (e: "focus", camera: TWallCamera): void }>();
117
213
 
118
214
  const root = ref<HTMLElement | null>(null);
215
+ const frameEl = ref<HTMLIFrameElement | null>(null);
119
216
  const player = ref<TPlayerState>("loading");
120
217
  let timer: ReturnType<typeof setTimeout> | null = null;
218
+ let frameTimer: ReturnType<typeof setTimeout> | null = null;
121
219
 
122
220
  const view = computed(() =>
123
221
  tileView(props.camera, { online: props.online, player: player.value })
124
222
  );
125
223
 
224
+ /* ------------------------------------------------------------------ health */
225
+
226
+ const recorder = computed(() => healthView(props.health, props.now || Date.now()));
227
+
228
+ /**
229
+ * One line, and the last-picture time only when there is one. A tile that has
230
+ * never served a picture must not read as "last picture: never" — it has its
231
+ * own reason across the tile already, and repeating it small and grey is noise.
232
+ */
233
+ const healthLine = computed(() => {
234
+ if (!props.health) return "";
235
+ const { label, lastFrame } = recorder.value;
236
+ // Nine tiles: the phrase stays, the timestamp goes. "Recorder not responding"
237
+ // is the fact; "· 4 min ago" is the detail, and detail is what a small tile
238
+ // gives up.
239
+ return lastFrame && !props.dense ? `${label} · ${lastFrame}` : label;
240
+ });
241
+
242
+ /** Everything the line had no room for, on hover. */
243
+ const healthTitle = computed(() =>
244
+ [recorder.value.detail, recorder.value.deviceDetail].filter(Boolean).join(" ")
245
+ );
246
+
247
+ /* -------------------------------------------------------------------- zoom */
248
+
249
+ /**
250
+ * DIGITAL ZOOM — armed by the SERVER, not by this component.
251
+ *
252
+ * `digitalZoom` is a capability like any other, and `unknown` counts as no: a
253
+ * browser talking to an API that predates the descriptor gets no zoom rather
254
+ * than a gesture that silently does nothing. Nothing here contacts a device —
255
+ * this crops and enlarges the picture already on the screen — but the rule is
256
+ * the rule, and honouring it is what keeps zoom and "Move camera" visibly
257
+ * different things.
258
+ */
259
+ const canZoom = computed(
260
+ () => view.value.showPlayer && capabilityView(props.camera, "digitalZoom").available
261
+ );
262
+
263
+ const zoom = ref(1);
264
+ const pan = ref({ x: 0, y: 0 });
265
+ const frame = ref({ width: 0, height: 0 });
266
+
267
+ /** Live pointers, by id: two of them is a pinch, one is a drag. */
268
+ const pointers = new Map<number, { x: number; y: number }>();
269
+ let dragFrom = { x: 0, y: 0, pan: { x: 0, y: 0 }, distance: 0, zoom: 1 };
270
+
271
+ const stageStyle = computed(() => ({
272
+ transform: `translate(${pan.value.x}px, ${pan.value.y}px) scale(${zoom.value})`,
273
+ }));
274
+
275
+ function resetZoom() {
276
+ zoom.value = 1;
277
+ pan.value = { x: 0, y: 0 };
278
+ }
279
+
280
+ function measure(el: HTMLElement | null) {
281
+ if (!el) return;
282
+ const box = el.getBoundingClientRect();
283
+ frame.value = { width: box.width, height: box.height };
284
+ }
285
+
286
+ function setZoom(next: number) {
287
+ const scale = clampZoom(next);
288
+ zoom.value = scale;
289
+ pan.value = clampPan(pan.value, scale, frame.value);
290
+ }
291
+
292
+ function onWheel(event: WheelEvent) {
293
+ if (!canZoom.value) return;
294
+ // At 1x, scrolling DOWN is somebody scrolling the page and must stay that
295
+ // way — a wall two thirds of the way down a page that traps the wheel is
296
+ // a page you cannot get past.
297
+ if (zoom.value <= 1 && event.deltaY > 0) return;
298
+
299
+ event.preventDefault();
300
+ measure(event.currentTarget as HTMLElement);
301
+ setZoom(zoomStep(zoom.value, event.deltaY));
302
+ }
303
+
304
+ function onPointerDown(event: PointerEvent) {
305
+ if (!canZoom.value) return;
306
+ const el = event.currentTarget as HTMLElement;
307
+ pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
308
+ measure(el);
309
+
310
+ const two = [...pointers.values()];
311
+ dragFrom = {
312
+ x: event.clientX,
313
+ y: event.clientY,
314
+ pan: { ...pan.value },
315
+ distance: two.length >= 2 ? Math.hypot(two[0].x - two[1].x, two[0].y - two[1].y) : 0,
316
+ zoom: zoom.value,
317
+ };
318
+
319
+ // Only capture once there is something to drag. At 1x the tile must keep
320
+ // behaving like a button: click selects, double-click goes full screen.
321
+ if (zoom.value > 1 || pointers.size >= 2) el.setPointerCapture?.(event.pointerId);
322
+ }
323
+
324
+ function onPointerMove(event: PointerEvent) {
325
+ if (!canZoom.value || !pointers.has(event.pointerId)) return;
326
+ pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
327
+
328
+ const two = [...pointers.values()];
329
+ if (two.length >= 2) {
330
+ // Pinch. The starting distance is re-anchored on every release, because a
331
+ // second finger lifting mid-pinch otherwise leaves a stale one and the
332
+ // next move jumps.
333
+ const distance = Math.hypot(two[0].x - two[1].x, two[0].y - two[1].y);
334
+ if (dragFrom.distance > 0) setZoom((dragFrom.zoom * distance) / dragFrom.distance);
335
+ return;
336
+ }
337
+
338
+ if (zoom.value <= 1) return;
339
+ event.preventDefault();
340
+ pan.value = clampPan(
341
+ {
342
+ x: dragFrom.pan.x + (event.clientX - dragFrom.x),
343
+ y: dragFrom.pan.y + (event.clientY - dragFrom.y),
344
+ },
345
+ zoom.value,
346
+ frame.value
347
+ );
348
+ }
349
+
350
+ function onPointerEnd(event: PointerEvent) {
351
+ pointers.delete(event.pointerId);
352
+ dragFrom = { ...dragFrom, distance: 0, zoom: zoom.value, pan: { ...pan.value } };
353
+ }
354
+
355
+ /**
356
+ * A tile reused for a different camera must not inherit the previous one's
357
+ * magnification, and a camera that stops supporting zoom must not stay stuck
358
+ * in a crop nobody can now reset.
359
+ */
360
+ watch([() => props.camera?._id, canZoom], () => resetZoom());
361
+
362
+ /**
363
+ * The document arrived. That is ALL this proves — the video service answers
364
+ * with the same player page for a channel that has no camera behind it — so it
365
+ * buys `page-up` and no more. Only `onPlayerMessage` can reach "Live".
366
+ */
126
367
  function onPlayerLoad() {
127
- player.value = "ready";
128
368
  clearTimer();
369
+ if (player.value !== "playing") player.value = "page-up";
370
+ }
371
+
372
+ /** Rare for a cross-origin frame, but free, and unambiguous when it happens. */
373
+ function onPlayerError() {
374
+ clearTimer();
375
+ player.value = "failed";
376
+ }
377
+
378
+ /**
379
+ * THE ONLY ROUTE TO A GREEN BADGE.
380
+ *
381
+ * Two checks before a message is believed, and both matter: the message must
382
+ * come from the window we embedded (any page can `postMessage` this one), and
383
+ * it must come from the camera's own origin. An unrecognised payload returns
384
+ * `null` and leaves the tile exactly where it was — a message we do not
385
+ * understand must never be able to turn a badge green.
386
+ */
387
+ function onPlayerMessage(event: MessageEvent) {
388
+ const origin = playerOrigin(props.camera?.host);
389
+ if (!origin || event.origin !== origin) return;
390
+ if (frameEl.value && event.source !== frameEl.value.contentWindow) return;
391
+
392
+ const signal = playerSignal(event.data);
393
+ if (!signal) return;
394
+
395
+ clearTimer();
396
+ if (signal === "playing") {
397
+ player.value = "playing";
398
+ // A picture that arrived once and stopped must not stay green. This is the
399
+ // mobile app's "Live over a nine-second-old still" defect, and the clock is
400
+ // the only thing standing between us and repeating it.
401
+ restartFrameClock();
402
+ } else {
403
+ clearFrameTimer();
404
+ player.value = signal === "stalled" ? "stalled" : "failed";
405
+ }
129
406
  }
130
407
 
131
408
  function clearTimer() {
@@ -135,25 +412,51 @@ function clearTimer() {
135
412
  }
136
413
  }
137
414
 
415
+ function clearFrameTimer() {
416
+ if (frameTimer) {
417
+ clearTimeout(frameTimer);
418
+ frameTimer = null;
419
+ }
420
+ }
421
+
422
+ function restartFrameClock() {
423
+ clearFrameTimer();
424
+ frameTimer = setTimeout(() => {
425
+ if (player.value === "playing") player.value = "stalled";
426
+ }, LIVE_FRAME_TIMEOUT_MS);
427
+ }
428
+
138
429
  /**
139
430
  * Restart the clock whenever the address changes — a tile reused for a
140
431
  * different camera must not inherit the previous one's verdict, in either
141
432
  * direction.
433
+ *
434
+ * The timeout only fires while nothing at all has been heard: a page that
435
+ * loaded is already at `page-up` and stays there. Sitting on "Not verified"
436
+ * indefinitely is deliberate — declaring a camera dead on a timer, with no
437
+ * evidence, is the same mistake as declaring it live on a timer.
142
438
  */
143
439
  watch(
144
440
  () => props.camera?.host,
145
441
  (host) => {
146
442
  clearTimer();
443
+ clearFrameTimer();
147
444
  player.value = "loading";
148
445
  if (!host) return;
149
446
  timer = setTimeout(() => {
150
- if (player.value === "loading") player.value = "timeout";
447
+ if (player.value === "loading") player.value = "failed";
151
448
  }, LIVE_CONNECT_TIMEOUT_MS);
152
449
  },
153
450
  { immediate: true }
154
451
  );
155
452
 
156
- onBeforeUnmount(clearTimer);
453
+ onMounted(() => window.addEventListener("message", onPlayerMessage));
454
+
455
+ onBeforeUnmount(() => {
456
+ window.removeEventListener("message", onPlayerMessage);
457
+ clearTimer();
458
+ clearFrameTimer();
459
+ });
157
460
 
158
461
  /** The browser's own fullscreen. Nothing to build, and it survives the F11 key. */
159
462
  async function toggleFullscreen() {
@@ -193,6 +496,18 @@ async function toggleFullscreen() {
193
496
  border: 1px dashed var(--vms-line, #232b36);
194
497
  }
195
498
 
499
+ /*
500
+ The stage is what the transform is applied to; the tile clips it. No
501
+ transition: a supervisor dragging a magnified picture expects it under their
502
+ pointer, and 150 ms of easing on every move reads as lag, not polish.
503
+ */
504
+ .vms-tile__stage {
505
+ position: absolute;
506
+ inset: 0;
507
+ transform-origin: center center;
508
+ will-change: transform;
509
+ }
510
+
196
511
  .vms-tile__frame {
197
512
  width: 100%;
198
513
  height: 100%;
@@ -201,6 +516,25 @@ async function toggleFullscreen() {
201
516
  background: #000;
202
517
  }
203
518
 
519
+ /*
520
+ Transparent, and over the picture. It is what gives the tile its clicks back
521
+ from the iframe; `touch-action: none` is what lets a pinch be a pinch rather
522
+ than the browser zooming the whole page.
523
+ */
524
+ .vms-tile__grab {
525
+ position: absolute;
526
+ inset: 0;
527
+ touch-action: none;
528
+ }
529
+
530
+ .vms-tile__grab--zoomed {
531
+ cursor: grab;
532
+ }
533
+
534
+ .vms-tile__grab--zoomed:active {
535
+ cursor: grabbing;
536
+ }
537
+
204
538
  .vms-tile__overlay {
205
539
  position: absolute;
206
540
  inset: 0;
@@ -254,6 +588,17 @@ async function toggleFullscreen() {
254
588
  .vms-tile__dot--connecting {
255
589
  background: #fb8c00;
256
590
  }
591
+ /*
592
+ NOT VERIFIED is deliberately not amber and deliberately not green. Amber is
593
+ "something is wrong", and nothing is known to be wrong; green is the claim
594
+ being withdrawn. A hollow ring reads as "no answer here", which is exactly
595
+ the fact. #7a8697 measures 5.0:1 on a tile — a dot nobody can see is a state
596
+ nobody knows about.
597
+ */
598
+ .vms-tile__dot--unverified {
599
+ background: transparent;
600
+ box-shadow: inset 0 0 0 2px #7a8697;
601
+ }
257
602
  .vms-tile__dot--no-signal,
258
603
  .vms-tile__dot--offline {
259
604
  background: #e0241c;
@@ -269,15 +614,119 @@ async function toggleFullscreen() {
269
614
  white-space: nowrap;
270
615
  }
271
616
 
617
+ /* Nine tiles: smaller and tighter, still a word. */
618
+ .vms-tile--dense .vms-tile__status {
619
+ top: 4px;
620
+ right: 4px;
621
+ gap: 4px;
622
+ padding: 1px 5px;
623
+ }
624
+
625
+ .vms-tile--dense .vms-tile__state {
626
+ font-size: 10px;
627
+ line-height: 13px;
628
+ }
629
+
630
+ /*
631
+ `wrap` is what keeps the promise that the name and the recorder line both
632
+ survive a narrow tile: on a cramped nine-tile wall the health phrase drops to
633
+ its own line instead of squeezing the name into an ellipsis.
634
+ */
272
635
  .vms-tile__chrome {
273
636
  position: absolute;
274
637
  left: 6px;
275
638
  right: 6px;
276
639
  bottom: 5px;
277
640
  display: flex;
641
+ align-items: center;
642
+ flex-wrap: wrap;
643
+ gap: 3px 6px;
278
644
  pointer-events: none;
279
645
  }
280
646
 
647
+ .vms-tile--dense .vms-tile__chrome {
648
+ left: 4px;
649
+ right: 4px;
650
+ bottom: 4px;
651
+ }
652
+
653
+ /*
654
+ The RECORDER's state, and deliberately quieter than the name and the badge:
655
+ it is a second-order fact a supervisor reads when something looks wrong, not
656
+ something that should compete with the picture every second it is fine.
657
+ */
658
+ .vms-tile__health {
659
+ display: inline-flex;
660
+ align-items: center;
661
+ gap: 5px;
662
+ flex: 0 1 auto;
663
+ min-width: 0;
664
+ padding: 1px 6px;
665
+ border-radius: var(--vms-radius, 3px);
666
+ background: var(--vms-scrim, rgba(7, 9, 12, 0.62));
667
+ color: var(--vms-text-dim, #94a0b0);
668
+ font-size: 11px;
669
+ line-height: 15px;
670
+ white-space: nowrap;
671
+ overflow: hidden;
672
+ text-overflow: ellipsis;
673
+ }
674
+
675
+ .vms-tile--dense .vms-tile__health {
676
+ font-size: 10px;
677
+ line-height: 14px;
678
+ }
679
+
680
+ .vms-tile__dot--h-ok {
681
+ background: #4caf50;
682
+ }
683
+ .vms-tile__dot--h-warn {
684
+ background: #fb8c00;
685
+ }
686
+ .vms-tile__dot--h-down {
687
+ background: #e0241c;
688
+ }
689
+ .vms-tile__dot--h-unknown {
690
+ background: var(--vms-text-faint, #5c6675);
691
+ }
692
+
693
+ /* --------------------------------------------------------------- the zoom */
694
+
695
+ .vms-tile__zoom {
696
+ display: inline-flex;
697
+ align-items: center;
698
+ gap: 6px;
699
+ /* Pushed to the far end of the chrome row, opposite the name. */
700
+ margin-left: auto;
701
+ flex: none;
702
+ padding: 1px 6px;
703
+ border-radius: var(--vms-radius, 3px);
704
+ background: var(--vms-scrim, rgba(7, 9, 12, 0.62));
705
+ font-size: 11px;
706
+ line-height: 15px;
707
+ /* The row is click-through; this one control is not. */
708
+ pointer-events: auto;
709
+ }
710
+
711
+ .vms-tile__zoom-level {
712
+ color: var(--vms-text, #e8ecf1);
713
+ font-variant-numeric: tabular-nums;
714
+ }
715
+
716
+ .vms-tile__zoom-reset {
717
+ background: none;
718
+ border: none;
719
+ padding: 2px 2px;
720
+ color: var(--vms-accent, #5b9be0);
721
+ font-size: 11px;
722
+ line-height: 15px;
723
+ cursor: pointer;
724
+ }
725
+
726
+ .vms-tile__zoom-reset:focus-visible {
727
+ outline: 2px solid var(--vms-accent, #5b9be0);
728
+ }
729
+
281
730
  .vms-tile__name {
282
731
  color: var(--vms-text, #e8ecf1);
283
732
  font-size: 12px;