@7365admin1/layer-common 3.2.4 → 3.2.6

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.
@@ -0,0 +1,818 @@
1
+ <template>
2
+ <div
3
+ ref="root"
4
+ class="vms-tile"
5
+ :class="{ 'vms-tile--dense': dense, 'vms-tile--focused': focused, 'vms-tile--empty': !camera }"
6
+ :tabindex="camera ? 0 : -1"
7
+ :aria-label="camera ? `${camera.name || camera._id}, ${view.label}` : 'Empty position'"
8
+ :aria-current="focused ? 'true' : undefined"
9
+ @click="camera && $emit('focus', camera)"
10
+ @dblclick="camera && toggleFullscreen()"
11
+ @keydown.enter="camera && toggleFullscreen()"
12
+ >
13
+ <!--
14
+ The picture, when there is one to show. This is an embed of the video
15
+ service's own player page: the stored address IS the live view, and there
16
+ is no other transport for live web video today.
17
+
18
+ The STAGE around it is what zooms: the transform is on our own element,
19
+ so magnifying the picture needs nothing from inside that page and reaches
20
+ into no other origin.
21
+ -->
22
+ <div
23
+ v-if="camera && view.showPlayer"
24
+ class="vms-tile__stage"
25
+ :style="stageStyle"
26
+ >
27
+ <iframe
28
+ ref="frameEl"
29
+ :key="camera.host"
30
+ :src="camera.host"
31
+ class="vms-tile__frame"
32
+ :title="`Live view of ${camera.name || 'camera'}`"
33
+ allowfullscreen
34
+ referrerpolicy="no-referrer"
35
+ @load="onPlayerLoad"
36
+ @error="onPlayerError"
37
+ />
38
+ </div>
39
+
40
+ <!--
41
+ THE LAYER THAT MAKES THE TILE A TILE AGAIN.
42
+
43
+ An iframe swallows every pointer event that lands on it, so before this
44
+ existed a click on a tile that was actually showing a picture never
45
+ reached us: selecting a camera only worked on the tiles with no video in
46
+ them, which is the opposite of useful. This transparent layer sits over
47
+ the picture so a click selects, a double-click goes full screen, and —
48
+ when the server says this camera supports it — a wheel zooms and a drag
49
+ pans. The player page underneath has no controls of its own to lose.
50
+ -->
51
+ <div
52
+ v-if="camera && view.showPlayer"
53
+ class="vms-tile__grab"
54
+ :class="{ 'vms-tile__grab--zoomed': zoom > 1 }"
55
+ @wheel="onWheel"
56
+ @pointerdown="onPointerDown"
57
+ @pointermove="onPointerMove"
58
+ @pointerup="onPointerEnd"
59
+ @pointercancel="onPointerEnd"
60
+ />
61
+
62
+ <!--
63
+ A tile with nothing to show says so across the whole tile, not in a
64
+ corner. At nine tiles a supervisor is scanning, and a one-line reason
65
+ tucked under a black rectangle is exactly the thing that gets missed.
66
+ -->
67
+ <div v-if="view.detail" class="vms-tile__overlay">
68
+ <p class="vms-tile__reason">{{ view.detail }}</p>
69
+ </div>
70
+
71
+ <!-- Chrome sits OVER the picture, never around it. -->
72
+ <!--
73
+ THE STATUS WORD IS NEVER DROPPED, AT ANY LAYOUT.
74
+
75
+ It used to be: at nine tiles the badge became a bare 7 px dot, on the
76
+ argument that the word was unreadable at wall-scanning distance. That is
77
+ a phone's argument. Nine tiles on a desktop wall is the layout a
78
+ supervisor watches from furthest away and the one where a dying camera is
79
+ most likely to go unnoticed, so it is the last place to remove the only
80
+ words that say whether a camera is alive. The type shrinks; the fact
81
+ stays.
82
+ -->
83
+ <div v-if="camera" class="vms-tile__status" :title="view.note || undefined">
84
+ <span class="vms-tile__dot" :class="`vms-tile__dot--${view.state}`" />
85
+ <span class="vms-tile__state">{{ view.label }}</span>
86
+ </div>
87
+
88
+ <div v-if="camera" class="vms-tile__chrome">
89
+ <!-- The NAME wins the fight for room: it is the tile's entire job. -->
90
+ <span class="vms-tile__name">{{ camera.name || camera._id }}</span>
91
+ <!--
92
+ RULE 6: the RECORDER's state, kept apart from the badge above, which is
93
+ about the player page. At nine tiles it keeps its phrase and loses only
94
+ the timestamp — a dropped recorder is not a detail to hide on the
95
+ busiest layout.
96
+ -->
97
+ <span
98
+ v-if="healthLine"
99
+ class="vms-tile__health"
100
+ :title="healthTitle"
101
+ >
102
+ <span class="vms-tile__dot" :class="`vms-tile__dot--h-${recorder.tone}`" />
103
+ {{ healthLine }}
104
+ </span>
105
+
106
+ <!--
107
+ THE ZOOM READOUT AND THE WAY BACK.
108
+
109
+ Only drawn once the picture is actually magnified. A supervisor who has
110
+ zoomed into a corner needs one obvious control out, and needs to be
111
+ able to tell a magnified picture from a camera that has been moved —
112
+ hence the "2.0x" reading rather than a bare arrow. In the same row as
113
+ the name, so it can never sit on top of it.
114
+ -->
115
+ <span v-if="zoom > 1" class="vms-tile__zoom">
116
+ <span class="vms-tile__zoom-level">{{ zoom.toFixed(1) }}x</span>
117
+ <button
118
+ type="button"
119
+ class="vms-tile__zoom-reset"
120
+ title="Back to the whole picture"
121
+ @click.stop="resetZoom"
122
+ >
123
+ Reset
124
+ </button>
125
+ </span>
126
+ </div>
127
+
128
+ <button
129
+ v-if="camera"
130
+ type="button"
131
+ class="vms-tile__expand"
132
+ title="Fill the screen with this camera"
133
+ aria-label="Fill the screen with this camera"
134
+ @click.stop="toggleFullscreen()"
135
+ >
136
+ <svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
137
+ <path
138
+ d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"
139
+ fill="none"
140
+ stroke="currentColor"
141
+ stroke-width="2"
142
+ stroke-linecap="square"
143
+ />
144
+ </svg>
145
+ </button>
146
+ </div>
147
+ </template>
148
+
149
+ <script setup lang="ts">
150
+ import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
151
+
152
+ import {
153
+ capabilityView,
154
+ clampPan,
155
+ clampZoom,
156
+ healthView,
157
+ LIVE_CONNECT_TIMEOUT_MS,
158
+ LIVE_FRAME_TIMEOUT_MS,
159
+ playerOrigin,
160
+ playerSignal,
161
+ tileView,
162
+ zoomStep,
163
+ type TPlayerState,
164
+ type TWallCamera,
165
+ type TWallCameraHealth,
166
+ } from "../utils/camera-wall";
167
+
168
+ /**
169
+ * ONE TILE OF THE CAMERA WALL.
170
+ *
171
+ * Everything this component decides comes from `utils/camera-wall.ts`; what is
172
+ * left here is the picture, the chrome over it, and the one thing only a
173
+ * browser can know — whether the player page ever loaded.
174
+ *
175
+ * ## The ceiling, and what the badge is now allowed to claim
176
+ *
177
+ * We can tell when the player PAGE loads. We cannot tell when a FRAME decodes:
178
+ * the page belongs to the video service, not to us, and reaching into another
179
+ * origin's document is blocked by the browser — correctly.
180
+ *
181
+ * That ceiling has not moved. What has changed is that the badge no longer
182
+ * pretends otherwise. A loaded page earns **"Not confirmed"**, not "Live"; only
183
+ * a frame the video service itself reports over `postMessage` earns "Live".
184
+ * The service sends nothing today, so every tile on this estate sits at "Not
185
+ * confirmed" — which is the true answer. It went in because on the live wall two
186
+ * cameras rendered a blank rectangle under a green "Live" badge, which is the
187
+ * one thing a monitoring screen must never do.
188
+ *
189
+ * The listener below is inert until the video service emits. The change needed
190
+ * there is small — jsmpeg already calls back on every decoded frame, so
191
+ * `parent.postMessage({ event: "frame" }, "*")` from its `onVideoDecode` and
192
+ * `{ event: "stalled" }` from `onStalled` is the whole of it — but it lives in
193
+ * another repository and another team's deployment, and is written up in the
194
+ * PR rather than done here.
195
+ */
196
+
197
+ const props = withDefaults(
198
+ defineProps<{
199
+ camera: TWallCamera | null;
200
+ /** Nine tiles: drop the status word, keep the dot. */
201
+ dense?: boolean;
202
+ /** Browser connectivity. One dead tile and a dead browser look identical otherwise. */
203
+ online?: boolean;
204
+ focused?: boolean;
205
+ /** The recorder's own state, from the health sweep. Separate fact — rule 6. */
206
+ health?: TWallCameraHealth | null;
207
+ /** Recomputed by the wall on its own clock so "4 min ago" does not freeze. */
208
+ now?: number;
209
+ }>(),
210
+ { dense: false, online: true, focused: false, health: null, now: 0 }
211
+ );
212
+
213
+ defineEmits<{ (e: "focus", camera: TWallCamera): void }>();
214
+
215
+ const root = ref<HTMLElement | null>(null);
216
+ const frameEl = ref<HTMLIFrameElement | null>(null);
217
+ const player = ref<TPlayerState>("loading");
218
+ let timer: ReturnType<typeof setTimeout> | null = null;
219
+ let frameTimer: ReturnType<typeof setTimeout> | null = null;
220
+
221
+ const view = computed(() =>
222
+ tileView(props.camera, { online: props.online, player: player.value })
223
+ );
224
+
225
+ /* ------------------------------------------------------------------ health */
226
+
227
+ const recorder = computed(() => healthView(props.health, props.now || Date.now()));
228
+
229
+ /**
230
+ * One line, and the last-picture time only when there is one. A tile that has
231
+ * never served a picture must not read as "last picture: never" — it has its
232
+ * own reason across the tile already, and repeating it small and grey is noise.
233
+ */
234
+ const healthLine = computed(() => {
235
+ if (!props.health) return "";
236
+ const { label, lastFrame } = recorder.value;
237
+ // Nine tiles: the phrase stays, the timestamp goes. "Recorder not responding"
238
+ // is the fact; "· 4 min ago" is the detail, and detail is what a small tile
239
+ // gives up.
240
+ return lastFrame && !props.dense ? `${label} · ${lastFrame}` : label;
241
+ });
242
+
243
+ /**
244
+ * Everything the line had no room for, on hover. The firmware-and-clock
245
+ * sentence used to be appended here too; it is gone for the same reason it is
246
+ * gone from the panel — this screen never showed either value, so explaining
247
+ * their absence gave the reader nothing to do. See `THealthView`.
248
+ */
249
+ const healthTitle = computed(() => recorder.value.detail || "");
250
+
251
+ /* -------------------------------------------------------------------- zoom */
252
+
253
+ /**
254
+ * DIGITAL ZOOM — armed by the SERVER, not by this component.
255
+ *
256
+ * `digitalZoom` is a capability like any other, and `unknown` counts as no: a
257
+ * browser talking to an API that predates the descriptor gets no zoom rather
258
+ * than a gesture that silently does nothing. Nothing here contacts a device —
259
+ * this crops and enlarges the picture already on the screen — but the rule is
260
+ * the rule, and honouring it is what keeps zoom and "Move camera" visibly
261
+ * different things.
262
+ */
263
+ const canZoom = computed(
264
+ () => view.value.showPlayer && capabilityView(props.camera, "digitalZoom").available
265
+ );
266
+
267
+ const zoom = ref(1);
268
+ const pan = ref({ x: 0, y: 0 });
269
+ const frame = ref({ width: 0, height: 0 });
270
+
271
+ /** Live pointers, by id: two of them is a pinch, one is a drag. */
272
+ const pointers = new Map<number, { x: number; y: number }>();
273
+ let dragFrom = { x: 0, y: 0, pan: { x: 0, y: 0 }, distance: 0, zoom: 1 };
274
+
275
+ const stageStyle = computed(() => ({
276
+ transform: `translate(${pan.value.x}px, ${pan.value.y}px) scale(${zoom.value})`,
277
+ }));
278
+
279
+ function resetZoom() {
280
+ zoom.value = 1;
281
+ pan.value = { x: 0, y: 0 };
282
+ }
283
+
284
+ function measure(el: HTMLElement | null) {
285
+ if (!el) return;
286
+ const box = el.getBoundingClientRect();
287
+ frame.value = { width: box.width, height: box.height };
288
+ }
289
+
290
+ function setZoom(next: number) {
291
+ const scale = clampZoom(next);
292
+ zoom.value = scale;
293
+ pan.value = clampPan(pan.value, scale, frame.value);
294
+ }
295
+
296
+ function onWheel(event: WheelEvent) {
297
+ if (!canZoom.value) return;
298
+ // At 1x, scrolling DOWN is somebody scrolling the page and must stay that
299
+ // way — a wall two thirds of the way down a page that traps the wheel is
300
+ // a page you cannot get past.
301
+ if (zoom.value <= 1 && event.deltaY > 0) return;
302
+
303
+ event.preventDefault();
304
+ measure(event.currentTarget as HTMLElement);
305
+ setZoom(zoomStep(zoom.value, event.deltaY));
306
+ }
307
+
308
+ function onPointerDown(event: PointerEvent) {
309
+ if (!canZoom.value) return;
310
+ const el = event.currentTarget as HTMLElement;
311
+ pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
312
+ measure(el);
313
+
314
+ const two = [...pointers.values()];
315
+ dragFrom = {
316
+ x: event.clientX,
317
+ y: event.clientY,
318
+ pan: { ...pan.value },
319
+ distance: two.length >= 2 ? Math.hypot(two[0].x - two[1].x, two[0].y - two[1].y) : 0,
320
+ zoom: zoom.value,
321
+ };
322
+
323
+ // Only capture once there is something to drag. At 1x the tile must keep
324
+ // behaving like a button: click selects, double-click goes full screen.
325
+ if (zoom.value > 1 || pointers.size >= 2) el.setPointerCapture?.(event.pointerId);
326
+ }
327
+
328
+ function onPointerMove(event: PointerEvent) {
329
+ if (!canZoom.value || !pointers.has(event.pointerId)) return;
330
+ pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
331
+
332
+ const two = [...pointers.values()];
333
+ if (two.length >= 2) {
334
+ // Pinch. The starting distance is re-anchored on every release, because a
335
+ // second finger lifting mid-pinch otherwise leaves a stale one and the
336
+ // next move jumps.
337
+ const distance = Math.hypot(two[0].x - two[1].x, two[0].y - two[1].y);
338
+ if (dragFrom.distance > 0) setZoom((dragFrom.zoom * distance) / dragFrom.distance);
339
+ return;
340
+ }
341
+
342
+ if (zoom.value <= 1) return;
343
+ event.preventDefault();
344
+ pan.value = clampPan(
345
+ {
346
+ x: dragFrom.pan.x + (event.clientX - dragFrom.x),
347
+ y: dragFrom.pan.y + (event.clientY - dragFrom.y),
348
+ },
349
+ zoom.value,
350
+ frame.value
351
+ );
352
+ }
353
+
354
+ function onPointerEnd(event: PointerEvent) {
355
+ pointers.delete(event.pointerId);
356
+ dragFrom = { ...dragFrom, distance: 0, zoom: zoom.value, pan: { ...pan.value } };
357
+ }
358
+
359
+ /**
360
+ * A tile reused for a different camera must not inherit the previous one's
361
+ * magnification, and a camera that stops supporting zoom must not stay stuck
362
+ * in a crop nobody can now reset.
363
+ */
364
+ watch([() => props.camera?._id, canZoom], () => resetZoom());
365
+
366
+ /**
367
+ * The document arrived. That is ALL this proves — the video service answers
368
+ * with the same player page for a channel that has no camera behind it — so it
369
+ * buys `page-up` and no more. Only `onPlayerMessage` can reach "Live".
370
+ */
371
+ function onPlayerLoad() {
372
+ clearTimer();
373
+ if (player.value !== "playing") player.value = "page-up";
374
+ }
375
+
376
+ /** Rare for a cross-origin frame, but free, and unambiguous when it happens. */
377
+ function onPlayerError() {
378
+ clearTimer();
379
+ player.value = "failed";
380
+ }
381
+
382
+ /**
383
+ * THE ONLY ROUTE TO A GREEN BADGE.
384
+ *
385
+ * Two checks before a message is believed, and both matter: the message must
386
+ * come from the window we embedded (any page can `postMessage` this one), and
387
+ * it must come from the camera's own origin. An unrecognised payload returns
388
+ * `null` and leaves the tile exactly where it was — a message we do not
389
+ * understand must never be able to turn a badge green.
390
+ */
391
+ function onPlayerMessage(event: MessageEvent) {
392
+ const origin = playerOrigin(props.camera?.host);
393
+ if (!origin || event.origin !== origin) return;
394
+ if (frameEl.value && event.source !== frameEl.value.contentWindow) return;
395
+
396
+ const signal = playerSignal(event.data);
397
+ if (!signal) return;
398
+
399
+ clearTimer();
400
+ if (signal === "playing") {
401
+ player.value = "playing";
402
+ // A picture that arrived once and stopped must not stay green. This is the
403
+ // mobile app's "Live over a nine-second-old still" defect, and the clock is
404
+ // the only thing standing between us and repeating it.
405
+ restartFrameClock();
406
+ } else {
407
+ clearFrameTimer();
408
+ player.value = signal === "stalled" ? "stalled" : "failed";
409
+ }
410
+ }
411
+
412
+ function clearTimer() {
413
+ if (timer) {
414
+ clearTimeout(timer);
415
+ timer = null;
416
+ }
417
+ }
418
+
419
+ function clearFrameTimer() {
420
+ if (frameTimer) {
421
+ clearTimeout(frameTimer);
422
+ frameTimer = null;
423
+ }
424
+ }
425
+
426
+ function restartFrameClock() {
427
+ clearFrameTimer();
428
+ frameTimer = setTimeout(() => {
429
+ if (player.value === "playing") player.value = "stalled";
430
+ }, LIVE_FRAME_TIMEOUT_MS);
431
+ }
432
+
433
+ /**
434
+ * Restart the clock whenever the address changes — a tile reused for a
435
+ * different camera must not inherit the previous one's verdict, in either
436
+ * direction.
437
+ *
438
+ * The timeout only fires while nothing at all has been heard: a page that
439
+ * loaded is already at `page-up` and stays there. Sitting on "Not confirmed"
440
+ * indefinitely is deliberate — declaring a camera dead on a timer, with no
441
+ * evidence, is the same mistake as declaring it live on a timer.
442
+ */
443
+ watch(
444
+ () => props.camera?.host,
445
+ (host) => {
446
+ clearTimer();
447
+ clearFrameTimer();
448
+ player.value = "loading";
449
+ if (!host) return;
450
+ timer = setTimeout(() => {
451
+ if (player.value === "loading") player.value = "failed";
452
+ }, LIVE_CONNECT_TIMEOUT_MS);
453
+ },
454
+ { immediate: true }
455
+ );
456
+
457
+ onMounted(() => window.addEventListener("message", onPlayerMessage));
458
+
459
+ onBeforeUnmount(() => {
460
+ window.removeEventListener("message", onPlayerMessage);
461
+ clearTimer();
462
+ clearFrameTimer();
463
+ });
464
+
465
+ /** The browser's own fullscreen. Nothing to build, and it survives the F11 key. */
466
+ async function toggleFullscreen() {
467
+ const el = root.value;
468
+ if (!el) return;
469
+ try {
470
+ if (document.fullscreenElement) await document.exitFullscreen();
471
+ else await el.requestFullscreen();
472
+ } catch {
473
+ // A browser that refuses fullscreen (permissions policy, an iframe, an old
474
+ // Safari) is not an error worth interrupting a supervisor over — the tile
475
+ // keeps working exactly as it did.
476
+ }
477
+ }
478
+ </script>
479
+
480
+ <style scoped>
481
+ .vms-tile {
482
+ position: relative;
483
+ overflow: hidden;
484
+ background: var(--vms-tile, #101418);
485
+ border-radius: var(--vms-radius, 3px);
486
+ outline: none;
487
+ min-height: 0;
488
+ }
489
+
490
+ /*
491
+ THE SELECTED TILE, AND WHY IT IS NOW LOUDER.
492
+
493
+ The panel under the wall describes ONE tile. It used to say which by printing
494
+ the camera's name — directly under a tile already captioned with that name —
495
+ because the ring here was too quiet to be doing that job: 2 px of #5b9be0
496
+ disappears against a bright picture, and at nine tiles it was a hairline the
497
+ eye skipped. So the panel was answering "which one?" in words, and the words
498
+ read as the name repeated twice.
499
+
500
+ Two marks now, and between them they hold at 1x1, 2x2 and 3x3:
501
+
502
+ - a **3 px accent ring**, with a 1 px scrim hairline INSIDE it. The hairline is
503
+ what makes the ring survive a white picture — accent-on-white is a weak edge,
504
+ accent-on-near-black is not, so the ring carries its own dark backing rather
505
+ than depending on what the camera happens to be pointing at.
506
+ - the **name chip underlined in the accent**, which is the same mark the
507
+ toolbar already uses for a selected tool (`.vms__tool--on`). At 3x3 the ring
508
+ is 3 px on a ~118 px tile; the underline sits right beside the caption a
509
+ supervisor is already reading, so identification does not depend on
510
+ perceiving 3 px from across a room.
511
+
512
+ INVENTED VALUES, recorded: the ring is 3 px where it was 2 px. Everything else
513
+ is existing — `--vms-accent`, `--vms-scrim-solid`, and the toolbar's 2 px
514
+ underline idiom. No new colour.
515
+ */
516
+ .vms-tile--focused {
517
+ box-shadow:
518
+ inset 0 0 0 3px var(--vms-accent, #5b9be0),
519
+ inset 0 0 0 4px var(--vms-scrim-solid, rgba(7, 9, 12, 0.82));
520
+ }
521
+
522
+ .vms-tile--focused .vms-tile__name {
523
+ box-shadow: inset 0 -2px 0 var(--vms-accent, #5b9be0);
524
+ }
525
+
526
+ .vms-tile:focus-visible {
527
+ box-shadow: inset 0 0 0 2px var(--vms-accent, #5b9be0);
528
+ }
529
+
530
+ .vms-tile--empty {
531
+ background: transparent;
532
+ border: 1px dashed var(--vms-line, #232b36);
533
+ }
534
+
535
+ /*
536
+ The stage is what the transform is applied to; the tile clips it. No
537
+ transition: a supervisor dragging a magnified picture expects it under their
538
+ pointer, and 150 ms of easing on every move reads as lag, not polish.
539
+ */
540
+ .vms-tile__stage {
541
+ position: absolute;
542
+ inset: 0;
543
+ transform-origin: center center;
544
+ will-change: transform;
545
+ }
546
+
547
+ .vms-tile__frame {
548
+ width: 100%;
549
+ height: 100%;
550
+ border: none;
551
+ display: block;
552
+ background: #000;
553
+ }
554
+
555
+ /*
556
+ Transparent, and over the picture. It is what gives the tile its clicks back
557
+ from the iframe; `touch-action: none` is what lets a pinch be a pinch rather
558
+ than the browser zooming the whole page.
559
+ */
560
+ .vms-tile__grab {
561
+ position: absolute;
562
+ inset: 0;
563
+ touch-action: none;
564
+ }
565
+
566
+ .vms-tile__grab--zoomed {
567
+ cursor: grab;
568
+ }
569
+
570
+ .vms-tile__grab--zoomed:active {
571
+ cursor: grabbing;
572
+ }
573
+
574
+ .vms-tile__overlay {
575
+ position: absolute;
576
+ inset: 0;
577
+ display: flex;
578
+ align-items: center;
579
+ justify-content: center;
580
+ padding: 10px;
581
+ background: var(--vms-scrim-solid, rgba(7, 9, 12, 0.82));
582
+ pointer-events: none;
583
+ }
584
+
585
+ .vms-tile__reason {
586
+ margin: 0;
587
+ text-align: center;
588
+ color: var(--vms-text-dim, #94a0b0);
589
+ font-size: 12px;
590
+ line-height: 16px;
591
+ max-width: 32ch;
592
+ }
593
+
594
+ .vms-tile--dense .vms-tile__reason {
595
+ font-size: 11px;
596
+ line-height: 15px;
597
+ }
598
+
599
+ .vms-tile__status {
600
+ position: absolute;
601
+ top: 6px;
602
+ right: 6px;
603
+ display: flex;
604
+ align-items: center;
605
+ gap: 5px;
606
+ padding: 2px 6px;
607
+ border-radius: var(--vms-radius, 3px);
608
+ background: var(--vms-scrim, rgba(7, 9, 12, 0.62));
609
+ pointer-events: none;
610
+ }
611
+
612
+ .vms-tile__dot {
613
+ width: 7px;
614
+ height: 7px;
615
+ border-radius: 50%;
616
+ background: var(--vms-text-faint, #5c6675);
617
+ flex: none;
618
+ }
619
+
620
+ /* State colours only. The chrome itself stays achromatic on purpose. */
621
+ .vms-tile__dot--live {
622
+ background: #4caf50;
623
+ }
624
+ .vms-tile__dot--connecting {
625
+ background: #fb8c00;
626
+ }
627
+ /*
628
+ NOT VERIFIED is deliberately not amber and deliberately not green. Amber is
629
+ "something is wrong", and nothing is known to be wrong; green is the claim
630
+ being withdrawn. A hollow ring reads as "no answer here", which is exactly
631
+ the fact. #7a8697 measures 5.0:1 on a tile — a dot nobody can see is a state
632
+ nobody knows about.
633
+ */
634
+ .vms-tile__dot--unverified {
635
+ background: transparent;
636
+ box-shadow: inset 0 0 0 2px #7a8697;
637
+ }
638
+ .vms-tile__dot--no-signal,
639
+ .vms-tile__dot--offline {
640
+ background: #e0241c;
641
+ }
642
+ .vms-tile__dot--unavailable {
643
+ background: var(--vms-text-faint, #5c6675);
644
+ }
645
+
646
+ .vms-tile__state {
647
+ color: var(--vms-text, #e8ecf1);
648
+ font-size: 11px;
649
+ line-height: 14px;
650
+ white-space: nowrap;
651
+ }
652
+
653
+ /* Nine tiles: smaller and tighter, still a word. */
654
+ .vms-tile--dense .vms-tile__status {
655
+ top: 4px;
656
+ right: 4px;
657
+ gap: 4px;
658
+ padding: 1px 5px;
659
+ }
660
+
661
+ .vms-tile--dense .vms-tile__state {
662
+ font-size: 10px;
663
+ line-height: 13px;
664
+ }
665
+
666
+ /*
667
+ `wrap` is what keeps the promise that the name and the recorder line both
668
+ survive a narrow tile: on a cramped nine-tile wall the health phrase drops to
669
+ its own line instead of squeezing the name into an ellipsis.
670
+ */
671
+ .vms-tile__chrome {
672
+ position: absolute;
673
+ left: 6px;
674
+ right: 6px;
675
+ bottom: 5px;
676
+ display: flex;
677
+ align-items: center;
678
+ flex-wrap: wrap;
679
+ gap: 3px 6px;
680
+ pointer-events: none;
681
+ }
682
+
683
+ .vms-tile--dense .vms-tile__chrome {
684
+ left: 4px;
685
+ right: 4px;
686
+ bottom: 4px;
687
+ }
688
+
689
+ /*
690
+ The RECORDER's state, and deliberately quieter than the name and the badge:
691
+ it is a second-order fact a supervisor reads when something looks wrong, not
692
+ something that should compete with the picture every second it is fine.
693
+ */
694
+ .vms-tile__health {
695
+ display: inline-flex;
696
+ align-items: center;
697
+ gap: 5px;
698
+ flex: 0 1 auto;
699
+ min-width: 0;
700
+ padding: 1px 6px;
701
+ border-radius: var(--vms-radius, 3px);
702
+ background: var(--vms-scrim, rgba(7, 9, 12, 0.62));
703
+ color: var(--vms-text-dim, #94a0b0);
704
+ font-size: 11px;
705
+ line-height: 15px;
706
+ white-space: nowrap;
707
+ overflow: hidden;
708
+ text-overflow: ellipsis;
709
+ }
710
+
711
+ .vms-tile--dense .vms-tile__health {
712
+ font-size: 10px;
713
+ line-height: 14px;
714
+ }
715
+
716
+ .vms-tile__dot--h-ok {
717
+ background: #4caf50;
718
+ }
719
+ .vms-tile__dot--h-warn {
720
+ background: #fb8c00;
721
+ }
722
+ .vms-tile__dot--h-down {
723
+ background: #e0241c;
724
+ }
725
+ .vms-tile__dot--h-unknown {
726
+ background: var(--vms-text-faint, #5c6675);
727
+ }
728
+
729
+ /* --------------------------------------------------------------- the zoom */
730
+
731
+ .vms-tile__zoom {
732
+ display: inline-flex;
733
+ align-items: center;
734
+ gap: 6px;
735
+ /* Pushed to the far end of the chrome row, opposite the name. */
736
+ margin-left: auto;
737
+ flex: none;
738
+ padding: 1px 6px;
739
+ border-radius: var(--vms-radius, 3px);
740
+ background: var(--vms-scrim, rgba(7, 9, 12, 0.62));
741
+ font-size: 11px;
742
+ line-height: 15px;
743
+ /* The row is click-through; this one control is not. */
744
+ pointer-events: auto;
745
+ }
746
+
747
+ .vms-tile__zoom-level {
748
+ color: var(--vms-text, #e8ecf1);
749
+ font-variant-numeric: tabular-nums;
750
+ }
751
+
752
+ .vms-tile__zoom-reset {
753
+ background: none;
754
+ border: none;
755
+ padding: 2px 2px;
756
+ color: var(--vms-accent, #5b9be0);
757
+ font-size: 11px;
758
+ line-height: 15px;
759
+ cursor: pointer;
760
+ }
761
+
762
+ .vms-tile__zoom-reset:focus-visible {
763
+ outline: 2px solid var(--vms-accent, #5b9be0);
764
+ }
765
+
766
+ .vms-tile__name {
767
+ color: var(--vms-text, #e8ecf1);
768
+ font-size: 12px;
769
+ line-height: 16px;
770
+ font-weight: 600;
771
+ padding: 1px 6px;
772
+ border-radius: var(--vms-radius, 3px);
773
+ background: var(--vms-scrim, rgba(7, 9, 12, 0.62));
774
+ overflow: hidden;
775
+ text-overflow: ellipsis;
776
+ white-space: nowrap;
777
+ }
778
+
779
+ .vms-tile--dense .vms-tile__name {
780
+ font-size: 11px;
781
+ line-height: 15px;
782
+ }
783
+
784
+ .vms-tile__expand {
785
+ position: absolute;
786
+ top: 6px;
787
+ left: 6px;
788
+ display: flex;
789
+ align-items: center;
790
+ justify-content: center;
791
+ width: 24px;
792
+ height: 24px;
793
+ border-radius: var(--vms-radius, 3px);
794
+ background: var(--vms-scrim, rgba(7, 9, 12, 0.62));
795
+ color: var(--vms-text, #e8ecf1);
796
+ border: none;
797
+ cursor: pointer;
798
+ opacity: 0;
799
+ transition: opacity 120ms ease;
800
+ }
801
+
802
+ /* The control is always THERE; it only stops competing with the picture. */
803
+ .vms-tile:hover .vms-tile__expand,
804
+ .vms-tile:focus-within .vms-tile__expand {
805
+ opacity: 1;
806
+ }
807
+
808
+ .vms-tile__expand:focus-visible {
809
+ opacity: 1;
810
+ outline: 2px solid var(--vms-accent, #5b9be0);
811
+ }
812
+
813
+ /* Fullscreen: the picture takes the whole screen, the chrome stays legible. */
814
+ .vms-tile:fullscreen {
815
+ border-radius: 0;
816
+ background: #000;
817
+ }
818
+ </style>