@7365admin1/layer-common 3.2.1-staging.67 → 3.2.1-staging.69

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,60 @@
1
+ ---
2
+ "@7365admin1/layer-common": patch
3
+ ---
4
+
5
+ Rewrite the camera wall's detail panel for the people who read it, and stop
6
+ repeating the same sentence four times.
7
+
8
+ The panel under the tiles was written for a developer. On the live wall it
9
+ read:
10
+
11
+ > BBQ AREA
12
+ > The video page loaded, but this browser cannot confirm a picture is arriving.
13
+ > The player belongs to the video service and a browser cannot look inside it.
14
+ > Judge the tile by what you can see, and report a blank or frozen picture.
15
+ > ● Recorder responding Last picture this server received: none yet
16
+ > Firmware and device clock are not available: this recorder is reachable over
17
+ > video only.
18
+
19
+ Every one of those statements is true, and the screen was still wrong: it
20
+ explained cross-origin isolation to a guard, repeated the camera's name the
21
+ tile already showed, put a green light beside the words "none yet", and printed
22
+ the same reason on four capability cards in a row.
23
+
24
+ Changes, all copy and layout — **no behaviour, no capability gating and no
25
+ permission check is touched**:
26
+
27
+ - **The badge reads "Not confirmed"**, not "Not verified". "Not verified"
28
+ describes a check we failed to make; "Not confirmed" describes the picture,
29
+ which is the thing the reader cares about. Same claim, and the wall still
30
+ refuses to say "Live" until the video service reports a decoded frame.
31
+ - **The sentence behind it is now an instruction**: "This page cannot confirm
32
+ the picture is arriving, so check the tile yourself. If it is blank or frozen,
33
+ report the camera." Why the software cannot confirm it is our problem, and it
34
+ has been removed from the product.
35
+ - **The camera's name is no longer a heading of its own.** It is the subject of
36
+ the status line — "BBQ AREA · Recorder responding" — so a reader on a 3x3 wall
37
+ still knows which tile the panel describes, without a second caption under the
38
+ first one.
39
+ - **"Last picture" is drawn only when there has been one.** A green "Recorder
40
+ responding" beside "none yet" read as a contradiction. They were never in
41
+ conflict, but a screen that has to be explained is wrong. The tile's own
42
+ health line has always behaved this way; the panel now matches it.
43
+ - **The firmware-and-device-clock sentence is gone**, from the panel and from
44
+ the tile's tooltip. This screen never showed either value, so explaining their
45
+ absence gave the reader nothing to do. The server still sends
46
+ `detailUnavailableReason`; nothing draws it.
47
+ - **One reason for the group instead of four copies.** When every switched-off
48
+ control is off for the same reason, that reason and its next step are stated
49
+ once above the row and each card keeps only its title and its state. When the
50
+ reasons differ — "the recorder is unreachable" and "this camera cannot move"
51
+ are two problems with two different people to ask — the group note is refused
52
+ and each card carries its own again. New rule 7, in `camera-wall.ts`, with
53
+ four tests.
54
+ - Shorter next-step and no-signal wording, and a subject on the recorder's
55
+ status labels ("Recorder not checked yet" rather than "Health not checked
56
+ yet").
57
+
58
+ Rules 3 and 6 are intact: the server's own sentence is still shown verbatim and
59
+ never rewritten, and the recorder's state is still a separate fact from the
60
+ tile's badge.
@@ -0,0 +1,65 @@
1
+ ---
2
+ "@7365admin1/layer-common": patch
3
+ ---
4
+
5
+ Mark the selected camera on the wall, and stop telling the reader to raise a
6
+ ticket without saying what for.
7
+
8
+ Two things the owner found on the live wall after the last copy pass. Both are
9
+ copy and presentation only — **no behaviour, no capability gating, no permission
10
+ check and no request is changed.**
11
+
12
+ **1. The camera's name was still printed twice.**
13
+
14
+ The tile said `BBQ AREA` and the panel underneath said it again. The last pass
15
+ merged the panel's heading into its status line, which changed the layout and
16
+ left the repetition exactly where it was.
17
+
18
+ The name was there to answer "which of these nine tiles is this panel about?".
19
+ That is a real question and a name is the wrong answer to it — it makes the
20
+ reader scan the wall for a matching caption. So:
21
+
22
+ - **the selected tile is now visibly selected**: a 3 px accent ring with a scrim
23
+ hairline inside it, so the ring holds against a bright picture as well as a
24
+ dark one, plus the accent underline on the name chip that the toolbar already
25
+ uses to mark a selected tool. Both read at 1x1, 2x2 and 3x3;
26
+ - **the camera's name is removed from the panel entirely**. The panel opens on
27
+ the recorder's state — "● Recorder responding";
28
+ - `aria-current` marks the same tile for anyone not looking at the ring.
29
+
30
+ The highlight had never worked in the most common case, which is why the name
31
+ was load-bearing: it was bound to the raw clicked id, and before anyone clicks —
32
+ or after paging — nothing on the wall was marked while the panel was already
33
+ describing the first tile. It is now bound to the tile the panel actually
34
+ resolves to, so the two can never disagree.
35
+
36
+ **The panel can never be about a tile that is off screen**, so it never needs to
37
+ name one: the focused camera is resolved out of the current PAGE, so paging away
38
+ from a selection re-resolves to the first tile of the page in front of you and
39
+ the ring follows. Paging back restores the original selection. Extracted as
40
+ `focusedCamera()` in `camera-wall.ts` with four tests, including the 5-cameras
41
+ -across-2-pages case.
42
+
43
+ **2. "Ask your Seven365 administrator" was a dead end.**
44
+
45
+ It was the whole of the advice under every switched-off control, and it tells
46
+ the administrator nothing and a property manager only to go and wait. The screen
47
+ never said what the request should ask for.
48
+
49
+ The advice now names the two changes that are actually missing, because they are
50
+ two different jobs for two different people: **the site's network has to let our
51
+ server reach the camera recorder**, and **that recorder then has to be added to
52
+ the server's configuration**. Both are stated in plain words, with who arranges
53
+ them, and with the fact that neither is switchable from this page.
54
+
55
+ No environment variable name appears in any of it — those belong in
56
+ `iservice365-core/docs/camera-integration-config.md`, not on a property
57
+ manager's screen — and there is a test that keeps it that way. The other reason
58
+ codes (`device-http-disabled`, `device-http-unreachable`, `device-http-locked-out`,
59
+ `control-not-enabled`, `no-recorder-configured`, and the unknown-code fallback)
60
+ get the same treatment; the Site Settings ones already said what to do and are
61
+ unchanged.
62
+
63
+ The truthful-status rules are untouched: the wall still refuses to say "Live"
64
+ until the video service reports a decoded frame, and the recorder's state is
65
+ still a separate fact from the tile's badge.
@@ -126,7 +126,7 @@
126
126
  :online="online"
127
127
  :health="healthById[camera._id]"
128
128
  :now="now"
129
- :focused="camera._id === focusedId"
129
+ :focused="camera._id === focused?._id"
130
130
  @focus="focusedId = $event._id"
131
131
  />
132
132
  </div>
@@ -170,44 +170,80 @@
170
170
  ask for it, and hiding it is what stops anyone asking.
171
171
  -->
172
172
  <div v-if="focused" class="vms__caps">
173
- <p class="vms__caps-head">{{ focused.name || focused._id }}</p>
174
-
175
- <!--
176
- HEALTH — the recorder's own state, which is a different fact from the
177
- tile badge above it (that one is about the video page loading). They can
178
- disagree, and when they do a supervisor needs both, not a merged verdict.
179
-
180
- Firmware and the device clock are blank on this estate and the server
181
- says why, in its own words. An empty field with no explanation reads as
182
- "checked, and fine", which would be the opposite of the truth.
183
- -->
184
173
  <!--
185
- WHAT THE BADGE ON THE TILE ACTUALLY MEANS, said once here rather than
186
- nine times across the wall. "Not verified" is a word a supervisor has
187
- every right to challenge, and the answer — the picture belongs to
188
- another origin's page and a browser cannot look inside itis too long
189
- for a tile and too important to leave out.
174
+ THE CAMERA'S NAME IS GONE FROM THIS PANEL ENTIRELY. THE SELECTED TILE
175
+ SAYS WHICH CAMERA THIS IS.
176
+
177
+ It was here twice before: once as a heading, thenafter a first
178
+ attempt at this as the subject of the status line. The owner's point
179
+ was never the line break. It was the REPETITION: the tile directly above
180
+ is already captioned "BBQ AREA", and printing it again underneath adds
181
+ no fact.
182
+
183
+ The reason it was kept is real — the panel describes ONE of up to nine
184
+ tiles and the reader has to know which — but a name is the wrong way to
185
+ answer it. Naming a tile makes the reader search the wall for a matching
186
+ caption; an accent ring makes the wall answer at a glance. So the ring
187
+ is the identification (`vms-tile--focused`) and the panel is free to be
188
+ about the camera rather than about naming it.
189
+
190
+ **And the panel can never describe a tile that is off screen**, so it
191
+ never needs to fall back to naming one. `focused` is resolved out of
192
+ `tiles` — the CURRENT page — so paging away re-resolves it to the first
193
+ tile of the page you are now looking at. There is no state in which this
194
+ panel is about something you cannot see. That is asserted in
195
+ `camera-wall.test.ts` so it cannot quietly stop being true.
190
196
  -->
191
- <p v-if="embedsPlayer" class="vms__badge-note">{{ UNVERIFIED_DETAIL }}</p>
192
-
193
197
  <div class="vms__health">
198
+ <!--
199
+ THE RECORDER's own state — a different fact from the tile badge above,
200
+ which is about the picture. They can disagree, and when they do the
201
+ reader needs both, not a merged verdict.
202
+ -->
194
203
  <span class="vms__health-state">
195
204
  <span class="vms__health-dot" :class="`vms__health-dot--${focusedHealth.tone}`" />
196
205
  {{ focusedHealth.label }}
197
206
  </span>
198
- <span class="vms__health-item">
199
- Last picture this server received:
200
- <b>{{ focusedHealth.lastFrame || "none yet" }}</b>
207
+ <!--
208
+ "Last picture" ONLY WHEN THERE HAS BEEN ONE.
209
+
210
+ It used to read "Last picture this server received: none yet" beside a
211
+ green "Recorder responding", and the two together read as a
212
+ contradiction: a green light next to "none yet" tells a guard nothing
213
+ they can act on. They were never in conflict — one is the recorder
214
+ answering, the other is a still frame this server has never been set
215
+ up to fetch — but a screen that has to be explained is a screen that
216
+ is wrong. When there IS a picture, its age is worth knowing and it is
217
+ shown. When there is not, the line is silent, exactly as the tile's
218
+ own health line has always behaved.
219
+ -->
220
+ <span v-if="focusedHealth.lastFrame" class="vms__health-item">
221
+ Last picture <b>{{ focusedHealth.lastFrame }}</b>
201
222
  </span>
202
223
  <span v-if="focusedHealth.detail" class="vms__health-item">
203
224
  {{ focusedHealth.detail }}
204
225
  </span>
205
- <span v-if="focusedHealth.deviceDetail" class="vms__health-item">
206
- {{ focusedHealth.deviceDetail }}
207
- </span>
208
226
  <span v-if="healthFailed" class="vms__health-item">{{ healthFailed }}</span>
209
227
  </div>
210
228
 
229
+ <!--
230
+ WHAT THE TILE's BADGE MEANS, said once here rather than nine times
231
+ across the wall — and said in the reader's terms, not ours. See
232
+ `UNVERIFIED_DETAIL`.
233
+ -->
234
+ <p v-if="embedsPlayer" class="vms__badge-note">{{ UNVERIFIED_DETAIL }}</p>
235
+
236
+ <p class="vms__caps-head">Camera controls</p>
237
+ <!--
238
+ RULE 7. One reason, stated once, when it is genuinely one reason — see
239
+ `sharedCapabilityNote`. When the reasons differ the note is `null` and
240
+ every card carries its own, as before.
241
+ -->
242
+ <div v-if="sharedNote" class="vms__caps-note">
243
+ <p class="vms__cap-detail">{{ sharedNote.detail }}</p>
244
+ <p v-if="sharedNote.nextStep" class="vms__cap-next">{{ sharedNote.nextStep }}</p>
245
+ </div>
246
+
211
247
  <div class="vms__caps-row">
212
248
  <div
213
249
  v-for="c in WALL_CAPABILITY_CONTROLS"
@@ -224,17 +260,21 @@
224
260
  {{ capability(c.key).tag }}
225
261
  </span>
226
262
  </p>
227
- <!-- Working: what it does HERE. Off: the server's sentence, verbatim. -->
228
- <p class="vms__cap-detail">
229
- {{ capability(c.key).available ? c.ready : capability(c.key).detail }}
230
- </p>
263
+ <!--
264
+ Working: what it does HERE. Off: the server's sentence, verbatim
265
+ (rule 3) unless the whole group shares that sentence, in which
266
+ case it has already been said once above and the card carries only
267
+ its title and its tag.
268
+ -->
269
+ <p v-if="capabilityText(c)" class="vms__cap-detail">{{ capabilityText(c) }}</p>
231
270
  <!--
232
271
  RULE 5. The line the server cannot write, because it does not know
233
272
  who the reader can go and ask. Somebody read "Not set up yet" on this
234
273
  screen and asked how to set it up — which is a fair question that the
235
- screen was not answering.
274
+ screen was not answering. Suppressed here for the same reason as
275
+ the sentence above it: it is in the group note.
236
276
  -->
237
- <p v-if="capability(c.key).nextStep" class="vms__cap-next">
277
+ <p v-if="!sharedNote && capability(c.key).nextStep" class="vms__cap-next">
238
278
  {{ capability(c.key).nextStep }}
239
279
  </p>
240
280
  </div>
@@ -250,8 +290,10 @@ import {
250
290
  capabilityView,
251
291
  clampPage,
252
292
  DEFAULT_WALL_LAYOUT,
293
+ focusedCamera,
253
294
  healthView,
254
295
  isDenseLayout,
296
+ sharedCapabilityNote,
255
297
  wallCameras,
256
298
  wallLayout,
257
299
  wallPageCount,
@@ -407,17 +449,57 @@ function togglePick(id: string) {
407
449
 
408
450
  /* --------------------------------------------------------- what is focused */
409
451
 
452
+ /**
453
+ * WHICH TILE THE PANEL IS ABOUT — and it is always a tile you can see.
454
+ *
455
+ * `focusedId` is what somebody clicked; `focused` is what the panel actually
456
+ * describes, and the two are deliberately not the same thing. It is resolved out
457
+ * of `tiles`, which is the CURRENT PAGE, so:
458
+ *
459
+ * - before anyone clicks, the panel describes the first tile on the wall;
460
+ * - paging away from your selection re-resolves to the first tile of the page
461
+ * you are now on, rather than describing a camera that is no longer drawn;
462
+ * - paging back returns to your selection, because `focusedId` is not cleared.
463
+ *
464
+ * **The tile highlight is bound to `focused`, not to `focusedId`** — that one
465
+ * character was the whole defect behind "the panel repeats the tile's name".
466
+ * Bound to `focusedId`, the fallback cases above lit NO tile at all: the panel
467
+ * described the first camera on the page and nothing on the wall said so, which
468
+ * is exactly why the name had to be printed in the panel to make sense of it.
469
+ * Bound to `focused`, the wall always shows which tile is being described, and
470
+ * the name in the panel becomes the repetition the owner said it was.
471
+ */
410
472
  const focusedId = ref("");
411
- const focused = computed(
412
- () => tiles.value.find((c) => c._id === focusedId.value) || tiles.value[0] || null
413
- );
473
+ const focused = computed(() => focusedCamera(tiles.value, focusedId.value));
414
474
 
415
475
  function capability(key: string) {
416
476
  return capabilityView(focused.value, key);
417
477
  }
418
478
 
419
479
  /**
420
- * Whether this camera embeds a player at all i.e. whether the "Not verified"
480
+ * Rule 7: the one reason the whole group is off, or `null` when they differ.
481
+ * When it is set, the cards stop repeating it — see the template.
482
+ */
483
+ const sharedNote = computed(() =>
484
+ sharedCapabilityNote(
485
+ focused.value,
486
+ WALL_CAPABILITY_CONTROLS.map((c) => c.key)
487
+ )
488
+ );
489
+
490
+ /**
491
+ * What one card says under its title: what the control does when it works, the
492
+ * server's reason when it does not, and NOTHING when that reason has already
493
+ * been given once for the group.
494
+ */
495
+ function capabilityText(control: (typeof WALL_CAPABILITY_CONTROLS)[number]) {
496
+ const view = capability(control.key);
497
+ if (view.available) return control.ready;
498
+ return sharedNote.value ? "" : view.detail;
499
+ }
500
+
501
+ /**
502
+ * Whether this camera embeds a player at all — i.e. whether the "Not confirmed"
421
503
  * badge is something its tile can ever show. A camera the server has already
422
504
  * refused has a reason of its own on the tile and does not need this one too.
423
505
  */
@@ -470,7 +552,7 @@ async function sweepHealth() {
470
552
  } catch {
471
553
  // Kept, not cleared: the last known health is still the most useful thing
472
554
  // we have, and "the recorder was fine a moment ago" is a real answer.
473
- healthFailed.value = "The camera health check did not answer this time.";
555
+ healthFailed.value = "Could not check the recorder just now.";
474
556
  }
475
557
  }
476
558
 
@@ -831,14 +913,31 @@ async function toggleFullscreen() {
831
913
  border-top: 1px solid var(--vms-line);
832
914
  }
833
915
 
916
+ /*
917
+ Now a GROUP LABEL rather than the camera's name repeated: it names what the
918
+ row below it is, which is the one job a heading here has. The camera is
919
+ identified once, in the status line above.
920
+ */
834
921
  .vms__caps-head {
835
- margin: 0 0 8px;
922
+ margin: 0 0 6px;
836
923
  font-size: 11px;
837
924
  letter-spacing: 0.06em;
838
925
  text-transform: uppercase;
839
926
  color: var(--vms-text-dim);
840
927
  }
841
928
 
929
+ /*
930
+ RULE 7's one-sentence-for-the-group. Sits between the group label and the
931
+ cards, indented on the same 2 px accent rail the cards wear, so it reads as
932
+ belonging to all of them rather than to the first one.
933
+ */
934
+ .vms__caps-note {
935
+ margin: 0 0 8px;
936
+ padding-left: 10px;
937
+ border-left: 2px solid var(--vms-accent);
938
+ max-width: 92ch;
939
+ }
940
+
842
941
  .vms__caps-row {
843
942
  display: grid;
844
943
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
@@ -5,6 +5,7 @@
5
5
  :class="{ 'vms-tile--dense': dense, 'vms-tile--focused': focused, 'vms-tile--empty': !camera }"
6
6
  :tabindex="camera ? 0 : -1"
7
7
  :aria-label="camera ? `${camera.name || camera._id}, ${view.label}` : 'Empty position'"
8
+ :aria-current="focused ? 'true' : undefined"
8
9
  @click="camera && $emit('focus', camera)"
9
10
  @dblclick="camera && toggleFullscreen()"
10
11
  @keydown.enter="camera && toggleFullscreen()"
@@ -178,10 +179,10 @@ import {
178
179
  * origin's document is blocked by the browser — correctly.
179
180
  *
180
181
  * 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
+ * pretends otherwise. A loaded page earns **"Not confirmed"**, not "Live"; only
182
183
  * a frame the video service itself reports over `postMessage` earns "Live".
183
184
  * 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
+ * confirmed" — which is the true answer. It went in because on the live wall two
185
186
  * cameras rendered a blank rectangle under a green "Live" badge, which is the
186
187
  * one thing a monitoring screen must never do.
187
188
  *
@@ -239,10 +240,13 @@ const healthLine = computed(() => {
239
240
  return lastFrame && !props.dense ? `${label} · ${lastFrame}` : label;
240
241
  });
241
242
 
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
- );
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 || "");
246
250
 
247
251
  /* -------------------------------------------------------------------- zoom */
248
252
 
@@ -432,7 +436,7 @@ function restartFrameClock() {
432
436
  * direction.
433
437
  *
434
438
  * 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"
439
+ * loaded is already at `page-up` and stays there. Sitting on "Not confirmed"
436
440
  * indefinitely is deliberate — declaring a camera dead on a timer, with no
437
441
  * evidence, is the same mistake as declaring it live on a timer.
438
442
  */
@@ -483,8 +487,40 @@ async function toggleFullscreen() {
483
487
  min-height: 0;
484
488
  }
485
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
+ */
486
516
  .vms-tile--focused {
487
- box-shadow: inset 0 0 0 2px var(--vms-accent, #5b9be0);
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);
488
524
  }
489
525
 
490
526
  .vms-tile:focus-visible {
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@7365admin1/layer-common",
3
3
  "license": "MIT",
4
4
  "type": "module",
5
- "version": "3.2.1-staging.67",
5
+ "version": "3.2.1-staging.69",
6
6
  "author": "7365admin1",
7
7
  "main": "./nuxt.config.ts",
8
8
  "publishConfig": {
@@ -4,13 +4,16 @@ import { test } from "node:test";
4
4
  import {
5
5
  agoLabel,
6
6
  capabilityView,
7
+ CAPABILITY_NEXT_STEPS,
7
8
  clampPage,
8
9
  clampPan,
9
10
  clampZoom,
11
+ focusedCamera,
10
12
  healthView,
11
13
  isDenseLayout,
12
14
  playerOrigin,
13
15
  playerSignal,
16
+ sharedCapabilityNote,
14
17
  tileView,
15
18
  wallCameras,
16
19
  wallLayout,
@@ -69,6 +72,46 @@ test("a selection sets the order, and a stale id is dropped rather than drawn",
69
72
  assert.equal(tiles[0]._id, "b");
70
73
  });
71
74
 
75
+ /* ------------------------------------------- which tile the panel is about */
76
+
77
+ /*
78
+ THE PANEL STOPPED PRINTING THE CAMERA'S NAME, AND THESE ARE THE TWO FACTS
79
+ THAT LET IT.
80
+
81
+ The name was there so the reader could tell which of up to nine tiles the
82
+ panel described. It is now the selected tile's accent ring that answers that —
83
+ which is only safe while the panel can never be about a tile that is not
84
+ drawn. Five cameras across two pages is exactly the case the owner asked
85
+ about, so it is exactly the case pinned here.
86
+ */
87
+ const FIVE = ["a", "b", "c", "d", "e"].map((id) => ip({ _id: id, name: id.toUpperCase() }));
88
+
89
+ test("nothing selected yet: the panel is about the first tile ON THIS PAGE", () => {
90
+ assert.equal(focusedCamera(wallTiles(FIVE, [], "2x2", 0), "")?._id, "a");
91
+ // Page 2 of the same wall holds one camera, and the panel is about that one —
92
+ // not still about "a", which is no longer on screen.
93
+ assert.equal(focusedCamera(wallTiles(FIVE, [], "2x2", 1), "")?._id, "e");
94
+ });
95
+
96
+ test("paging away from your selection never leaves the panel describing an off-page camera", () => {
97
+ // Selected "a" on page 1, then paged to page 2. "a" is not drawn there.
98
+ const pageTwo = wallTiles(FIVE, [], "2x2", 1);
99
+ const shown = focusedCamera(pageTwo, "a");
100
+ assert.equal(shown?._id, "e");
101
+ assert.ok(
102
+ pageTwo.some((c) => c._id === shown?._id),
103
+ "the panel must always describe a tile on the current page"
104
+ );
105
+ });
106
+
107
+ test("paging back restores the selection, because the raw id is kept", () => {
108
+ assert.equal(focusedCamera(wallTiles(FIVE, [], "2x2", 0), "c")?._id, "c");
109
+ });
110
+
111
+ test("an empty wall has nothing to describe", () => {
112
+ assert.equal(focusedCamera([], "a"), null);
113
+ });
114
+
72
115
  /* ---------------------------------------------------------------- rule 2/3 */
73
116
 
74
117
  test("no descriptor at all counts as NOT available", () => {
@@ -131,7 +174,98 @@ test("a switched-off control says what would switch it on and who to ask", () =>
131
174
  // The server's sentence is still verbatim — rule 3 is not traded away for rule 5.
132
175
  assert.match(v.detail, /^No direct connection/);
133
176
  assert.match(v.nextStep, /Seven365 administrator/);
134
- assert.match(v.nextStep, /not something to switch on from this page/i);
177
+ assert.match(v.nextStep, /not switchable from this page/i);
178
+ });
179
+
180
+ /*
181
+ THE ADVICE HAS TO NAME THE TWO CHANGES, AND IT HAS TO STAY OUT OF THE
182
+ DEPLOYMENT MANUAL.
183
+
184
+ "Ask your Seven365 administrator" was the whole of the old advice and it was a
185
+ dead end — worse than useless for the owner, who IS the administrator. What
186
+ makes the new line actionable is that it names the two real changes: the
187
+ network has to let the server reach the recorder, and the recorder then has to
188
+ be configured on the server. Both halves are pinned here, because dropping
189
+ either one silently returns the sentence to a shrug.
190
+
191
+ And the other direction is pinned too: no environment variable name may appear
192
+ in copy a property manager reads. The names are correct, they are in
193
+ `iservice365-core/docs/camera-integration-config.md`, and they are unreadable
194
+ to the person this screen is written for.
195
+ */
196
+ test("the setup advice names the network change AND the server change", () => {
197
+ const advice = CAPABILITY_NEXT_STEPS["device-http-not-configured"];
198
+ assert.match(advice, /network/i);
199
+ assert.match(advice, /recorder/i);
200
+ assert.match(advice, /server's configuration/i);
201
+ });
202
+
203
+ test("no environment variable name is ever shown to a reader", () => {
204
+ // Anything SCREAMING_SNAKE would be a deployment detail leaking into product
205
+ // copy. `CAMERA_DEVICE_HTTP` and friends belong in the deployment document.
206
+ for (const [reason, advice] of Object.entries(CAPABILITY_NEXT_STEPS)) {
207
+ assert.doesNotMatch(advice, /[A-Z][A-Z0-9]*_[A-Z0-9_]+/, `${reason} names a variable`);
208
+ }
209
+ });
210
+
211
+ /* ---------------------------------------------------------------- rule 7 */
212
+
213
+ const OFF_FOR_THE_SAME_REASON = {
214
+ state: "unsupported",
215
+ reason: "device-http-not-configured",
216
+ detail: "No direct connection to this camera's recorder is configured on this server.",
217
+ };
218
+ const CAP_KEYS = WALL_CAPABILITY_CONTROLS.map((c) => c.key);
219
+
220
+ test("four controls off for one reason state that reason once", () => {
221
+ // What the owner actually saw: the same sentence and the same next step,
222
+ // four times in a row, under four different titles.
223
+ const cam = ip({
224
+ capabilities: {
225
+ // Exactly the live wall: zoom works, the other four are off together.
226
+ digitalZoom: { state: "supported" },
227
+ playback: OFF_FOR_THE_SAME_REASON,
228
+ events: OFF_FOR_THE_SAME_REASON,
229
+ ptz: OFF_FOR_THE_SAME_REASON,
230
+ presets: OFF_FOR_THE_SAME_REASON,
231
+ },
232
+ });
233
+ const note = sharedCapabilityNote(cam, CAP_KEYS);
234
+ assert.match(note.detail, /^No direct connection/);
235
+ assert.match(note.nextStep, /Seven365 administrator/);
236
+ });
237
+
238
+ test("controls off for DIFFERENT reasons are never merged into one", () => {
239
+ // "The recorder is unreachable" and "this camera cannot move" are two
240
+ // problems with two different people to ask. Flattening them would be a lie
241
+ // of convenience, so the group note is refused and each card keeps its own.
242
+ const cam = ip({
243
+ capabilities: {
244
+ playback: OFF_FOR_THE_SAME_REASON,
245
+ ptz: { state: "unsupported", reason: "device-no-ptz", detail: "This camera cannot move." },
246
+ },
247
+ });
248
+ assert.equal(sharedCapabilityNote(cam, CAP_KEYS), null);
249
+ });
250
+
251
+ test("one control off on its own does not get a group note", () => {
252
+ const cam = ip({
253
+ capabilities: {
254
+ playback: OFF_FOR_THE_SAME_REASON,
255
+ events: { state: "supported" },
256
+ ptz: { state: "supported" },
257
+ presets: { state: "supported" },
258
+ digitalZoom: { state: "supported" },
259
+ },
260
+ });
261
+ assert.equal(sharedCapabilityNote(cam, CAP_KEYS), null);
262
+ });
263
+
264
+ test("a camera with no descriptor at all still groups its unknowns", () => {
265
+ // Every control reads "Not checked yet" with the same not-probed sentence —
266
+ // which is four identical cards, the same noise, and the same fix.
267
+ const note = sharedCapabilityNote(ip(), CAP_KEYS);
268
+ assert.match(note.detail, /has not been asked/);
135
269
  });
136
270
 
137
271
  test("a reason code this build has never seen still names somebody to ask", () => {
@@ -175,15 +309,19 @@ test("a page that has not loaded is Connecting, never Live", () => {
175
309
  * green "Live". The page had loaded; no picture was arriving. A loaded page is
176
310
  * now worth exactly one thing, and it is not the word "Live".
177
311
  */
178
- test("a loaded player page is NOT Live - it is Not verified", () => {
312
+ test("a loaded player page is NOT Live - it is Not confirmed", () => {
179
313
  const v = tileView(ip(), { player: "page-up" });
180
314
  assert.equal(v.state, "unverified");
181
- assert.equal(v.label, "Not verified");
315
+ assert.equal(v.label, "Not confirmed");
182
316
  // No scrim: there may be a perfectly good picture under that badge, and
183
317
  // covering a working camera would be a worse lie than the one being fixed.
184
318
  assert.equal(v.detail, null);
185
319
  assert.equal(v.showPlayer, true);
186
- assert.match(v.note, /cannot confirm a picture is arriving/);
320
+ assert.match(v.note, /cannot confirm the picture is arriving/);
321
+ // Plain English, and an instruction rather than an explanation: the reader is
322
+ // a guard, not a browser engineer. Nothing about origins, players or frames.
323
+ assert.match(v.note, /check the tile/i);
324
+ assert.doesNotMatch(v.note, /browser|origin|player|iframe|service/i);
187
325
  });
188
326
 
189
327
  test("only a reported frame earns the word Live", () => {
@@ -349,16 +487,44 @@ test("reachability alone is enough when the server sends no summary word", () =>
349
487
  assert.equal(healthView({ reachable: false }).tone, "down");
350
488
  });
351
489
 
352
- test("the server's health sentences are carried through, not rewritten", () => {
490
+ test("the server's health sentence is carried through, not rewritten", () => {
353
491
  const v = healthView({
354
492
  health: "unreachable",
355
493
  reachable: false,
356
494
  reason: "The recorder did not respond.",
495
+ });
496
+ assert.equal(v.detail, "The recorder did not respond.");
497
+ });
498
+
499
+ /**
500
+ * The firmware-and-clock sentence is the server explaining why two values it
501
+ * never showed are blank. It was drawn on the panel and appended to the tile's
502
+ * tooltip; a guard can do nothing with a firmware version, so it is not carried
503
+ * at all now. Pinned, because "soften it" was the tempting answer and the wrong
504
+ * one.
505
+ */
506
+ test("the firmware and device-clock sentence is not carried to the screen", () => {
507
+ const v = healthView({
508
+ health: "ok",
509
+ reachable: true,
357
510
  detailUnavailableReason:
358
511
  "Firmware and device clock are not available: this recorder is reachable over video only.",
359
512
  });
360
- assert.equal(v.detail, "The recorder did not respond.");
361
- assert.match(v.deviceDetail, /^Firmware and device clock/);
513
+ assert.equal(v.detail, null);
514
+ assert.equal("deviceDetail" in v, false);
515
+ });
516
+
517
+ /**
518
+ * The complaint that started this: a green "Recorder responding" beside "Last
519
+ * picture this server received: none yet" reads as a contradiction. It is not
520
+ * one — but the panel now simply says nothing when there has never been a
521
+ * picture, which is the only reading a guard can act on.
522
+ */
523
+ test("a recorder that is up with no picture yet reports no picture line at all", () => {
524
+ const v = healthView({ health: "ok", reachable: true, lastFrameAt: null });
525
+ assert.equal(v.tone, "ok");
526
+ assert.equal(v.label, "Recorder responding");
527
+ assert.equal(v.lastFrame, null);
362
528
  });
363
529
 
364
530
  test("a picture's age is coarse above a minute, and never a fake time", () => {
@@ -196,6 +196,30 @@ export function wallTiles(
196
196
  return chosen.slice(start, start + tiles);
197
197
  }
198
198
 
199
+ /**
200
+ * WHICH TILE THE DETAIL PANEL IS ABOUT, and the guarantee that makes the panel
201
+ * able to stop naming it.
202
+ *
203
+ * The panel used to print the camera's name so the reader could tell which of
204
+ * up to nine tiles it was describing. The name is gone — the selected tile wears
205
+ * an accent ring instead — and that only works because **this can never return a
206
+ * camera that is not on the page being looked at.** It resolves out of the
207
+ * CURRENT page's tiles, so a selection left behind on page 1 does not leave the
208
+ * panel describing an invisible camera from page 2; it falls back to the first
209
+ * tile of the page you are actually on, which the ring then marks.
210
+ *
211
+ * The caller keeps the raw id, so paging back restores the original selection.
212
+ *
213
+ * Extracted from the component purely so this guarantee is a tested rule rather
214
+ * than a line of template nobody would think to check.
215
+ */
216
+ export function focusedCamera(
217
+ tiles: TWallCamera[],
218
+ focusedId: string | undefined
219
+ ): TWallCamera | null {
220
+ return tiles.find((c) => c._id === focusedId) || tiles[0] || null;
221
+ }
222
+
199
223
  /**
200
224
  * How many grid rows this page actually needs — the other half of packing.
201
225
  *
@@ -253,22 +277,49 @@ const NOT_PROBED = "This camera has not been asked what it can do yet.";
253
277
  * **Every one of these says the same true and unwelcome thing in a different
254
278
  * way: none of it is switched on from this page.** Saying so plainly is the
255
279
  * point. An empty settings screen that quietly does nothing would be worse.
280
+ *
281
+ * ## "Ask your Seven365 administrator" was a dead end, and it has been replaced
282
+ *
283
+ * That was the whole of the advice, and the owner — who IS the administrator —
284
+ * read it on the live wall and pointed out that it tells him nothing. It tells a
285
+ * property manager nothing either: they raise a ticket and wait, because the
286
+ * screen never said what the ticket should ASK FOR.
287
+ *
288
+ * The real answer is two changes, and they are two different jobs for two
289
+ * different people:
290
+ *
291
+ * 1. **a network change** — the site's network has to let our server reach the
292
+ * camera recorder. Today it does not: the recorder answers video only.
293
+ * 2. **a configuration change on our server** — that recorder then has to be
294
+ * added to the server's configuration.
295
+ *
296
+ * So each line below names the change that is missing and who does it, in that
297
+ * order. **No environment variable is named**, deliberately: the names belong in
298
+ * the deployment documentation (`iservice365-core/docs/camera-integration-config.md`),
299
+ * not on a property manager's screen, and printing them would make the sentence
300
+ * unreadable to the person it is written for without helping the person who
301
+ * needs them, who has the document.
302
+ *
303
+ * Nor does any line point at the camera setup guide. That guide covers ADDING a
304
+ * camera in Site Settings; it does not cover recorder connectivity, so sending
305
+ * the reader there would be sending them somewhere that does not answer the
306
+ * question. Naming the two changes IS the actionable content.
256
307
  */
257
308
  export const CAPABILITY_NEXT_STEPS: Record<string, string> = {
258
309
  "device-http-not-configured":
259
- "This is a server setting, not something to switch on from this page: the server needs a network route to the camera's recorder, plus the recorder's sign-in details. Ask your Seven365 administrator.",
310
+ "Not switchable from this page. It needs the site's network opened so our server can reach the camera recorder, and that recorder then added to the server's configuration your Seven365 administrator arranges both with whoever installed the cameras.",
260
311
  "device-http-disabled":
261
- "Direct camera access is switched off on this server. Ask your Seven365 administrator to switch it on.",
312
+ "Not switchable from this page. The connection to the recorder exists but is turned off in the server's configuration your Seven365 administrator turns it on.",
262
313
  "device-http-unreachable":
263
- "The recorder did not answer this server. Ask your Seven365 administrator or the camera installer to check the recorder and its network.",
314
+ "Our server cannot reach this camera's recorder. Usually the site's network is blocking it or the recorder is down your Seven365 administrator and whoever installed the cameras check it between them.",
264
315
  "device-http-locked-out":
265
- "Sign-ins to the recorder are being held back so its account is not locked out. Ask your Seven365 administrator to check the recorder's sign-in details.",
316
+ "Sign-ins to the recorder are being held back so its account is not locked out. It clears on its own; if it keeps happening the password held for the recorder is wrong and your Seven365 administrator corrects it in the server's configuration.",
266
317
  "control-not-enabled":
267
- "Camera control is switched off on this server. Switching it on moves real hardware, so it has to be approved by a Seven365 backend lead first.",
318
+ "Not switchable from this page. This one moves real hardware, so a Seven365 backend lead has to approve it before it is turned on in the server's configuration.",
268
319
  "device-no-ptz":
269
320
  "This camera cannot move, so there is nothing to switch on. A different camera would be needed.",
270
321
  "no-recorder-configured":
271
- "No recorder is set up for this camera's video service. Ask your Seven365 administrator to add it.",
322
+ "Not switchable from this page. This camera's video service has no recorder linked to it in the server's configuration — your Seven365 administrator adds it.",
272
323
  "no-channel-in-address":
273
324
  "This camera's address does not say which stream it is. Correct it in Site Settings, under CCTV.",
274
325
  "not-a-relay-player-url":
@@ -282,7 +333,7 @@ export const CAPABILITY_NEXT_STEPS: Record<string, string> = {
282
333
  "device-not-probed":
283
334
  "The camera is asked what it can do the first time it is opened. Nothing to do — check back in a moment.",
284
335
  "no-transport":
285
- "Nothing on this server can do this yet. It has to be built before it can be switched on.",
336
+ "Not built yet. Nothing on this server can do it.",
286
337
  };
287
338
 
288
339
  /**
@@ -292,7 +343,7 @@ export const CAPABILITY_NEXT_STEPS: Record<string, string> = {
292
343
  * setting on this page, and there is still somebody to ask.
293
344
  */
294
345
  const NEXT_STEP_FALLBACK =
295
- "This is not something to switch on from this page. Ask your Seven365 administrator.";
346
+ "Not switchable from this page it needs a change to the server's configuration, which your Seven365 administrator makes.";
296
347
 
297
348
  /**
298
349
  * The capabilities the wall draws a control for, and their plain names.
@@ -386,6 +437,47 @@ export function capabilityView(
386
437
  };
387
438
  }
388
439
 
440
+ /**
441
+ * RULE 7 — SAY IT ONCE FOR THE GROUP WHEN IT IS ONE FACT, NOT FOUR.
442
+ *
443
+ * On this estate all four of the switched-off controls are off for the SAME
444
+ * reason: there is no direct connection to the recorder. So the panel drew four
445
+ * cards, each carrying an identical sentence and an identical next step —
446
+ * eight lines of repetition saying one thing. Four copies of a sentence do not
447
+ * make it four facts; they make the reader skim, and skimming is how the one
448
+ * line that mattered got missed.
449
+ *
450
+ * So when every unavailable control shares the same reason, this returns it and
451
+ * the panel prints it once above the row. Each card keeps its own title and its
452
+ * own tag, so the individual state is never lost — only the echo is.
453
+ *
454
+ * Returns `null` the moment the reasons DIFFER, which is the case that must not
455
+ * be flattened: "the recorder is unreachable" and "this camera cannot move" are
456
+ * two different problems with two different people to ask, and merging them
457
+ * would be a lie of convenience. Also `null` when nothing is off (nothing to
458
+ * explain) or when only one control is off (a group note for a group of one is
459
+ * just the card, moved).
460
+ */
461
+ export function sharedCapabilityNote(
462
+ camera: TWallCamera | null | undefined,
463
+ keys: readonly string[]
464
+ ): { detail: string; nextStep: string | null } | null {
465
+ const off = keys
466
+ .map((key) => capabilityView(camera, key))
467
+ .filter((view) => !view.available);
468
+
469
+ if (off.length < 2) return null;
470
+
471
+ const first = off[0];
472
+ const same = off.every(
473
+ (view) => view.detail === first.detail && view.nextStep === first.nextStep
474
+ );
475
+
476
+ return same && first.detail
477
+ ? { detail: first.detail, nextStep: first.nextStep }
478
+ : null;
479
+ }
480
+
389
481
  /* -------------------------------------------------------------------------- */
390
482
  /* Tile state */
391
483
  /* -------------------------------------------------------------------------- */
@@ -428,10 +520,16 @@ export type TTileState =
428
520
  *
429
521
  * `unverified` is the state this wall is in for every camera on this estate
430
522
  * today, and saying so is the point — see the note above `tileView`.
523
+ *
524
+ * It used to read "Not verified", which is a word from our side of the fence:
525
+ * it describes a check we did not manage to make. "Not confirmed" describes the
526
+ * PICTURE, which is the only thing the reader cares about, and it is the same
527
+ * claim. The instruction that goes with it — check the tile — is in
528
+ * `UNVERIFIED_DETAIL`, on the badge's tooltip and once on the panel.
431
529
  */
432
530
  export const TILE_LABELS = {
433
531
  live: "Live",
434
- unverified: "Not verified",
532
+ unverified: "Not confirmed",
435
533
  connecting: "Connecting…",
436
534
  "no-signal": "No signal",
437
535
  offline: "No connection",
@@ -548,12 +646,23 @@ export type TTileView = {
548
646
  };
549
647
 
550
648
  /**
551
- * The sentence behind "Not verified", said once on the focused camera's panel
552
- * rather than nine times across the wall. Invented here: no server has a
553
- * reason code for "your browser cannot see inside another origin's page".
649
+ * The sentence behind "Not confirmed", said once on the focused camera's panel
650
+ * rather than nine times across the wall.
651
+ *
652
+ * **Written for a guard or a property manager, not for us.** The first version
653
+ * of this line explained cross-origin isolation — "the player belongs to the
654
+ * video service and a browser cannot look inside it" — which is true, is the
655
+ * reason, and is none of the reader's business. The owner read it on the live
656
+ * wall and said so. A reader of this screen has exactly three questions: is
657
+ * this camera working, what do I do if it is not, and what can I do here. WHY
658
+ * the software cannot answer the first one is our problem, not theirs.
659
+ *
660
+ * So: what we cannot do, then what they should do. Nothing else. The claim is
661
+ * every bit as honest as the paragraph it replaces — it just stops making the
662
+ * reader sit through an engineering lesson to get to the instruction.
554
663
  */
555
664
  export const UNVERIFIED_DETAIL =
556
- "The video page loaded, but this browser cannot confirm a picture is arriving. The player belongs to the video service and a browser cannot look inside it. Judge the tile by what you can see, and report a blank or frozen picture.";
665
+ "This page cannot confirm the picture is arriving, so check the tile yourself. If it is blank or frozen, report the camera.";
557
666
 
558
667
  /**
559
668
  * Rule 4, plus every reason a tile may have nothing to show.
@@ -632,14 +741,14 @@ export function tileView(
632
741
  // we genuinely cannot tell a dead camera from a dead video service.
633
742
  return showing(
634
743
  "no-signal",
635
- "The live view did not load. The camera or the video service may be down."
744
+ "No picture. The camera or the video service may be down. Report it if it stays like this."
636
745
  );
637
746
  case "stalled":
638
747
  // It WAS playing and stopped. That is the one case where we can say a
639
748
  // picture has genuinely gone away, because we saw one arrive first.
640
749
  return showing(
641
750
  "no-signal",
642
- "The picture stopped arriving. The camera or the video service may have dropped."
751
+ "The picture stopped arriving. The camera or the video service may have dropped. Report it if it does not come back."
643
752
  );
644
753
  case "playing":
645
754
  return showing("live", null);
@@ -688,15 +797,27 @@ export type THealthView = {
688
797
  detail: string | null;
689
798
  /** "4 min ago", or `null` when this server has never had a picture. */
690
799
  lastFrame: string | null;
691
- /** Why firmware and clock are blank. The server's sentence, verbatim. */
692
- deviceDetail: string | null;
693
800
  };
694
801
 
802
+ /*
803
+ THE FIRMWARE-AND-CLOCK LINE IS GONE, AND IT IS NOT COMING BACK.
804
+
805
+ The server sends `detailUnavailableReason` — "Firmware and device clock are
806
+ not available: this recorder is reachable over video only." — so that two
807
+ blank fields do not read as "checked, and fine". That is a good reason to
808
+ send it and a bad reason to DRAW it: this screen never showed firmware or a
809
+ device clock in the first place, so the sentence was explaining the absence
810
+ of something the reader had not been offered and could not use. A guard
811
+ cannot act on a firmware version. Softening the wording would have kept the
812
+ noise, so the line is removed instead. The field is still on
813
+ `TWallCameraHealth` because the API sends it; nothing on the wall draws it.
814
+ */
815
+
695
816
  const HEALTH_LABELS: Record<string, { tone: THealthTone; label: string }> = {
696
817
  ok: { tone: "ok", label: "Recorder responding" },
697
818
  drifted: { tone: "warn", label: "Recorder clock is out" },
698
819
  unreachable: { tone: "down", label: "Recorder not responding" },
699
- unsupported: { tone: "unknown", label: "Not checked" },
820
+ unsupported: { tone: "unknown", label: "Recorder not checked" },
700
821
  };
701
822
 
702
823
  /**
@@ -716,7 +837,7 @@ export function healthView(
716
837
  ? HEALTH_LABELS.ok
717
838
  : health?.reachable === false
718
839
  ? HEALTH_LABELS.unreachable
719
- : { tone: "unknown", label: "Health not checked yet" };
840
+ : { tone: "unknown", label: "Recorder not checked yet" };
720
841
 
721
842
  const { tone, label } = known ?? fallback;
722
843
 
@@ -725,7 +846,6 @@ export function healthView(
725
846
  label,
726
847
  detail: (health?.reason || "").trim() || null,
727
848
  lastFrame: agoLabel(health?.lastFrameAt, now),
728
- deviceDetail: (health?.detailUnavailableReason || "").trim() || null,
729
849
  };
730
850
  }
731
851