@7365admin1/layer-common 3.2.2-staging.80 → 3.2.2-staging.81

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,258 @@
1
+ /**
2
+ * THE RESPONSIVE SHELL PRIMITIVES.
3
+ *
4
+ * The prototype is not responsive. Rendered at 360 and 768 its sidebar stays a
5
+ * fixed 252px, the content column is clipped, and the hamburger only hides the
6
+ * rail instead of overlaying it - so there is no reference to copy and this
7
+ * behaviour is designed rather than transcribed. It is built from the same
8
+ * tokens, so nothing here introduces a colour, a size or a radius of its own.
9
+ *
10
+ * Breakpoints, and why these:
11
+ * >= 1024 the layout the design draws: 252px rail beside the content.
12
+ * < 1024 the rail becomes an overlay drawer behind the hamburger. 252px
13
+ * out of 1024 is a quarter of the screen; below that the content
14
+ * column stops being usable before the rail stops fitting.
15
+ * < 768 one column. Filter rows stack, the breadcrumb truncates.
16
+ * < 600 modals go near-full-screen with a 16px inset.
17
+ *
18
+ * TWO KINDS OF RULE LIVE HERE, and the distinction is deliberate:
19
+ *
20
+ * 1. Token adjustments at a breakpoint. These change what `--content-pad-x`
21
+ * and friends MEAN on a small screen. They reach every surface that reads
22
+ * the tokens and touch nothing that does not, which is why the padding is
23
+ * done this way instead of as a rule on someone else's markup.
24
+ *
25
+ * 2. Named primitives (`.app-shell`, `.app-topbar`, `.filter-row`,
26
+ * `.table-scroll`, `.modal-body`). These are what Phase 2 onwards builds
27
+ * the shell out of. NOTHING in Phase 1 applies them to a module screen -
28
+ * the primitives exist and are reviewable; rolling them across the
29
+ * product is the next phase's work.
30
+ *
31
+ * The one thing here that DOES reach the existing product is the touch-target
32
+ * rule at the bottom, and that is on purpose - see the note above it.
33
+ */
34
+
35
+ /* ------------------------------------------------------------------ */
36
+ /* 1. What the tokens mean on a smaller screen. */
37
+ /* ------------------------------------------------------------------ */
38
+
39
+ @media (max-width: 1023.98px) {
40
+ :root {
41
+ --content-pad-x: 20px;
42
+ --content-pad-y: 18px;
43
+ }
44
+ }
45
+
46
+ @media (max-width: 767.98px) {
47
+ :root {
48
+ --content-pad-x: 16px;
49
+ --content-pad-y: 16px;
50
+ --content-pad-bottom: 28px;
51
+ /* The row keeps its vertical rhythm and gives back side padding, which is
52
+ what buys a table its columns before it has to scroll. */
53
+ --cellpad-x: 14px;
54
+ --grid-gap: 10px;
55
+ }
56
+ }
57
+
58
+ @media (max-width: 599.98px) {
59
+ :root {
60
+ --content-pad-x: 14px;
61
+ /* 22px on a 360px screen is a headline that wraps to three lines. */
62
+ --fs-h1: 19px;
63
+ --fs-kpi: 26px;
64
+ }
65
+ }
66
+
67
+ /* ------------------------------------------------------------------ */
68
+ /* 2. The primitives. */
69
+ /* ------------------------------------------------------------------ */
70
+
71
+ /**
72
+ * The shell. A flex row at desktop; at < 1024 the rail is out of flow (it is
73
+ * an overlay) and the content column takes the full width.
74
+ *
75
+ * `min-width: 0` on the content column is not decoration. A flex child's
76
+ * default `min-width: auto` refuses to shrink below its content, so ONE wide
77
+ * table pushes the whole column past the viewport and the page scrolls
78
+ * sideways with the sidebar attached. This is the line that stops that.
79
+ */
80
+ .app-shell {
81
+ display: flex;
82
+ min-height: 100vh;
83
+ background: var(--bg);
84
+ }
85
+
86
+ .app-shell__rail {
87
+ flex: 0 0 var(--sidebar-w);
88
+ width: var(--sidebar-w);
89
+ background: var(--sidebar);
90
+ border-right: 1px solid var(--border);
91
+ }
92
+
93
+ .app-shell__content {
94
+ flex: 1 1 auto;
95
+ min-width: 0;
96
+ display: flex;
97
+ flex-direction: column;
98
+ }
99
+
100
+ .app-content {
101
+ flex: 1 1 auto;
102
+ min-width: 0;
103
+ padding: var(--content-pad-y) var(--content-pad-x) var(--content-pad-bottom);
104
+ }
105
+
106
+ @media (max-width: 1023.98px) {
107
+ .app-shell__rail {
108
+ display: none;
109
+ }
110
+ }
111
+
112
+ /** The 58px top bar. Its two ends are fixed; the middle is what gives way. */
113
+ .app-topbar {
114
+ display: flex;
115
+ align-items: center;
116
+ gap: 12px;
117
+ height: var(--topbar-h);
118
+ min-height: var(--topbar-h);
119
+ padding: 0 var(--content-pad-x);
120
+ background: var(--card);
121
+ border-bottom: 1px solid var(--border);
122
+ }
123
+
124
+ .app-topbar__crumb {
125
+ flex: 1 1 auto;
126
+ min-width: 0;
127
+ font-size: var(--fs-breadcrumb);
128
+ color: var(--muted);
129
+ /* Truncates rather than wrapping: a two-line breadcrumb would break the
130
+ 58px bar, and the site name is the half worth losing first. */
131
+ overflow: hidden;
132
+ white-space: nowrap;
133
+ text-overflow: ellipsis;
134
+ }
135
+
136
+ .app-topbar__actions {
137
+ flex: 0 0 auto;
138
+ display: flex;
139
+ align-items: center;
140
+ gap: 8px;
141
+ }
142
+
143
+ /** The hamburger only exists where the rail does not. */
144
+ .app-topbar__toggle {
145
+ display: none;
146
+ }
147
+
148
+ @media (max-width: 1023.98px) {
149
+ .app-topbar__toggle {
150
+ display: inline-flex;
151
+ }
152
+ }
153
+
154
+ /**
155
+ * A filter row wraps before it stacks. Wrapping keeps two short selects side
156
+ * by side on a 768px tablet, where forcing them full-width would waste half
157
+ * the row; below 600 there is no room for two, so they stack.
158
+ */
159
+ .filter-row {
160
+ display: flex;
161
+ flex-wrap: wrap;
162
+ gap: var(--grid-gap);
163
+ align-items: center;
164
+ }
165
+
166
+ .filter-row > * {
167
+ flex: 1 1 200px;
168
+ min-width: 0;
169
+ }
170
+
171
+ @media (max-width: 599.98px) {
172
+ .filter-row {
173
+ flex-direction: column;
174
+ align-items: stretch;
175
+ }
176
+
177
+ .filter-row > * {
178
+ flex: 1 1 auto;
179
+ width: 100%;
180
+ }
181
+ }
182
+
183
+ /**
184
+ * A table scrolls sideways inside its own card rather than reflowing into
185
+ * stacked key/value blocks. The design is a table card and its columns carry
186
+ * meaning by position; restacking them at 360 would be a different design, not
187
+ * a smaller one - and it is the kind of change rule 2 rules out. So the card
188
+ * edge stays put and the table moves within it.
189
+ */
190
+ .table-scroll {
191
+ overflow-x: auto;
192
+ -webkit-overflow-scrolling: touch;
193
+ overscroll-behavior-x: contain;
194
+ }
195
+
196
+ .table-scroll > table {
197
+ min-width: max-content;
198
+ }
199
+
200
+ /**
201
+ * Modal container. 520px as designed, but never wider or taller than the
202
+ * viewport it has to fit inside, and the BODY is what scrolls - a modal whose
203
+ * whole card scrolls takes its Cancel/Submit footer off screen with it.
204
+ */
205
+ .modal-card {
206
+ width: min(var(--modal-w), calc(100vw - 32px));
207
+ max-height: calc(100vh - 32px);
208
+ display: flex;
209
+ flex-direction: column;
210
+ border-radius: var(--r-modal);
211
+ background: var(--card);
212
+ box-shadow: var(--shadow-modal);
213
+ }
214
+
215
+ .modal-body {
216
+ flex: 1 1 auto;
217
+ min-height: 0;
218
+ overflow-y: auto;
219
+ }
220
+
221
+ @media (max-width: 599.98px) {
222
+ .modal-card {
223
+ width: calc(100vw - 32px);
224
+ max-height: calc(100dvh - 32px);
225
+ }
226
+ }
227
+
228
+ /* ------------------------------------------------------------------ */
229
+ /* 3. Touch targets. This one DOES reach the existing product. */
230
+ /* ------------------------------------------------------------------ */
231
+
232
+ /**
233
+ * A 36px icon button is comfortable with a mouse and a miss with a thumb.
234
+ * WCAG 2.2 asks for 44x44 on a touch pointer, and this is the one rule in
235
+ * Phase 1 that changes existing screens rather than waiting for a phase to
236
+ * adopt it - because it only applies on a COARSE pointer, so no desktop
237
+ * rendering moves by a pixel, and because leaving it to seven more phases
238
+ * means shipping the mobile-hostile version for all of them.
239
+ *
240
+ * `min-height`/`min-width` only, so nothing shrinks and no layout is forced.
241
+ */
242
+ @media (pointer: coarse) {
243
+ .v-btn--icon,
244
+ .v-btn--size-small,
245
+ .v-btn--size-x-small {
246
+ min-height: 44px;
247
+ min-width: 44px;
248
+ }
249
+
250
+ .v-list-item {
251
+ min-height: 44px;
252
+ }
253
+
254
+ .v-selection-control__wrapper {
255
+ min-height: 44px;
256
+ min-width: 44px;
257
+ }
258
+ }
@@ -0,0 +1,220 @@
1
+ /**
2
+ * PLATFORM REDESIGN - THE DESIGN TOKENS.
3
+ *
4
+ * Not one colour is written down in this file. Every colour token is an ALIAS
5
+ * onto the Vuetify theme variable that already holds it, and the values
6
+ * themselves live in `utils/theme.ts` (`PALETTE`), which `utils/theme.test.ts`
7
+ * measures. Two systems, one source of truth, flowing one way:
8
+ *
9
+ * PALETTE -> LIGHT_THEME/DARK_THEME -> --v-theme-* -> --bg, --card, --muted
10
+ *
11
+ * The aliases are declared on `[class*="v-theme--"]` rather than on `:root`
12
+ * ON PURPOSE. Vuetify puts that class on the application root AND on every
13
+ * `<v-theme-provider>` subtree, so `--card` read inside the `plain-dark`
14
+ * layout, or inside a dialog that forces a theme, resolves to THAT subtree's
15
+ * theme. Declaring the aliases at `:root` - or flipping a class on `<body>`,
16
+ * which is how the prototype does it - would have painted those subtrees with
17
+ * the page's theme instead.
18
+ *
19
+ * Values below that are NOT colours (opacity overlays, shadows, the type
20
+ * scale, the spacing scale) have nothing on the Vuetify side to alias, so they
21
+ * are stated here. The theme-dependent ones are stated twice, once per theme.
22
+ */
23
+
24
+ /* ------------------------------------------------------------------ */
25
+ /* Colour tokens - aliases only. */
26
+ /* ------------------------------------------------------------------ */
27
+
28
+ [class*="v-theme--"] {
29
+ /* Surfaces */
30
+ --bg: rgb(var(--v-theme-background));
31
+ --sidebar: rgb(var(--v-theme-sidebar));
32
+ --card: rgb(var(--v-theme-surface));
33
+ --border: rgb(var(--v-theme-border));
34
+
35
+ /* Text */
36
+ --text: rgb(var(--v-theme-text-primary));
37
+ --text2: rgb(var(--v-theme-text2));
38
+ --muted: rgb(var(--v-theme-muted));
39
+
40
+ /* Semantic status */
41
+ --ok: rgb(var(--v-theme-success));
42
+ --warn: rgb(var(--v-theme-warning));
43
+ --err: rgb(var(--v-theme-error));
44
+ --info: rgb(var(--v-theme-info));
45
+
46
+ /*
47
+ * The brand accent, exactly as the design specifies it. `--accent-strong` is
48
+ * the same colour 18% darker and is the ONLY thing a white label may sit on:
49
+ * white on `--accent` measures 3.23:1 in both themes. Everything else - the
50
+ * links, the active nav item, the tab underline, the toggle's on state, the
51
+ * chart lines, `--accent-soft` - uses `--accent` unchanged.
52
+ */
53
+ --accent: rgb(var(--v-theme-accent));
54
+ --accent-strong: rgb(var(--v-theme-accent-strong));
55
+ --accent-text: rgb(var(--v-theme-accent-text));
56
+ --accent-soft: rgba(var(--v-theme-accent), 0.12);
57
+ --on-accent-strong: #ffffff;
58
+ }
59
+
60
+ /*
61
+ * Overlays and shadows. Not colours in the Vuetify sense - an overlay is a
62
+ * colour AND an alpha, and a shadow is a whole box-shadow - so these are the
63
+ * only values restated per theme. Equal specificity to the block above, so
64
+ * source order decides: light first, dark second.
65
+ */
66
+ [class*="v-theme--"] {
67
+ --hover: rgba(20, 22, 26, 0.05);
68
+ --thead: rgba(20, 22, 26, 0.02);
69
+ --shadow: 0 1px 2px rgba(16, 24, 40, 0.05);
70
+ --shadow-modal: 0 24px 64px rgba(10, 12, 16, 0.3);
71
+ --shadow-menu: 0 12px 32px rgba(16, 24, 40, 0.14);
72
+ --scrim: rgba(12, 14, 18, 0.5);
73
+
74
+ /* Soft status backgrounds. Stated by the design literally rather than
75
+ derived from the foreground, so darkening a label for AA leaves its chip
76
+ the colour it was drawn. */
77
+ --ok-bg: rgba(15, 138, 98, 0.1);
78
+ --warn-bg: rgba(190, 140, 25, 0.13);
79
+ --err-bg: rgba(207, 75, 75, 0.1);
80
+ --info-bg: rgba(59, 111, 212, 0.1);
81
+ }
82
+
83
+ .v-theme--dark {
84
+ --hover: rgba(255, 255, 255, 0.06);
85
+ --thead: rgba(255, 255, 255, 0.02);
86
+ --shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
87
+
88
+ --ok-bg: rgba(65, 199, 149, 0.12);
89
+ --warn-bg: rgba(220, 175, 78, 0.13);
90
+ --err-bg: rgba(229, 115, 115, 0.12);
91
+ --info-bg: rgba(125, 166, 236, 0.12);
92
+ }
93
+
94
+ /* ------------------------------------------------------------------ */
95
+ /* Type, shape and spacing - the same in both themes. */
96
+ /* ------------------------------------------------------------------ */
97
+
98
+ :root {
99
+ /*
100
+ * Manrope is loaded in `nuxt.config.ts` and this token names it, but NOTHING
101
+ * in Phase 1 sets `font-family` on the page. Switching eleven applications to
102
+ * a new typeface before a single screen has been rebuilt would leave the
103
+ * product looking half-migrated; each phase adopts the token as it rebuilds
104
+ * its surfaces.
105
+ */
106
+ --font-sans: "Manrope", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
107
+ "Helvetica Neue", Arial, sans-serif;
108
+
109
+ /* Type scale - size / weight / letter-spacing, straight from the contract. */
110
+ --fs-h1: 22px;
111
+ --fw-h1: 800;
112
+ --ls-h1: -0.01em;
113
+
114
+ --fs-card-title: 15.5px;
115
+ --fw-card-title: 800;
116
+
117
+ --fs-kpi: 30px;
118
+ --fw-kpi: 800;
119
+ --ls-kpi: -0.02em;
120
+
121
+ --fs-table-head: 11px;
122
+ --fw-table-head: 800;
123
+ --ls-table-head: 0.07em;
124
+
125
+ --fs-cell: 13.5px;
126
+ --fw-cell: 600;
127
+ --fs-cell-dense: 13px;
128
+
129
+ --fs-nav: 13px;
130
+
131
+ --fs-section-label: 10.5px;
132
+ --fw-section-label: 800;
133
+ --ls-section-label: 0.09em;
134
+
135
+ --fs-btn: 13.5px;
136
+ --fw-btn: 700;
137
+ --fw-btn-strong: 800;
138
+
139
+ --fs-chip: 11.5px;
140
+ --fs-chip-lg: 12px;
141
+ --fw-chip: 700;
142
+
143
+ --fs-breadcrumb: 12.5px;
144
+
145
+ /* Shape */
146
+ --r-card: 14px;
147
+ --r-inner: 10px;
148
+ --r-inner-sm: 9px;
149
+ --r-inner-lg: 12px;
150
+ --r-tag: 7px;
151
+ --r-pill: 999px;
152
+ --r-modal: 16px;
153
+ --r-menu: 14px;
154
+
155
+ /* Shell metrics */
156
+ --sidebar-w: 252px;
157
+ --topbar-h: 58px;
158
+ --content-pad-y: 22px;
159
+ --content-pad-x: 26px;
160
+ --content-pad-bottom: 40px;
161
+
162
+ /* Controls */
163
+ --input-h: 40px;
164
+ --input-h-compact: 34px;
165
+ --btn-h: 40px;
166
+ --icon-btn: 36px;
167
+ --avatar: 36px;
168
+ --toggle-w: 38px;
169
+ --toggle-h: 22px;
170
+ --grid-gap: 12px;
171
+ --modal-w: 520px;
172
+ --menu-w: 240px;
173
+
174
+ /*
175
+ * Table density - the design's own knob. `--cellpad` is the comfortable row
176
+ * padding; put `data-density="compact"` on a table card for the dense one.
177
+ * The accent is the other knob the handoff asks for, and it is a value in
178
+ * `PALETTE` (utils/theme.ts) rather than a runtime switch, because nothing
179
+ * changes it at runtime yet.
180
+ */
181
+ --cellpad: 15px;
182
+ --cellpad-x: 20px;
183
+
184
+ --motion: 150ms;
185
+ }
186
+
187
+ [data-density="compact"] {
188
+ --cellpad: 9px;
189
+ --fs-cell: var(--fs-cell-dense);
190
+ }
191
+
192
+ /* ------------------------------------------------------------------ */
193
+ /* Semantic status tones. */
194
+ /* ------------------------------------------------------------------ */
195
+
196
+ /*
197
+ * The mapping from a status word to a tone lives in `utils/status.ts`, so that
198
+ * one list serves the chips, the dots and anything else. These classes are
199
+ * what that tone paints with. A chip is `.tone-*` plus its own shape.
200
+ */
201
+ .tone-ok {
202
+ color: var(--ok);
203
+ --tone-bg: var(--ok-bg);
204
+ }
205
+ .tone-warn {
206
+ color: var(--warn);
207
+ --tone-bg: var(--warn-bg);
208
+ }
209
+ .tone-err {
210
+ color: var(--err);
211
+ --tone-bg: var(--err-bg);
212
+ }
213
+ .tone-info {
214
+ color: var(--info);
215
+ --tone-bg: var(--info-bg);
216
+ }
217
+ .tone-neutral {
218
+ color: var(--text2);
219
+ --tone-bg: var(--hover);
220
+ }
@@ -3,7 +3,18 @@
3
3
  <v-app-bar-nav-icon v-if="!props.hideNavIcon" @click="drawer = !drawer"></v-app-bar-nav-icon>
4
4
 
5
5
  <template #append>
6
- <v-btn icon="mdi-theme-light-dark" variant="text" class="mx-2" @click="toggleTheme" />
6
+ <!--
7
+ The icon now says which theme you would GET, and the choice is
8
+ remembered - see `composables/useThemePreference.ts`. Same button, same
9
+ place, same one click.
10
+ -->
11
+ <v-btn
12
+ :icon="current === 'dark' ? 'mdi-weather-sunny' : 'mdi-weather-night'"
13
+ :aria-label="current === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'"
14
+ variant="text"
15
+ class="mx-2"
16
+ @click="toggleTheme"
17
+ />
7
18
 
8
19
  <v-menu offset="10px" :close-on-content-click="false">
9
20
  <template #activator="{ props }">
@@ -45,8 +56,6 @@
45
56
  </template>
46
57
 
47
58
  <script setup lang="ts">
48
- import { useTheme } from "vuetify";
49
-
50
59
  const props = defineProps({
51
60
  hideNavIcon: {
52
61
  type: Boolean,
@@ -64,11 +73,13 @@ const { redirect } = useUtils();
64
73
 
65
74
  const { APP_ACCOUNT, APP_MAIN, APP_NAME } = useRuntimeConfig().public;
66
75
 
67
- const theme = useTheme();
68
-
69
- function toggleTheme() {
70
- theme.global.name.value = theme.global.current.value.dark ? "light" : "dark";
71
- }
76
+ /**
77
+ * This used to be `theme.global.name.value = ...` and nothing else, so the
78
+ * choice was gone on the next reload - and, because these are eleven separate
79
+ * applications, gone again on every hop between them. The composable writes the
80
+ * layer's shared `theme` cookie, and `plugins/vuetify.ts` boots on it.
81
+ */
82
+ const { current, toggleTheme } = useThemePreference();
72
83
 
73
84
  const { currentUser } = useLocalAuth();
74
85
 
@@ -28,9 +28,26 @@
28
28
  primary-tinted fill (see `NavigationItem.vue`); 0.12 left the fill at
29
29
  1.15:1, 0.18 is 1.44:1 light, 1.33:1 dark.
30
30
  -->
31
+ <!--
32
+ RESPONSIVE: THE RAIL BECOMES AN OVERLAY BELOW 1024px.
33
+
34
+ `permanent` was hardcoded, which is why the prototype's own behaviour - and
35
+ ours - was a 252px rail still sitting there on a 360px phone with the
36
+ content squeezed into what was left. Dropping `permanent` and naming the
37
+ breakpoint hands the whole behaviour to Vuetify: at >= 1024 the drawer is
38
+ part of the layout exactly as it is today, and below it the same drawer
39
+ becomes temporary, floats OVER the content with a scrim, and closes on the
40
+ scrim, on Escape and on a navigation.
41
+
42
+ 1024 rather than Vuetify's default 1280: at 1024 the rail still leaves a
43
+ 772px content column, which is a usable table. Below that it does not.
44
+
45
+ Nothing about the menu changes - same items, same order, same permissions,
46
+ same routes. Only where the panel is drawn.
47
+ -->
31
48
  <v-navigation-drawer
32
49
  v-model="drawer"
33
- permanent
50
+ :mobile-breakpoint="1024"
34
51
  class="bg-brand-surface brand-drawer"
35
52
  >
36
53
  <v-list>
@@ -75,6 +92,8 @@
75
92
  </template>
76
93
 
77
94
  <script setup lang="ts">
95
+ import { useDisplay } from "vuetify";
96
+
78
97
  const props = defineProps({
79
98
  navigationItems: { type: Array, required: true },
80
99
  title: { type: String, default: "" },
@@ -85,6 +104,32 @@ const emit = defineEmits(["navigate"]);
85
104
  const { drawer } = useLocal();
86
105
  const { APP_NAME } = useRuntimeConfig().public;
87
106
  const route = useRoute();
107
+
108
+ /**
109
+ * `drawer` is shared application state and it defaults to OPEN, which is right
110
+ * for a rail and wrong for an overlay - a phone would load with the menu
111
+ * covering the page behind a scrim. So the default follows the viewport, and a
112
+ * user who resizes past the breakpoint gets the state that suits where they
113
+ * landed.
114
+ *
115
+ * Only the DEFAULT is managed here. Once the hamburger has been used, the
116
+ * user's choice stands until the layout itself changes underneath them.
117
+ */
118
+ const { mobile } = useDisplay({ mobileBreakpoint: 1024 });
119
+
120
+ watch(mobile, (isOverlay) => (drawer.value = !isOverlay), { immediate: true });
121
+
122
+ /**
123
+ * An overlay drawer that survives a navigation leaves the scrim over the page
124
+ * the user just asked for. A rail must NOT close - that would be a step added
125
+ * to every desktop navigation.
126
+ */
127
+ watch(
128
+ () => route.fullPath,
129
+ () => {
130
+ if (mobile.value) drawer.value = false;
131
+ }
132
+ );
88
133
  const orgId = computed(() => String(route.params.org ?? ""));
89
134
  const siteId = computed(() => String(route.params.site ?? ""));
90
135
  const { useHidFaceReaderMenu } = useHidNavigation();
@@ -0,0 +1,63 @@
1
+ /**
2
+ * THE USER'S LIGHT/DARK CHOICE, REMEMBERED.
3
+ *
4
+ * Until now `Layout/Header.vue` flipped `theme.global.name` and wrote nothing,
5
+ * so the choice was gone on the next reload.
6
+ *
7
+ * A COOKIE, NOT localStorage. The handoff says "persists in localStorage", and
8
+ * for a single-page prototype that is right - but this product is ELEVEN
9
+ * applications on eleven hostnames. localStorage is per-origin, so a user who
10
+ * picked dark mode in Security would land on a white screen in Property
11
+ * Management, and again in Account, and would have to set it eleven times.
12
+ *
13
+ * A cookie written with the layer's own `cookieConfig` carries the parent
14
+ * domain, which is exactly how `sid`, `user` and `landing-page` already travel
15
+ * between these applications. So this is the estate's existing preference
16
+ * mechanism rather than a new one, and it is also the only one that makes the
17
+ * setting mean what a user expects it to mean.
18
+ *
19
+ * NO FLASH OF THE WRONG THEME. Nothing here corrects the theme after mount -
20
+ * `plugins/vuetify.ts` reads the same cookie BEFORE `createVuetify` and boots
21
+ * on it, so the first paint is already right. The apps are `ssr: false`, so
22
+ * there is no server render to disagree with.
23
+ */
24
+ import { useTheme } from "vuetify";
25
+ import type { CookieOptions } from "#app";
26
+
27
+ export type TThemeName = "light" | "dark";
28
+
29
+ /** The one place the cookie is named. `plugins/vuetify.ts` reads the same one. */
30
+ export const THEME_COOKIE = "theme";
31
+
32
+ export default function useThemePreference() {
33
+ // `runtimeConfig.public.cookieConfig` is typed `unknown`, so it is narrowed
34
+ // here rather than at every call site. Same object `sid`, `user` and
35
+ // `landing-page` are written with - it carries the shared parent domain.
36
+ const cookieConfig = useRuntimeConfig().public.cookieConfig as CookieOptions<
37
+ TThemeName | null
38
+ > & { readonly?: false };
39
+
40
+ const vuetifyTheme = useTheme();
41
+
42
+ const stored = useCookie<TThemeName | null>(THEME_COOKIE, cookieConfig);
43
+
44
+ const current = computed<TThemeName>(() =>
45
+ vuetifyTheme.global.current.value.dark ? "dark" : "light"
46
+ );
47
+
48
+ /**
49
+ * Sets the theme AND remembers it. The write is the whole point - a caller
50
+ * that only wants a temporary theme for one subtree should use
51
+ * `<v-theme-provider>` instead, which is what `plain-dark` does.
52
+ */
53
+ function setTheme(name: TThemeName) {
54
+ vuetifyTheme.global.name.value = name;
55
+ stored.value = name;
56
+ }
57
+
58
+ function toggleTheme() {
59
+ setTheme(current.value === "dark" ? "light" : "dark");
60
+ }
61
+
62
+ return { current, setTheme, toggleTheme, stored };
63
+ }
package/nuxt.config.ts CHANGED
@@ -7,6 +7,37 @@ export default defineNuxtConfig({
7
7
  transpile: ["vuetify"],
8
8
  },
9
9
 
10
+ /**
11
+ * THE REDESIGN'S TYPEFACE - LOADED, DELIBERATELY NOT APPLIED YET.
12
+ *
13
+ * `--font-sans` in `assets/css/tokens.css` names Manrope, and a token that
14
+ * names a font nobody loaded is a trap: the next phase writes
15
+ * `font-family: var(--font-sans)`, silently falls back to Arial, and looks
16
+ * fine to whoever wrote it. So the stylesheet is requested here.
17
+ *
18
+ * Nothing sets `font-family` on the page in Phase 1. Switching eleven
19
+ * applications to a new typeface before one screen has been rebuilt would
20
+ * leave the whole product looking half-migrated; each phase adopts the token
21
+ * as it rebuilds its own surfaces. Browsers do not download a webfont no
22
+ * element uses, so until then this costs one small CSS request.
23
+ */
24
+ app: {
25
+ head: {
26
+ link: [
27
+ { rel: "preconnect", href: "https://fonts.googleapis.com" },
28
+ {
29
+ rel: "preconnect",
30
+ href: "https://fonts.gstatic.com",
31
+ crossorigin: "",
32
+ },
33
+ {
34
+ rel: "stylesheet",
35
+ href: "https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap",
36
+ },
37
+ ],
38
+ },
39
+ },
40
+
10
41
  runtimeConfig: {
11
42
  public: {
12
43
  cookieConfig: {