@visns-studio/visns-components 6.32.3 → 6.34.3

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 (53) hide show
  1. package/README.md +483 -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 +274 -15
  7. package/src/components/Notification.jsx +13 -2
  8. package/src/components/TableFilter.jsx +14 -2
  9. package/src/components/auth/Login.jsx +11 -4
  10. package/src/components/auth/PasskeyEnrolPrompt.jsx +269 -0
  11. package/src/components/auth/Profile.jsx +422 -26
  12. package/src/components/auth/Reset.jsx +1 -1
  13. package/src/components/auth/TwoFactorAuth.jsx +1 -1
  14. package/src/components/auth/Verify.jsx +2 -2
  15. package/src/components/auth/authEndpoints.js +43 -0
  16. package/src/components/auth/passkeyClient.js +102 -0
  17. package/src/components/auth/passkeyPrompt.js +408 -0
  18. package/src/components/auth/profileLayout.js +45 -0
  19. package/src/components/controls/DataGridSearch.jsx +16 -2
  20. package/src/components/controls/DataGridSortSheet.jsx +2 -0
  21. package/src/components/emailCampaigns/BlockEditor.jsx +486 -0
  22. package/src/components/emailCampaigns/CampaignEditor.jsx +614 -0
  23. package/src/components/emailCampaigns/CampaignReport.jsx +98 -0
  24. package/src/components/emailCampaigns/EmailCampaigns.jsx +422 -0
  25. package/src/components/emailCampaigns/EmailLists.jsx +505 -0
  26. package/src/components/emailCampaigns/emailCampaignApi.js +135 -0
  27. package/src/components/generic/ActionButtons.jsx +1 -1
  28. package/src/components/generic/GenericAuth.jsx +197 -21
  29. package/src/components/generic/GenericDetail.jsx +95 -3
  30. package/src/components/generic/GenericMain.jsx +7 -0
  31. package/src/components/generic/StandardModal.jsx +71 -158
  32. package/src/components/sms/SmsCampaigns.jsx +2517 -0
  33. package/src/components/sms/SmsInbox.jsx +20 -5
  34. package/src/components/sms/SmsLineSettings.jsx +2 -5
  35. package/src/components/sms/SmsThreadPanel.jsx +20 -0
  36. package/src/components/sms/smsEndpoints.js +23 -0
  37. package/src/components/styles/DataGrid.module.scss +22 -0
  38. package/src/components/styles/EmailCampaigns.module.scss +922 -0
  39. package/src/components/styles/Form.module.scss +100 -2
  40. package/src/components/styles/Navigation.module.scss +322 -5
  41. package/src/components/styles/Notification.module.scss +140 -8
  42. package/src/components/styles/PasskeyEnrolPrompt.module.scss +105 -0
  43. package/src/components/styles/Profile.module.scss +157 -0
  44. package/src/components/styles/Sms.module.scss +484 -4
  45. package/src/components/styles/StandardModal.module.scss +273 -0
  46. package/src/components/styles/Vault.module.scss +5 -3
  47. package/src/components/styles/global.css +38 -7
  48. package/src/components/utils/navCollapsed.js +201 -0
  49. package/src/components/utils/rowActionSettings.js +76 -0
  50. package/src/components/utils/usePasskeysEnabled.js +84 -0
  51. package/src/components/utils/useShellLayout.js +131 -0
  52. package/src/index.js +107 -1
  53. package/src/utils/rememberTab.js +70 -0
@@ -1,4 +1,10 @@
1
- import React, { useEffect, useRef, useState, useCallback } from 'react';
1
+ import React, {
2
+ useEffect,
3
+ useInsertionEffect,
4
+ useRef,
5
+ useState,
6
+ useCallback,
7
+ } from 'react';
2
8
  import { Link, useLocation, useNavigate } from 'react-router-dom';
3
9
  import {
4
10
  AudioLines,
@@ -26,6 +32,8 @@ import {
26
32
  Lock,
27
33
  MessageSquare,
28
34
  MessagesSquare,
35
+ PanelLeftClose,
36
+ PanelLeftOpen,
29
37
  TrendingUp,
30
38
  } from 'lucide-react';
31
39
  import CustomFetch from './Fetch';
@@ -40,6 +48,13 @@ import {
40
48
  DEFAULT_BADGE_TONE,
41
49
  } from './navBadges';
42
50
  import { drawerRows } from './navDrawer';
51
+ // The collapsed-rail preference: remembered in localStorage, published on
52
+ // <html> as `data-nav-collapsed`. See utils/navCollapsed.js.
53
+ import {
54
+ applyNavCollapsed,
55
+ getStoredNavCollapsed,
56
+ storeNavCollapsed,
57
+ } from './utils/navCollapsed';
43
58
  import styles from './styles/Navigation.module.scss';
44
59
 
45
60
  /**
@@ -444,6 +459,16 @@ function Navigation({
444
459
  * shared bar has no way to know any of it.
445
460
  */
446
461
  badges = {},
462
+ /*
463
+ * The square brand mark for the collapsed rail, if the project has one.
464
+ *
465
+ * `logo` is a wide lock-up — a mark plus a wordmark — and there is no room
466
+ * for a wordmark in a 64px strip. Passing nothing is fine and is the
467
+ * default: the logo plate then holds the collapse toggle alone, which
468
+ * reads as deliberate in a way a wordmark cropped to its first two letters
469
+ * does not.
470
+ */
471
+ collapsedLogo,
447
472
  }) {
448
473
  const [search, setSearch] = useState('');
449
474
  const [searchResultShow, setSearchResultShow] = useState(false);
@@ -463,6 +488,65 @@ function Navigation({
463
488
  const isNarrow = useMediaQuery(DRAWER_BREAKPOINT);
464
489
  const isCoarsePointer = useMediaQuery(COARSE_POINTER);
465
490
 
491
+ /*
492
+ * THE COLLAPSED RAIL.
493
+ *
494
+ * Only the sidebar layouts have a rail to collapse; the header bar is
495
+ * untouched by every line below, and writes no attribute.
496
+ *
497
+ * The initial value is read from localStorage in a LAZY INITIALISER, not
498
+ * in an effect. An effect runs after the first paint, so the rail would
499
+ * render 216px wide, the content would lay out beside it, and both would
500
+ * then jump — which is exactly the flash a remembered layout preference
501
+ * exists to avoid.
502
+ */
503
+ const isRailLayout = layout === 'simple' || layout === 'cms';
504
+ const [collapsed, setCollapsed] = useState(() =>
505
+ isRailLayout ? getStoredNavCollapsed() : false
506
+ );
507
+
508
+ /*
509
+ * Below 1024px the rail IS the drawer — a panel that slides over the page
510
+ * and is dismissed by tapping the backdrop — so there is nothing to
511
+ * collapse and nothing the extra width is being taken from. The state is
512
+ * kept (widening the window restores what the user chose); it simply does
513
+ * not apply.
514
+ */
515
+ const railCollapsed = isRailLayout && collapsed && !isNarrow;
516
+
517
+ /*
518
+ * Publish it on <html>, the way GenericAuth publishes `data-layout` and
519
+ * `data-density` — so the rail's own stylesheet, and a consuming app's,
520
+ * key off one attribute rather than off a class threaded through the tree.
521
+ * `useInsertionEffect` so the width is in place before the first paint.
522
+ */
523
+ useInsertionEffect(() => {
524
+ if (!isRailLayout) {
525
+ return undefined;
526
+ }
527
+
528
+ applyNavCollapsed(railCollapsed);
529
+
530
+ return () => applyNavCollapsed(false);
531
+ }, [isRailLayout, railCollapsed]);
532
+
533
+ /**
534
+ * Collapse, or expand. The preference is remembered against the browser,
535
+ * not the account: it is about this screen and this window.
536
+ */
537
+ /** The toggle is an icon in both states, so it is named in both. */
538
+ const collapseLabel = railCollapsed ? 'Expand menu' : 'Collapse menu';
539
+
540
+ const toggleCollapsed = useCallback(() => {
541
+ setCollapsed((previous) => {
542
+ const next = !previous;
543
+
544
+ storeNavCollapsed(next);
545
+
546
+ return next;
547
+ });
548
+ }, []);
549
+
466
550
  // The mobile drawer is a full-viewport fixed overlay; without a scroll
467
551
  // lock the page behind it keeps scrolling under the user's thumb.
468
552
  useEffect(() => {
@@ -675,6 +759,36 @@ function Navigation({
675
759
  const labelVar = { '--nav-action-label': JSON.stringify(label) };
676
760
  const IconComponent = SETTING_ICONS[n.icon];
677
761
 
762
+ /*
763
+ * The same tooltip the header chips carry, placed to the RIGHT of the
764
+ * rail and drawn only while the rail is collapsed — see the note in
765
+ * `renderNav`, which does this for the nav rows above these. Profile
766
+ * and Logout now behave like the bell, which was the only labelled
767
+ * thing in the rail. `actionLabel` already falls back to the humanised
768
+ * id, so a settings entry with no label of its own still says
769
+ * something.
770
+ */
771
+ const railTooltip =
772
+ railCollapsed && label
773
+ ? {
774
+ 'data-tooltip-id': 'system-tooltip',
775
+ 'data-tooltip-place': 'right',
776
+ 'data-tooltip-content': label,
777
+ }
778
+ : {};
779
+
780
+ // Same fallback the nav rows use: an action with no icon would
781
+ // collapse to an empty square, so it shows its initial instead.
782
+ const railInitial =
783
+ railCollapsed && !IconComponent && label
784
+ ? String(label).trim().charAt(0).toUpperCase()
785
+ : '';
786
+ const railInitialMark = railInitial ? (
787
+ <span className={styles['nav-initial']} aria-hidden="true">
788
+ {railInitial}
789
+ </span>
790
+ ) : null;
791
+
678
792
  // Same slot as the header's, publishing the row label the same way a
679
793
  // mounted component gets one (see the note above renderRailSetting).
680
794
  if (typeof renderers[n.id] === 'function') {
@@ -692,7 +806,14 @@ function Navigation({
692
806
  if (n.id === 'notifications') {
693
807
  return (
694
808
  <li style={labelVar}>
695
- <Notification setSystemAuth={setSystemAuth} />
809
+ {/* The bell draws its own trigger, so the placement is
810
+ the one thing it has to be told: its tooltip is
811
+ hard-placed below the chip for the header bar, and
812
+ below a row in a rail is on top of the next row. */}
813
+ <Notification
814
+ setSystemAuth={setSystemAuth}
815
+ tooltipPlace="right"
816
+ />
696
817
  </li>
697
818
  );
698
819
  } else if (n.id === 'businessCardOcr') {
@@ -702,9 +823,11 @@ function Navigation({
702
823
  onClick={() => setShowBusinessCardOcr(true)}
703
824
  className={activeClass}
704
825
  type="button"
826
+ aria-label={label}
827
+ {...railTooltip}
705
828
  >
706
829
  <CreditCard size={19} strokeWidth={2} />
707
- <span>{label}</span>
830
+ <span className={styles['nav-label']}>{label}</span>
708
831
  </button>
709
832
  </li>
710
833
  );
@@ -725,11 +848,14 @@ function Navigation({
725
848
  onClick={handleLogout}
726
849
  className={activeClass}
727
850
  type="button"
851
+ aria-label={label}
852
+ {...railTooltip}
728
853
  >
729
854
  {IconComponent && (
730
855
  <IconComponent size={19} strokeWidth={2} />
731
856
  )}
732
- <span>{label}</span>
857
+ {railInitialMark}
858
+ <span className={styles['nav-label']}>{label}</span>
733
859
  </button>
734
860
  </li>
735
861
  );
@@ -741,6 +867,8 @@ function Navigation({
741
867
  to={n.url}
742
868
  className={`${activeClass}`}
743
869
  target={n.target ? n.target : '_self'}
870
+ aria-label={label}
871
+ {...railTooltip}
744
872
  // The rail is the mobile drawer below 1024px; navigating
745
873
  // from it has to close the drawer, as the nav list does.
746
874
  onClick={() => setOpen(false)}
@@ -748,7 +876,8 @@ function Navigation({
748
876
  {IconComponent && (
749
877
  <IconComponent size={19} strokeWidth={2} />
750
878
  )}
751
- <span>{label}</span>
879
+ {railInitialMark}
880
+ <span className={styles['nav-label']}>{label}</span>
752
881
  </Link>
753
882
  </li>
754
883
  );
@@ -847,10 +976,42 @@ function Navigation({
847
976
 
848
977
  const renderNav = (n) => {
849
978
  let navItemClasses;
850
- const NavItemIcon =
851
- layout === 'simple' || layout === 'cms'
852
- ? NAV_ICONS[n.icon || n.id]
853
- : undefined;
979
+ const NavItemIcon = isRailLayout
980
+ ? NAV_ICONS[n.icon || n.id]
981
+ : undefined;
982
+
983
+ /*
984
+ * The rail's rows get the same tooltip every account chip in the
985
+ * header has always had — `data-tooltip-*` read by the one
986
+ * `<Tooltip id="system-tooltip">` GenericAuth mounts.
987
+ *
988
+ * ONLY WHILE COLLAPSED, and that is the whole point of it: an icon
989
+ * strip is unreadable without one, and a tooltip repeating a label
990
+ * that is already printed six pixels to its right is noise. The
991
+ * attributes are omitted rather than the tooltip disabled — react-
992
+ * tooltip shows nothing for an anchor with no `data-tooltip-content`.
993
+ *
994
+ * `place="right"`, not the header's "bottom": a tooltip under a row
995
+ * in a vertical list covers the next row, which is the one you are
996
+ * about to read. To the right of the rail it covers page content
997
+ * nobody is pointing at.
998
+ *
999
+ * No `title` attribute anywhere near this — the native tooltip and
1000
+ * react-tooltip both fire, half a second apart, and you get the label
1001
+ * twice in two different boxes.
1002
+ *
1003
+ * Spread as an object rather than written inline so the HEADER layout
1004
+ * renders exactly the attributes it rendered before: on `full` this
1005
+ * is empty and nothing is added.
1006
+ */
1007
+ const railTooltip =
1008
+ railCollapsed && n.label
1009
+ ? {
1010
+ 'data-tooltip-id': 'system-tooltip',
1011
+ 'data-tooltip-place': 'right',
1012
+ 'data-tooltip-content': n.label,
1013
+ }
1014
+ : {};
854
1015
 
855
1016
  if (n.class.includes('active')) {
856
1017
  navItemClasses =
@@ -939,10 +1100,45 @@ function Navigation({
939
1100
  */
940
1101
  const itemBadge = navBadge(badges, n);
941
1102
  const ariaLabel = badgeAriaLabel(n.label, itemBadge);
1103
+ /*
1104
+ * The label, wrapped in a span ONLY WHILE THE RAIL IS COLLAPSED.
1105
+ *
1106
+ * A collapsed row shows its icon alone, and a bare text node cannot be
1107
+ * hidden — only deleted, which takes the row's accessible name with
1108
+ * it. Wrapped, it is clipped to a pixel and stays in the accessibility
1109
+ * tree where the screen reader and the tooltip both still find it.
1110
+ *
1111
+ * CONDITIONAL, not permanent, and that is the whole lesson of the
1112
+ * first attempt: this rail's own stylesheet styles `span` as a
1113
+ * DESCENDANT of `.nav-item` — the rule was written for the group
1114
+ * heading, which used to be a `<span>` — so an unconditional wrapper
1115
+ * turned every expanded row into a `--primary-color` block with 1.25em
1116
+ * of padding and squeezed the icon beside it to nothing. Emitting the
1117
+ * bare text node when expanded, exactly as before, means there is no
1118
+ * selector to audit: the expanded rail renders what it has always
1119
+ * rendered, in both layouts.
1120
+ *
1121
+ * An item with no icon would collapse to an empty square, so it falls
1122
+ * back to the first letter of its label in the icon's slot — again
1123
+ * only while collapsed, where it is the only thing in the row.
1124
+ */
1125
+ const railInitial =
1126
+ railCollapsed && !NavItemIcon && n.label
1127
+ ? String(n.label).trim().charAt(0).toUpperCase()
1128
+ : '';
942
1129
  const contents = (
943
1130
  <>
944
1131
  {NavItemIcon ? <NavItemIcon size={18} /> : null}
945
- {n.label}
1132
+ {railInitial ? (
1133
+ <span className={styles['nav-initial']} aria-hidden="true">
1134
+ {railInitial}
1135
+ </span>
1136
+ ) : null}
1137
+ {railCollapsed ? (
1138
+ <span className={styles['nav-label']}>{n.label}</span>
1139
+ ) : (
1140
+ n.label
1141
+ )}
946
1142
  <BadgePill badge={itemBadge} />
947
1143
  </>
948
1144
  );
@@ -953,7 +1149,7 @@ function Navigation({
953
1149
  onMouseEnter={handlePin}
954
1150
  onFocus={handlePin}
955
1151
  >
956
- {n.url && !(hasChildren && isCoarsePointer) ? (
1152
+ {n.url && !(hasChildren && (isCoarsePointer || railCollapsed)) ? (
957
1153
  // Close the mobile drawer when navigating; no-op for the
958
1154
  // header layout where the drawer state is never opened.
959
1155
  //
@@ -971,6 +1167,7 @@ function Navigation({
971
1167
  to={n.url}
972
1168
  aria-label={ariaLabel}
973
1169
  onClick={() => setOpen(false)}
1170
+ {...railTooltip}
974
1171
  >
975
1172
  {contents}
976
1173
  </Link>
@@ -1020,6 +1217,28 @@ function Navigation({
1020
1217
  since a fixed panel has no position of its own until
1021
1218
  somebody measures the item for it. */
1022
1219
  onClick={(e) => {
1220
+ /*
1221
+ * COLLAPSED: there is nowhere for a submenu to go.
1222
+ * A 64px strip cannot hold a list of child labels,
1223
+ * and a flyout anchored to a row is a second
1224
+ * floating surface with its own placement,
1225
+ * clipping and dismissal problems. Expanding the
1226
+ * rail is the simple answer that cannot go wrong:
1227
+ * the group is then an ordinary heading in an
1228
+ * ordinary rail, and the next hover opens it.
1229
+ *
1230
+ * `n.url` groups take this path too while
1231
+ * collapsed (see the branch condition above) —
1232
+ * otherwise the tap would navigate to the parent
1233
+ * page and the children would be unreachable
1234
+ * without expanding the rail by hand.
1235
+ */
1236
+ if (railCollapsed) {
1237
+ toggleCollapsed();
1238
+
1239
+ return;
1240
+ }
1241
+
1023
1242
  if (isCoarsePointer) {
1024
1243
  const next = openGroup === n.id ? null : n.id;
1025
1244
  setOpenGroup(next);
@@ -1035,11 +1254,14 @@ function Navigation({
1035
1254
  e.currentTarget.blur();
1036
1255
  }
1037
1256
  }}
1257
+ {...railTooltip}
1038
1258
  >
1039
1259
  {contents}
1040
1260
  </button>
1041
1261
  ) : (
1042
- <span aria-label={ariaLabel}>{contents}</span>
1262
+ <span aria-label={ariaLabel} {...railTooltip}>
1263
+ {contents}
1264
+ </span>
1043
1265
  )}
1044
1266
  {renderChildren()}
1045
1267
  </li>
@@ -1514,18 +1736,55 @@ function Navigation({
1514
1736
  return layout === 'simple' || layout === 'cms' ? (
1515
1737
  <div className={styles.cwrap}>
1516
1738
  <aside
1739
+ id="nav-rail"
1517
1740
  className={[
1518
1741
  styles['aside--cms'],
1519
1742
  isOpen ? styles['aside--cms--open'] : '',
1520
1743
  actionsInSidebar ? styles['aside--cms--rail'] : '',
1744
+ railCollapsed ? styles['aside--cms--collapsed'] : '',
1521
1745
  ]
1522
1746
  .filter(Boolean)
1523
1747
  .join(' ')}
1524
1748
  >
1525
1749
  <div className={styles.logo}>
1526
- <Link to="/">
1527
- <img src={logo} alt="System Logo" />
1528
- </Link>
1750
+ {/* A square mark for the 64px strip when the project has
1751
+ one. With none, the plate holds the collapse toggle
1752
+ alone — a wide lock-up cropped to its first two letters
1753
+ is worse than no logo, and the plate is still the
1754
+ brand-coloured band it always was. */}
1755
+ {railCollapsed && !collapsedLogo ? null : (
1756
+ <Link to="/">
1757
+ <img
1758
+ src={
1759
+ railCollapsed && collapsedLogo
1760
+ ? collapsedLogo
1761
+ : logo
1762
+ }
1763
+ alt="System Logo"
1764
+ />
1765
+ </Link>
1766
+ )}
1767
+ {/* Desktop only — below 1024px the rail is a drawer and
1768
+ there is no width to reclaim. Hidden in CSS as well,
1769
+ because `isNarrow` resolves from a media query that
1770
+ answers `false` for one render on a cold load. */}
1771
+ <button
1772
+ type="button"
1773
+ className={styles['rail-toggle']}
1774
+ aria-expanded={!railCollapsed}
1775
+ aria-controls="nav-rail"
1776
+ data-tooltip-id="system-tooltip"
1777
+ data-tooltip-place="right"
1778
+ data-tooltip-content={collapseLabel}
1779
+ aria-label={collapseLabel}
1780
+ onClick={toggleCollapsed}
1781
+ >
1782
+ {railCollapsed ? (
1783
+ <PanelLeftOpen size={18} strokeWidth={2} />
1784
+ ) : (
1785
+ <PanelLeftClose size={18} strokeWidth={2} />
1786
+ )}
1787
+ </button>
1529
1788
  </div>
1530
1789
  <div className={styles.awrap}>
1531
1790
  <nav className={styles.navwrap} ref={navWrapRef}>
@@ -100,7 +100,18 @@ function Notification(props) {
100
100
  const [show, setShow] = useState(false);
101
101
  const [loading, setLoading] = useState(false);
102
102
  const [processingIds, setProcessingIds] = useState([]);
103
- const { setSystemAuth, echo = null, channel = null } = props;
103
+ const {
104
+ setSystemAuth,
105
+ echo = null,
106
+ channel = null,
107
+ /**
108
+ * Where the trigger's tooltip opens. 'bottom' is the header chip,
109
+ * which is what this has always been and what every existing mount
110
+ * keeps. The sidebar rail passes 'right': below a row in a vertical
111
+ * list is on top of the next row.
112
+ */
113
+ tooltipPlace = 'bottom',
114
+ } = props;
104
115
 
105
116
  // Live means *confirmed*: Echo hands back a channel object long before
106
117
  // /broadcasting/auth has approved it, and treating that as live is how a
@@ -451,7 +462,7 @@ function Notification(props) {
451
462
  className={styles.notificationTrigger}
452
463
  onClick={toggleNotification}
453
464
  data-tooltip-id="system-tooltip"
454
- data-tooltip-place="bottom"
465
+ data-tooltip-place={tooltipPlace}
455
466
  data-tooltip-content="Notifications"
456
467
  aria-label="Notifications"
457
468
  >
@@ -34,8 +34,10 @@ function TableFilter({
34
34
  // `.flatFilter` block in the stylesheet), promoted to an opt-in.
35
35
  //
36
36
  // A vertical rail spends a fifth of the page on navigation. Detail pages
37
- // now ask for the strip unconditionally (see GenericDetail); index pages
38
- // and Profile still get the rail, so the default here is unchanged.
37
+ // now ask for the strip unconditionally (see GenericDetail); Profile asks
38
+ // for it on the sidebar shells and keeps the rail on the header-bar one
39
+ // (see auth/profileLayout.js); index pages still get the rail unless the
40
+ // host says otherwise. The default here is unchanged.
39
41
  //
40
42
  // In this variant the strip also carries real tab semantics — tablist /
41
43
  // tab / aria-selected, one tab stop for the whole strip, and arrow keys to
@@ -565,6 +567,14 @@ function TableFilter({
565
567
  }
566
568
  : { 'aria-current': isSelected ? 'page' : undefined };
567
569
 
570
+ // The strip's own tooltip. A tab's label is a noun ("Documents") and
571
+ // what a reader wants back from a hover is the verb — which verb it is
572
+ // depends on whether they are standing on it already. Both branches
573
+ // below carry it: a detail page's strip renders the anchor and an
574
+ // index page's `simple` strip the button, and one strip must not
575
+ // answer a hover differently depending on where it is drawn.
576
+ const hint = isSelected ? `You are on ${d.label}` : `Show ${d.label}`;
577
+
568
578
  if (type === 'simple') {
569
579
  return (
570
580
  <li className={styles[d.class]}>
@@ -574,6 +584,7 @@ function TableFilter({
574
584
  data-url={d.url || ''}
575
585
  data-value={d.hasOwnProperty('value') ? d.value : ''}
576
586
  data-active={isSelected ? 'true' : undefined}
587
+ title={hint}
577
588
  onClick={filterTable}
578
589
  {...a11y}
579
590
  >
@@ -591,6 +602,7 @@ function TableFilter({
591
602
  data-url={d.url || ''}
592
603
  data-value={d.hasOwnProperty('value') ? d.value : ''}
593
604
  data-active={isSelected ? 'true' : undefined}
605
+ title={hint}
594
606
  onClick={filterTable}
595
607
  className={parentClass}
596
608
  {...a11y}
@@ -266,7 +266,14 @@ const Login = ({
266
266
  // Stays pending: GenericAuth's curtain takes over from here,
267
267
  // and a button snapping back to idle underneath it reads as
268
268
  // "nothing happened".
269
- setSystemAuth(true);
269
+ //
270
+ // The second argument says HOW this sign-in happened.
271
+ // GenericAuth uses it to decide whether to offer a passkey
272
+ // afterwards — and offering one to somebody who just used one
273
+ // is the one thing that offer must never do. A host passing a
274
+ // plain `useState` setter here ignores the extra argument, so
275
+ // this is safe for every existing caller.
276
+ setSystemAuth(true, { method: 'passkey' });
270
277
  }
271
278
  } catch (error) {
272
279
  setIsPasskeyPending(false);
@@ -383,7 +390,7 @@ const Login = ({
383
390
  // profile check performs the one navigation
384
391
  // to '/'. Navigating here as well produced a
385
392
  // double route change and a visible flicker.
386
- setSystemAuth(true);
393
+ setSystemAuth(true, { method: 'password' });
387
394
  }
388
395
  }
389
396
  } else {
@@ -517,7 +524,7 @@ const Login = ({
517
524
  value={auth.email}
518
525
  onChange={handleChange}
519
526
  tabIndex="1"
520
- className={authClass.username}
527
+ className={`visns-auth-input ${authClass.username}`.trim()}
521
528
  autoComplete="username"
522
529
  placeholder=" "
523
530
  disabled={isBusy}
@@ -533,7 +540,7 @@ const Login = ({
533
540
  value={auth.password}
534
541
  onChange={handleChange}
535
542
  tabIndex="2"
536
- className={authClass.password}
543
+ className={`visns-auth-input ${authClass.password}`.trim()}
537
544
  autoComplete="current-password"
538
545
  placeholder=" "
539
546
  disabled={isBusy}