@visns-studio/visns-components 6.0.5 → 6.1.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.
@@ -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
+ }
@@ -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
+ }
@@ -2275,6 +2275,66 @@ select:not(:placeholder-shown) + .fi__span {
2275
2275
  }
2276
2276
  }
2277
2277
 
2278
+ // Outstanding item rule — a per-answer switch inside the options editor, plus
2279
+ // its hint and stale-reference warning. All of it renders only when the
2280
+ // consuming app enables `outstandingItems` in its builder config.
2281
+ .outstandingHint {
2282
+ font-size: 0.78rem;
2283
+ line-height: 1.45;
2284
+ color: rgba(var(--paragraph-color-rgb), 0.65);
2285
+ padding: 0 0 6px;
2286
+ }
2287
+
2288
+ .outstandingStale {
2289
+ background: #fff8e1;
2290
+ border: 1px solid #f4c544;
2291
+ border-radius: 6px;
2292
+ padding: 8px 12px;
2293
+ margin-bottom: 6px;
2294
+ color: #7a5c00;
2295
+ font-size: 0.8rem;
2296
+ line-height: 1.45;
2297
+ }
2298
+
2299
+ .outstandingFlag {
2300
+ flex-shrink: 0;
2301
+ display: inline-flex;
2302
+ align-items: center;
2303
+ gap: 5px;
2304
+ padding: 4px 9px;
2305
+ border: 1px solid rgba(var(--primary-rgb), 0.15);
2306
+ border-radius: 6px;
2307
+ background: white;
2308
+ font-size: 0.72rem;
2309
+ font-weight: 600;
2310
+ text-transform: uppercase;
2311
+ letter-spacing: 0.04em;
2312
+ color: rgba(var(--paragraph-color-rgb), 0.45);
2313
+ cursor: pointer;
2314
+ user-select: none;
2315
+ transition: all 0.2s ease;
2316
+
2317
+ &:hover {
2318
+ border-color: var(--primary-color);
2319
+ color: var(--paragraph-color);
2320
+ }
2321
+
2322
+ // The modal forces a 44px min-height on every input; a tick box must opt out.
2323
+ input[type='checkbox'] {
2324
+ margin: 0;
2325
+ width: auto !important;
2326
+ height: auto !important;
2327
+ min-height: 0 !important;
2328
+ cursor: pointer;
2329
+ }
2330
+ }
2331
+
2332
+ .outstandingFlagOn {
2333
+ border-color: #e0a800;
2334
+ background: #fff8e1;
2335
+ color: #7a5c00;
2336
+ }
2337
+
2278
2338
  .btnCancel {
2279
2339
  background: rgba(var(--paragraph-color-rgb), 0.08) !important;
2280
2340
  color: var(--paragraph-color) !important;
@@ -52,6 +52,17 @@
52
52
  // A scrim anchored to the bottom-left, where the copy sits — not a flat
53
53
  // wash over the whole image, which would dull the dusk light that makes
54
54
  // the photograph worth using.
55
+ //
56
+ // The foot is a near-solid plate rather than a fade. The brand line has to
57
+ // hold against whatever the photograph is doing behind it, and this one is
58
+ // bright exactly where the type sits, which left "Prime Builders" washing
59
+ // into the render. Held high — 0.88 still at 18%, which is where the ink
60
+ // block ends — so the whole block reads on one ground, then released
61
+ // quickly to 0.06 at the top, so the sky above is still a photograph and
62
+ // not a tint.
63
+ //
64
+ // Shared verbatim with the supervisor PWA
65
+ // (prime-web-nextjs/app/login/Login.module.scss). If one moves, move both.
55
66
  &::after {
56
67
  content: '';
57
68
  position: absolute;
@@ -59,9 +70,11 @@
59
70
  background:
60
71
  linear-gradient(
61
72
  to top,
62
- rgba(16, 25, 42, 0.92) 0%,
63
- rgba(16, 25, 42, 0.55) 32%,
64
- rgba(16, 25, 42, 0.08) 62%
73
+ rgba(16, 25, 42, 0.94) 0%,
74
+ rgba(16, 25, 42, 0.88) 18%,
75
+ rgba(16, 25, 42, 0.62) 42%,
76
+ rgba(16, 25, 42, 0.22) 70%,
77
+ rgba(16, 25, 42, 0.06) 100%
65
78
  ),
66
79
  linear-gradient(
67
80
  to right,
@@ -90,6 +103,11 @@
90
103
  font-size: 0.72rem;
91
104
  font-weight: 700;
92
105
  letter-spacing: 0.22em;
106
+ // Stated, not left to the cascade: global.css sets
107
+ // `p { line-height: var(--para-height) }`, which is 1.6 here. That put the
108
+ // kicker on a taller line than the same kicker in the PWA, so the red dash
109
+ // sat at a different height and the gap beneath it read as wider.
110
+ line-height: 1.4;
93
111
  text-transform: uppercase;
94
112
  color: rgba(255, 255, 255, 0.75);
95
113
  // The single use of the logo's red in the whole screen.
@@ -114,6 +132,10 @@
114
132
  line-height: 1;
115
133
  // Tight tracking at display size — Barlow opens up as it scales.
116
134
  letter-spacing: -0.02em;
135
+ // Stated rather than inherited from .brandInk: this is the one line on the
136
+ // screen that has to be white, and finding that out should not mean
137
+ // reading the parent.
138
+ color: #fff;
117
139
  text-wrap: balance;
118
140
  }
119
141
 
@@ -123,6 +145,10 @@
123
145
  font-size: 1rem;
124
146
  font-weight: 300;
125
147
  line-height: 1.5;
148
+ // No-op against this app's globals, but the PWA's own `p` rule tracks body
149
+ // copy at 0.015rem, so the value is pinned in both files rather than left
150
+ // to whichever global layer the screen happens to be rendered under.
151
+ letter-spacing: 0;
126
152
  color: rgba(255, 255, 255, 0.82);
127
153
  }
128
154