@visns-studio/visns-components 6.17.2 → 6.20.1

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 (83) hide show
  1. package/package.json +10 -7
  2. package/src/components/AssociationManager.jsx +344 -177
  3. package/src/components/AsyncSelect.jsx +2 -2
  4. package/src/components/Autocomplete.jsx +57 -12
  5. package/src/components/Breadcrumb.jsx +23 -4
  6. package/src/components/ClientAssociationManager.jsx +321 -145
  7. package/src/components/DataGrid.jsx +625 -20
  8. package/src/components/DropZone.jsx +4 -1
  9. package/src/components/Form.jsx +233 -6
  10. package/src/components/MultiSelect.jsx +2 -2
  11. package/src/components/Navigation.jsx +39 -23
  12. package/src/components/TableFilter.jsx +172 -12
  13. package/src/components/UniversalSearch.jsx +390 -0
  14. package/src/components/auth/Login.jsx +164 -8
  15. package/src/components/auth/Profile.jsx +209 -22
  16. package/src/components/auth/authEndpoints.js +4 -0
  17. package/src/components/auth/webauthn.js +194 -0
  18. package/src/components/columns/ColumnRenderers.jsx +6 -1
  19. package/src/components/controls/DataGridSearch.jsx +11 -5
  20. package/src/components/generic/GenericAuth.jsx +6 -0
  21. package/src/components/generic/GenericDashboard.jsx +93 -36
  22. package/src/components/generic/GenericDetail.jsx +146 -61
  23. package/src/components/generic/GenericIndex.jsx +142 -38
  24. package/src/components/generic/GenericSettings.jsx +220 -0
  25. package/src/components/generic/StandardModal.jsx +165 -8
  26. package/src/components/generic/dashboardChartDefaults.js +230 -0
  27. package/src/components/generic/shared/indexLayout.js +38 -0
  28. package/src/components/settings/IntegrationsManager.jsx +474 -0
  29. package/src/components/sms/SmsLineSettings.jsx +16 -12
  30. package/src/components/styles/AssociationManager.module.scss +4 -363
  31. package/src/components/styles/AuditLog.module.scss +6 -6
  32. package/src/components/styles/AuditLogs.module.scss +3 -3
  33. package/src/components/styles/Autocomplete.module.scss +7 -7
  34. package/src/components/styles/BusinessCardOcr.module.scss +6 -1
  35. package/src/components/styles/CallQueueSettings.module.scss +4 -1
  36. package/src/components/styles/CameraPlacement.module.scss +19 -19
  37. package/src/components/styles/CategorizedDropZone.module.scss +6 -1
  38. package/src/components/styles/ClientAssociationManager.module.scss +5 -289
  39. package/src/components/styles/ClientAuth.module.scss +1 -1
  40. package/src/components/styles/DataGrid.module.scss +455 -8
  41. package/src/components/styles/Field.module.scss +32 -24
  42. package/src/components/styles/Form.module.scss +123 -3
  43. package/src/components/styles/GenericDashboard.module.scss +612 -825
  44. package/src/components/styles/GenericDetail.module.scss +406 -61
  45. package/src/components/styles/GenericDynamic.module.scss +25 -25
  46. package/src/components/styles/GenericEditableTable.module.scss +33 -33
  47. package/src/components/styles/GenericFormBuilder.module.scss +119 -119
  48. package/src/components/styles/GenericIndex.module.scss +483 -38
  49. package/src/components/styles/GenericMain.module.scss +6 -3
  50. package/src/components/styles/GenericQuote.module.scss +399 -81
  51. package/src/components/styles/GenericReport.module.scss +8 -8
  52. package/src/components/styles/GenericSettings.module.scss +209 -0
  53. package/src/components/styles/GenericSort.module.scss +9 -9
  54. package/src/components/styles/Integrations.module.scss +321 -0
  55. package/src/components/styles/List.module.scss +4 -4
  56. package/src/components/styles/Login.module.scss +57 -0
  57. package/src/components/styles/MultiSelect.module.scss +4 -4
  58. package/src/components/styles/Navigation.module.scss +12 -12
  59. package/src/components/styles/Notification.module.scss +12 -8
  60. package/src/components/styles/NotificationList.module.scss +5 -5
  61. package/src/components/styles/Profile.module.scss +5 -5
  62. package/src/components/styles/QrCode.module.scss +4 -4
  63. package/src/components/styles/QuickAction.module.scss +20 -20
  64. package/src/components/styles/SortableQuoteItems.module.scss +4 -4
  65. package/src/components/styles/TableFilter.module.scss +128 -17
  66. package/src/components/styles/TicketConversation.module.scss +497 -0
  67. package/src/components/styles/UniversalSearch.module.scss +229 -0
  68. package/src/components/styles/Vault.module.scss +413 -0
  69. package/src/components/styles/_associationManager.scss +467 -0
  70. package/src/components/styles/_authBrandPanel.scss +15 -9
  71. package/src/components/styles/_controls.scss +43 -39
  72. package/src/components/styles/global-datagrid.css +196 -0
  73. package/src/components/styles/global.css +168 -17
  74. package/src/components/tickets/TicketConversation.jsx +455 -0
  75. package/src/components/utils/rowActionSettings.js +117 -0
  76. package/src/components/vault/VaultClientPicker.jsx +183 -0
  77. package/src/components/vault/VaultEntryForm.jsx +23 -0
  78. package/src/components/vault/VaultManager.jsx +382 -34
  79. package/src/components/vault/VaultShareModal.jsx +454 -0
  80. package/src/components/vault/vaultEndpoints.js +16 -2
  81. package/src/components/vault/vaultShares.js +247 -0
  82. package/src/index.js +35 -0
  83. package/src/utils/fetchUtil.js +38 -14
@@ -0,0 +1,220 @@
1
+ import React, { useCallback, useEffect, useMemo, useState } from 'react';
2
+ import { useNavigate, useParams } from 'react-router-dom';
3
+ import { Search } from 'lucide-react';
4
+
5
+ import styles from '../styles/GenericSettings.module.scss';
6
+
7
+ /**
8
+ * The settings hub: a rail of sections beside one panel.
9
+ *
10
+ * CONFIG-DRIVEN, like every other Generic in this library. A section is a
11
+ * config entry, not a route and not a component this file knows about:
12
+ *
13
+ * {
14
+ * id: 'integrations',
15
+ * label: 'Integrations',
16
+ * description: 'Connected systems and their credentials.',
17
+ * group: 'Platform', // optional rail heading
18
+ * permission: 'Settings', // optional; hidden when the user lacks it
19
+ * renderer: 'integrations', // key into the `renderers` prop
20
+ * }
21
+ *
22
+ * The host supplies the actual panels through `renderers`, the same slot
23
+ * GenericDetail and Navigation use. That is deliberate: the library owns the
24
+ * layout, the deep-linking and the permission filtering; the application owns
25
+ * what a section actually shows, so adding one never means editing this file.
26
+ *
27
+ * Sections deep-link as `/settings/:section`, so a panel can be linked to and
28
+ * survives a refresh — which a tab index held only in state cannot.
29
+ */
30
+ const GenericSettings = ({
31
+ setting,
32
+ userProfile,
33
+ renderers = {},
34
+ hasPermission,
35
+ basePath = '/settings',
36
+ }) => {
37
+ const params = useParams();
38
+ const navigate = useNavigate();
39
+
40
+ const page = setting?.page ?? {};
41
+ const allSections = useMemo(
42
+ () => (Array.isArray(setting?.sections) ? setting.sections : []),
43
+ [setting]
44
+ );
45
+
46
+ /**
47
+ * Only the sections this user may see.
48
+ *
49
+ * A section with no `permission` is open. The check is delegated to the
50
+ * host via `hasPermission`, because the library has no opinion on how the
51
+ * application models permissions — and a section hidden here is still
52
+ * enforced by whatever its panel calls.
53
+ */
54
+ const sections = useMemo(
55
+ () =>
56
+ allSections.filter((section) => {
57
+ if (!section?.permission) return true;
58
+ if (typeof hasPermission !== 'function') return true;
59
+
60
+ return hasPermission(userProfile, section.permission);
61
+ }),
62
+ [allSections, hasPermission, userProfile]
63
+ );
64
+
65
+ const [query, setQuery] = useState('');
66
+
67
+ const visible = useMemo(() => {
68
+ const term = query.trim().toLowerCase();
69
+
70
+ if (!term) return sections;
71
+
72
+ return sections.filter((s) =>
73
+ `${s.label} ${s.description ?? ''} ${s.keywords ?? ''}`
74
+ .toLowerCase()
75
+ .includes(term)
76
+ );
77
+ }, [sections, query]);
78
+
79
+ const activeId = params.section ?? sections[0]?.id;
80
+ const active = sections.find((s) => s.id === activeId) ?? sections[0];
81
+
82
+ // A URL naming a section that does not exist (a stale bookmark, or one the
83
+ // user lost permission to) lands on the first section rather than a blank
84
+ // panel with no way forward.
85
+ useEffect(() => {
86
+ if (!params.section || sections.length === 0) return;
87
+
88
+ if (!sections.some((s) => s.id === params.section)) {
89
+ navigate(`${basePath}/${sections[0].id}`, { replace: true });
90
+ }
91
+ }, [params.section, sections, navigate, basePath]);
92
+
93
+ const open = useCallback(
94
+ (id) => navigate(`${basePath}/${id}`),
95
+ [navigate, basePath]
96
+ );
97
+
98
+ const grouped = useMemo(() => {
99
+ const out = new Map();
100
+
101
+ visible.forEach((section) => {
102
+ const key = section.group ?? '';
103
+
104
+ if (!out.has(key)) out.set(key, []);
105
+ out.get(key).push(section);
106
+ });
107
+
108
+ return [...out.entries()];
109
+ }, [visible]);
110
+
111
+ const renderPanel = () => {
112
+ if (!active) {
113
+ return <p className={styles.empty}>Nothing to configure here.</p>;
114
+ }
115
+
116
+ const render = renderers[active.renderer];
117
+
118
+ if (typeof render !== 'function') {
119
+ // A named renderer the host did not supply is a config error, and a
120
+ // blank panel is the one outcome that hides it.
121
+ return (
122
+ <p className={styles.empty}>
123
+ No panel is registered for “{active.label}”.
124
+ </p>
125
+ );
126
+ }
127
+
128
+ return render({ section: active, userProfile });
129
+ };
130
+
131
+ // Enough sections that scanning the rail is slower than typing.
132
+ const showSearch = sections.length >= 8;
133
+
134
+ return (
135
+ <div className={styles.page}>
136
+ <div className={styles.layout}>
137
+ <nav className={styles.rail} aria-label="Settings sections">
138
+ {showSearch && (
139
+ <div className={styles.railSearch}>
140
+ <Search size={14} strokeWidth={2} aria-hidden="true" />
141
+ <input
142
+ type="text"
143
+ value={query}
144
+ placeholder="Find a setting…"
145
+ aria-label="Find a setting"
146
+ onChange={(e) => setQuery(e.target.value)}
147
+ />
148
+ </div>
149
+ )}
150
+
151
+ {grouped.map(([group, list]) => (
152
+ <div key={group || 'ungrouped'} className={styles.railGroup}>
153
+ {group && (
154
+ <p className={styles.railGroupTitle}>{group}</p>
155
+ )}
156
+
157
+ <ul>
158
+ {list.map((section) => {
159
+ const isActive = section.id === active?.id;
160
+
161
+ return (
162
+ <li key={section.id}>
163
+ <button
164
+ type="button"
165
+ className={
166
+ isActive
167
+ ? styles.railItemActive
168
+ : styles.railItem
169
+ }
170
+ // The rail is a set of
171
+ // destinations, so the current
172
+ // one is announced as such.
173
+ aria-current={
174
+ isActive ? 'page' : undefined
175
+ }
176
+ onClick={() => open(section.id)}
177
+ >
178
+ {section.label}
179
+ </button>
180
+ </li>
181
+ );
182
+ })}
183
+ </ul>
184
+ </div>
185
+ ))}
186
+
187
+ {visible.length === 0 && (
188
+ <p className={styles.railEmpty}>
189
+ Nothing matches “{query}”.
190
+ </p>
191
+ )}
192
+ </nav>
193
+
194
+ <section className={styles.panel} aria-live="polite">
195
+ {active && (
196
+ <header className={styles.panelHead}>
197
+ {/* The only heading on the screen: the page title
198
+ is a kicker above the section, rather than a
199
+ page header repeating what the rail already
200
+ says. `page.description` survives as its
201
+ tooltip so hosts still passing one keep it. */}
202
+ <p
203
+ className={styles.eyebrow}
204
+ title={page.description || undefined}
205
+ >
206
+ {page.title ?? 'Settings'}
207
+ </p>
208
+ <h1>{active.label}</h1>
209
+ {active.description && <p>{active.description}</p>}
210
+ </header>
211
+ )}
212
+
213
+ <div className={styles.panelBody}>{renderPanel()}</div>
214
+ </section>
215
+ </div>
216
+ </div>
217
+ );
218
+ };
219
+
220
+ export default GenericSettings;
@@ -1,7 +1,29 @@
1
- import React, { useState, useEffect, useCallback } from 'react';
1
+ import React, { useState, useEffect, useCallback, useId, useRef } from 'react';
2
2
  import { X } from 'lucide-react';
3
3
  import formStyles from '../styles/Form.module.scss';
4
4
 
5
+ /**
6
+ * The sheet's entry animation.
7
+ *
8
+ * Inline rather than in the stylesheet because everything else about this
9
+ * component's geometry is inline too, and splitting the two means a consumer
10
+ * overriding one gets the other from somewhere else. The reduced-motion query
11
+ * is not optional: a panel flying in from the edge is exactly the vestibular
12
+ * trigger the preference exists for.
13
+ */
14
+ const SHEET_KEYFRAMES = `
15
+ @keyframes visnsSheetIn {
16
+ from { transform: translateX(24px); opacity: 0.6; }
17
+ to { transform: translateX(0); opacity: 1; }
18
+ }
19
+ @media (prefers-reduced-motion: reduce) {
20
+ @keyframes visnsSheetIn {
21
+ from { opacity: 1; }
22
+ to { opacity: 1; }
23
+ }
24
+ }
25
+ `;
26
+
5
27
  /**
6
28
  * StandardModal Component
7
29
  *
@@ -13,7 +35,8 @@ import formStyles from '../styles/Form.module.scss';
13
35
  * @param {Function} props.onClose - Function to call when modal should close
14
36
  * @param {string} props.title - Modal title (only used if showHeader is true)
15
37
  * @param {React.ReactNode} props.children - Modal content
16
- * @param {string} props.size - Modal size: 'small', 'medium', 'large' (default: 'medium')
38
+ * @param {string} props.size - Size: 'small', 'medium', 'large' (default: 'medium')
39
+ * @param {string} props.variant - 'modal' (centred) or 'sheet' (side panel)
17
40
  * @param {boolean} props.closeOnDocumentClick - Whether to close on outside click (default: true)
18
41
  * @param {boolean} props.closeOnEscape - Whether to close on escape key (default: true)
19
42
  * @param {string} props.verticalAlign - Vertical alignment: 'center', 'top' (default: 'center')
@@ -26,6 +49,22 @@ const StandardModal = ({
26
49
  title,
27
50
  children,
28
51
  size = 'medium',
52
+ /**
53
+ * 'modal' — centred dialog. Correct for a short, atomic task: a
54
+ * confirmation, a destructive action, a handful of fields.
55
+ * 'sheet' — a panel sliding in from the right. The 2026 default for CRUD
56
+ * on a record reached from a list, and what every comparable
57
+ * product (Linear, Stripe, Shopify, Salesforce) settled on.
58
+ *
59
+ * The reason is not fashion. A centred dialog over a table hides the row
60
+ * you are editing and the rows around it, and a tall form makes the dialog
61
+ * grow until it fights the viewport — which is exactly what a `medium`
62
+ * modal at `80vw` was doing here. A sheet keeps the list visible, gives a
63
+ * long form a natural vertical scroll, and has a fixed width, so the form
64
+ * inside has one column measure instead of a width that changes with the
65
+ * window.
66
+ */
67
+ variant = 'modal',
29
68
  closeOnDocumentClick = true,
30
69
  closeOnEscape = true,
31
70
  verticalAlign = 'center',
@@ -54,6 +93,69 @@ const StandardModal = ({
54
93
  };
55
94
 
56
95
  const { isMobile, isTablet } = getDeviceType();
96
+
97
+ const titleId = useId();
98
+ const panelRef = useRef(null);
99
+ const returnFocusRef = useRef(null);
100
+
101
+ /**
102
+ * Move focus into the panel on open, and put it back where it came from on
103
+ * close.
104
+ *
105
+ * Neither happened before. Opening left focus on the row behind the
106
+ * dialog, so the first Tab walked the table underneath instead of the
107
+ * form; closing dropped focus to <body>, which sends a keyboard user back
108
+ * to the top of the page every time they edit a record.
109
+ */
110
+ useEffect(() => {
111
+ if (!isOpen) {
112
+ return undefined;
113
+ }
114
+
115
+ returnFocusRef.current =
116
+ typeof document !== 'undefined' ? document.activeElement : null;
117
+
118
+ const focusable = panelRef.current?.querySelector(
119
+ 'input:not([type="hidden"]):not([disabled]), select, textarea, button, [href], [tabindex]:not([tabindex="-1"])'
120
+ );
121
+
122
+ // The panel itself as a fallback, so focus is never left outside it.
123
+ (focusable ?? panelRef.current)?.focus?.({ preventScroll: true });
124
+
125
+ return () => {
126
+ returnFocusRef.current?.focus?.({ preventScroll: true });
127
+ };
128
+ }, [isOpen]);
129
+
130
+ /** Keep Tab inside the dialog while it is open. */
131
+ const handleTabKey = useCallback((event) => {
132
+ if (event.key !== 'Tab' || !panelRef.current) {
133
+ return;
134
+ }
135
+
136
+ const items = [
137
+ ...panelRef.current.querySelectorAll(
138
+ 'input:not([type="hidden"]):not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), [href], [tabindex]:not([tabindex="-1"])'
139
+ ),
140
+ ].filter((el) => el.offsetParent !== null);
141
+
142
+ if (items.length === 0) {
143
+ return;
144
+ }
145
+
146
+ const first = items[0];
147
+ const last = items[items.length - 1];
148
+
149
+ // Wrap at both ends, or Tab escapes to the browser chrome and the user
150
+ // has no way back into the dialog without a mouse.
151
+ if (event.shiftKey && document.activeElement === first) {
152
+ event.preventDefault();
153
+ last.focus();
154
+ } else if (!event.shiftKey && document.activeElement === last) {
155
+ event.preventDefault();
156
+ first.focus();
157
+ }
158
+ }, []);
57
159
 
58
160
  // Handle window resize
59
161
  useEffect(() => {
@@ -106,6 +208,8 @@ const StandardModal = ({
106
208
  return classes;
107
209
  };
108
210
 
211
+ const isSheet = variant === 'sheet';
212
+
109
213
  // Get overlay styles
110
214
  const getOverlayStyle = () => {
111
215
  const baseStyle = {
@@ -114,24 +218,66 @@ const StandardModal = ({
114
218
  left: 0,
115
219
  right: 0,
116
220
  bottom: 0,
117
- backgroundColor: 'rgba(0, 0, 0, 0.5)',
221
+ // Was a flat 50% black. A scrim that heavy on a sheet buries the
222
+ // list the sheet exists to keep visible, so the sheet dims less.
223
+ backgroundColor: isSheet
224
+ ? 'rgba(15, 23, 32, 0.32)'
225
+ : 'rgba(0, 0, 0, 0.5)',
118
226
  display: 'flex',
119
- alignItems: verticalAlign === 'top' ? 'flex-start' : 'center',
120
- justifyContent: 'center',
227
+ alignItems: isSheet
228
+ ? 'stretch'
229
+ : verticalAlign === 'top'
230
+ ? 'flex-start'
231
+ : 'center',
232
+ // A sheet is pinned to the right edge; a modal is centred.
233
+ justifyContent: isSheet ? 'flex-end' : 'center',
121
234
  zIndex: 1000,
122
- padding: '20px',
235
+ padding: isSheet ? 0 : '20px',
123
236
  ...customStyles.overlay
124
237
  };
125
238
 
126
- if (verticalAlign === 'top') {
239
+ if (!isSheet && verticalAlign === 'top') {
127
240
  baseStyle.paddingTop = '5%';
128
241
  }
129
242
 
130
243
  return baseStyle;
131
244
  };
132
245
 
246
+ /**
247
+ * A sheet's width is FIXED, not a share of the window.
248
+ *
249
+ * A form wants one comfortable measure. `80vw` gave it a different measure
250
+ * on every monitor and stretched two-column rows into something unreadable
251
+ * on a wide screen. Below the tablet breakpoint the sheet becomes
252
+ * full-width, which is the only sensible shape on a phone.
253
+ */
254
+ const getSheetStyle = () => {
255
+ const width = { small: 420, medium: 560, large: 800 }[size] ?? 560;
256
+
257
+ return {
258
+ position: 'relative',
259
+ backgroundColor: 'white',
260
+ width: isMobile ? '100vw' : `min(${width}px, 96vw)`,
261
+ maxWidth: '100vw',
262
+ height: '100vh',
263
+ display: 'flex',
264
+ flexDirection: 'column',
265
+ borderRadius: 0,
266
+ boxShadow: '-12px 0 40px -12px rgba(0, 0, 0, 0.28)',
267
+ overflowY: 'auto',
268
+ // Honours prefers-reduced-motion via the media query below; the
269
+ // transform is the entry, and it is deliberately short.
270
+ animation: 'visnsSheetIn 180ms cubic-bezier(0.32, 0.72, 0, 1)',
271
+ ...customStyles.modal,
272
+ };
273
+ };
274
+
133
275
  // Get size-based styles for the modal content with device-aware positioning
134
276
  const getContentStyle = () => {
277
+ if (isSheet) {
278
+ return getSheetStyle();
279
+ }
280
+
135
281
  const baseStyle = {
136
282
  position: 'relative',
137
283
  backgroundColor: 'white',
@@ -190,9 +336,20 @@ const StandardModal = ({
190
336
  style={getOverlayStyle()}
191
337
  onClick={handleBackdropClick}
192
338
  >
339
+ <style>{SHEET_KEYFRAMES}</style>
193
340
  <div
341
+ ref={panelRef}
194
342
  style={getContentStyle()}
195
343
  onClick={(e) => e.stopPropagation()}
344
+ /* This had none of these. Without `role="dialog"` and
345
+ `aria-modal` a screen reader treats the panel as ordinary
346
+ page content and keeps reading the table behind it; without
347
+ the label it announces an unnamed dialog. */
348
+ role="dialog"
349
+ aria-modal="true"
350
+ aria-label={!showHeader && title ? title : undefined}
351
+ aria-labelledby={showHeader && title ? titleId : undefined}
352
+ onKeyDown={handleTabKey}
196
353
  >
197
354
  <div className={formStyles.modalwrap}>
198
355
  <div
@@ -200,7 +357,7 @@ const StandardModal = ({
200
357
  >
201
358
  {showHeader && (
202
359
  <div className={formStyles.modal__header} style={customStyles.header}>
203
- <h1 style={customStyles.title}>{title}</h1>
360
+ <h1 id={titleId} style={customStyles.title}>{title}</h1>
204
361
  <button
205
362
  className={formStyles.modal__close}
206
363
  onClick={onClose}
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Chart defaults for GenericDashboard — 2026.
3
+ *
4
+ * The widget renderers used to spread `widget.props` straight into the nivo
5
+ * component with nothing underneath, so any dashboard.json that did not spell
6
+ * out a palette got nivo's default `nivo` scheme: a rainbow, cycled, with no
7
+ * colour-vision guarantee and a heavy dashed grid. These defaults sit BENEATH
8
+ * the spread, so every existing config keeps whatever it set and only the gaps
9
+ * are filled.
10
+ *
11
+ * ---------------------------------------------------------------------------
12
+ * The palette is validated, not chosen by eye.
13
+ * ---------------------------------------------------------------------------
14
+ * Six categorical hues, slot 1 the brand green. Assigned in fixed order and
15
+ * never cycled — a seventh series folds into "Other" or becomes small
16
+ * multiples rather than inventing a hue.
17
+ *
18
+ * light worst adjacent CVD ΔE 9.2 (deutan) · normal-vision ΔE 27.6
19
+ * dark worst adjacent CVD ΔE 9.4 (deutan) · normal-vision ΔE 19.7
20
+ *
21
+ * Both clear the ≥8 CVD target and the ≥15 normal-vision floor. The dark row
22
+ * is the same six hues re-stepped for a dark surface and validated against it,
23
+ * not an inversion of the light row.
24
+ *
25
+ * Three light-mode hues sit below 3:1 against white. That is a contrast WARN,
26
+ * not a pass — it obliges relief, which is why `defaultsFor` turns legends on
27
+ * for every multi-series chart and the table widget exists as the text view.
28
+ */
29
+
30
+ export const DASHBOARD_SERIES_LIGHT = [
31
+ '#1baf7a', // 1 · green (brand)
32
+ '#eb6834', // 2 · orange
33
+ '#2a78d6', // 3 · blue
34
+ '#eda100', // 4 · yellow
35
+ '#4a3aa7', // 5 · violet
36
+ '#e87ba4', // 6 · magenta
37
+ ];
38
+
39
+ export const DASHBOARD_SERIES_DARK = [
40
+ '#199e70',
41
+ '#d95926',
42
+ '#3987e5',
43
+ '#c98500',
44
+ '#9085e9',
45
+ '#d55181',
46
+ ];
47
+
48
+ /**
49
+ * Status colours are reserved. They mean a state, never "series 4", and always
50
+ * ship with a label or an icon rather than carrying meaning by colour alone.
51
+ */
52
+ export const DASHBOARD_STATUS = {
53
+ good: '#1f7a47',
54
+ warning: '#a36a14',
55
+ critical: '#be3e3e',
56
+ };
57
+
58
+ const prefersDark = () =>
59
+ typeof window !== 'undefined' &&
60
+ typeof window.matchMedia === 'function' &&
61
+ (document?.documentElement?.dataset?.theme === 'dark' ||
62
+ (document?.documentElement?.dataset?.theme !== 'light' &&
63
+ window.matchMedia('(prefers-color-scheme: dark)').matches));
64
+
65
+ export const seriesColors = () =>
66
+ prefersDark() ? DASHBOARD_SERIES_DARK : DASHBOARD_SERIES_LIGHT;
67
+
68
+ /**
69
+ * The nivo theme. Grid and axes are one step off the surface, hairline and
70
+ * SOLID — a dashed grid competes with the data for attention, and there is
71
+ * nothing for the dashes to mean.
72
+ */
73
+ export const dashboardTheme = () => {
74
+ const dark = prefersDark();
75
+ const ink = dark ? '#e6e9e8' : '#2b2b2b';
76
+ const muted = dark ? 'rgba(230,233,232,0.62)' : 'rgba(43,43,43,0.62)';
77
+ const line = dark ? 'rgba(230,233,232,0.14)' : 'rgba(43,43,43,0.10)';
78
+ const surface = dark ? '#171b1a' : '#ffffff';
79
+
80
+ return {
81
+ background: 'transparent',
82
+ text: {
83
+ // Text wears text tokens, never a series colour.
84
+ fill: muted,
85
+ fontSize: 11,
86
+ fontFamily: 'inherit',
87
+ },
88
+ axis: {
89
+ domain: { line: { stroke: line, strokeWidth: 1 } },
90
+ ticks: {
91
+ line: { stroke: line, strokeWidth: 1 },
92
+ text: { fill: muted, fontSize: 11 },
93
+ },
94
+ legend: { text: { fill: muted, fontSize: 11, fontWeight: 600 } },
95
+ },
96
+ grid: { line: { stroke: line, strokeWidth: 1 } },
97
+ legends: { text: { fill: ink, fontSize: 11 } },
98
+ tooltip: {
99
+ container: {
100
+ background: surface,
101
+ color: ink,
102
+ fontSize: 12,
103
+ borderRadius: 8,
104
+ boxShadow: '0 4px 16px rgba(0,0,0,0.18)',
105
+ padding: '6px 10px',
106
+ },
107
+ },
108
+ };
109
+ };
110
+
111
+ const legendBelow = (count) =>
112
+ count >= 2
113
+ ? [
114
+ {
115
+ // A legend is always present for two or more series: identity
116
+ // must never rest on colour alone.
117
+ anchor: 'bottom',
118
+ direction: 'row',
119
+ translateY: 56,
120
+ itemWidth: 96,
121
+ itemHeight: 16,
122
+ symbolSize: 10,
123
+ symbolShape: 'circle',
124
+ itemTextColor: 'inherit',
125
+ },
126
+ ]
127
+ : [];
128
+
129
+ /**
130
+ * Per-type defaults. `seriesCount` decides only whether a legend is drawn — a
131
+ * single series needs none, because the card's own heading names it.
132
+ */
133
+ export const defaultsFor = (type, seriesCount = 1) => {
134
+ const colors = seriesColors();
135
+ const theme = dashboardTheme();
136
+ const legends = legendBelow(seriesCount);
137
+
138
+ switch (type) {
139
+ case 'bar':
140
+ return {
141
+ theme,
142
+ colors,
143
+ // 4px rounded data-end, square at the baseline is nivo's
144
+ // behaviour for a positive bar with a radius.
145
+ borderRadius: 4,
146
+ // A 2px gap in the surface colour separates adjacent bars.
147
+ // padding is a fraction of the band; innerPadding is the pixel
148
+ // gap between grouped bars.
149
+ padding: 0.45,
150
+ innerPadding: 2,
151
+ enableGridX: false,
152
+ enableGridY: true,
153
+ enableLabel: false,
154
+ axisTop: null,
155
+ axisRight: null,
156
+ margin: {
157
+ top: 12,
158
+ right: 16,
159
+ bottom: legends.length ? 72 : 40,
160
+ left: 48,
161
+ },
162
+ legends,
163
+ animate: true,
164
+ motionConfig: 'gentle',
165
+ };
166
+
167
+ case 'line':
168
+ return {
169
+ theme,
170
+ colors,
171
+ lineWidth: 2,
172
+ pointSize: 8,
173
+ pointBorderWidth: 2,
174
+ // The 2px ring is in the SURFACE colour so a marker stays
175
+ // legible where it crosses another line.
176
+ pointBorderColor: {
177
+ from: 'color',
178
+ modifiers: [['brighter', 3]],
179
+ },
180
+ pointColor: { from: 'color' },
181
+ enableArea: false,
182
+ areaOpacity: 0.1,
183
+ useMesh: true,
184
+ enableGridX: false,
185
+ enableGridY: true,
186
+ curve: 'monotoneX',
187
+ axisTop: null,
188
+ axisRight: null,
189
+ margin: {
190
+ top: 12,
191
+ right: 20,
192
+ bottom: legends.length ? 72 : 40,
193
+ left: 48,
194
+ },
195
+ legends,
196
+ animate: true,
197
+ motionConfig: 'gentle',
198
+ };
199
+
200
+ case 'pie':
201
+ return {
202
+ theme,
203
+ colors,
204
+ innerRadius: 0.62,
205
+ padAngle: 1.2,
206
+ cornerRadius: 3,
207
+ activeOuterRadiusOffset: 6,
208
+ borderWidth: 0,
209
+ // Direct labels on the arcs, which is the relief the light
210
+ // palette's contrast WARN requires.
211
+ enableArcLinkLabels: true,
212
+ arcLinkLabelsThickness: 1,
213
+ arcLinkLabelsColor: { from: 'color' },
214
+ arcLinkLabelsTextColor: 'inherit',
215
+ arcLabelsSkipAngle: 12,
216
+ arcLabelsTextColor: {
217
+ from: 'color',
218
+ modifiers: [['darker', 3]],
219
+ },
220
+ margin: { top: 16, right: 90, bottom: 16, left: 90 },
221
+ animate: true,
222
+ motionConfig: 'gentle',
223
+ };
224
+
225
+ default:
226
+ return { theme, colors };
227
+ }
228
+ };
229
+
230
+ export default defaultsFor;