@7365admin1/layer-common 3.2.1-staging.64 → 3.2.1-staging.66

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.
@@ -42,6 +42,67 @@ import {
42
42
  } from "vuetify-pro-tiptap";
43
43
  import "vuetify-pro-tiptap/style.css";
44
44
 
45
+ /**
46
+ * Contrast decisions live here rather than inline, because both themes need the
47
+ * same two opacity corrections and a value that differs by accident between
48
+ * light and dark is a bug nobody sees until somebody flips the switch.
49
+ *
50
+ * `disabled-opacity`: Material's 0.38 measures 2.68:1 on white, below the 4.5:1
51
+ * a sentence needs, and disabled fields in this product carry sentences people
52
+ * are expected to read. 0.55 is 4.74:1 on white and 6.21:1 on the dark page -
53
+ * the same value the camera wall settled on, for the same reason.
54
+ *
55
+ * `border-opacity`: 0.12 measures 1.32:1, which is a divider you cannot see.
56
+ * Dark goes to 0.35 (3.22:1) and carries the boundary properly. Light goes to
57
+ * 0.26 (1.88:1) - visible, but short of the 3:1 a component boundary wants.
58
+ * Reaching it on white needs 0.42, which turns every table and card edge into a
59
+ * mid-grey grid across eleven applications; that is a change somebody should
60
+ * look at before it ships, not one to slip in behind a contrast fix.
61
+ */
62
+ const LIGHT_THEME = {
63
+ dark: false,
64
+ colors: {
65
+ primary: "#042134",
66
+ "primary-button": "#1867C0",
67
+ "text-primary": "#052439",
68
+ },
69
+ variables: {
70
+ "disabled-opacity": 0.55,
71
+ "border-opacity": 0.26,
72
+ },
73
+ };
74
+
75
+ /**
76
+ * Only the keys that must differ; Vuetify merges the rest from its stock dark.
77
+ */
78
+ const DARK_THEME = {
79
+ dark: true,
80
+ colors: {
81
+ /**
82
+ * Stock dark's #121212 page and #212121 surface measure 1.16:1 apart, so a
83
+ * card, a dialog and the page behind them were one flat sheet. These are
84
+ * 1.19:1 - elevation on a dark screen is a genuinely small delta - but they
85
+ * are tinted towards the brand navy rather than neutral grey, and the
86
+ * divider opacity above is what actually draws the edges.
87
+ */
88
+ background: "#0E1319",
89
+ surface: "#1B242F",
90
+ /** Stock dark ships a lavender #ccbfd6 here, which is not our product. */
91
+ "surface-bright": "#26313E",
92
+ "surface-light": "#26313E",
93
+ /** The brand navy lifted until the nav drawer is a surface, not a hole. */
94
+ primary: "#17506F",
95
+ /** Unchanged on purpose: it already passes in both themes. */
96
+ "primary-button": "#1867C0",
97
+ /** #052439 on a dark page measures 1.18:1. This is 15.72:1. */
98
+ "text-primary": "#E8ECF1",
99
+ },
100
+ variables: {
101
+ "disabled-opacity": 0.55,
102
+ "border-opacity": 0.35,
103
+ },
104
+ };
105
+
45
106
  export default defineNuxtPlugin((app) => {
46
107
  const vuetify = createVuetify({
47
108
  defaults: {
@@ -69,17 +130,53 @@ export default defineNuxtPlugin((app) => {
69
130
  // hint: "This field is required",
70
131
  // },
71
132
  },
133
+ /**
134
+ * THE PRODUCT HAS TWO THEMES, AND UNTIL NOW ONLY ONE OF THEM WAS OURS.
135
+ *
136
+ * Every app shows a light/dark toggle in the app bar (Layout/Header.vue),
137
+ * and it sets Vuetify's built-in "light" and "dark" themes. Those were left
138
+ * at Vuetify's factory values, so one click on any screen in any of the
139
+ * eleven apps threw the brand away: `primary` stopped being the Seven365
140
+ * navy and became Material blue #2196F3 - and `primary` is the navigation
141
+ * drawer's fill - while `primary-button` and `text-primary`, used in around
142
+ * eighty places across the layer and the apps, do not exist in a stock
143
+ * theme at all, so those components lost their colour to an undefined CSS
144
+ * variable. There was also no way back to the brand: the toggle only ever
145
+ * flipped between the two stock themes.
146
+ *
147
+ * The fix is to make the stock names OURS rather than to add new names.
148
+ * Several components already branch on `theme.global.name === "dark"` (this
149
+ * layer's FormDialog, property-management's facility icons), so a
150
+ * differently-named dark theme would have left every one of them silently
151
+ * on their light branch inside a dark app. Overriding `light` and `dark`
152
+ * means the toggle, `v-theme-provider theme="light"`, the `plain-dark`
153
+ * layout and all of those comparisons keep working, and start being right.
154
+ *
155
+ * Measured, not eyeballed - WCAG 2.1, alpha composited against the surface
156
+ * each value actually sits on:
157
+ *
158
+ * text-primary on the page dark 1.18:1 -> 15.72:1
159
+ * nav drawer fill vs the page dark 1.13:1 -> 2.14:1 (+ a real border)
160
+ * white label in the nav drawer dark 3.12:1 -> 8.70:1
161
+ * primary-button vs the page dark 3.34:1 -> 3.32:1 (unchanged)
162
+ * white label on that button both 5.61:1 -> 5.61:1 (unchanged)
163
+ * disabled text light 2.68:1 -> 4.74:1
164
+ * disabled text dark 3.57:1 -> 6.21:1
165
+ * divider / outline dark 1.38:1 -> 3.22:1
166
+ * divider / outline light 1.32:1 -> 1.88:1
167
+ */
72
168
  theme: {
73
- defaultTheme: "iservice365",
169
+ defaultTheme: "light",
74
170
  themes: {
75
- iservice365: {
76
- dark: false,
77
- colors: {
78
- primary: "#042134",
79
- "primary-button": "#1867C0",
80
- "text-primary": "#052439",
81
- },
82
- },
171
+ light: LIGHT_THEME,
172
+ dark: DARK_THEME,
173
+ /**
174
+ * The name this product booted on before the two above carried the
175
+ * brand. Kept, and identical to `light`, so anything that asks for it
176
+ * by name - in an app that is not in this repository - still gets a
177
+ * theme rather than a blank one.
178
+ */
179
+ iservice365: LIGHT_THEME,
83
180
  },
84
181
  },
85
182
  icons: {
@@ -2,12 +2,23 @@ import assert from "node:assert/strict";
2
2
  import { test } from "node:test";
3
3
 
4
4
  import {
5
+ agoLabel,
5
6
  capabilityView,
7
+ clampPage,
8
+ clampPan,
9
+ clampZoom,
10
+ healthView,
6
11
  isDenseLayout,
12
+ playerOrigin,
13
+ playerSignal,
7
14
  tileView,
8
15
  wallCameras,
9
16
  wallLayout,
17
+ wallPageCount,
18
+ wallRows,
10
19
  wallTiles,
20
+ WALL_CAPABILITY_CONTROLS,
21
+ zoomStep,
11
22
  } from "./camera-wall.ts";
12
23
 
13
24
  const ip = (over: Record<string, unknown> = {}) => ({
@@ -44,10 +55,11 @@ test("an unknown layout key falls back to four tiles", () => {
44
55
  test("no selection means the site's own order, not a blank wall", () => {
45
56
  const cams = [ip({ _id: "a" }), ip({ _id: "b" })];
46
57
  const tiles = wallTiles(cams, [], "2x2");
47
- assert.equal(tiles.length, 4);
58
+ // Two cameras on a four-tile wall are TWO tiles, not two tiles and two
59
+ // dashed boxes. The grid packs; see wallRows.
60
+ assert.equal(tiles.length, 2);
48
61
  assert.equal(tiles[0]._id, "a");
49
62
  assert.equal(tiles[1]._id, "b");
50
- assert.equal(tiles[2], null);
51
63
  });
52
64
 
53
65
  test("a selection sets the order, and a stale id is dropped rather than drawn", () => {
@@ -83,23 +95,132 @@ test("the server's own sentence is shown, never rewritten", () => {
83
95
  );
84
96
  });
85
97
 
86
- test("a supported capability wears no tag", () => {
98
+ /*
99
+ CHANGED DELIBERATELY. This asserted `tag === null` for a working capability,
100
+ and that was fine while every capability on this screen was switched off. It
101
+ stopped being fine the moment one was on: an untagged panel beside four
102
+ tagged ones reads as a panel whose tag failed to render, not as good news.
103
+ It also hid a real defect, which the next test pins.
104
+ */
105
+ test("a working capability says so, and does not fall back to the not-probed sentence", () => {
87
106
  const cam = ip({ capabilities: { playback: { state: "supported", detail: null } } });
88
- assert.equal(capabilityView(cam, "playback").available, true);
89
- assert.equal(capabilityView(cam, "playback").tag, null);
107
+ const v = capabilityView(cam, "playback");
108
+ assert.equal(v.available, true);
109
+ assert.equal(v.tag, "Ready");
110
+ // The defect: `detail` used to fall back to "has not been asked what it can
111
+ // do yet" whenever the server sent none — and the server sends none for a
112
+ // capability that WORKS. A working control claiming it had never been checked
113
+ // is a contradiction on screen, and it would have shipped the day zoom landed.
114
+ assert.equal(v.detail, null);
115
+ assert.equal(v.nextStep, null);
116
+ });
117
+
118
+ /* ---------------------------------------------------------------- rule 5 */
119
+
120
+ test("a switched-off control says what would switch it on and who to ask", () => {
121
+ const cam = ip({
122
+ capabilities: {
123
+ playback: {
124
+ state: "unsupported",
125
+ reason: "device-http-not-configured",
126
+ detail: "No direct connection to this camera's recorder is configured on this server.",
127
+ },
128
+ },
129
+ });
130
+ const v = capabilityView(cam, "playback");
131
+ // The server's sentence is still verbatim — rule 3 is not traded away for rule 5.
132
+ assert.match(v.detail, /^No direct connection/);
133
+ assert.match(v.nextStep, /Seven365 administrator/);
134
+ assert.match(v.nextStep, /not something to switch on from this page/i);
135
+ });
136
+
137
+ test("a reason code this build has never seen still names somebody to ask", () => {
138
+ const cam = ip({
139
+ capabilities: { ptz: { state: "unsupported", reason: "invented-later", detail: "Nope." } },
140
+ });
141
+ assert.match(capabilityView(cam, "ptz").nextStep, /Seven365 administrator/);
142
+ });
143
+
144
+ test("every control the wall draws has a sentence for when it works", () => {
145
+ // Otherwise a capability flipping on shows a panel with a "Ready" tag and no
146
+ // explanation of what to do with it.
147
+ for (const control of WALL_CAPABILITY_CONTROLS) {
148
+ assert.ok(control.ready.length > 0, `${control.key} has no ready sentence`);
149
+ }
150
+ });
151
+
152
+ test("zoom is drawn as a capability, and it is the one that works today", () => {
153
+ const keys = WALL_CAPABILITY_CONTROLS.map((c) => c.key);
154
+ assert.ok(keys.includes("digitalZoom"));
155
+ // Digital zoom is cropping, not movement. If these two ever read the same on
156
+ // screen, somebody will believe they moved a camera that never moved.
157
+ assert.ok(keys.includes("ptz"));
158
+ assert.notEqual(
159
+ WALL_CAPABILITY_CONTROLS.find((c) => c.key === "digitalZoom").title,
160
+ WALL_CAPABILITY_CONTROLS.find((c) => c.key === "ptz").title
161
+ );
90
162
  });
91
163
 
92
164
  /* ------------------------------------------------------------------ rule 4 */
93
165
 
94
166
  test("a page that has not loaded is Connecting, never Live", () => {
95
167
  assert.equal(tileView(ip(), { player: "loading" }).state, "connecting");
96
- assert.equal(tileView(ip(), { player: "ready" }).label, "Live");
97
- assert.equal(tileView(ip(), { player: "timeout" }).state, "no-signal");
168
+ assert.equal(tileView(ip(), { player: "failed" }).state, "no-signal");
169
+ });
170
+
171
+ /**
172
+ * THE DEFECT THIS FILE EXISTS FOR.
173
+ *
174
+ * On the live wall two cameras rendered a blank rectangle and both carried a
175
+ * green "Live". The page had loaded; no picture was arriving. A loaded page is
176
+ * now worth exactly one thing, and it is not the word "Live".
177
+ */
178
+ test("a loaded player page is NOT Live - it is Not verified", () => {
179
+ const v = tileView(ip(), { player: "page-up" });
180
+ assert.equal(v.state, "unverified");
181
+ assert.equal(v.label, "Not verified");
182
+ // No scrim: there may be a perfectly good picture under that badge, and
183
+ // covering a working camera would be a worse lie than the one being fixed.
184
+ assert.equal(v.detail, null);
185
+ assert.equal(v.showPlayer, true);
186
+ assert.match(v.note, /cannot confirm a picture is arriving/);
187
+ });
188
+
189
+ test("only a reported frame earns the word Live", () => {
190
+ assert.equal(tileView(ip(), { player: "playing" }).label, "Live");
191
+ assert.equal(tileView(ip(), { player: "playing" }).state, "live");
192
+ });
193
+
194
+ test("a picture that arrived and stopped stops being Live", () => {
195
+ // The mobile app's "Live over a nine-second-old still", refused here.
196
+ const v = tileView(ip(), { player: "stalled" });
197
+ assert.equal(v.state, "no-signal");
198
+ assert.match(v.detail, /stopped arriving/);
199
+ });
200
+
201
+ test("only signals the video service could actually send are believed", () => {
202
+ assert.equal(playerSignal({ event: "frame" }), "playing");
203
+ assert.equal(playerSignal("videoDecode"), "playing");
204
+ assert.equal(playerSignal({ type: "stalled" }), "stalled");
205
+ assert.equal(playerSignal({ event: "error" }), "failed");
206
+ // Anything else leaves the tile exactly where it was. A message we do not
207
+ // understand must never be able to turn a badge green.
208
+ assert.equal(playerSignal({ event: "live" }), null);
209
+ assert.equal(playerSignal(null), null);
210
+ assert.equal(playerSignal(42), null);
211
+ assert.equal(playerSignal({}), null);
212
+ });
213
+
214
+ test("a message is only believed from the camera's own origin", () => {
215
+ assert.equal(playerOrigin("https://relay.example.test/7"), "https://relay.example.test");
216
+ assert.equal(playerOrigin("not a url"), "");
217
+ assert.equal(playerOrigin(""), "");
218
+ assert.equal(playerOrigin(null), "");
98
219
  });
99
220
 
100
221
  test("the browser being offline outranks everything and says so differently", () => {
101
- assert.equal(tileView(ip(), { online: false, player: "ready" }).label, "No connection");
102
- assert.equal(tileView(ip(), { player: "timeout" }).label, "No signal");
222
+ assert.equal(tileView(ip(), { online: false, player: "playing" }).label, "No connection");
223
+ assert.equal(tileView(ip(), { player: "failed" }).label, "No signal");
103
224
  });
104
225
 
105
226
  test("an inactive camera, a camera with no address, and an empty tile all explain themselves", () => {
@@ -115,7 +236,7 @@ test("a server that says live video is unsupported is believed", () => {
115
236
  liveVideo: { state: "unsupported", detail: "This camera is not active." },
116
237
  },
117
238
  });
118
- const v = tileView(cam, { player: "ready" });
239
+ const v = tileView(cam, { player: "playing" });
119
240
  assert.equal(v.showPlayer, false);
120
241
  assert.equal(v.detail, "This camera is not active.");
121
242
  });
@@ -147,3 +268,138 @@ test("being offline still explains every tile at once, ahead of the server's rea
147
268
  const view = tileView(ip({ unavailableReason: "Something server-side." }), { online: false });
148
269
  assert.equal(view.state, "offline");
149
270
  });
271
+
272
+ /* ------------------------------------------------------------------ paging */
273
+
274
+ test("the fifth camera on a four-tile wall is reachable, not dropped", () => {
275
+ // The defect this fixes, exactly: a site with five cameras showed four, said
276
+ // "Cameras 5" in the toolbar, and had nowhere to put the fifth.
277
+ const cams = ["a", "b", "c", "d", "e"].map((id) => ip({ _id: id }));
278
+ assert.equal(wallPageCount(cams, [], "2x2"), 2);
279
+
280
+ const second = wallTiles(cams, [], "2x2", 1);
281
+ assert.equal(second[0]._id, "e");
282
+ // A short last page is short, not padded - and it must never wrap back
283
+ // round to the first camera, which would show "a" twice on one wall.
284
+ assert.equal(second.length, 1);
285
+ });
286
+
287
+ test("the grid packs to the cameras on it instead of drawing holes", () => {
288
+ // Five cameras on a nine-tile wall: five pictures over two rows, not five
289
+ // pictures and four dead grey boxes over three.
290
+ const cams = ["a", "b", "c", "d", "e"].map((id) => ip({ _id: id }));
291
+ const tiles = wallTiles(cams, [], "3x3");
292
+ assert.equal(tiles.length, 5);
293
+ assert.equal(wallRows(tiles.length, "3x3"), 2);
294
+
295
+ assert.equal(wallRows(1, "3x3"), 1);
296
+ assert.equal(wallRows(9, "3x3"), 3);
297
+ // Never taller than the layout asked for, and never zero rows.
298
+ assert.equal(wallRows(99, "3x3"), 3);
299
+ assert.equal(wallRows(0, "2x2"), 1);
300
+ });
301
+
302
+ test("an exactly-full wall is one page, and an empty one is still one", () => {
303
+ const four = ["a", "b", "c", "d"].map((id) => ip({ _id: id }));
304
+ assert.equal(wallPageCount(four, [], "2x2"), 1);
305
+ assert.equal(wallPageCount([], [], "2x2"), 1);
306
+ });
307
+
308
+ test("a page number is clamped rather than left pointing past the end", () => {
309
+ const cams = ["a", "b", "c", "d", "e"].map((id) => ip({ _id: id }));
310
+ // Going from 2x2 page 2 to a 3x3 wall: page 1 no longer exists.
311
+ assert.equal(clampPage(1, wallPageCount(cams, [], "3x3")), 0);
312
+ assert.equal(clampPage(-4, 3), 0);
313
+ assert.equal(clampPage(99, 3), 2);
314
+ assert.equal(clampPage(NaN, 3), 0);
315
+ // Asking for a page past the end returns the last one's tiles, never nothing.
316
+ assert.equal(wallTiles(cams, [], "2x2", 9)[0]._id, "e");
317
+ });
318
+
319
+ test("paging respects a selection instead of quietly paging the whole site", () => {
320
+ const cams = ["a", "b", "c", "d", "e"].map((id) => ip({ _id: id }));
321
+ assert.equal(wallPageCount(cams, ["a", "b"], "1x1"), 2);
322
+ assert.equal(wallTiles(cams, ["a", "b"], "1x1", 1)[0]._id, "b");
323
+ });
324
+
325
+ /* ------------------------------------------------------------------ health */
326
+
327
+ test("the recorder's state and the player page's state are separate answers", () => {
328
+ const camera = ip();
329
+ // The tile badge says the page is up; the recorder says it is not. Both are
330
+ // true at once when a relay outlives the device behind it, and a wall that
331
+ // merged them would tell a supervisor only the reassuring half.
332
+ assert.equal(tileView(camera, { player: "playing" }).label, "Live");
333
+ assert.equal(healthView({ health: "unreachable", reachable: false }).tone, "down");
334
+ });
335
+
336
+ test("health that has never been asked for does not read as healthy", () => {
337
+ assert.equal(healthView(null).tone, "unknown");
338
+ assert.equal(healthView(undefined).lastFrame, null);
339
+ assert.match(healthView(null).label, /not checked/i);
340
+ });
341
+
342
+ test("a drifted clock is a warning, not a failure", () => {
343
+ assert.equal(healthView({ health: "drifted", reachable: true }).tone, "warn");
344
+ assert.equal(healthView({ health: "ok", reachable: true }).tone, "ok");
345
+ });
346
+
347
+ test("reachability alone is enough when the server sends no summary word", () => {
348
+ assert.equal(healthView({ reachable: true }).tone, "ok");
349
+ assert.equal(healthView({ reachable: false }).tone, "down");
350
+ });
351
+
352
+ test("the server's health sentences are carried through, not rewritten", () => {
353
+ const v = healthView({
354
+ health: "unreachable",
355
+ reachable: false,
356
+ reason: "The recorder did not respond.",
357
+ detailUnavailableReason:
358
+ "Firmware and device clock are not available: this recorder is reachable over video only.",
359
+ });
360
+ assert.equal(v.detail, "The recorder did not respond.");
361
+ assert.match(v.deviceDetail, /^Firmware and device clock/);
362
+ });
363
+
364
+ test("a picture's age is coarse above a minute, and never a fake time", () => {
365
+ const now = Date.parse("2026-08-11T10:00:00.000Z");
366
+ const at = (ms) => new Date(now - ms).toISOString();
367
+ assert.equal(agoLabel(at(1000), now), "just now");
368
+ assert.equal(agoLabel(at(40_000), now), "40s ago");
369
+ assert.equal(agoLabel(at(6 * 60_000), now), "6 min ago");
370
+ assert.equal(agoLabel(at(3 * 3_600_000), now), "3 h ago");
371
+ // Never invent one: no timestamp and a broken timestamp both mean "no answer".
372
+ assert.equal(agoLabel(null, now), null);
373
+ assert.equal(agoLabel("not a date", now), null);
374
+ // A clock ahead of ours must not read as a picture from the future.
375
+ assert.equal(agoLabel(at(-5000), now), "just now");
376
+ });
377
+
378
+ /* -------------------------------------------------------------------- zoom */
379
+
380
+ test("zoom stops at 4x and never goes below the whole picture", () => {
381
+ assert.equal(clampZoom(0.2), 1);
382
+ assert.equal(clampZoom(99), 4);
383
+ assert.equal(clampZoom(NaN), 1);
384
+ assert.equal(clampZoom(2.5), 2.5);
385
+ });
386
+
387
+ test("a wheel notch zooms by direction, not by however much the device reports", () => {
388
+ assert.ok(zoomStep(1, -120) > 1);
389
+ assert.ok(zoomStep(2, 3) < 2);
390
+ // A trackpad's tiny delta and a mouse's huge one take the same step.
391
+ assert.equal(zoomStep(1, -1), zoomStep(1, -4000));
392
+ assert.equal(zoomStep(1, 120), 1, "cannot zoom out past the whole picture");
393
+ assert.equal(zoomStep(4, -120), 4, "cannot zoom in past the ceiling");
394
+ });
395
+
396
+ test("panning cannot walk the picture off the tile", () => {
397
+ const frame = { width: 400, height: 200 };
398
+ // At 2x the picture is twice the frame, so half the frame each way and no more.
399
+ assert.deepEqual(clampPan({ x: 9999, y: 9999 }, 2, frame), { x: 200, y: 100 });
400
+ assert.deepEqual(clampPan({ x: -9999, y: -9999 }, 2, frame), { x: -200, y: -100 });
401
+ // At 1x there is nothing to pan, so every drag resolves to centred.
402
+ assert.deepEqual(clampPan({ x: 50, y: 50 }, 1, frame), { x: 0, y: 0 });
403
+ // A tile that has not been measured yet cannot be dragged into the void.
404
+ assert.deepEqual(clampPan({ x: 50, y: 50 }, 3, { width: 0, height: 0 }), { x: 0, y: 0 });
405
+ });