@visns-studio/visns-components 6.32.2 → 6.34.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 (41) hide show
  1. package/README.md +504 -0
  2. package/package.json +1 -1
  3. package/src/components/DataGrid.jsx +44 -2
  4. package/src/components/Field.jsx +32 -0
  5. package/src/components/Form.jsx +39 -2
  6. package/src/components/Navigation.jsx +353 -43
  7. package/src/components/Notification.jsx +13 -2
  8. package/src/components/TableFilter.jsx +14 -2
  9. package/src/components/auth/Login.jsx +9 -2
  10. package/src/components/auth/PasskeyEnrolPrompt.jsx +269 -0
  11. package/src/components/auth/Profile.jsx +422 -26
  12. package/src/components/auth/authEndpoints.js +43 -0
  13. package/src/components/auth/passkeyClient.js +102 -0
  14. package/src/components/auth/passkeyPrompt.js +408 -0
  15. package/src/components/auth/profileLayout.js +45 -0
  16. package/src/components/controls/DataGridSearch.jsx +16 -2
  17. package/src/components/controls/DataGridSortSheet.jsx +2 -0
  18. package/src/components/generic/ActionButtons.jsx +1 -1
  19. package/src/components/generic/GenericAuth.jsx +197 -21
  20. package/src/components/generic/GenericDetail.jsx +95 -3
  21. package/src/components/generic/GenericMain.jsx +7 -0
  22. package/src/components/generic/StandardModal.jsx +71 -158
  23. package/src/components/sms/SmsCampaigns.jsx +2487 -0
  24. package/src/components/sms/SmsInbox.jsx +18 -0
  25. package/src/components/sms/SmsThreadPanel.jsx +20 -0
  26. package/src/components/sms/smsEndpoints.js +23 -0
  27. package/src/components/styles/DataGrid.module.scss +22 -0
  28. package/src/components/styles/Form.module.scss +100 -2
  29. package/src/components/styles/Navigation.module.scss +354 -15
  30. package/src/components/styles/Notification.module.scss +140 -8
  31. package/src/components/styles/PasskeyEnrolPrompt.module.scss +105 -0
  32. package/src/components/styles/Profile.module.scss +157 -0
  33. package/src/components/styles/Sms.module.scss +451 -0
  34. package/src/components/styles/StandardModal.module.scss +273 -0
  35. package/src/components/styles/global.css +24 -5
  36. package/src/components/utils/navCollapsed.js +201 -0
  37. package/src/components/utils/rowActionSettings.js +76 -0
  38. package/src/components/utils/usePasskeysEnabled.js +84 -0
  39. package/src/components/utils/useShellLayout.js +131 -0
  40. package/src/index.js +103 -1
  41. package/src/utils/rememberTab.js +70 -0
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
2
  import {
3
3
  Inbox,
4
4
  Loader2,
5
+ Megaphone,
5
6
  MessageSquare,
6
7
  PenSquare,
7
8
  Unplug,
@@ -103,6 +104,13 @@ const SmsInboxInner = ({
103
104
  echo = null,
104
105
  channelFor = defaultChannelFor,
105
106
  settingsUrl = '/settings/sms',
107
+ // Opt-in, and absent by default: a deployment with no campaigns screen
108
+ // must not grow a link to one. Drawn beside the settings link rather than
109
+ // beside "New message", because a campaign is somewhere you GO and a new
110
+ // message is something you DO — and it is deliberately a ghost button
111
+ // rather than a second primary, since the inbox's own primary action is
112
+ // still writing to one person.
113
+ campaignsUrl = null,
106
114
  clientUrl = (id) => `/clients/${id}`,
107
115
  // Both opt-in, both handed straight to the conversation pane — see
108
116
  // SmsThreadPanel for the shapes. Absent, this page is exactly what it was.
@@ -301,6 +309,16 @@ const SmsInboxInner = ({
301
309
  </div>
302
310
 
303
311
  <div className={styles.headActions}>
312
+ {campaignsUrl && (
313
+ <button
314
+ type="button"
315
+ className={styles.ghostButton}
316
+ onClick={() => navigate(campaignsUrl)}
317
+ >
318
+ <Megaphone size={14} strokeWidth={2} aria-hidden="true" />
319
+ Campaigns
320
+ </button>
321
+ )}
304
322
  {canManage && (
305
323
  <button
306
324
  type="button"
@@ -12,6 +12,7 @@ import {
12
12
  ArrowLeft,
13
13
  CheckSquare,
14
14
  ChevronUp,
15
+ CircleSlash,
15
16
  ExternalLink,
16
17
  FileText,
17
18
  Link2,
@@ -1054,6 +1055,25 @@ const SmsThreadPanel = ({
1054
1055
  </div>
1055
1056
  ) : (
1056
1057
  <div className={styles.composer}>
1058
+ {/*
1059
+ * Opted out of CAMPAIGNS, which is not the same thing as
1060
+ * opted out of being spoken to.
1061
+ *
1062
+ * Nothing is disabled and nothing should be: this person
1063
+ * texted us, and refusing to let a member of staff answer a
1064
+ * question because a bulk send would skip them is the wrong
1065
+ * answer to the wrong question. What the line does is say so,
1066
+ * once, in the muted register — so a reply here is a decision
1067
+ * somebody made rather than one they made unaware.
1068
+ */}
1069
+ {thread?.opted_out && (
1070
+ <p className={styles.optedOutNote}>
1071
+ <CircleSlash size={13} strokeWidth={2} aria-hidden="true" />
1072
+ This number has opted out of messages. A reply is still allowed,
1073
+ but Zoom blocks texts to them until they reply START.
1074
+ </p>
1075
+ )}
1076
+
1057
1077
  <div className={styles.composerBox}>
1058
1078
  <textarea
1059
1079
  ref={composerRef}
@@ -36,6 +36,29 @@ export const makeSmsEndpoints = (base = '/ajax/sms') => ({
36
36
  templates: `${base}/templates`,
37
37
  template: (id) => `${base}/templates/${id}`,
38
38
 
39
+ // Bulk sends. `campaigns` is both the list and the create; everything else
40
+ // hangs off one campaign's id.
41
+ //
42
+ // `campaignPreview` is a POST that only reads, and it is a route rather
43
+ // than a client-side render on purpose: the server owns what `{first_name}`
44
+ // means, and a composer that filled the tokens itself would show a message
45
+ // that differed from the one that went out on any placeholder the two
46
+ // implementations disagreed about.
47
+ campaigns: `${base}/campaigns`,
48
+ campaignPreview: `${base}/campaigns/preview`,
49
+ campaign: (id) => `${base}/campaigns/${id}`,
50
+ campaignRecipients: (id) => `${base}/campaigns/${id}/recipients`,
51
+ campaignStart: (id) => `${base}/campaigns/${id}/start`,
52
+ campaignPause: (id) => `${base}/campaigns/${id}/pause`,
53
+ campaignCancel: (id) => `${base}/campaigns/${id}/cancel`,
54
+ campaignRetryFailed: (id) => `${base}/campaigns/${id}/retry-failed`,
55
+
56
+ // Numbers that must not be sent to again. Kept beside campaigns because a
57
+ // bulk send is the only place the list is applied in anger, but an opt-out
58
+ // is a fact about the number and outlives every campaign that honoured it.
59
+ optOuts: `${base}/opt-outs`,
60
+ optOut: (id) => `${base}/opt-outs/${id}`,
61
+
39
62
  settingsLines: `${base}/settings/lines`,
40
63
  settingsLine: (id) => `${base}/settings/lines/${id}`,
41
64
  });
@@ -478,6 +478,28 @@
478
478
  }
479
479
  }
480
480
 
481
+ /* A row action that is PRESENT but not permitted on this row — `disabled` on a
482
+ settings entry (see resolveRowActionDisabled in utils/rowActionSettings.js).
483
+ The icon stays in the cell so the column does not change shape from row to
484
+ row; it simply stops being an offer.
485
+
486
+ POINTER EVENTS ARE NOT TURNED OFF, and that is deliberate rather than an
487
+ omission: the whole value of this over `condition` is the tooltip that says
488
+ why, and a glyph with `pointer-events: none` can never be hovered to show
489
+ one. The click is swallowed in JS instead, where it can also be stopped from
490
+ reaching the row underneath. */
491
+ .vs-action--disabled {
492
+ cursor: not-allowed;
493
+ opacity: 0.38;
494
+
495
+ /* The hover tint below would otherwise say the icon is still live. */
496
+ .tdaction,
497
+ .tdaction:hover {
498
+ cursor: not-allowed;
499
+ color: #6b7280;
500
+ }
501
+ }
502
+
481
503
  .uploadSection {
482
504
  margin-bottom: 16px;
483
505
  border-bottom: 1px solid #e5e7eb;
@@ -336,6 +336,16 @@ input[type='file'] {
336
336
  justify-content: space-between;
337
337
  gap: var(--spacing-md);
338
338
  padding: var(--spacing-md) var(--spacing-lg);
339
+ /* STUCK TO THE TOP OF THE PANEL. The dialog is one scroll region now (see
340
+ `.modal__content` below and StandardModal.module.scss), and in a single
341
+ scroller a header that is not sticky is a header that scrolls away —
342
+ taking the close button with it, which on a phone is the only obvious
343
+ way out of the dialog. Opaque, or the form scrolls through it. */
344
+ position: sticky;
345
+ top: 0;
346
+ /* Above the form's own sticky button bar, which is 2. */
347
+ z-index: 3;
348
+ background: var(--tertiary-color);
339
349
 
340
350
  h1 {
341
351
  /* Was --secondary-color, the same red that means destructive
@@ -418,12 +428,25 @@ input[type='file'] {
418
428
  }
419
429
  }
420
430
 
431
+ /* ONE SCROLL REGION PER DIALOG, and it is the panel — not this.
432
+ *
433
+ * This used to be `max-height: 85vh; overflow-y: auto` INSIDE a panel that was
434
+ * also `max-height: 85vh; overflow-y: auto`, so a dialog had two nested
435
+ * scrollers of the same height. The form's Save bar is `position: sticky;
436
+ * bottom: 0`, which sticks it to the bottom of the INNER box — and the inner
437
+ * box starts below the header, so its bottom edge sits below the bottom of the
438
+ * outer one. The bar was therefore off-screen until the outer region had been
439
+ * scrolled as well: reach the end of the form, and Save is still not there.
440
+ * On a phone, where the sheet was `100vh` against a shorter visual viewport,
441
+ * it could not be reached at all.
442
+ *
443
+ * With the cap and the overflow gone this is ordinary content in the panel's
444
+ * scroller, the header above it is sticky and the button bar below it is
445
+ * sticky, and both stay on screen at every height. */
421
446
  .modal__content {
422
447
  width: 100%;
423
448
  position: relative;
424
449
  padding: 1rem;
425
- max-height: 85vh;
426
- overflow-y: auto;
427
450
  min-height: 400px;
428
451
 
429
452
  .btn {
@@ -675,3 +698,78 @@ input[type='file'] {
675
698
  color-mix(in srgb, var(--highlight-color, #3cbf7d) 30%, transparent);
676
699
  }
677
700
  }
701
+
702
+ /* ============================================================================
703
+ * The dialog on a phone
704
+ * ----------------------------------------------------------------------------
705
+ * The panel's own shape is StandardModal.module.scss's job — full width, a
706
+ * bottom sheet, `100dvh`. What is left is what the panel CONTAINS, and all of
707
+ * it is inside a `max-width: 640px` query, so not one declaration applies to
708
+ * the desktop dialog.
709
+ *
710
+ * The field grid needs nothing: `.formcontainer` is a `container-type:
711
+ * inline-size` container and `size: "half"` / `"quarter"` already collapse to
712
+ * one track at 640px of CONTAINER width, which a full-width phone panel always
713
+ * is. See the block at the top of this file.
714
+ * ==========================================================================*/
715
+ @media (max-width: 640px) {
716
+ /* 400px of empty white under a two-line confirm dialog, on a screen with
717
+ maybe 700 to spend. The panel is bounded and scrolls; the content does
718
+ not need a floor. */
719
+ .modal__content {
720
+ min-height: 0;
721
+ padding: 0.875rem;
722
+ }
723
+
724
+ .modal__header {
725
+ padding: var(--spacing-sm, 0.5rem) var(--spacing-md, 1rem);
726
+
727
+ h1 {
728
+ /* One step down: a 1.125rem title beside a 44px close button wraps
729
+ to two lines on a 390px screen. */
730
+ font-size: var(--font-size-md, 1rem);
731
+ }
732
+ }
733
+
734
+ /* A 32px target is a miss. 2.75rem is the 44px Apple and Google both
735
+ publish, and it is the only way out of a dialog that fills the screen. */
736
+ .modal__close {
737
+ width: 2.75rem;
738
+ height: 2.75rem;
739
+ min-width: 2.75rem;
740
+ min-height: 2.75rem;
741
+ }
742
+
743
+ /* The bar keeps its full-bleed margins against the smaller padding above,
744
+ or it sits inset from the panel edges by the difference. */
745
+ .buttonContainer {
746
+ margin: 1rem -0.875rem -0.875rem;
747
+ padding: 0.75rem 0.875rem;
748
+ /* Both buttons reachable with one thumb; Save stays on the right. */
749
+ gap: 0.5rem;
750
+ }
751
+ }
752
+
753
+ /* ============================================================================
754
+ * iOS zooms the page when a control smaller than 16px takes focus
755
+ * ----------------------------------------------------------------------------
756
+ * Not a preference and not configurable: Safari on iPhone zooms to the focused
757
+ * field whenever its computed font-size is below 16px, and it does not zoom
758
+ * back out. The form's controls are `--font-size-sm` (13px), so every tap into
759
+ * a field in a dialog scaled the page up and left the user pinching back.
760
+ *
761
+ * Scoped to a coarse pointer as well as to the width, so a 640px-wide desktop
762
+ * window keeps the type scale it has always had — this is a fix for a touch
763
+ * keyboard, not for a narrow window. Scoped to the dialog because that is
764
+ * where the complaint is; a page form on a phone is a separate question with a
765
+ * separate blast radius.
766
+ * ==========================================================================*/
767
+ @media (max-width: 640px) and (pointer: coarse) {
768
+ .modalwrap {
769
+ input:not([type='checkbox']):not([type='radio']),
770
+ select,
771
+ textarea {
772
+ font-size: 16px;
773
+ }
774
+ }
775
+ }
@@ -320,10 +320,22 @@
320
320
  color: #fff;
321
321
  }
322
322
 
323
+ /* THE HEADER BAR'S ICONS ONLY, and the two hiding declarations
324
+ have moved out of here for it — see `.hwrap .app-nav … svg` at
325
+ the foot of this file.
326
+
327
+ `.app-nav` is not a header class. The same list class is used by
328
+ the top bar and by the sidebar rail, so a bare `svg { display:
329
+ none }` in here reached the rail as well and hid every icon
330
+ `renderNav` draws for the `cms` / `simple` layouts. It was not
331
+ even a fight the rail could win on specificity: the rail's own
332
+ svg rule is five classes deep and outranks this one easily, but
333
+ it only ever set `position`, `right`, `opacity` and `color` — it
334
+ never mentioned `display`, so there was nothing for the higher
335
+ specificity to override and `display: none` simply applied. That
336
+ is the whole bug: a blank rail, expanded and collapsed alike. */
323
337
  svg {
324
338
  color: var(--secondary-color);
325
- display: none;
326
- visibility: hidden;
327
339
  }
328
340
 
329
341
  /* The current page: a filled pill AND the rule below. The pill
@@ -363,13 +375,29 @@
363
375
 
364
376
  > ul {
365
377
  width: 220px;
366
- position: absolute;
367
- /* Anchored explicitly rather than left wherever its static
368
- position lands: the item is now a full-height flex box, so
369
- an un-anchored absolute child is at the mercy of the flex
370
- line. */
371
- top: 100%;
372
- left: 0;
378
+ /* FIXED, not absolute, and the strip above is the reason.
379
+ `.navwrap` is `overflow-x: auto`, and a scroll container
380
+ clips on BOTH axes however `visible` the other one is
381
+ written — so an absolutely-positioned panel hanging below
382
+ the bar was cut off at the bar's own bottom edge. The old
383
+ way out was to flip the strip to `overflow-x: visible`
384
+ while a group was hovered, which destroys the scroll
385
+ container: the browser resets `scrollLeft` to 0, the item
386
+ slides out from under the pointer, and the menu closes
387
+ itself in the same gesture. A fixed panel is outside the
388
+ scroller's flow altogether, so the clip never applies and
389
+ the scroller is never touched — Navigation.jsx measures the
390
+ item and hands its two coordinates down as custom
391
+ properties. The fallbacks below are the old anchoring, for
392
+ the frame before a measurement lands.
393
+
394
+ ONE TRADE-OFF: an ancestor carrying `transform`, `filter`
395
+ or `contain: paint` becomes the containing block for its
396
+ fixed descendants, and would trap the panel inside the bar
397
+ again. */
398
+ position: fixed;
399
+ top: var(--nav-sub-top, 100%);
400
+ left: var(--nav-sub-left, 0);
373
401
  height: auto;
374
402
  margin: 0;
375
403
  /* The rows used to sit flush against the panel edge, which is
@@ -552,9 +580,15 @@
552
580
 
553
581
  > ul {
554
582
  width: 220px;
555
- position: absolute;
556
- top: 100%;
557
- left: 0;
583
+ /* Fixed for the same reason as `.nav-item` above — the strip
584
+ is a scroll container and clips on both axes, and flipping
585
+ its overflow to escape the clip reset `scrollLeft` out from
586
+ under the pointer. Coordinates come from Navigation.jsx per
587
+ item; see the long note there for the transform/filter
588
+ caveat. */
589
+ position: fixed;
590
+ top: var(--nav-sub-top, 100%);
591
+ left: var(--nav-sub-left, 0);
558
592
  height: auto;
559
593
  margin: 0;
560
594
  padding: 4px;
@@ -760,8 +794,14 @@
760
794
  centre. The anchor is the chip itself, and the pill stays INSIDE its
761
795
  box: the chip row is a scroll container (see the overflow comment
762
796
  below), and a scroll container clips both axes — a pill overhanging
763
- the edge by 2px rendered as a numberless green sliver. */
764
- > ul > li > a {
797
+ the edge by 2px rendered as a numberless green sliver.
798
+
799
+ `:not([data-nav-overlay] *)` for the same reason every other rule in
800
+ this block carries it (see the long note below): a popover mounted in
801
+ the cluster advertises itself with `data-nav-overlay`, and a link in
802
+ ITS list is not a chip — this rule was pinning any `.nav-badge` inside
803
+ one to the corner of its row. */
804
+ > ul > li > a:not([data-nav-overlay] *) {
765
805
  position: relative;
766
806
 
767
807
  .nav-badge {
@@ -1250,6 +1290,9 @@
1250
1290
  display: flex;
1251
1291
  flex-wrap: nowrap;
1252
1292
  align-items: center;
1293
+ /* Icon, then label. Only pays for itself when there is
1294
+ an icon: a row with one child has no gap to draw. */
1295
+ gap: 0.6rem;
1253
1296
  color: var(--tertiary-color);
1254
1297
  box-sizing: border-box;
1255
1298
  padding: 0.5rem 1rem;
@@ -1263,11 +1306,30 @@
1263
1306
 
1264
1307
  svg {
1265
1308
  position: relative;
1266
- right: 8px;
1309
+ /* Was `right: 8px` — a nudge that made sense while
1310
+ these rows were text only and the occasional icon
1311
+ was hidden anyway. With the icon drawn it pulls the
1312
+ glyph into the row's left padding and out of line
1313
+ with the rows above and below it. The space between
1314
+ the icon and the label is the `gap` on the row now,
1315
+ which is what it should have been. */
1316
+ right: 0;
1267
1317
  opacity: 1;
1268
1318
  transition: opacity 0.65s
1269
1319
  cubic-bezier(0.25, 0.8, 0.25, 1);
1270
1320
  color: var(--secondary-color);
1321
+ /* STATED, not assumed. Something else in this file
1322
+ used to hide these (see the `.hwrap` note), and the
1323
+ reason it could is that this rule never said what
1324
+ `display` a rail icon has. It says so now, so the
1325
+ rail cannot be blanked by a rule it outranks. */
1326
+ display: block;
1327
+ visibility: visible;
1328
+ /* An 18px glyph in a flex row shrinks with the row
1329
+ unless it refuses to; a label long enough to squeeze
1330
+ it is a label that would otherwise take the icon's
1331
+ width and leave nothing. */
1332
+ flex: 0 0 auto;
1271
1333
  }
1272
1334
 
1273
1335
  &:hover a,
@@ -2447,3 +2509,280 @@ $drawer-top: var(
2447
2509
  padding-right: var(--spacing-sm, 0.5rem);
2448
2510
  }
2449
2511
  }
2512
+
2513
+ /* ============================================================================
2514
+ * The collapsible rail (sidebar layouts, desktop only)
2515
+ * ----------------------------------------------------------------------------
2516
+ * The rail costs `--sidebar-width` of every page, permanently, to show a
2517
+ * handful of words. Collapsed, it is an icon strip and the page gets the rest
2518
+ * back; the tooltips the rows now carry become the labels (they are rendered
2519
+ * only in this state — see Navigation.jsx).
2520
+ *
2521
+ * ONE LEVER does the geometry: `--sidebar-width` is REDEFINED on the root when
2522
+ * the rail is collapsed. The rail's own `width`, the content's `margin-left`
2523
+ * and everything else in the product that already sizes itself against that
2524
+ * variable follow with no second rule to keep in step — and an app that has
2525
+ * retuned `--sidebar-width` keeps its own expanded width, because only the
2526
+ * collapsed value is stated here.
2527
+ *
2528
+ * --sidebar-collapsed-width the icon strip's width. default: 64px
2529
+ *
2530
+ * The state lives on `<html>` as `data-nav-collapsed="true"`, written by
2531
+ * Navigation from a preference remembered in localStorage (see
2532
+ * utils/navCollapsed.js). ABSENT IS EXPANDED, so an app that has never had the
2533
+ * toggle clicked matches not one selector in this block.
2534
+ *
2535
+ * EVERYTHING IS INSIDE `min-width: 1025px`. Below that the rail is the mobile
2536
+ * drawer — a panel that slides over the page and is dismissed by tapping the
2537
+ * backdrop — where there is no width to reclaim and no room for a second
2538
+ * dismissal mechanism. The drawer is untouched by every rule here, and the
2539
+ * component refuses to apply the state there as well (`railCollapsed`).
2540
+ * ==========================================================================*/
2541
+
2542
+ /* `.nav-label` — the label a collapsed row hides — deliberately has NO rule of
2543
+ its own here. On the nav rows it exists only while the rail is collapsed
2544
+ (Navigation.jsx emits the bare text node otherwise), and on the action rows
2545
+ it is the span those rows have always rendered, now merely named. Giving it
2546
+ so much as a `min-width` would be a declaration applying to an expanded rail
2547
+ that was not applying to it before. The only rules that touch it are inside
2548
+ the collapsed block below. */
2549
+
2550
+ /* The stand-in for a row with no icon. Rendered only while collapsed, so this
2551
+ never appears beside an expanded label. */
2552
+ .nav-initial {
2553
+ display: inline-flex;
2554
+ flex: 0 0 auto;
2555
+ align-items: center;
2556
+ justify-content: center;
2557
+ width: 22px;
2558
+ height: 22px;
2559
+ border-radius: 50%;
2560
+ background: rgba(var(--tertiary-color-rgb, 255, 255, 255), 0.14);
2561
+ color: var(--secondary-color);
2562
+ font-size: 0.75rem;
2563
+ font-weight: 700;
2564
+ line-height: 1;
2565
+ }
2566
+
2567
+ /* Hidden by default, in both senses: the header layout has no rail, and below
2568
+ 1024px the rail is a drawer. Only the block below turns it on. */
2569
+ .rail-toggle {
2570
+ display: none;
2571
+ }
2572
+
2573
+ @media (min-width: 1025px) {
2574
+ :root[data-nav-collapsed='true'] {
2575
+ --sidebar-width: var(--sidebar-collapsed-width, 64px);
2576
+ }
2577
+
2578
+ /* The only two properties that move, so the two move together. */
2579
+ .aside--cms {
2580
+ transition: width var(--speed, 0.18s) var(--ease, ease);
2581
+ }
2582
+
2583
+ .content {
2584
+ transition: margin-left var(--speed, 0.18s) var(--ease, ease);
2585
+ }
2586
+
2587
+ /* The toggle sits ON the logo plate, absolutely positioned against it, so
2588
+ an expanded rail is laid out exactly as it was — the logo stays centred
2589
+ in the plate and nothing is pushed anywhere. */
2590
+ .aside--cms .logo .rail-toggle {
2591
+ display: inline-flex;
2592
+ align-items: center;
2593
+ justify-content: center;
2594
+ position: absolute;
2595
+ top: 50%;
2596
+ right: 4px;
2597
+ transform: translateY(-50%);
2598
+ width: 26px;
2599
+ height: 26px;
2600
+ box-sizing: border-box;
2601
+ margin: 0;
2602
+ padding: 0;
2603
+ border: none;
2604
+ border-radius: var(--br);
2605
+ background: transparent;
2606
+ /* The plate is the near-white band, so the ink is the page's, not the
2607
+ rail's white-on-dark. */
2608
+ color: var(--primary-color, #1b3933);
2609
+ opacity: 0.5;
2610
+ cursor: pointer;
2611
+ appearance: none;
2612
+ transition: opacity var(--speed, 0.18s) var(--ease, ease),
2613
+ background-color var(--speed, 0.18s) var(--ease, ease);
2614
+ }
2615
+
2616
+ .aside--cms .logo .rail-toggle:hover,
2617
+ .aside--cms .logo .rail-toggle:focus-visible {
2618
+ opacity: 1;
2619
+ background-color: rgba(var(--primary-rgb, 27, 57, 51), 0.08);
2620
+ }
2621
+
2622
+ :root[data-nav-collapsed='true'] {
2623
+ /* With no square mark the plate holds the toggle alone (the link is
2624
+ not rendered), so it centres rather than hugging an edge. With one,
2625
+ the mark keeps clear of it. */
2626
+ .aside--cms .logo .rail-toggle {
2627
+ right: 50%;
2628
+ transform: translate(50%, -50%);
2629
+ }
2630
+
2631
+ .aside--cms .logo a {
2632
+ max-width: calc(100% - 26px);
2633
+ margin-right: 24px;
2634
+ }
2635
+
2636
+ .aside--cms .logo a img {
2637
+ max-width: 100%;
2638
+ max-height: calc(var(--nav-logo-height, 50px) - 12px);
2639
+ }
2640
+
2641
+ .aside--cms .logo:has(a) .rail-toggle {
2642
+ right: 2px;
2643
+ transform: translateY(-50%);
2644
+ }
2645
+
2646
+ /* --- the nav rows ------------------------------------------------ */
2647
+ /* The rail's own rules are five classes deep
2648
+ (`.aside--cms .awrap .navwrap .app-nav .nav-item a`), so these
2649
+ carry the same chain rather than relying on source order to win a
2650
+ specificity tie. */
2651
+ .aside--cms .awrap .navwrap .app-nav .nav-item {
2652
+ padding: 0.15rem 0.35rem;
2653
+ }
2654
+
2655
+ .aside--cms .awrap .navwrap .app-nav .nav-item > a,
2656
+ .aside--cms .awrap .navwrap .app-nav .nav-item > button,
2657
+ .aside--cms .awrap .navwrap .app-nav .nav-item > span {
2658
+ justify-content: center;
2659
+ gap: 0;
2660
+ padding: 0.6rem 0;
2661
+ }
2662
+
2663
+ /* The rail nudges a row's icon 8px left to sit under the label's
2664
+ optical start. Centred in a square there is no label to sit under. */
2665
+ .aside--cms .awrap .navwrap .app-nav .nav-item > a > svg,
2666
+ .aside--cms .awrap .navwrap .app-nav .nav-item > button > svg,
2667
+ .aside--cms .awrap .navwrap .app-nav .nav-item > span > svg {
2668
+ right: 0;
2669
+ }
2670
+
2671
+ /* A two-level menu has nowhere to open in a 64px strip; tapping a
2672
+ group expands the rail instead (see Navigation.jsx). */
2673
+ .aside--cms .navDropdown,
2674
+ .aside--cms .awrap .navwrap .app-nav .nav-item > ul {
2675
+ display: none;
2676
+ }
2677
+
2678
+ /* --- the pinned action rows -------------------------------------- */
2679
+ .rail-actions > ul > li {
2680
+ padding: 0.15rem 0.35rem;
2681
+ }
2682
+
2683
+ .rail-actions > ul > li > a,
2684
+ .rail-actions > ul > li > button,
2685
+ .rail-actions > ul > li > div > a,
2686
+ .rail-actions > ul > li > div > button {
2687
+ justify-content: center;
2688
+ gap: 0;
2689
+ padding: 0.6rem 0;
2690
+ }
2691
+
2692
+ /* A mounted component's label is a `::after` fed by
2693
+ `--nav-action-label` (the account switcher, the vault palette). It
2694
+ is the same label the row's tooltip now carries. */
2695
+ .rail-actions > ul > li > div > a::after,
2696
+ .rail-actions > ul > li > div > button::after {
2697
+ display: none;
2698
+ }
2699
+
2700
+ /* Every visible label, gone — but still in the accessibility tree.
2701
+ `clip-path` plus a 1px box is the treatment that survives being
2702
+ inside a flex row; `display: none` would take it out of the
2703
+ accessible name as well.
2704
+
2705
+ TWO SELECTORS, and the long one is not decoration. This rail styles
2706
+ `span` as a descendant of `.nav-item`, five classes deep
2707
+ (`.aside--cms .awrap .navwrap .app-nav .nav-item span` — the rule
2708
+ belongs to the group heading, which used to be a `<span>`), and it
2709
+ sets a background, a display and 1.25em of padding. A one-class
2710
+ `.nav-label` loses that cascade outright, which is a full-width
2711
+ `--primary-color` block where a hidden label should be. The action
2712
+ rows have no such rule, so the short selector is enough for them. */
2713
+ .nav-label {
2714
+ position: absolute;
2715
+ width: 1px;
2716
+ height: 1px;
2717
+ margin: -1px;
2718
+ padding: 0;
2719
+ overflow: hidden;
2720
+ clip: rect(0 0 0 0);
2721
+ clip-path: inset(50%);
2722
+ white-space: nowrap;
2723
+ border: 0;
2724
+ }
2725
+
2726
+ .aside--cms .awrap .navwrap .app-nav .nav-item .nav-label {
2727
+ display: block;
2728
+ position: absolute;
2729
+ width: 1px;
2730
+ height: 1px;
2731
+ margin: -1px;
2732
+ padding: 0;
2733
+ overflow: hidden;
2734
+ clip: rect(0 0 0 0);
2735
+ clip-path: inset(50%);
2736
+ white-space: nowrap;
2737
+ border: 0;
2738
+ background: none;
2739
+ }
2740
+
2741
+ /* Same cascade, same answer: the initial is a `<span>` inside a
2742
+ `.nav-item` too, so every property that makes it a circle has to be
2743
+ restated above that rule rather than inherited from the one-class
2744
+ definition at the top of this block. */
2745
+ .aside--cms .awrap .navwrap .app-nav .nav-item .nav-initial {
2746
+ display: inline-flex;
2747
+ position: static;
2748
+ flex: 0 0 auto;
2749
+ align-items: center;
2750
+ justify-content: center;
2751
+ width: 22px;
2752
+ height: 22px;
2753
+ margin: 0;
2754
+ padding: 0;
2755
+ border: none;
2756
+ border-radius: 50%;
2757
+ background: rgba(var(--tertiary-color-rgb, 255, 255, 255), 0.14);
2758
+ color: var(--secondary-color);
2759
+ font-size: 0.75rem;
2760
+ font-weight: 700;
2761
+ line-height: 1;
2762
+ text-align: center;
2763
+ }
2764
+ }
2765
+ }
2766
+
2767
+ /* ============================================================================
2768
+ * The header bar hides its nav icons — and only the header bar
2769
+ * ----------------------------------------------------------------------------
2770
+ * This pair used to live inside `.app-nav .nav-item { svg { … } }`, which is a
2771
+ * class BOTH layouts use: the one-line bar across the top and the sidebar rail
2772
+ * down the left. So the bar's decision — the bar is a row of words, and an icon
2773
+ * per word at that density is noise — was silently taken for the rail as well,
2774
+ * where the icon is the point and, once collapsed, the only thing in the row.
2775
+ *
2776
+ * `.hwrap` is the header layout's own root element (`layout="full"`); the
2777
+ * sidebar layouts render `.cwrap > .aside--cms` and never match it. The
2778
+ * `full` layout's ≤1024px drawer is inside `.hwrap` too, but it draws its rows
2779
+ * through `.drawer-nav`, not `.app-nav`, so it is unaffected either way.
2780
+ *
2781
+ * Both declarations are kept exactly as they were, because the bar's rendering
2782
+ * is not what was wrong.
2783
+ * ==========================================================================*/
2784
+ .hwrap .app-nav .nav-item svg,
2785
+ .hwrap .app-nav .nav-item-alternate svg {
2786
+ display: none;
2787
+ visibility: hidden;
2788
+ }