@visns-studio/visns-components 6.0.4 → 6.1.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 (26) hide show
  1. package/package.json +1 -1
  2. package/src/components/DataGrid.jsx +37 -12
  3. package/src/components/Navigation.jsx +59 -3
  4. package/src/components/auth/Login.jsx +203 -175
  5. package/src/components/columns/ColumnRenderers.jsx +34 -0
  6. package/src/components/generic/GenericDashboard.jsx +181 -1
  7. package/src/components/generic/GenericFormBuilder.jsx +735 -37
  8. package/src/components/generic/GenericIndex.jsx +15 -5
  9. package/src/components/generic/OutstandingRuleEditor.jsx +313 -0
  10. package/src/components/styles/DataGrid.module.scss +38 -22
  11. package/src/components/styles/Form.module.scss +32 -33
  12. package/src/components/styles/GenericDashboard.module.scss +155 -0
  13. package/src/components/styles/GenericDetail.module.scss +28 -30
  14. package/src/components/styles/GenericDynamic.module.scss +7 -21
  15. package/src/components/styles/GenericEditableTable.module.scss +5 -22
  16. package/src/components/styles/GenericFormBuilder.module.scss +605 -30
  17. package/src/components/styles/GenericIndex.module.scss +17 -32
  18. package/src/components/styles/GenericMain.module.scss +18 -6
  19. package/src/components/styles/GenericQuote.module.scss +7 -22
  20. package/src/components/styles/GenericReport.module.scss +5 -37
  21. package/src/components/styles/Login.module.scss +415 -239
  22. package/src/components/styles/Navigation.module.scss +124 -35
  23. package/src/components/styles/Profile.module.scss +5 -26
  24. package/src/components/styles/QuickAction.module.scss +34 -29
  25. package/src/components/styles/_controls.scss +139 -0
  26. package/src/components/styles/global.css +54 -15
@@ -923,15 +923,25 @@ function GenericIndex({
923
923
  ) &&
924
924
  setting.page.tableInfo.heading.length > 0 &&
925
925
  setting.page.tableInfo.heading.map((h) => (
926
- <span>
927
- [<strong>{tableInfo[h.id]}</strong>{' '}
928
- {h.label}]{' '}
926
+ <span key={h.id}>
927
+ <strong>{tableInfo[h.id]}</strong>{' '}
928
+ {h.label}
929
929
  </span>
930
930
  ))}
931
+ {/* Square brackets round a count is a convention
932
+ from a spreadsheet, not an interface — and it
933
+ repeated the page title back at you. The count
934
+ alone is what anyone actually reads. */}
931
935
  {total > 0 && (
932
936
  <span>
933
- [<strong>{total}</strong> Total{' '}
934
- {parse(setting.page?.title || '')}]
937
+ {/* Grouped, matching the pager's own
938
+ "of 32,134" — the same figure was
939
+ printing two different ways on one
940
+ screen. */}
941
+ <strong>
942
+ {Number(total).toLocaleString()}
943
+ </strong>{' '}
944
+ total
935
945
  </span>
936
946
  )}
937
947
  </div>
@@ -0,0 +1,313 @@
1
+ import React from 'react';
2
+ import styles from '../styles/GenericFormBuilder.module.scss';
3
+
4
+ /**
5
+ * Per-question "outstanding item" rules.
6
+ *
7
+ * OPT-IN for consuming apps that model outstanding/defect follow-up items.
8
+ * Everything here is inert unless the app's builder config carries
9
+ * `outstandingItems: { enabled: true, ... }`, so no other project is affected.
10
+ *
11
+ * The stored schema is one optional per-field key, `outstanding_when`:
12
+ *
13
+ * key absent the app's own legacy convention applies (see legacyTriggerId)
14
+ * [] this question can never raise an outstanding item
15
+ * ['no-'] outstanding when the answer is this OPTION ID
16
+ *
17
+ * Option ids are stored, never labels, because editing an option label
18
+ * regenerates its id — exactly as `conditional_value` does.
19
+ *
20
+ * The UI is a single "Outstanding" switch on each row of the field's existing
21
+ * options editor, with radio semantics: at most one answer may be marked.
22
+ */
23
+
24
+ /** Field types the editor offers when the config does not name any. */
25
+ export const DEFAULT_OUTSTANDING_TYPES = ['checkbox'];
26
+
27
+ /** Types the legacy (unconfigured) convention applies to. */
28
+ export const LEGACY_OUTSTANDING_TYPES = ['checkbox'];
29
+
30
+ /**
31
+ * The one label whose sense is inverted under the legacy convention: a "Yes"
32
+ * here means there IS something else affecting the build.
33
+ *
34
+ * Mirrors App\Support\OutstandingRule::SPECIAL_LABEL / ::SPECIAL_SIMILARITY.
35
+ */
36
+ export const SPECIAL_LABEL =
37
+ 'Other features affecting the build (provide details and photos)';
38
+ export const SPECIAL_SIMILARITY = 85;
39
+
40
+ // --------------------------------------------------------------- similar_text
41
+
42
+ /**
43
+ * Longest common substring, PHP's php_similar_str() — first-found wins on ties,
44
+ * scanning the first string outermost.
45
+ */
46
+ const similarStr = (s1, s2) => {
47
+ let max = 0;
48
+ let count = 0;
49
+ let pos1 = 0;
50
+ let pos2 = 0;
51
+
52
+ for (let p = 0; p < s1.length; p++) {
53
+ for (let q = 0; q < s2.length; q++) {
54
+ let l = 0;
55
+ while (
56
+ p + l < s1.length &&
57
+ q + l < s2.length &&
58
+ s1[p + l] === s2[q + l]
59
+ ) {
60
+ l++;
61
+ }
62
+ if (l > max) {
63
+ max = l;
64
+ count++;
65
+ pos1 = p;
66
+ pos2 = q;
67
+ }
68
+ }
69
+ }
70
+
71
+ return { pos1, pos2, max, count };
72
+ };
73
+
74
+ /** PHP's php_similar_char(): the common substring, then recurse either side. */
75
+ const similarChar = (s1, s2) => {
76
+ const { pos1, pos2, max, count } = similarStr(s1, s2);
77
+ let sum = max;
78
+
79
+ if (sum) {
80
+ if (pos1 && pos2 && count > 1) {
81
+ sum += similarChar(s1.slice(0, pos1), s2.slice(0, pos2));
82
+ }
83
+ if (pos1 + max < s1.length && pos2 + max < s2.length) {
84
+ sum += similarChar(s1.slice(pos1 + max), s2.slice(pos2 + max));
85
+ }
86
+ }
87
+
88
+ return sum;
89
+ };
90
+
91
+ /**
92
+ * PHP's similar_text($first, $second, $percent) percentage.
93
+ *
94
+ * Verified byte-for-byte against PHP 8.5 over the real label corpus plus 400
95
+ * random strings. The argument order is load-bearing — the function is not
96
+ * symmetric — and must match the backend's call exactly.
97
+ *
98
+ * (PHP compares bytes; this compares UTF-16 code units. Identical for the
99
+ * ASCII labels in use, and only ever used against an ASCII constant.)
100
+ */
101
+ export const similarTextPercent = (first, second) => {
102
+ const s1 = typeof first === 'string' ? first : '';
103
+ const s2 = typeof second === 'string' ? second : '';
104
+ const total = s1.length + s2.length;
105
+
106
+ if (total === 0) return 0;
107
+
108
+ return (similarChar(s1, s2) * 2 * 100) / total;
109
+ };
110
+
111
+ // ---------------------------------------------------------------- the config
112
+
113
+ export const outstandingTypesOf = (config) => {
114
+ const types = config?.types;
115
+ return Array.isArray(types) ? types : DEFAULT_OUTSTANDING_TYPES;
116
+ };
117
+
118
+ /** The single gate: config must enable it AND the type must store one option id. */
119
+ export const isOutstandingConfigurable = (config, fieldType) =>
120
+ !!(config && config.enabled) &&
121
+ outstandingTypesOf(config).includes(fieldType);
122
+
123
+ // ------------------------------------------------------------- the rule state
124
+
125
+ const optionsOf = (field) =>
126
+ (Array.isArray(field?.options) ? field.options : []).filter(Boolean);
127
+
128
+ const storedIds = (field) =>
129
+ Array.isArray(field?.outstanding_when) ? field.outstanding_when : null;
130
+
131
+ /**
132
+ * The legacy trigger for a field: 'yes-' for the special "Other features
133
+ * affecting the build" question, 'no-' for any other checkbox, and null for
134
+ * every other type (the legacy convention never reached them).
135
+ *
136
+ * Mirrors App\Support\OutstandingRule::legacyTrigger().
137
+ */
138
+ export const legacyTriggerId = (field) => {
139
+ if (!LEGACY_OUTSTANDING_TYPES.includes(field?.type)) return null;
140
+
141
+ return similarTextPercent(field?.label, SPECIAL_LABEL) >=
142
+ SPECIAL_SIMILARITY
143
+ ? 'yes-'
144
+ : 'no-';
145
+ };
146
+
147
+ /**
148
+ * What the legacy convention would show as toggled, which is only meaningful
149
+ * when the field actually offers that answer. A checkbox with no 'no-' option
150
+ * (or no options yet) has no legacy toggle, and the backend agrees: its trigger
151
+ * can never match a stored value.
152
+ */
153
+ export const legacyDefaultOptionId = (field) => {
154
+ const trigger = legacyTriggerId(field);
155
+ if (!trigger) return null;
156
+
157
+ return optionsOf(field).some((option) => option.id === trigger)
158
+ ? trigger
159
+ : null;
160
+ };
161
+
162
+ /**
163
+ * Which option should show toggled when the modal opens.
164
+ *
165
+ * Configured → the first stored id the field still offers. (No template today
166
+ * carries more than one; a multi-entry array shows its first and is normalised
167
+ * to a single id on save.) An explicit `[]` shows nothing toggled.
168
+ * Unconfigured → whatever the legacy convention would flag.
169
+ */
170
+ export const deriveOutstandingSelection = (field) => {
171
+ const stored = storedIds(field);
172
+
173
+ if (stored) {
174
+ const options = optionsOf(field);
175
+ return (
176
+ stored.find((id) => options.some((option) => option.id === id)) ??
177
+ null
178
+ );
179
+ }
180
+
181
+ return legacyDefaultOptionId(field);
182
+ };
183
+
184
+ /** Stored ids the field no longer offers — the rename/delete hazard. */
185
+ export const staleOutstandingIds = (field) => {
186
+ const stored = storedIds(field);
187
+ if (!stored) return [];
188
+
189
+ const options = optionsOf(field);
190
+ return stored.filter((id) => !options.some((option) => option.id === id));
191
+ };
192
+
193
+ /**
194
+ * Fold the toggle state back into the field.
195
+ *
196
+ * The key is written only when the outcome differs from what the legacy
197
+ * convention would produce, so a field nobody configured never gains it just
198
+ * because its modal was opened and saved:
199
+ *
200
+ * selection === legacy default → key deleted (including both being null)
201
+ * an answer toggled → ['<id>']
202
+ * nothing toggled, legacy would → [] (an explicit "never")
203
+ *
204
+ * A stale stored id is not toggleable, so saving normalises it to [] (checkbox)
205
+ * or an absent key (dropdown) — both meaning "never flags", which is precisely
206
+ * what an id no answer can produce already meant.
207
+ */
208
+ export const applyOutstandingRule = (field, selectedId, config) => {
209
+ if (!isOutstandingConfigurable(config, field?.type)) return field;
210
+
211
+ const selection = selectedId || null;
212
+ const next = { ...field };
213
+
214
+ if (selection === legacyDefaultOptionId(field)) {
215
+ delete next.outstanding_when;
216
+ } else {
217
+ next.outstanding_when = selection ? [selection] : [];
218
+ }
219
+
220
+ return next;
221
+ };
222
+
223
+ /**
224
+ * One-line summary for field-list chips and tooltips. Returns null when the
225
+ * field carries no rule, so callers can skip falsy results.
226
+ */
227
+ export const summariseOutstandingRule = (field) => {
228
+ const stored = storedIds(field);
229
+ if (!stored) return null;
230
+ if (stored.length === 0) return 'Never outstanding';
231
+
232
+ const options = optionsOf(field);
233
+ const labels = stored.map((id) => {
234
+ const match = options.find((option) => option.id === id);
235
+ return match ? match.label || match.id : `${id} (missing)`;
236
+ });
237
+
238
+ return `Outstanding: ${labels.join(', ')}`;
239
+ };
240
+
241
+ /**
242
+ * Remap a stored id after an option's id was regenerated from an edited label.
243
+ * Returns the field untouched (same reference) when there is nothing to remap.
244
+ */
245
+ export const remapOutstandingWhen = (field, previousId, nextId) => {
246
+ const stored = storedIds(field);
247
+
248
+ if (!stored || previousId === nextId) return field;
249
+ if (!stored.includes(previousId)) return field;
250
+
251
+ return {
252
+ ...field,
253
+ outstanding_when: stored.map((id) => (id === previousId ? nextId : id)),
254
+ };
255
+ };
256
+
257
+ // ------------------------------------------------------------------------ UI
258
+
259
+ /**
260
+ * The per-row switch. Radio semantics live in the caller: `onToggle` receives
261
+ * the id to select, or null when the row was switched off.
262
+ */
263
+ export function OutstandingOptionToggle({ optionId, label, checked, onToggle }) {
264
+ return (
265
+ <label
266
+ className={`${styles.outstandingFlag} ${
267
+ checked ? styles.outstandingFlagOn : ''
268
+ }`}
269
+ title={
270
+ checked
271
+ ? `An answer of "${label}" raises an outstanding item`
272
+ : `Mark "${label}" as the answer that raises an outstanding item`
273
+ }
274
+ >
275
+ <input
276
+ type="checkbox"
277
+ checked={checked}
278
+ onChange={() => onToggle(checked ? null : optionId)}
279
+ />
280
+ <span>Outstanding</span>
281
+ </label>
282
+ );
283
+ }
284
+
285
+ /**
286
+ * The hint shown once above the option rows, plus the amber stale-reference
287
+ * warning. Renders nothing when there is nothing to say.
288
+ */
289
+ export function OutstandingRuleHint({ config, staleIds = [] }) {
290
+ const help = config?.help;
291
+
292
+ if (!help && staleIds.length === 0) return null;
293
+
294
+ return (
295
+ <>
296
+ {help ? (
297
+ <div className={styles.outstandingHint}>{help}</div>
298
+ ) : null}
299
+
300
+ {staleIds.length > 0 ? (
301
+ <div className={styles.outstandingStale}>
302
+ {staleIds.length === 1
303
+ ? 'The answer this question’s outstanding rule refers to no longer exists'
304
+ : 'Answers this question’s outstanding rule refers to no longer exist'}{' '}
305
+ ({staleIds.join(', ')}) — editing an answer’s label gives it
306
+ a new id. Set the switch on the right answer, or leave every
307
+ switch off to mean “never outstanding”. Saving tidies this
308
+ up.
309
+ </div>
310
+ ) : null}
311
+ </>
312
+ );
313
+ }
@@ -1,25 +1,8 @@
1
- .btn {
2
- width: max-content;
3
- display: inline-block;
4
- position: relative;
5
- padding: 0.65rem 1rem;
6
- cursor: pointer;
7
- font-size: 1rem;
8
- color: var(--tertiary-color);
9
- text-decoration: none;
10
- overflow: hidden;
11
- background: var(--primary-color);
12
- border: 1px solid rgba(var(--primary-rgb), 1.1);
13
- outline: none;
14
- transition: all 0.2s cubic-bezier(0.85, 0, 0.15, 1) 0s;
1
+ @use 'controls' as *;
15
2
 
16
- &:hover {
17
- color: var(--primary-color);
18
- background: var(--secondary-color);
19
- border: 1px solid rgba(var(--secondary-rgb), 1.05);
20
- }
3
+ .btn {
4
+ @include button-primary;
21
5
  }
22
-
23
6
  /* Clock button styles - Compact version */
24
7
  .clockButton {
25
8
  display: flex;
@@ -59,8 +42,41 @@
59
42
  overflow: hidden !important;
60
43
  }
61
44
 
62
- .InovuaReactDataGrid__header {
63
- color: var(--primary-color) !important;
45
+ :global {
46
+ /* The header is a label row, not a heading. It was navy body text at the same
47
+ size and weight as the data, so it read as another row of values. Smaller,
48
+ uppercase and recessive puts the emphasis back on the data, and it stays put
49
+ while the rows scroll under it. */
50
+ .InovuaReactDataGrid__header {
51
+ color: var(--muted-color) !important;
52
+ background: var(--alternate-color) !important;
53
+ border-bottom: 1px solid var(--border-color) !important;
54
+ font-size: var(--font-size-xs) !important;
55
+ font-weight: var(--font-weight-semibold) !important;
56
+ letter-spacing: 0.06em;
57
+ text-transform: uppercase;
58
+ }
59
+
60
+ /* Digits only line up if they are the same width. Applied to the numeric and
61
+ date columns rather than globally, since tabular figures are wider and
62
+ would loosen ordinary text. */
63
+ .InovuaReactDataGrid__cell[data-column-id*='date'],
64
+ .InovuaReactDataGrid__cell[data-column-id*='Date'],
65
+ .InovuaReactDataGrid__cell[data-column-id*='count'],
66
+ .InovuaReactDataGrid__cell[data-column-id*='total'],
67
+ .InovuaReactDataGrid__cell[data-column-id*='no'],
68
+ .InovuaReactDataGrid__cell[data-column-id*='number'],
69
+ .InovuaReactDataGrid__cell[data-column-id*='amount'],
70
+ .InovuaReactDataGrid__cell[data-column-id*='qty'] {
71
+ font-variant-numeric: tabular-nums;
72
+ }
73
+
74
+ /* Banding, so the eye can track a row across a wide table. Kept very faint —
75
+ strong enough to follow, quiet enough not to compete with the hover state
76
+ or a status colour in the row. */
77
+ .InovuaReactDataGrid__row--odd .InovuaReactDataGrid__row-cell-wrap {
78
+ background: var(--alternate-color);
79
+ }
64
80
  }
65
81
 
66
82
  .InovuaReactDataGrid__row-hover-target:hover {
@@ -1,3 +1,8 @@
1
+ @use 'controls' as *;
2
+
3
+ .btn {
4
+ @include button-primary;
5
+ }
1
6
  .label {
2
7
  display: block;
3
8
  }
@@ -209,34 +214,50 @@ input[type='file'] {
209
214
 
210
215
  .modal__header {
211
216
  width: 100%;
212
- border-bottom: 1px solid rgba(var(--primary-rgb), 0.15);
217
+ border-bottom: 1px solid var(--border-color);
213
218
  display: flex;
214
219
  align-items: center;
215
220
  justify-content: space-between;
216
- padding: 0.75rem;
221
+ gap: var(--spacing-md);
222
+ padding: var(--spacing-md) var(--spacing-lg);
217
223
 
218
224
  h1 {
219
- color: var(--secondary-color);
225
+ /* Was --secondary-color, the same red that means destructive
226
+ everywhere else in the console — so every edit dialog opened
227
+ looking like a warning. A dialog title is just a title. */
228
+ color: var(--heading-color);
220
229
  margin: 0;
221
230
  padding: 0;
222
- font-size: 1.15em;
231
+ font-size: var(--font-size-lg);
232
+ font-weight: var(--font-weight-bold);
233
+ letter-spacing: -0.01em;
223
234
  }
224
235
  }
225
236
 
226
237
  .modal__close {
227
- width: max-content;
228
- background: rgba(var(--paragraph-rgb), 0.05);
238
+ /* A close control should recede until reached for. It was a permanently
239
+ tinted box with a navy glyph, which read as another action. */
240
+ display: flex;
241
+ align-items: center;
242
+ justify-content: center;
243
+ width: 2rem;
244
+ height: 2rem;
245
+ flex: none;
246
+ background: none;
229
247
  cursor: pointer;
230
- padding: 0.25rem;
248
+ padding: 0;
231
249
  margin: 0;
232
- outline: none;
233
250
  border: none;
234
- border-radius: var(--br);
235
- display: block;
251
+ border-radius: var(--radius-sm);
236
252
  line-height: 1;
253
+ transition: background-color var(--speed) var(--ease);
254
+
255
+ &:hover {
256
+ background: var(--hover-color);
257
+ }
237
258
 
238
259
  svg {
239
- color: var(--primary-color);
260
+ color: var(--muted-color);
240
261
  display: block;
241
262
  }
242
263
  }
@@ -272,29 +293,7 @@ input[type='file'] {
272
293
  }
273
294
  }
274
295
 
275
- .btn {
276
- width: max-content;
277
- display: inline-block;
278
- position: relative;
279
- padding: 0.65rem 1rem;
280
- cursor: pointer;
281
- font-size: 1rem;
282
- color: var(--tertiary-color);
283
- text-decoration: none;
284
- overflow: hidden;
285
- background: var(--primary-color);
286
- border: 1px solid rgba(var(--primary-color--rgb), 1.1);
287
- border-radius: var(--br);
288
- outline: none;
289
- transition: all 0.2s cubic-bezier(0.85, 0, 0.15, 1) 0s;
290
- font-size: 1.25em;
291
296
 
292
- &:hover {
293
- color: var(--primary-color);
294
- background: var(--highlight-color);
295
- border: 1px solid rgba(var(--highlight-rgb), 1.05);
296
- }
297
- }
298
297
 
299
298
  .saveBtn {
300
299
  width: max-content;
@@ -814,3 +814,158 @@
814
814
  color: #888;
815
815
  padding: 20px;
816
816
  }
817
+
818
+ /* ---------------------------------------------------------------------------
819
+ Loading skeletons
820
+ Neutral greys only (no brand colours) so every tenant gets a safe default.
821
+ Keyframes are uniquely prefixed to avoid colliding with host app globals.
822
+ --------------------------------------------------------------------------- */
823
+ @keyframes visns-dash-skeleton-sweep {
824
+ 0% {
825
+ background-position: 150% 0;
826
+ }
827
+ 100% {
828
+ background-position: -150% 0;
829
+ }
830
+ }
831
+
832
+ .skeleton {
833
+ width: 100%;
834
+ box-sizing: border-box;
835
+ }
836
+
837
+ .skeletonBlock {
838
+ display: block;
839
+ box-sizing: border-box;
840
+ border-radius: var(--br, 5px);
841
+ background-color: rgba(0, 0, 0, 0.06);
842
+ background-image: linear-gradient(
843
+ 90deg,
844
+ rgba(0, 0, 0, 0) 0%,
845
+ rgba(0, 0, 0, 0.05) 50%,
846
+ rgba(0, 0, 0, 0) 100%
847
+ );
848
+ background-repeat: no-repeat;
849
+ background-size: 150% 100%;
850
+ animation: visns-dash-skeleton-sweep 1.4s ease-in-out infinite;
851
+ }
852
+
853
+ /* Counter: matches .widgetNo (2rem bottom padding, 3em value) */
854
+ .skeletonCounter {
855
+ width: 100%;
856
+ padding: 0 0 2rem 0;
857
+ box-sizing: border-box;
858
+ }
859
+
860
+ .skeletonCounterValue {
861
+ width: 55%;
862
+ max-width: 220px;
863
+ height: 3.6rem;
864
+ margin: 0 auto;
865
+ }
866
+
867
+ /* Counter button: matches .widgetLink .btn */
868
+ .skeletonButtonRow {
869
+ width: 100%;
870
+ display: block;
871
+ }
872
+
873
+ .skeletonButton {
874
+ width: 180px;
875
+ max-width: 100%;
876
+ height: 34px;
877
+ margin: 0 auto;
878
+ }
879
+
880
+ /* Charts: the wrapper takes widget.height (or the 600px chart default) inline */
881
+ .skeletonChart {
882
+ width: 100%;
883
+ display: flex;
884
+ flex-direction: column;
885
+ align-items: center;
886
+ justify-content: center;
887
+ box-sizing: border-box;
888
+ padding: 20px 0;
889
+ }
890
+
891
+ .skeletonChartStrips {
892
+ align-items: flex-start;
893
+ gap: 14px;
894
+ }
895
+
896
+ .skeletonStrip {
897
+ height: 16px;
898
+ min-height: 16px;
899
+ }
900
+
901
+ .skeletonCircle {
902
+ width: 60%;
903
+ max-width: 260px;
904
+ aspect-ratio: 1 / 1;
905
+ border-radius: 50%;
906
+ }
907
+
908
+ /* Table: header 44px / rows 38px, matching .table-container th and td */
909
+ .skeletonTable {
910
+ width: 100%;
911
+ display: flex;
912
+ flex-direction: column;
913
+ gap: 1px;
914
+ box-sizing: border-box;
915
+ border: 1px solid #dadce0;
916
+ border-radius: var(--br, 5px);
917
+ overflow: hidden;
918
+ }
919
+
920
+ .skeletonTableHeader {
921
+ width: 100%;
922
+ height: 44px;
923
+ border-radius: 0;
924
+ }
925
+
926
+ .skeletonTableRow {
927
+ width: 100%;
928
+ height: 38px;
929
+ border-radius: 0;
930
+ }
931
+
932
+ /* List: rows sized like .dashList li button */
933
+ .skeletonList {
934
+ width: 100%;
935
+ display: flex;
936
+ flex-direction: column;
937
+ gap: 1px;
938
+ font-size: 0.875rem;
939
+ }
940
+
941
+ .skeletonListRow {
942
+ width: 100%;
943
+ height: 30px;
944
+ border-radius: 0;
945
+
946
+ &:first-child {
947
+ border-top-left-radius: var(--br, 5px);
948
+ border-top-right-radius: var(--br, 5px);
949
+ }
950
+
951
+ &:last-child {
952
+ border-bottom-left-radius: var(--br, 5px);
953
+ border-bottom-right-radius: var(--br, 5px);
954
+ }
955
+ }
956
+
957
+ .skeletonTimeline {
958
+ width: 100%;
959
+ }
960
+
961
+ .skeletonGeneric {
962
+ width: 100%;
963
+ height: 120px;
964
+ }
965
+
966
+ /* Keep the shape, drop the motion */
967
+ @media (prefers-reduced-motion: reduce) {
968
+ .skeletonBlock {
969
+ animation: none;
970
+ }
971
+ }