@7365admin1/layer-common 3.2.1-staging.68 → 3.2.1-staging.70
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.changeset/camera-wall-selection-and-guidance.md +65 -0
- package/.changeset/dark-primary-contrast.md +39 -0
- package/components/CameraWall.vue +49 -23
- package/components/CameraWallTile.vue +34 -1
- package/components/Layout/NavigationDrawer.vue +13 -1
- package/package.json +1 -1
- package/plugins/vuetify.ts +3 -72
- package/utils/camera-wall.test.ts +74 -1
- package/utils/camera-wall.ts +58 -7
- package/utils/theme.test.ts +121 -0
- package/utils/theme.ts +101 -0
|
@@ -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.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
---
|
|
2
|
+
"@7365admin1/layer-common": patch
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
Split the dark theme's `primary` into a foreground colour and a fill, so text
|
|
6
|
+
using it is readable again.
|
|
7
|
+
|
|
8
|
+
Dark `primary` was set to the brand navy `#17506F` so the navigation drawer
|
|
9
|
+
would read as a surface. That is the right value for a fill — a white label on
|
|
10
|
+
it measures 8.70:1 — but Vuetify emits `.text-primary` from the same token, so
|
|
11
|
+
every `class="text-primary"` sentence and every `color="primary"` icon, spinner
|
|
12
|
+
and text button rendered navy on a dark background:
|
|
13
|
+
|
|
14
|
+
| | before | now |
|
|
15
|
+
|---|---|---|
|
|
16
|
+
| `primary` as text on the dark page `#0E1319` | 2.14:1 | **6.40:1** |
|
|
17
|
+
| `primary` as text on a dark card `#1B242F` | 1.80:1 | **5.38:1** |
|
|
18
|
+
| `primary` as text on `surface-bright` `#26313E`| 1.52:1 | **4.53:1** |
|
|
19
|
+
|
|
20
|
+
Dark `primary` is now `#5B9BE0`. That is not a new colour: it is the blue the
|
|
21
|
+
camera wall already draws its accents in (`--vms-accent`), so the two dark
|
|
22
|
+
surfaces in this product agree on one blue.
|
|
23
|
+
|
|
24
|
+
The navigation drawer no longer depends on `primary`. Both themes gain a
|
|
25
|
+
`brand-surface` colour — `#042134` light (identical to light `primary`, so
|
|
26
|
+
light mode renders exactly as before) and `#17506F` dark — and
|
|
27
|
+
`Layout/NavigationDrawer.vue` is painted with it. Its white label still reads
|
|
28
|
+
16.51:1 light and 8.70:1 dark. `floating` was dropped from the drawer so it
|
|
29
|
+
draws its own edge: the fill is 2.14:1 against the dark page, under the 3:1 a
|
|
30
|
+
boundary wants, and the border at `border-opacity` 0.35 is 3.22:1.
|
|
31
|
+
|
|
32
|
+
Where `primary` is still used as a fill, Vuetify derives `on-primary` from it
|
|
33
|
+
and picks black, which reads at 7.21:1. What a light `primary` cannot carry is
|
|
34
|
+
a hardcoded white label, and a handful of app-side sites still set one — see
|
|
35
|
+
the pull request for the list.
|
|
36
|
+
|
|
37
|
+
The light theme is unchanged. The colours moved to `utils/theme.ts` and
|
|
38
|
+
`utils/theme.test.ts` now measures them, so a token cannot be moved for one use
|
|
39
|
+
and quietly broken for the other again.
|
|
@@ -126,7 +126,7 @@
|
|
|
126
126
|
:online="online"
|
|
127
127
|
:health="healthById[camera._id]"
|
|
128
128
|
:now="now"
|
|
129
|
-
:focused="camera._id ===
|
|
129
|
+
:focused="camera._id === focused?._id"
|
|
130
130
|
@focus="focusedId = $event._id"
|
|
131
131
|
/>
|
|
132
132
|
</div>
|
|
@@ -171,32 +171,39 @@
|
|
|
171
171
|
-->
|
|
172
172
|
<div v-if="focused" class="vms__caps">
|
|
173
173
|
<!--
|
|
174
|
-
THE
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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, then — after 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.
|
|
188
196
|
-->
|
|
189
197
|
<div class="vms__health">
|
|
190
|
-
<span class="vms__health-state">
|
|
191
|
-
<span class="vms__health-dot" :class="`vms__health-dot--${focusedHealth.tone}`" />
|
|
192
|
-
{{ focused.name || focused._id }}
|
|
193
|
-
</span>
|
|
194
198
|
<!--
|
|
195
199
|
THE RECORDER's own state — a different fact from the tile badge above,
|
|
196
200
|
which is about the picture. They can disagree, and when they do the
|
|
197
201
|
reader needs both, not a merged verdict.
|
|
198
202
|
-->
|
|
199
|
-
<span class="vms__health-
|
|
203
|
+
<span class="vms__health-state">
|
|
204
|
+
<span class="vms__health-dot" :class="`vms__health-dot--${focusedHealth.tone}`" />
|
|
205
|
+
{{ focusedHealth.label }}
|
|
206
|
+
</span>
|
|
200
207
|
<!--
|
|
201
208
|
"Last picture" ONLY WHEN THERE HAS BEEN ONE.
|
|
202
209
|
|
|
@@ -283,6 +290,7 @@ import {
|
|
|
283
290
|
capabilityView,
|
|
284
291
|
clampPage,
|
|
285
292
|
DEFAULT_WALL_LAYOUT,
|
|
293
|
+
focusedCamera,
|
|
286
294
|
healthView,
|
|
287
295
|
isDenseLayout,
|
|
288
296
|
sharedCapabilityNote,
|
|
@@ -441,10 +449,28 @@ function togglePick(id: string) {
|
|
|
441
449
|
|
|
442
450
|
/* --------------------------------------------------------- what is focused */
|
|
443
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
|
+
*/
|
|
444
472
|
const focusedId = ref("");
|
|
445
|
-
const focused = computed(
|
|
446
|
-
() => tiles.value.find((c) => c._id === focusedId.value) || tiles.value[0] || null
|
|
447
|
-
);
|
|
473
|
+
const focused = computed(() => focusedCamera(tiles.value, focusedId.value));
|
|
448
474
|
|
|
449
475
|
function capability(key: string) {
|
|
450
476
|
return capabilityView(focused.value, key);
|
|
@@ -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()"
|
|
@@ -486,8 +487,40 @@ async function toggleFullscreen() {
|
|
|
486
487
|
min-height: 0;
|
|
487
488
|
}
|
|
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
|
+
*/
|
|
489
516
|
.vms-tile--focused {
|
|
490
|
-
box-shadow:
|
|
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);
|
|
491
524
|
}
|
|
492
525
|
|
|
493
526
|
.vms-tile:focus-visible {
|
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
<!-- LayoutNavigationDrawer.vue -->
|
|
2
2
|
<template>
|
|
3
|
-
|
|
3
|
+
<!--
|
|
4
|
+
`bg-brand-surface`, not `bg-primary`: `primary` is a foreground colour on a
|
|
5
|
+
dark page (it has to carry `class="text-primary"` sentences at 4.5:1), and a
|
|
6
|
+
fill that carries a white label cannot also be that. `brand-surface` is the
|
|
7
|
+
deep brand navy in both themes - identical to light `primary`, so light mode
|
|
8
|
+
is unchanged - and the white label on it reads 16.51:1 light, 8.70:1 dark.
|
|
9
|
+
|
|
10
|
+
`floating` dropped so the drawer draws its own edge. The fill is 2.14:1
|
|
11
|
+
against the dark page, under the 3:1 a boundary wants; the border is 3.22:1
|
|
12
|
+
and carries it. In light mode the border is black at 0.26 over the navy, so
|
|
13
|
+
nothing there changes.
|
|
14
|
+
-->
|
|
15
|
+
<v-navigation-drawer v-model="drawer" permanent class="bg-brand-surface">
|
|
4
16
|
<v-list>
|
|
5
17
|
<v-list-item>
|
|
6
18
|
<v-list-item-title class="text-h6 text-white">
|
package/package.json
CHANGED
package/plugins/vuetify.ts
CHANGED
|
@@ -42,66 +42,7 @@ 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
|
-
};
|
|
45
|
+
import { LIGHT_THEME, DARK_THEME } from "../utils/theme";
|
|
105
46
|
|
|
106
47
|
export default defineNuxtPlugin((app) => {
|
|
107
48
|
const vuetify = createVuetify({
|
|
@@ -152,18 +93,8 @@ export default defineNuxtPlugin((app) => {
|
|
|
152
93
|
* means the toggle, `v-theme-provider theme="light"`, the `plain-dark`
|
|
153
94
|
* layout and all of those comparisons keep working, and start being right.
|
|
154
95
|
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
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
|
|
96
|
+
* The colours themselves, and every ratio they were chosen for, are in
|
|
97
|
+
* `utils/theme.ts`, which `utils/theme.test.ts` measures on every run.
|
|
167
98
|
*/
|
|
168
99
|
theme: {
|
|
169
100
|
defaultTheme: "light",
|
|
@@ -4,9 +4,11 @@ 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,
|
|
@@ -70,6 +72,46 @@ test("a selection sets the order, and a stale id is dropped rather than drawn",
|
|
|
70
72
|
assert.equal(tiles[0]._id, "b");
|
|
71
73
|
});
|
|
72
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
|
+
|
|
73
115
|
/* ---------------------------------------------------------------- rule 2/3 */
|
|
74
116
|
|
|
75
117
|
test("no descriptor at all counts as NOT available", () => {
|
|
@@ -132,7 +174,38 @@ test("a switched-off control says what would switch it on and who to ask", () =>
|
|
|
132
174
|
// The server's sentence is still verbatim — rule 3 is not traded away for rule 5.
|
|
133
175
|
assert.match(v.detail, /^No direct connection/);
|
|
134
176
|
assert.match(v.nextStep, /Seven365 administrator/);
|
|
135
|
-
assert.match(v.nextStep, /not
|
|
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
|
+
}
|
|
136
209
|
});
|
|
137
210
|
|
|
138
211
|
/* ---------------------------------------------------------------- rule 7 */
|
package/utils/camera-wall.ts
CHANGED
|
@@ -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
|
-
"Not
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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.
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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":
|
|
@@ -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
|
-
"
|
|
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.
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
|
|
4
|
+
import { DARK_THEME, LIGHT_THEME } from "./theme.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The dark theme was shipped once with `primary` chosen for the one place it
|
|
8
|
+
* was a fill, which left the twenty-six places it is a sentence at 1.80:1.
|
|
9
|
+
* These are the measurements that would have caught it.
|
|
10
|
+
*
|
|
11
|
+
* WCAG 2.1 relative luminance and contrast ratio, nothing else.
|
|
12
|
+
*/
|
|
13
|
+
const luminance = (hex: string): number => {
|
|
14
|
+
const channels = [0, 2, 4]
|
|
15
|
+
.map((i) => parseInt(hex.slice(1 + i, 3 + i), 16) / 255)
|
|
16
|
+
.map((c) => (c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)));
|
|
17
|
+
|
|
18
|
+
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const contrast = (a: string, b: string): number => {
|
|
22
|
+
const [x, y] = [luminance(a), luminance(b)];
|
|
23
|
+
|
|
24
|
+
return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const WHITE = "#FFFFFF";
|
|
28
|
+
const dark = DARK_THEME.colors;
|
|
29
|
+
const light = LIGHT_THEME.colors;
|
|
30
|
+
|
|
31
|
+
/** Sanity check on the measuring tape before it is used to judge anything. */
|
|
32
|
+
test("contrast is measured the way WCAG measures it", () => {
|
|
33
|
+
assert.equal(contrast(WHITE, "#000000").toFixed(2), "21.00");
|
|
34
|
+
assert.equal(contrast("#777777", WHITE).toFixed(2), "4.48");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("dark primary is readable as text on the page, a card and a menu", () => {
|
|
38
|
+
assert.ok(
|
|
39
|
+
contrast(dark.primary, dark.background) >= 4.5,
|
|
40
|
+
`primary on the page: ${contrast(dark.primary, dark.background).toFixed(2)}`
|
|
41
|
+
);
|
|
42
|
+
assert.ok(
|
|
43
|
+
contrast(dark.primary, dark.surface) >= 4.5,
|
|
44
|
+
`primary on a card: ${contrast(dark.primary, dark.surface).toFixed(2)}`
|
|
45
|
+
);
|
|
46
|
+
assert.ok(
|
|
47
|
+
contrast(dark.primary, dark["surface-bright"]) >= 4.5,
|
|
48
|
+
`primary on surface-bright: ${contrast(
|
|
49
|
+
dark.primary,
|
|
50
|
+
dark["surface-bright"]
|
|
51
|
+
).toFixed(2)}`
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("light primary is readable as text on white", () => {
|
|
56
|
+
assert.ok(contrast(light.primary, WHITE) >= 4.5);
|
|
57
|
+
assert.ok(contrast(light["text-primary"], WHITE) >= 4.5);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("text-primary is readable on the surface each theme puts it on", () => {
|
|
61
|
+
assert.ok(contrast(dark["text-primary"], dark.background) >= 4.5);
|
|
62
|
+
assert.ok(contrast(dark["text-primary"], dark.surface) >= 4.5);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The drawer is the reason `primary` was moved in the first place. If someone
|
|
67
|
+
* points it back at `primary` this fails, because a foreground colour cannot
|
|
68
|
+
* carry a white label.
|
|
69
|
+
*/
|
|
70
|
+
test("the nav drawer fill carries a white label in both themes", () => {
|
|
71
|
+
assert.ok(
|
|
72
|
+
contrast(WHITE, dark["brand-surface"]) >= 4.5,
|
|
73
|
+
`white on the dark drawer: ${contrast(
|
|
74
|
+
WHITE,
|
|
75
|
+
dark["brand-surface"]
|
|
76
|
+
).toFixed(2)}`
|
|
77
|
+
);
|
|
78
|
+
assert.ok(contrast(WHITE, light["brand-surface"]) >= 4.5);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("light mode's drawer fill is exactly what light primary used to be", () => {
|
|
82
|
+
assert.equal(light["brand-surface"], light.primary);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("primary-button carries a white label in both themes", () => {
|
|
86
|
+
assert.ok(contrast(WHITE, dark["primary-button"]) >= 4.5);
|
|
87
|
+
assert.ok(contrast(WHITE, light["primary-button"]) >= 4.5);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Both themes correct the same two Vuetify defaults, and a value that drifts
|
|
92
|
+
* apart between them is a bug nobody sees until they flip the switch.
|
|
93
|
+
*/
|
|
94
|
+
test("disabled text clears 4.5:1 on the surface each theme puts it on", () => {
|
|
95
|
+
const over = (fg: string, bg: string, alpha: number) =>
|
|
96
|
+
"#" +
|
|
97
|
+
[0, 2, 4]
|
|
98
|
+
.map((i) =>
|
|
99
|
+
Math.round(
|
|
100
|
+
parseInt(fg.slice(1 + i, 3 + i), 16) * alpha +
|
|
101
|
+
parseInt(bg.slice(1 + i, 3 + i), 16) * (1 - alpha)
|
|
102
|
+
)
|
|
103
|
+
.toString(16)
|
|
104
|
+
.padStart(2, "0")
|
|
105
|
+
)
|
|
106
|
+
.join("");
|
|
107
|
+
|
|
108
|
+
assert.equal(LIGHT_THEME.variables["disabled-opacity"], 0.55);
|
|
109
|
+
assert.equal(DARK_THEME.variables["disabled-opacity"], 0.55);
|
|
110
|
+
assert.ok(contrast(over("#000000", WHITE, 0.55), WHITE) >= 4.5);
|
|
111
|
+
assert.ok(
|
|
112
|
+
contrast(over(WHITE, dark.background, 0.55), dark.background) >= 4.5
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("the dark divider clears the 3:1 a boundary wants", () => {
|
|
117
|
+
const border = "#62666a"; // white at 0.35 over the dark page
|
|
118
|
+
|
|
119
|
+
assert.equal(DARK_THEME.variables["border-opacity"], 0.35);
|
|
120
|
+
assert.ok(contrast(border, dark.background) >= 3);
|
|
121
|
+
});
|
package/utils/theme.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE TWO THEMES THIS PRODUCT SHIPS.
|
|
3
|
+
*
|
|
4
|
+
* They live here rather than inline in `plugins/vuetify.ts` so that
|
|
5
|
+
* `utils/theme.test.ts` can measure them. Every ratio quoted below is WCAG 2.1,
|
|
6
|
+
* alpha composited against the surface the value actually sits on, and the test
|
|
7
|
+
* re-measures the ones that matter on every run - this file has already been
|
|
8
|
+
* got wrong once by moving a colour for one use and forgetting the other.
|
|
9
|
+
*
|
|
10
|
+
* `disabled-opacity`: Material's 0.38 measures 2.68:1 on white, below the 4.5:1
|
|
11
|
+
* a sentence needs, and disabled fields in this product carry sentences people
|
|
12
|
+
* are expected to read. 0.55 is 4.74:1 on white and 6.21:1 on the dark page -
|
|
13
|
+
* the same value the camera wall settled on, for the same reason.
|
|
14
|
+
*
|
|
15
|
+
* `border-opacity`: 0.12 measures 1.32:1, which is a divider you cannot see.
|
|
16
|
+
* Dark goes to 0.35 (3.22:1) and carries the boundary properly. Light goes to
|
|
17
|
+
* 0.26 (1.88:1) - visible, but short of the 3:1 a component boundary wants.
|
|
18
|
+
* Reaching it on white needs 0.42, which turns every table and card edge into a
|
|
19
|
+
* mid-grey grid across eleven applications; that is a change somebody should
|
|
20
|
+
* look at before it ships, not one to slip in behind a contrast fix.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* `primary` IS BOTH A FILL AND A FOREGROUND, AND ONE VALUE CANNOT SERVE BOTH.
|
|
25
|
+
*
|
|
26
|
+
* Vuetify emits `.bg-primary` and `.text-primary` from the same token, so the
|
|
27
|
+
* colour that fills the navigation drawer is also the colour of every
|
|
28
|
+
* `class="text-primary"` sentence, every `color="primary"` icon, spinner and
|
|
29
|
+
* text button, and the focused outline on every input. On a dark page those
|
|
30
|
+
* pull in opposite directions: a fill that carries a white label wants to be
|
|
31
|
+
* dark, and a foreground that is readable on a dark card wants to be light.
|
|
32
|
+
* There is no overlap - a value light enough for 4.5:1 text on `surface`
|
|
33
|
+
* (#1B242F) can carry white at 3.48:1 at the very best.
|
|
34
|
+
*
|
|
35
|
+
* So the fill is not `primary` any more. `brand-surface` below is the deep
|
|
36
|
+
* brand navy, it is what the navigation drawer is painted with, and `primary`
|
|
37
|
+
* is free to be a foreground colour. The foreground uses outnumber the fill
|
|
38
|
+
* uses by roughly an order of magnitude, which is why the split falls this way
|
|
39
|
+
* round.
|
|
40
|
+
*/
|
|
41
|
+
export const LIGHT_THEME = {
|
|
42
|
+
dark: false,
|
|
43
|
+
colors: {
|
|
44
|
+
primary: "#042134",
|
|
45
|
+
/**
|
|
46
|
+
* The navigation drawer's own fill. Identical to light `primary`, so light
|
|
47
|
+
* mode renders exactly as it did before the drawer stopped using `primary`.
|
|
48
|
+
* White label on it: 16.51:1.
|
|
49
|
+
*/
|
|
50
|
+
"brand-surface": "#042134",
|
|
51
|
+
"primary-button": "#1867C0",
|
|
52
|
+
"text-primary": "#052439",
|
|
53
|
+
},
|
|
54
|
+
variables: {
|
|
55
|
+
"disabled-opacity": 0.55,
|
|
56
|
+
"border-opacity": 0.26,
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Only the keys that must differ; Vuetify merges the rest from its stock dark.
|
|
62
|
+
*/
|
|
63
|
+
export const DARK_THEME = {
|
|
64
|
+
dark: true,
|
|
65
|
+
colors: {
|
|
66
|
+
/**
|
|
67
|
+
* Stock dark's #121212 page and #212121 surface measure 1.16:1 apart, so a
|
|
68
|
+
* card, a dialog and the page behind them were one flat sheet. These are
|
|
69
|
+
* 1.19:1 - elevation on a dark screen is a genuinely small delta - but they
|
|
70
|
+
* are tinted towards the brand navy rather than neutral grey, and the
|
|
71
|
+
* divider opacity above is what actually draws the edges.
|
|
72
|
+
*/
|
|
73
|
+
background: "#0E1319",
|
|
74
|
+
surface: "#1B242F",
|
|
75
|
+
/** Stock dark ships a lavender #ccbfd6 here, which is not our product. */
|
|
76
|
+
"surface-bright": "#26313E",
|
|
77
|
+
"surface-light": "#26313E",
|
|
78
|
+
/**
|
|
79
|
+
* A FOREGROUND colour, chosen to be read: 6.40:1 on the page, 5.38:1 on a
|
|
80
|
+
* card, 4.53:1 on the bright surface. Not a new colour - it is the blue the
|
|
81
|
+
* camera wall already draws its accents in (`--vms-accent`), so the two
|
|
82
|
+
* dark surfaces in this product now agree on one blue.
|
|
83
|
+
*
|
|
84
|
+
* Where it is still used as a fill, Vuetify derives `on-primary` from it
|
|
85
|
+
* and picks black, which reads at 7.21:1. What it cannot carry is a
|
|
86
|
+
* hardcoded white label - see the note in the PR for the app-side sites
|
|
87
|
+
* that still do that.
|
|
88
|
+
*/
|
|
89
|
+
primary: "#5B9BE0",
|
|
90
|
+
/** The navigation drawer's own fill. White label on it: 8.70:1. */
|
|
91
|
+
"brand-surface": "#17506F",
|
|
92
|
+
/** Unchanged on purpose: it already passes in both themes. */
|
|
93
|
+
"primary-button": "#1867C0",
|
|
94
|
+
/** #052439 on a dark page measures 1.18:1. This is 15.72:1. */
|
|
95
|
+
"text-primary": "#E8ECF1",
|
|
96
|
+
},
|
|
97
|
+
variables: {
|
|
98
|
+
"disabled-opacity": 0.55,
|
|
99
|
+
"border-opacity": 0.35,
|
|
100
|
+
},
|
|
101
|
+
};
|