@visns-studio/visns-components 6.24.4 → 6.26.0

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.
Files changed (51) hide show
  1. package/package.json +4 -2
  2. package/src/components/Autocomplete.jsx +189 -119
  3. package/src/components/DataGrid.jsx +472 -31
  4. package/src/components/Navigation.jsx +475 -51
  5. package/src/components/auth/ClientAuthFrame.jsx +5 -0
  6. package/src/components/auth/ClientAuthScreen.jsx +29 -0
  7. package/src/components/callQueue/CallQueueDiagnostics.jsx +1043 -0
  8. package/src/components/callQueue/CallQueuePop.jsx +713 -90
  9. package/src/components/callQueue/CallQueueSettings.jsx +308 -146
  10. package/src/components/callQueue/callPopStatus.js +236 -0
  11. package/src/components/callQueue/callQueueHelpers.js +284 -2
  12. package/src/components/columns/ColumnRenderers.jsx +3 -46
  13. package/src/components/columns/StackedRow.jsx +186 -0
  14. package/src/components/controls/DataGridSearch.jsx +110 -2
  15. package/src/components/controls/DataGridSortSheet.jsx +155 -0
  16. package/src/components/generic/GenericAuth.jsx +50 -18
  17. package/src/components/generic/GenericDashboard.jsx +20 -1
  18. package/src/components/generic/GenericDetail.jsx +446 -259
  19. package/src/components/mapboxSearchBox.js +640 -0
  20. package/src/components/navBadges.js +63 -1
  21. package/src/components/navDrawer.js +147 -0
  22. package/src/components/sms/SmsThreadPanel.jsx +34 -6
  23. package/src/components/sms/smsHelpers.js +15 -0
  24. package/src/components/styles/CallQueueDiagnostics.module.scss +398 -0
  25. package/src/components/styles/CallQueuePop.module.scss +29 -0
  26. package/src/components/styles/CallQueueSettings.module.scss +93 -0
  27. package/src/components/styles/ClientAuth.module.scss +39 -0
  28. package/src/components/styles/DataGrid.module.scss +158 -5
  29. package/src/components/styles/Field.module.scss +52 -1
  30. package/src/components/styles/Form.module.scss +82 -0
  31. package/src/components/styles/GenericClientPortal.module.scss +72 -20
  32. package/src/components/styles/GenericDashboard.module.scss +50 -0
  33. package/src/components/styles/GenericDetail.module.scss +63 -1
  34. package/src/components/styles/GenericDynamic.module.scss +23 -0
  35. package/src/components/styles/GenericFormBuilder.module.scss +11 -0
  36. package/src/components/styles/GenericIndex.module.scss +6 -1
  37. package/src/components/styles/Navigation.module.scss +460 -7
  38. package/src/components/styles/Sms.module.scss +92 -0
  39. package/src/components/styles/StackedRow.module.scss +182 -0
  40. package/src/components/styles/TicketConversation.module.scss +76 -0
  41. package/src/components/styles/Vault.module.scss +192 -0
  42. package/src/components/styles/density.css +10 -0
  43. package/src/components/styles/global-datagrid.css +163 -0
  44. package/src/components/styles/global.css +20 -0
  45. package/src/components/tickets/TicketConversation.jsx +13 -8
  46. package/src/components/utils/ConfirmDialog.js +22 -3
  47. package/src/components/utils/cardLayout.js +666 -0
  48. package/src/components/utils/contactChannels.js +130 -0
  49. package/src/components/utils/editPlacement.js +95 -0
  50. package/src/components/utils/useDensity.js +303 -7
  51. package/src/index.js +42 -0
@@ -177,10 +177,22 @@
177
177
  z-index: 1000;
178
178
  }
179
179
 
180
+ /* The wide dialog: 90% of the glass, never past 1400px, and never narrower
181
+ than 600px — except that a 600px floor on a 390px phone is not a floor, it
182
+ is a 210px overflow with the dialog's right-hand edge and its close button
183
+ off the screen. `min-width` outranks `width` and `max-width` both, so the
184
+ flat 600 won every time it was wrong.
185
+ *
186
+ * `min(600px, 90vw)` keeps the floor doing its job on anything wide enough to
187
+ * honour it and hands the decision back to `width` on anything narrower — and
188
+ * 90vw is not a new number, it is the width this rule already asks for, so the
189
+ * floor can never outrank the line above it again. Restated verbatim in
190
+ * GenericIndex.module.scss and GenericDetail.module.scss; the three are one
191
+ * dialog wearing three hashed class names. */
180
192
  .modalWide {
181
193
  width: 90vw;
182
194
  max-width: 1400px;
183
- min-width: 600px;
195
+ min-width: min(600px, 90vw);
184
196
  }
185
197
 
186
198
  .modal {
@@ -1581,10 +1593,26 @@
1581
1593
  }
1582
1594
 
1583
1595
  /* Tablet mode is a bench at arm's length worked with gloves on, so the
1584
- controls step up to the 44px tap-target floor. Scoped to body.tablet-mode,
1585
- the same signal the package already styles against in global-datagrid.css.
1586
- The grid's tablet rows are 52px, so 44px still clears the row edges. */
1587
- :global(body.tablet-mode) {
1596
+ controls step up to the 44px tap-target floor. The grid's tablet rows are
1597
+ 52px, so 44px still clears the row edges.
1598
+
1599
+ Written once as a mixin and emitted twice, because there are now TWO signals
1600
+ that mean "a finger is driving this" and they must not be allowed to drift:
1601
+
1602
+ body.tablet-mode the explicit per-view opt-in, the signal the package
1603
+ already styles against in global-datagrid.css. It now
1604
+ means "treat this as touch even on a mouse".
1605
+ (pointer: coarse) the primary pointing device is a finger. Nobody has to
1606
+ set it and no view config can forget it, which is the
1607
+ whole point — the opt-in only ever reached the handful
1608
+ of views whose JSON carried it, and every phone opening
1609
+ every other grid got 32px targets.
1610
+
1611
+ `pointer` reports the PRIMARY device: a touchscreen laptop answers `fine`
1612
+ and is unaffected. `utils/useDensity.js` ORs the same two signals for the
1613
+ geometry the grid has to compute in JS; these two blocks and that hook are
1614
+ one decision expressed in the only two places it can be. */
1615
+ @mixin touch-tree-controls {
1588
1616
  .treeControlBtn {
1589
1617
  width: var(--grid-tree-control-size-tablet, 44px);
1590
1618
  height: var(--grid-tree-control-size-tablet, 44px);
@@ -1613,6 +1641,14 @@
1613
1641
  }
1614
1642
  }
1615
1643
 
1644
+ :global(body.tablet-mode) {
1645
+ @include touch-tree-controls;
1646
+ }
1647
+
1648
+ @media (pointer: coarse) {
1649
+ @include touch-tree-controls;
1650
+ }
1651
+
1616
1652
  /* =========================================================================
1617
1653
  treeGroupBy nesting: accent bar and member indent
1618
1654
 
@@ -2141,6 +2177,32 @@
2141
2177
  cursor: default;
2142
2178
  }
2143
2179
 
2180
+ /* The pager, under a finger.
2181
+ *
2182
+ * 28px is a mouse target. The first and last page arrows sit side by side with
2183
+ * about 4px between them, and on a phone the thumb covers both — which is why
2184
+ * paging a grid on a phone lands on the wrong page roughly as often as the
2185
+ * right one. 44px is the floor, and it is affordable here because the bar
2186
+ * already wraps (`flex-wrap: wrap`, above): the buttons take a second line
2187
+ * rather than pushing the record count off the side.
2188
+ *
2189
+ * The row is the only part of the grid's own chrome that needed this. The
2190
+ * COLUMN FILTER ROW could not be given the same treatment from here — Inovua
2191
+ * measures its header height in JS from the theme's 32px and a 44px input
2192
+ * inside it is clipped, not accommodated — so the filters step up only as far
2193
+ * as the cell the theme already reserves for them (global-datagrid.css). */
2194
+ @media (pointer: coarse) {
2195
+ .dataGridBottomContainer {
2196
+ gap: 0.5rem;
2197
+ padding: 0.5rem;
2198
+ }
2199
+
2200
+ .dataGridBottomContainer button {
2201
+ min-width: 44px;
2202
+ height: 44px;
2203
+ }
2204
+ }
2205
+
2144
2206
  // -- empty state -------------------------------------------------------------
2145
2207
 
2146
2208
  .noDataMessage {
@@ -2152,3 +2214,94 @@
2152
2214
  color: var(--dg-muted);
2153
2215
  font-size: 0.875rem;
2154
2216
  }
2217
+
2218
+ /* ==========================================================================
2219
+ CARD MODE — the two controls that only exist below 640px
2220
+ --------------------------------------------------------------------------
2221
+ Sort, and the Cards/Table toggle. Icon squares rather than labelled buttons
2222
+ because they share a 340px row with Add New, where three labelled buttons
2223
+ wrap onto two lines.
2224
+
2225
+ `--control-height` is the same token the search field and Add New beside
2226
+ them resolve, so the row keeps one height; it is 2.75rem under a coarse
2227
+ pointer, which is also the 44px minimum a finger needs. There is no separate
2228
+ touch rule here because there is nothing to correct.
2229
+ ========================================================================== */
2230
+ .cardControl {
2231
+ display: inline-flex;
2232
+ align-items: center;
2233
+ justify-content: center;
2234
+ width: var(--control-height, 34px);
2235
+ height: var(--control-height, 34px);
2236
+ padding: 0;
2237
+ color: var(--paragraph-color, #443c2b);
2238
+ background: var(--surface-color, #fff);
2239
+ border: 1px solid var(--dg-line, rgba(43, 43, 43, 0.14));
2240
+ border-radius: var(--btn-br, var(--radius-sm, 4px));
2241
+ cursor: pointer;
2242
+ }
2243
+
2244
+ /* The sort sheet's list. One row per sortable field, both directions drawn —
2245
+ a single button that toggles makes the reader press it to find out which
2246
+ way it is about to go. */
2247
+ .sortSheet {
2248
+ display: flex;
2249
+ flex-direction: column;
2250
+ padding: var(--spacing-sm, 0.5rem) 0;
2251
+ }
2252
+
2253
+ .sortSheetEmpty {
2254
+ margin: 0;
2255
+ padding: var(--spacing-md, 1rem);
2256
+ color: var(--muted-color, #6b7280);
2257
+ font-size: var(--font-size-sm, 0.8125rem);
2258
+ }
2259
+
2260
+ .sortSheetRow {
2261
+ display: flex;
2262
+ gap: var(--spacing-sm, 0.5rem);
2263
+ align-items: center;
2264
+ justify-content: space-between;
2265
+ min-height: 2.75rem;
2266
+ padding: 0 var(--spacing-md, 1rem);
2267
+ border-bottom: 1px solid var(--border-color, #e1e1e1);
2268
+ }
2269
+
2270
+ .sortSheetLabel {
2271
+ display: inline-flex;
2272
+ gap: 0.375rem;
2273
+ align-items: center;
2274
+ overflow: hidden;
2275
+ min-width: 0;
2276
+ color: var(--paragraph-color, #443c2b);
2277
+ font-size: var(--font-size-sm, 0.8125rem);
2278
+ white-space: nowrap;
2279
+ text-overflow: ellipsis;
2280
+ }
2281
+
2282
+ .sortSheetDirections {
2283
+ display: inline-flex;
2284
+ flex: 0 0 auto;
2285
+ gap: 0.25rem;
2286
+ }
2287
+
2288
+ .sortSheetDirection {
2289
+ display: inline-flex;
2290
+ align-items: center;
2291
+ justify-content: center;
2292
+ /* 2.75rem, not the control height: this is a bare icon in a list on a
2293
+ touch screen, and it has nothing beside it to borrow a hit area from. */
2294
+ width: 2.75rem;
2295
+ height: 2.75rem;
2296
+ padding: 0;
2297
+ color: var(--muted-color, #6b7280);
2298
+ background: none;
2299
+ border: 0;
2300
+ border-radius: var(--radius-sm, 4px);
2301
+ cursor: pointer;
2302
+ }
2303
+
2304
+ .sortSheetDirectionOn {
2305
+ color: var(--primary-color, #1b3933);
2306
+ background: var(--primary-color-lighter, rgba(27, 57, 51, 0.08));
2307
+ }
@@ -124,8 +124,14 @@
124
124
  min-height: var(--field-height, 50px);
125
125
  }
126
126
 
127
+ /* The same four-track grid Form.module.scss draws, and the same container
128
+ declaration, so a form assembled out of THIS module's classes collapses the
129
+ way one assembled out of Form's does. See the long note over
130
+ Form.module.scss's `.formcontainer` for why the query asks the panel rather
131
+ than the window, and for the one consequence of `container-type`. */
127
132
  .formcontainer {
128
133
  width: 100%;
134
+ container-type: inline-size;
129
135
 
130
136
  form {
131
137
  width: 100%;
@@ -309,6 +315,43 @@
309
315
  width: 100%;
310
316
  }
311
317
 
318
+ /* ----------------------------------------------------------------------------
319
+ * The field sizes, when the panel runs out of room.
320
+ *
321
+ * These are the OTHER HALF of the collapse declared over `.formcontainer` in
322
+ * Form.module.scss: that file narrows the track list, this one re-states what
323
+ * a "half" and a "quarter" mean against the narrower list. They have to be two
324
+ * files because CSS modules hash class names per module and Field.jsx writes
325
+ * these two classes while Form.jsx owns the container. Keep the breakpoints in
326
+ * step; there is no way to share them.
327
+ *
328
+ * The queries are unnamed, so each resolves against the nearest ancestor
329
+ * container — `.formcontainer`, whichever module declared it (Form's,
330
+ * GenericDynamic's, or this file's). A form with no container ancestor at all
331
+ * simply keeps the four tracks, which is the pre-collapse behaviour and a safe
332
+ * floor rather than a broken one.
333
+ *
334
+ * `grid-column: 1 / -1` and NOT `span 2` at the one-track step: `span 2` on a
335
+ * single-column grid does not clamp, it CREATES a second implicit column — the
336
+ * row would silently go back to two-up at exactly the width where two-up stops
337
+ * fitting.
338
+ * --------------------------------------------------------------------------*/
339
+
340
+ /* Two tracks: a half is the whole row, a quarter is half of it. */
341
+ @container (max-width: 768px) {
342
+ .halfItem {
343
+ grid-column: 1 / -1;
344
+ }
345
+ }
346
+
347
+ /* One track: everything is the row. */
348
+ @container (max-width: 640px) {
349
+ .halfItem,
350
+ .qtrItem {
351
+ grid-column: 1 / -1;
352
+ }
353
+ }
354
+
312
355
  input:not(:placeholder-shown) + .fi__span,
313
356
  textarea:not(:placeholder-shown) + .fi__span,
314
357
  select:not(:placeholder-shown) + .fi__span {
@@ -1333,7 +1376,15 @@ input[type='file']:hover {
1333
1376
  grid-template-columns: auto 1fr auto; /* Consistent grid for images */
1334
1377
  }
1335
1378
 
1336
- /* Responsive Design */
1379
+ /* Responsive Design — the file-upload field's own previews.
1380
+ *
1381
+ * Deliberately still VIEWPORT queries while the grid collapse above is a
1382
+ * container query, and the two are not in conflict: the grid asks how wide its
1383
+ * panel is because that is what decides how many fields fit beside each other,
1384
+ * and these ask how big the SCREEN is because a thumbnail is sized against the
1385
+ * reader's eye and their thumb, not against the panel it happens to sit in.
1386
+ * The 768 here and the 768 above therefore agree by coincidence rather than by
1387
+ * contract; changing one does not oblige the other. */
1337
1388
  @media (max-width: 768px) {
1338
1389
  .image-preview {
1339
1390
  max-width: 150px;
@@ -7,8 +7,46 @@
7
7
  display: block;
8
8
  }
9
9
 
10
+ /* ============================================================================
11
+ * THE FORM GRID — four tracks, and the two it gives up when there is no room
12
+ * ----------------------------------------------------------------------------
13
+ * The grid was a flat `1fr 1fr 1fr 1fr` with nothing underneath it, so a
14
+ * `size: "quarter"` field held one of four tracks at EVERY width: about 80px
15
+ * on a 390px phone, which is a label that cannot be read over an input that
16
+ * cannot be typed in. Two tracks at 768 and one at 640 is the collapse.
17
+ *
18
+ * WHY A CONTAINER QUERY AND NOT A MEDIA QUERY. The window's width is not the
19
+ * question. The same `<Form>` renders in a full-page panel, in a 560px sheet,
20
+ * in a modal, and in a narrow side rail beside a grid — and a form in a 420px
21
+ * rail on a 2560px monitor has exactly the problem a form on a phone has. A
22
+ * media query answers "how big is the glass"; this layout needs "how much room
23
+ * do I actually have", and only the container knows that. The library already
24
+ * reaches for this where the same argument holds (Integrations' `.fields`,
25
+ * global-datagrid's native date filter), so it is the house pattern rather
26
+ * than a new dependency. Nothing here pins a browser floor that would forbid
27
+ * it; the same three engines that ship `color-mix()` — used freely across this
28
+ * library — ship container queries.
29
+ *
30
+ * ONE CONSEQUENCE WORTH KNOWING, because it is not obvious from the one line
31
+ * that causes it: `container-type: inline-size` also applies LAYOUT
32
+ * containment, which makes this element the containing block for any
33
+ * `position: fixed` descendant. Inside a form that is the colour picker's
34
+ * click-away scrim (Field.jsx's `cover`), which now covers the form rather
35
+ * than the whole window — clicking another field still dismisses the picker,
36
+ * clicking the page header no longer does. The scrim is the only fixed thing
37
+ * that renders in here; `.polActions` in GenericDynamic is a SIBLING of the
38
+ * container, not a child, so it is untouched.
39
+ *
40
+ * The item classes that answer these queries live in Field.module.scss
41
+ * (`.halfItem` / `.qtrItem`, written by Field.jsx from `settings.size`) —
42
+ * a different module, but the query is unnamed, so it resolves against the
43
+ * nearest ancestor container whichever file declared it. Keep the two ends in
44
+ * step: the breakpoints below and the ones in Field.module.scss are one rule
45
+ * written in two files because the class names are hashed per module.
46
+ * ==========================================================================*/
10
47
  .formcontainer {
11
48
  width: 100%;
49
+ container-type: inline-size;
12
50
 
13
51
  form {
14
52
  width: 100%;
@@ -24,6 +62,50 @@
24
62
  }
25
63
  }
26
64
 
65
+ /* Two tracks. A half-width field is then the full row and a quarter is half of
66
+ one, which is the same proportion the four-track layout gave them — the
67
+ shape survives, it just has less of it.
68
+ *
69
+ * WHERE THESE TWO NUMBERS BITE, since they are container widths and not window
70
+ * widths and the two do not land in the same places. StandardModal's sizes are
71
+ * 420 / 560 / 800 and `.modal__content` spends 1rem a side, so:
72
+ *
73
+ * large (768 content) — exactly on this boundary, and `max-width` is
74
+ * inclusive, so a large dialog draws TWO tracks on a
75
+ * desktop where it used to draw four. Quarters go from
76
+ * ~180px to ~370px, which is roomier rather than
77
+ * wrong, but it is a visible change and this is where
78
+ * to look when somebody asks about it.
79
+ * medium (528 content) — one track.
80
+ * small (388 content) — one track.
81
+ * a 22rem side rail — one track.
82
+ *
83
+ * If a form is wanted back at four tracks in a large dialog, the fix is this
84
+ * number and only this number — but it has to move in Field.module.scss and
85
+ * GenericDynamic.module.scss with it. */
86
+ @container (max-width: 768px) {
87
+ .formcontainer form {
88
+ grid-template-columns: 1fr 1fr;
89
+ }
90
+
91
+ .halfBuilderItem {
92
+ grid-column: 1 / -1;
93
+ }
94
+ }
95
+
96
+ /* One track. Below this a two-up row is two ~150px fields, which is where a
97
+ date input starts clipping its own year. */
98
+ @container (max-width: 640px) {
99
+ .formcontainer form {
100
+ grid-template-columns: 1fr;
101
+ }
102
+
103
+ .qtrBuilderItem,
104
+ .halfBuilderItem {
105
+ grid-column: 1 / -1;
106
+ }
107
+ }
108
+
27
109
  .formItem {
28
110
  width: 100%;
29
111
  position: relative;
@@ -25,71 +25,123 @@
25
25
  }
26
26
  }
27
27
 
28
+ // ============================================================================
29
+ // Header chrome — tokenised, NOT restyled.
30
+ // ----------------------------------------------------------------------------
31
+ // Every `--client-portal-*` property below falls back to the value this file
32
+ // carried before, so a host that defines none of them renders byte-identically
33
+ // to the version that shipped. They exist because the header is the one piece
34
+ // of a client portal that has to look like the company whose portal it is, and
35
+ // the markup for it lives in this library rather than in the host app — so
36
+ // without a hook the only ways to brand it are forking the component or an
37
+ // attribute selector against a generated class name, and both of those break
38
+ // the next time this file is touched.
39
+ //
40
+ // Nothing here reads a host token directly. A host brands the header by
41
+ // defining these properties (typically on `:root`), and can undo it by not.
42
+ // ============================================================================
43
+
28
44
  .portalHeader {
29
45
  display: flex;
30
46
  justify-content: space-between;
31
47
  align-items: center;
32
- padding: 0.5rem 1.25rem;
33
- background-color: var(--tertiary-color, white);
34
- box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
48
+ gap: var(--client-portal-header-gap, 1rem);
49
+ padding: var(--client-portal-header-padding, 0.5rem 1.25rem);
50
+ background: var(--client-portal-header-bg, var(--tertiary-color, white));
51
+ color: var(--client-portal-header-ink, inherit);
52
+ border-bottom: var(--client-portal-header-border, 0 solid transparent);
53
+ box-shadow: var(--client-portal-header-shadow, 0 1px 3px rgba(0, 0, 0, 0.05));
35
54
  z-index: 10;
36
- height: 64px; // Increased height to accommodate larger logo
55
+ // Increased height to accommodate larger logo
56
+ height: var(--client-portal-header-height, 64px);
57
+ flex: none;
37
58
  }
38
59
 
39
60
  .logoContainer {
40
61
  display: flex;
41
62
  align-items: center;
42
- margin-left: -1.25rem;
63
+ margin-left: var(--client-portal-logo-offset, -1.25rem);
43
64
  padding-left: 0.5rem;
65
+ min-width: 0;
44
66
  }
45
67
 
46
68
  .logo {
47
- height: 52px;
48
- max-width: 250px;
69
+ height: var(--client-portal-logo-height, 52px);
70
+ max-width: var(--client-portal-logo-max-width, 250px);
49
71
  object-fit: contain;
72
+ // `none` unless the host asks otherwise. A dark header needs the reverse of
73
+ // a logo drawn for a white one, and the host cannot swap the file from
74
+ // here — `brightness(0) invert(1)` is the usual answer.
75
+ filter: var(--client-portal-logo-filter, none);
50
76
  }
51
77
 
52
78
  .userInfo {
53
79
  display: flex;
54
80
  align-items: center;
55
- gap: 0.75rem;
81
+ gap: var(--client-portal-user-gap, 0.75rem);
82
+ min-width: 0;
56
83
  }
57
84
 
58
85
  .welcomeText {
59
- font-size: 0.8rem;
60
- color: var(--paragraph-color, #555);
86
+ font-size: var(--client-portal-welcome-size, 0.8rem);
87
+ color: var(--client-portal-header-ink, var(--paragraph-color, #555));
88
+ white-space: nowrap;
89
+ overflow: hidden;
90
+ text-overflow: ellipsis;
61
91
  }
62
92
 
63
93
  .logoutButton {
64
94
  display: flex;
65
95
  align-items: center;
66
96
  justify-content: center;
67
- width: 28px;
68
- height: 28px;
97
+ flex: none;
98
+ width: var(--client-portal-logout-size, 28px);
99
+ height: var(--client-portal-logout-size, 28px);
69
100
  padding: 0;
70
101
  background-color: transparent;
71
- border: 1px solid rgba(var(--paragraph-color-rgb, 0, 0, 0), 0.15);
72
- border-radius: var(--radius, 4px);
73
- color: var(--paragraph-color, #555);
102
+ border: 1px solid
103
+ var(
104
+ --client-portal-header-line,
105
+ rgba(var(--paragraph-color-rgb, 0, 0, 0), 0.15)
106
+ );
107
+ border-radius: var(--client-portal-logout-radius, var(--radius, 4px));
108
+ color: var(--client-portal-header-ink, var(--paragraph-color, #555));
74
109
  cursor: pointer;
75
110
  transition: all 0.2s ease;
76
111
 
77
112
  &:hover {
78
- background-color: rgba(var(--primary-rgb, 0, 123, 255), 0.05);
79
- border-color: var(--primary-color, #0f4229);
80
- color: var(--primary-color, #0f4229);
113
+ background-color: var(
114
+ --client-portal-logout-hover-bg,
115
+ rgba(var(--primary-rgb, 0, 123, 255), 0.05)
116
+ );
117
+ border-color: var(
118
+ --client-portal-logout-hover-line,
119
+ var(--primary-color, #0f4229)
120
+ );
121
+ color: var(
122
+ --client-portal-logout-hover-ink,
123
+ var(--primary-color, #0f4229)
124
+ );
125
+ }
126
+
127
+ &:focus-visible {
128
+ outline: 2px solid
129
+ var(--client-portal-focus-ring, var(--primary-color, #0f4229));
130
+ outline-offset: 2px;
81
131
  }
82
132
  }
83
133
 
84
134
  .portalContent {
85
135
  display: flex;
86
136
  flex: 1;
87
- height: calc(100vh - 64px); // Subtract header height
137
+ // Subtract header height the same property the header is sized from, so
138
+ // a host that changes one cannot leave the two disagreeing.
139
+ height: calc(100vh - var(--client-portal-header-height, 64px));
88
140
  }
89
141
 
90
142
  .mainContent {
91
143
  flex: 1;
92
- padding: 1.25rem;
144
+ padding: var(--client-portal-content-padding, 1.25rem);
93
145
  overflow-y: auto;
94
146
  color: var(--paragraph-color, #333);
95
147
  }
@@ -756,3 +756,53 @@
756
756
  }
757
757
 
758
758
  .modal__content { padding: 1.25rem; }
759
+
760
+ // ============================================================================
761
+ // The card, on a phone
762
+ // ----------------------------------------------------------------------------
763
+ // LAST IN THE FILE ON PURPOSE. Every declaration below overrides one written
764
+ // earlier at the same specificity — `.widgetNo` is a single class here and a
765
+ // single class there — so the same block sitting up beside the `grid__*` span
766
+ // collapse it belongs with would be beaten by the rules it exists to override
767
+ // and do nothing at all. Media queries carry no weight of their own.
768
+ //
769
+ // What changes and why: below 700px every card is one column wide, and the
770
+ // reserved heights in this sheet exist so that cards SIDE BY SIDE line their
771
+ // labels, values and buttons up with one another. Nothing sits beside a card
772
+ // at this width. A stat tile is a label, a number and a button — three lines
773
+ // — and it was reserving 180px for them: 40 of padding, 24 of gaps, 24 held
774
+ // open under a 17px label and 48 held open under a 32px number. That is one
775
+ // and a half cards to a 402px screen, on the surface whose whole job is to
776
+ // answer three questions at a glance.
777
+ //
778
+ // Nothing here shrinks the number itself. The value is the content; the empty
779
+ // space around it was the problem.
780
+ // ============================================================================
781
+
782
+ @media (max-width: 700px) {
783
+ // The token's own default rather than a flat value, so a project that has
784
+ // set --dash-card-padding keeps its figure at every width; only the
785
+ // shipped default tightens.
786
+ .grid {
787
+ --dash-pad: var(--dash-card-padding, 0.9rem);
788
+ }
789
+
790
+ .widgetItem {
791
+ gap: 0.5rem;
792
+ }
793
+
794
+ // Both floors go: each element is still exactly as tall as its content,
795
+ // and there is no neighbouring card to hold a line for.
796
+ .widgetTitle {
797
+ min-height: 0;
798
+ }
799
+
800
+ .widgetNo,
801
+ .widgetNo-alt {
802
+ min-height: 0;
803
+ }
804
+
805
+ .widgetLink {
806
+ padding-top: 0.25rem;
807
+ }
808
+ }
@@ -1207,10 +1207,15 @@
1207
1207
  z-index: 1000;
1208
1208
  }
1209
1209
 
1210
+ /* See DataGrid.module.scss's `.modalWide` for the whole note. Short version:
1211
+ `min-width` outranks both `width` and `max-width`, so a flat 600px floor put
1212
+ 210px of the dialog — its right edge and its close button — off the side of
1213
+ a 390px phone. 90vw is the width this rule already asks for, so the floor
1214
+ can never outrank the line above it. */
1210
1215
  .modalWide {
1211
1216
  width: 90vw;
1212
1217
  max-width: var(--detail-max-width, 1400px);
1213
- min-width: 600px;
1218
+ min-width: min(600px, 90vw);
1214
1219
  }
1215
1220
 
1216
1221
  .modal {
@@ -1384,6 +1389,63 @@
1384
1389
  0 12px 24px -12px color-mix(in srgb, var(--primary-color, #1b3933) 35%, transparent);
1385
1390
  }
1386
1391
 
1392
+ /* -- the same cluster, in the title row ----------------------------------- */
1393
+
1394
+ /* `editPlacement: "header"` (see utils/editPlacement.js) hangs the SAME
1395
+ cluster off the breadcrumb row instead of pinning it to the viewport. It is
1396
+ a modifier rather than an edit to `.polActions` on purpose: the floating bar
1397
+ is still the default on every other detail page in every other application,
1398
+ and the two `:has(> :nth-child(2))` rules above are the whole of its
1399
+ treatment.
1400
+
1401
+ Which is exactly what has to come off here. A pill with a blurred backdrop
1402
+ is how a control says "I am floating over the page"; sitting in a header bar
1403
+ it reads as a second bar drawn inside the first, and the elevation a lone
1404
+ button carries reads as a button that has come loose. In the title row the
1405
+ ground is the header, so the cluster is nothing but its buttons — `.btn`
1406
+ itself is untouched, so they are the same buttons as everywhere else.
1407
+
1408
+ Both selectors are doubled (`.polActions.polActionsHeader`) to out-specify
1409
+ the two rules above, which `:has()` lifts to two classes' worth. Source
1410
+ order after them is not enough on its own. */
1411
+ .polActions.polActionsHeader {
1412
+ position: static;
1413
+ inset: auto;
1414
+ z-index: auto;
1415
+ padding: 0;
1416
+ border: 0;
1417
+ border-radius: 0;
1418
+ background: none;
1419
+ backdrop-filter: none;
1420
+ box-shadow: none;
1421
+ flex-wrap: wrap;
1422
+ justify-content: flex-end;
1423
+ }
1424
+
1425
+ .polActions.polActionsHeader > * {
1426
+ box-shadow: none;
1427
+ }
1428
+
1429
+ /* The right-hand end of the title row: the total, a host's `actions`, and —
1430
+ under this placement — the cluster. `margin-left: auto` does nothing while
1431
+ the row fits (the bar is already `justify-content: space-between`); it is
1432
+ what keeps the group on the right once it has WRAPPED onto its own line. */
1433
+ .titleActions {
1434
+ margin-left: auto;
1435
+ }
1436
+
1437
+ /* Wrapping is opt-in, and only for a header that actually received a cluster.
1438
+ A record title is long ("Verkada Camera & Access Control" under a client
1439
+ name) and a row of buttons is not compressible, so without this the title
1440
+ is squeezed to a word per line to hold a shape that stopped fitting. The
1441
+ buttons take their own line instead. Left alone for every page that has not
1442
+ opted in, so no existing header can start wrapping. */
1443
+ .crmtitle:has(.polActionsHeader),
1444
+ .crmtitle--alternate:has(.polActionsHeader) {
1445
+ flex-wrap: wrap;
1446
+ row-gap: var(--spacing-sm, 0.5rem);
1447
+ }
1448
+
1387
1449
  /* -- states --------------------------------------------------------------- */
1388
1450
 
1389
1451
  .noDataMessage {
@@ -176,8 +176,17 @@
176
176
  font-size: 1rem;
177
177
  }
178
178
 
179
+ /* A third copy of the four-track form grid — this one wraps Field.jsx directly
180
+ (see GenericDynamic.jsx), so it has to declare the container too, or the
181
+ collapse written in Field.module.scss finds nothing to measure and the
182
+ fields stay four-up on a phone. `container-type` is the whole of what is
183
+ added here; the reasoning is over Form.module.scss's `.formcontainer`.
184
+ `.polActions` in this module is `position: fixed`, but it is a SIBLING of
185
+ this element rather than a descendant, so the layout containment that comes
186
+ with `container-type` does not move it. */
179
187
  .formcontainer {
180
188
  width: 100%;
189
+ container-type: inline-size;
181
190
 
182
191
  form {
183
192
  width: 100%;
@@ -188,6 +197,20 @@
188
197
  }
189
198
  }
190
199
 
200
+ /* The track list, narrowed. What a "half" and a "quarter" mean against the
201
+ narrower list is re-stated in Field.module.scss, which owns those classes. */
202
+ @container (max-width: 768px) {
203
+ .formcontainer form {
204
+ grid-template-columns: 1fr 1fr;
205
+ }
206
+ }
207
+
208
+ @container (max-width: 640px) {
209
+ .formcontainer form {
210
+ grid-template-columns: 1fr;
211
+ }
212
+ }
213
+
191
214
  .formItem {
192
215
  width: 100%;
193
216
  position: relative;