@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.
- package/package.json +1 -1
- package/src/components/DataGrid.jsx +37 -12
- package/src/components/Navigation.jsx +59 -3
- package/src/components/auth/Login.jsx +203 -175
- package/src/components/columns/ColumnRenderers.jsx +34 -0
- package/src/components/generic/GenericDashboard.jsx +181 -1
- package/src/components/generic/GenericFormBuilder.jsx +735 -37
- package/src/components/generic/GenericIndex.jsx +15 -5
- package/src/components/generic/OutstandingRuleEditor.jsx +313 -0
- package/src/components/styles/DataGrid.module.scss +38 -22
- package/src/components/styles/Form.module.scss +32 -33
- package/src/components/styles/GenericDashboard.module.scss +155 -0
- package/src/components/styles/GenericDetail.module.scss +28 -30
- package/src/components/styles/GenericDynamic.module.scss +7 -21
- package/src/components/styles/GenericEditableTable.module.scss +5 -22
- package/src/components/styles/GenericFormBuilder.module.scss +605 -30
- package/src/components/styles/GenericIndex.module.scss +17 -32
- package/src/components/styles/GenericMain.module.scss +18 -6
- package/src/components/styles/GenericQuote.module.scss +7 -22
- package/src/components/styles/GenericReport.module.scss +5 -37
- package/src/components/styles/Login.module.scss +415 -239
- package/src/components/styles/Navigation.module.scss +124 -35
- package/src/components/styles/Profile.module.scss +5 -26
- package/src/components/styles/QuickAction.module.scss +34 -29
- package/src/components/styles/_controls.scss +139 -0
- 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
|
-
|
|
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
|
-
|
|
934
|
-
|
|
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
|
-
|
|
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
|
-
|
|
17
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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
|
|
217
|
+
border-bottom: 1px solid var(--border-color);
|
|
213
218
|
display: flex;
|
|
214
219
|
align-items: center;
|
|
215
220
|
justify-content: space-between;
|
|
216
|
-
|
|
221
|
+
gap: var(--spacing-md);
|
|
222
|
+
padding: var(--spacing-md) var(--spacing-lg);
|
|
217
223
|
|
|
218
224
|
h1 {
|
|
219
|
-
|
|
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:
|
|
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
|
-
|
|
228
|
-
|
|
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
|
|
248
|
+
padding: 0;
|
|
231
249
|
margin: 0;
|
|
232
|
-
outline: none;
|
|
233
250
|
border: none;
|
|
234
|
-
border-radius: var(--
|
|
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(--
|
|
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
|
+
}
|