@visns-studio/visns-components 6.3.0 → 6.3.2
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
CHANGED
|
@@ -91,7 +91,7 @@
|
|
|
91
91
|
"react-dom": "^17.0.0 || ^18.0.0"
|
|
92
92
|
},
|
|
93
93
|
"name": "@visns-studio/visns-components",
|
|
94
|
-
"version": "6.3.
|
|
94
|
+
"version": "6.3.2",
|
|
95
95
|
"description": "Various packages to assist in the development of our Custom Applications.",
|
|
96
96
|
"main": "src/index.js",
|
|
97
97
|
"files": [
|
|
@@ -4630,44 +4630,37 @@ const DataGrid = forwardRef(
|
|
|
4630
4630
|
|
|
4631
4631
|
{/* Opt-in colour legend (config key `legend`: array of
|
|
4632
4632
|
{colour, label}); entries without a colour render as
|
|
4633
|
-
plain lead-in text
|
|
4633
|
+
plain lead-in text.
|
|
4634
|
+
|
|
4635
|
+
Styling lives in DataGrid.module.scss (`.legend` and
|
|
4636
|
+
friends) rather than inline, so a consuming project can
|
|
4637
|
+
actually reach it — see the notes there. The only inline
|
|
4638
|
+
style left is the swatch's own fill, which is data from
|
|
4639
|
+
the config rather than a design decision. */}
|
|
4634
4640
|
{Array.isArray(legend) && legend.length > 0 && (
|
|
4635
|
-
<div
|
|
4636
|
-
|
|
4637
|
-
|
|
4638
|
-
|
|
4639
|
-
|
|
4640
|
-
|
|
4641
|
-
|
|
4642
|
-
|
|
4643
|
-
|
|
4644
|
-
|
|
4645
|
-
|
|
4646
|
-
|
|
4647
|
-
|
|
4648
|
-
|
|
4649
|
-
|
|
4650
|
-
|
|
4651
|
-
|
|
4652
|
-
|
|
4653
|
-
|
|
4654
|
-
|
|
4655
|
-
|
|
4656
|
-
|
|
4657
|
-
|
|
4658
|
-
width: '14px',
|
|
4659
|
-
height: '14px',
|
|
4660
|
-
borderRadius: '3px',
|
|
4661
|
-
backgroundColor:
|
|
4662
|
-
item.colour || item.color,
|
|
4663
|
-
display: 'inline-block',
|
|
4664
|
-
flexShrink: 0,
|
|
4665
|
-
}}
|
|
4666
|
-
/>
|
|
4667
|
-
)}
|
|
4668
|
-
{item.label}
|
|
4669
|
-
</span>
|
|
4670
|
-
))}
|
|
4641
|
+
<div className={styles.legend}>
|
|
4642
|
+
{legend.map((item, index) => {
|
|
4643
|
+
const swatch = item.colour || item.color;
|
|
4644
|
+
|
|
4645
|
+
return (
|
|
4646
|
+
<span
|
|
4647
|
+
key={index}
|
|
4648
|
+
className={
|
|
4649
|
+
swatch
|
|
4650
|
+
? styles.legendItem
|
|
4651
|
+
: `${styles.legendItem} ${styles.legendLead}`
|
|
4652
|
+
}
|
|
4653
|
+
>
|
|
4654
|
+
{swatch && (
|
|
4655
|
+
<span
|
|
4656
|
+
className={styles.legendSwatch}
|
|
4657
|
+
style={{ backgroundColor: swatch }}
|
|
4658
|
+
/>
|
|
4659
|
+
)}
|
|
4660
|
+
{item.label}
|
|
4661
|
+
</span>
|
|
4662
|
+
);
|
|
4663
|
+
})}
|
|
4671
4664
|
</div>
|
|
4672
4665
|
)}
|
|
4673
4666
|
|
|
@@ -184,6 +184,15 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
|
|
|
184
184
|
*/
|
|
185
185
|
const savedSnapshotRef = useRef(null);
|
|
186
186
|
const [unsavedChanges, setUnsavedChanges] = useState(false);
|
|
187
|
+
/** How many questions differ from the stored version, for the indicator. */
|
|
188
|
+
const [unsavedCount, setUnsavedCount] = useState(0);
|
|
189
|
+
/**
|
|
190
|
+
* Whether the "this is not stored yet" notice has already been given since
|
|
191
|
+
* the last successful save. Once per unsaved streak: the first Apply is
|
|
192
|
+
* where the misunderstanding happens, and a toast on every Apply after that
|
|
193
|
+
* would train people to dismiss it without reading.
|
|
194
|
+
*/
|
|
195
|
+
const stagedNoticeShownRef = useRef(false);
|
|
187
196
|
/** Bumped whenever the baseline moves, so the comparison below re-runs. */
|
|
188
197
|
const [savedBaselineTick, setSavedBaselineTick] = useState(0);
|
|
189
198
|
const lockEnabled = Boolean(optimisticLock);
|
|
@@ -1376,8 +1385,11 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
|
|
|
1376
1385
|
let errorMessage = validateField(field);
|
|
1377
1386
|
|
|
1378
1387
|
if (errorMessage === '') {
|
|
1388
|
+
const isNew = modalType.type === 'create';
|
|
1389
|
+
|
|
1379
1390
|
saveFieldData(field);
|
|
1380
1391
|
handleCloseModal();
|
|
1392
|
+
announceStagedChange(isNew);
|
|
1381
1393
|
} else {
|
|
1382
1394
|
displayErrorMessage(errorMessage);
|
|
1383
1395
|
}
|
|
@@ -1850,6 +1862,68 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
|
|
|
1850
1862
|
const snapshotOf = (label, detail) =>
|
|
1851
1863
|
JSON.stringify({ label: label ?? '', detail: detail ?? [] });
|
|
1852
1864
|
|
|
1865
|
+
/**
|
|
1866
|
+
* How many separate edits are waiting to be stored, counted by question.
|
|
1867
|
+
*
|
|
1868
|
+
* A number is worth more than "Unsaved changes" alone — it tells somebody
|
|
1869
|
+
* who stepped away how much they stand to lose. Counted by field id, so an
|
|
1870
|
+
* added, a reworded and a deleted question are three, while re-ordering the
|
|
1871
|
+
* same questions is none of them. That last case is not a miscount: the
|
|
1872
|
+
* order is written to the server the moment it changes, so a pure re-order
|
|
1873
|
+
* shows as dirty for only as long as that request is in flight, and the
|
|
1874
|
+
* wording below falls back to the unnumbered phrase.
|
|
1875
|
+
*/
|
|
1876
|
+
const countUnsavedChanges = (baseline, label, detail) => {
|
|
1877
|
+
let count = (baseline.label ?? '') === (label ?? '') ? 0 : 1;
|
|
1878
|
+
|
|
1879
|
+
const stored = new Map(
|
|
1880
|
+
(baseline.detail ?? []).map((field) => [
|
|
1881
|
+
field.id,
|
|
1882
|
+
JSON.stringify(field),
|
|
1883
|
+
])
|
|
1884
|
+
);
|
|
1885
|
+
const present = new Set();
|
|
1886
|
+
|
|
1887
|
+
(detail ?? []).forEach((field) => {
|
|
1888
|
+
present.add(field.id);
|
|
1889
|
+
|
|
1890
|
+
if (stored.get(field.id) !== JSON.stringify(field)) {
|
|
1891
|
+
count += 1;
|
|
1892
|
+
}
|
|
1893
|
+
});
|
|
1894
|
+
|
|
1895
|
+
stored.forEach((_encoded, id) => {
|
|
1896
|
+
if (!present.has(id)) {
|
|
1897
|
+
count += 1;
|
|
1898
|
+
}
|
|
1899
|
+
});
|
|
1900
|
+
|
|
1901
|
+
return count;
|
|
1902
|
+
};
|
|
1903
|
+
|
|
1904
|
+
/**
|
|
1905
|
+
* Say, at the moment it is least obvious, that Apply stored nothing.
|
|
1906
|
+
*
|
|
1907
|
+
* The question window closes and the new question appears in the list,
|
|
1908
|
+
* which looks exactly like a save. This is the one place where saying so
|
|
1909
|
+
* costs nothing and lands while the user is still looking.
|
|
1910
|
+
*/
|
|
1911
|
+
const announceStagedChange = (isNew) => {
|
|
1912
|
+
if (stagedNoticeShownRef.current) {
|
|
1913
|
+
return;
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
stagedNoticeShownRef.current = true;
|
|
1917
|
+
|
|
1918
|
+
toast.info(
|
|
1919
|
+
<div>
|
|
1920
|
+
{isNew ? 'Question added' : 'Question updated'} — this is on
|
|
1921
|
+
screen only. Tap <strong>Save Template</strong> to store it.
|
|
1922
|
+
</div>,
|
|
1923
|
+
{ toastId: 'fb-staged-change', autoClose: 6000 }
|
|
1924
|
+
);
|
|
1925
|
+
};
|
|
1926
|
+
|
|
1853
1927
|
/**
|
|
1854
1928
|
* Re-anchor the unsaved-changes baseline on what the server now holds.
|
|
1855
1929
|
*
|
|
@@ -1867,6 +1941,11 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
|
|
|
1867
1941
|
detail === undefined ? previous.detail : detail
|
|
1868
1942
|
);
|
|
1869
1943
|
|
|
1944
|
+
// The reminder is earned again by the next unsaved streak. Anyone who
|
|
1945
|
+
// has just stored their work has demonstrably understood the flow, and
|
|
1946
|
+
// will still be told the next time they walk into it.
|
|
1947
|
+
stagedNoticeShownRef.current = false;
|
|
1948
|
+
|
|
1870
1949
|
// Never `setUnsavedChanges(false)` directly: a partial save can leave
|
|
1871
1950
|
// something else still unsaved. Re-ordering writes `detail` and not the
|
|
1872
1951
|
// label, so declaring the screen clean here would lose a pending
|
|
@@ -2100,8 +2179,20 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
|
|
|
2100
2179
|
return;
|
|
2101
2180
|
}
|
|
2102
2181
|
|
|
2103
|
-
|
|
2104
|
-
snapshotOf(data.label, data.detail) !== savedSnapshotRef.current
|
|
2182
|
+
const dirty =
|
|
2183
|
+
snapshotOf(data.label, data.detail) !== savedSnapshotRef.current;
|
|
2184
|
+
|
|
2185
|
+
setUnsavedChanges(dirty);
|
|
2186
|
+
// Only worth counting when something is outstanding, so a clean screen
|
|
2187
|
+
// never pays for parsing the baseline.
|
|
2188
|
+
setUnsavedCount(
|
|
2189
|
+
dirty
|
|
2190
|
+
? countUnsavedChanges(
|
|
2191
|
+
JSON.parse(savedSnapshotRef.current),
|
|
2192
|
+
data.label,
|
|
2193
|
+
data.detail
|
|
2194
|
+
)
|
|
2195
|
+
: 0
|
|
2105
2196
|
);
|
|
2106
2197
|
}, [data, savedBaselineTick]);
|
|
2107
2198
|
|
|
@@ -2340,7 +2431,7 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
|
|
|
2340
2431
|
items: [
|
|
2341
2432
|
'<strong>Apply</strong> inside a question window only adds it to the list on screen — nothing is stored yet',
|
|
2342
2433
|
'To store the template, click <strong>Save Template</strong> (bottom right)',
|
|
2343
|
-
'
|
|
2434
|
+
'While anything is waiting, the button turns amber and the label beside it counts what you would lose — the browser warns you as well if you try to leave',
|
|
2344
2435
|
'<strong>Edit Form</strong> is only for renaming the template',
|
|
2345
2436
|
'Moving a question saves the whole template straight away, so the order is never lost',
|
|
2346
2437
|
'If someone else changed this template while you had it open, you are told nothing was saved — reload the page before saving again, or your changes would undo theirs',
|
|
@@ -2714,10 +2805,15 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
|
|
|
2714
2805
|
? styles.saveStateDirty
|
|
2715
2806
|
: styles.saveStateClean
|
|
2716
2807
|
}`}
|
|
2808
|
+
aria-live="polite"
|
|
2717
2809
|
>
|
|
2718
|
-
{unsavedChanges
|
|
2719
|
-
? '
|
|
2720
|
-
:
|
|
2810
|
+
{!unsavedChanges
|
|
2811
|
+
? 'All changes saved'
|
|
2812
|
+
: unsavedCount > 0
|
|
2813
|
+
? `${unsavedCount} unsaved change${
|
|
2814
|
+
unsavedCount === 1 ? '' : 's'
|
|
2815
|
+
}`
|
|
2816
|
+
: 'Unsaved changes'}
|
|
2721
2817
|
</span>
|
|
2722
2818
|
{/* Nothing to author on a dynamic template, but
|
|
2723
2819
|
Edit Form stays so it can still be renamed. */}
|
|
@@ -2741,10 +2837,20 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
|
|
|
2741
2837
|
questions looked like the one that renames the
|
|
2742
2838
|
form. It belongs out here, beside the work. */}
|
|
2743
2839
|
<button
|
|
2744
|
-
className={`${styles.btn} ${
|
|
2840
|
+
className={`${styles.btn} ${
|
|
2841
|
+
styles.btnPrimary
|
|
2842
|
+
} ${
|
|
2843
|
+
unsavedChanges
|
|
2844
|
+
? styles.btnSaveDirty
|
|
2845
|
+
: styles.btnSaveClean
|
|
2846
|
+
}`}
|
|
2745
2847
|
onClick={handleSubmit}
|
|
2746
2848
|
data-tooltip-id="system-tooltip"
|
|
2747
|
-
data-tooltip-content=
|
|
2849
|
+
data-tooltip-content={
|
|
2850
|
+
unsavedChanges
|
|
2851
|
+
? 'You have changes that are only on screen. Tap to store them.'
|
|
2852
|
+
: 'Everything on this screen is stored.'
|
|
2853
|
+
}
|
|
2748
2854
|
>
|
|
2749
2855
|
Save Template
|
|
2750
2856
|
</button>
|
|
@@ -526,6 +526,94 @@
|
|
|
526
526
|
justify-content: flex-end;
|
|
527
527
|
}
|
|
528
528
|
|
|
529
|
+
/* ============================================================================
|
|
530
|
+
Colour legend
|
|
531
|
+
----------------------------------------------------------------------------
|
|
532
|
+
The opt-in caption above the grid (config key `legend`: an array of
|
|
533
|
+
`{ colour | color, label }`, where an entry carrying no colour renders as
|
|
534
|
+
lead-in text introducing the swatches after it).
|
|
535
|
+
|
|
536
|
+
This was inline styles on the elements themselves — `fontSize: '0.8rem'`,
|
|
537
|
+
`color: '#555'`, a hardcoded 14px swatch, `padding: '6px 4px 8px'`. Inline
|
|
538
|
+
styles are unreachable from a consuming project's stylesheet, so the one
|
|
539
|
+
thing a legend needs to be — tuned to the app it sits in — was the one
|
|
540
|
+
thing it could not be. It was also set off any scale in the library: 0.8rem
|
|
541
|
+
falls between two steps of the type ramp, and #555 is a grey that appears
|
|
542
|
+
nowhere else.
|
|
543
|
+
|
|
544
|
+
It is a caption for the table below it, so it is set as one: the same
|
|
545
|
+
`--font-size-xs`, `--muted-color` and `--font-weight-semibold` the grid's
|
|
546
|
+
own header row uses, so the two label rows stacked above the data read as
|
|
547
|
+
one family. It deliberately stops short of that header's uppercase and
|
|
548
|
+
0.06em tracking — legend labels are sentences ("No Goods Received entered
|
|
549
|
+
yet"), not column names, and setting a sentence in caps would undo the
|
|
550
|
+
quiet the rest of this is after.
|
|
551
|
+
|
|
552
|
+
Density hooks, following the `--nav-*` set documented at the top of
|
|
553
|
+
Navigation.module.scss: every declaration below is written
|
|
554
|
+
`var(--hook, <the value it always had>)`, so an unset hook is a no-op and a
|
|
555
|
+
project retunes from `:root` with no fork, no prop and no class of its own.
|
|
556
|
+
The plant-floor tablet case is the one that needs them — 12px is small when
|
|
557
|
+
the screen is at arm's length on a bench.
|
|
558
|
+
|
|
559
|
+
--grid-legend-font-size label type size.
|
|
560
|
+
default: var(--font-size-xs, 0.75rem)
|
|
561
|
+
--grid-legend-gap space between entries along a line.
|
|
562
|
+
default: var(--spacing-md, 1rem)
|
|
563
|
+
--grid-legend-swatch-size the colour chip. default: 0.875rem (14px)
|
|
564
|
+
========================================================================= */
|
|
565
|
+
|
|
566
|
+
.legend {
|
|
567
|
+
display: flex;
|
|
568
|
+
flex-wrap: wrap;
|
|
569
|
+
align-items: center;
|
|
570
|
+
/* Row gap and column gap differ on purpose: a legend that wraps (despatch
|
|
571
|
+
runs to seven entries) needs its lines closer together than its entries
|
|
572
|
+
are along a line, or the wrapped rows read as separate captions. */
|
|
573
|
+
gap: var(--spacing-sm, 0.5rem)
|
|
574
|
+
var(--grid-legend-gap, var(--spacing-md, 1rem));
|
|
575
|
+
/* A caption belongs to the thing it describes, so it sits nearer the grid
|
|
576
|
+
than the toolbar row above it — which is also what stops it crowding
|
|
577
|
+
that row, the complaint this change came from. No horizontal padding:
|
|
578
|
+
`.dataGridContainer` has none either, so the caption starts on the
|
|
579
|
+
grid's own left edge rather than floating 4px off it. */
|
|
580
|
+
margin: var(--spacing-md, 1rem) 0 var(--spacing-sm, 0.5rem);
|
|
581
|
+
padding: 0;
|
|
582
|
+
font-size: var(--grid-legend-font-size, var(--font-size-xs, 0.75rem));
|
|
583
|
+
line-height: 1.4;
|
|
584
|
+
color: var(--muted-color, #6b7688);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
.legendItem {
|
|
588
|
+
display: inline-flex;
|
|
589
|
+
align-items: center;
|
|
590
|
+
gap: var(--spacing-xs, 0.25rem);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/* The lead-in entry: a legend line with no colour of its own, labelling the
|
|
594
|
+
swatches that follow. Weight and a darker ink rather than a different size,
|
|
595
|
+
so it introduces the row without breaking its line. */
|
|
596
|
+
.legendLead {
|
|
597
|
+
font-weight: var(--font-weight-semibold, 600);
|
|
598
|
+
color: var(--paragraph-color, #374151);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
.legendSwatch {
|
|
602
|
+
width: var(--grid-legend-swatch-size, 0.875rem);
|
|
603
|
+
height: var(--grid-legend-swatch-size, 0.875rem);
|
|
604
|
+
flex-shrink: 0;
|
|
605
|
+
display: inline-block;
|
|
606
|
+
border-radius: var(--radius-sm, 6px);
|
|
607
|
+
/* These are row tints, so they are pale by design — #FFEAEA has no edge
|
|
608
|
+
of its own against a white page and the swatch dissolves into it. A
|
|
609
|
+
low-alpha ink hairline gives every chip its shape back while staying
|
|
610
|
+
invisible against the darker ones. `--border-color` is the wrong token
|
|
611
|
+
here: it is tuned to be barely there on a white surface, which is
|
|
612
|
+
exactly where it is needed most. */
|
|
613
|
+
border: 1px solid rgba(var(--paragraph-color-rgb, 34, 30, 51), 0.18);
|
|
614
|
+
box-sizing: border-box;
|
|
615
|
+
}
|
|
616
|
+
|
|
529
617
|
/* Date field styling for uniform width */
|
|
530
618
|
:global {
|
|
531
619
|
// Target date cells specifically
|
|
@@ -833,8 +833,22 @@ select:not(:placeholder-shown) + .fi__span {
|
|
|
833
833
|
}
|
|
834
834
|
}
|
|
835
835
|
|
|
836
|
+
/* Amber, pilled and bolder than its calm twin. The indicator, the dot and the
|
|
837
|
+
Save Template button all sit on the same warning hue on purpose: one state,
|
|
838
|
+
said three times, so it cannot be read as decoration. */
|
|
836
839
|
.saveStateDirty {
|
|
837
|
-
color: #
|
|
840
|
+
color: #92400e;
|
|
841
|
+
font-weight: 700;
|
|
842
|
+
padding: 3px 10px;
|
|
843
|
+
border-radius: 999px;
|
|
844
|
+
background: rgba(245, 158, 11, 0.16);
|
|
845
|
+
border: 1px solid rgba(180, 83, 9, 0.35);
|
|
846
|
+
|
|
847
|
+
&::before {
|
|
848
|
+
background: #d97706;
|
|
849
|
+
box-shadow: 0 0 0 0 rgba(217, 119, 6, 0.6);
|
|
850
|
+
animation: fbSaveDot 2s ease-in-out infinite;
|
|
851
|
+
}
|
|
838
852
|
}
|
|
839
853
|
|
|
840
854
|
.saveStateClean {
|
|
@@ -846,6 +860,70 @@ select:not(:placeholder-shown) + .fi__span {
|
|
|
846
860
|
box-shadow: 0 0 0 2px rgba(var(--primary-rgb), 0.35);
|
|
847
861
|
}
|
|
848
862
|
|
|
863
|
+
/* Nothing to save: present and findable, but not competing for attention. */
|
|
864
|
+
.btnSaveClean {
|
|
865
|
+
opacity: 0.9;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* Something to save. Amber overrides the shared primary fill, and the ring
|
|
870
|
+
* breathes so the button reads as waiting on the user rather than as one more
|
|
871
|
+
* blue button in a row of blue buttons.
|
|
872
|
+
*/
|
|
873
|
+
.btnSaveDirty {
|
|
874
|
+
background: #d97706 !important;
|
|
875
|
+
border-color: #b45309 !important;
|
|
876
|
+
color: #fff !important;
|
|
877
|
+
font-weight: 700;
|
|
878
|
+
box-shadow:
|
|
879
|
+
0 0 0 2px rgba(217, 119, 6, 0.45),
|
|
880
|
+
0 4px 14px rgba(180, 83, 9, 0.35);
|
|
881
|
+
animation: fbSaveNudge 2s ease-in-out infinite;
|
|
882
|
+
|
|
883
|
+
&:hover:not(:disabled) {
|
|
884
|
+
background: #b45309 !important;
|
|
885
|
+
border-color: #92400e !important;
|
|
886
|
+
color: #fff !important;
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
@keyframes fbSaveNudge {
|
|
891
|
+
0%,
|
|
892
|
+
100% {
|
|
893
|
+
box-shadow:
|
|
894
|
+
0 0 0 2px rgba(217, 119, 6, 0.45),
|
|
895
|
+
0 4px 14px rgba(180, 83, 9, 0.35);
|
|
896
|
+
transform: scale(1);
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
50% {
|
|
900
|
+
box-shadow:
|
|
901
|
+
0 0 0 7px rgba(217, 119, 6, 0),
|
|
902
|
+
0 6px 18px rgba(180, 83, 9, 0.45);
|
|
903
|
+
transform: scale(1.035);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
@keyframes fbSaveDot {
|
|
908
|
+
0%,
|
|
909
|
+
100% {
|
|
910
|
+
box-shadow: 0 0 0 0 rgba(217, 119, 6, 0.6);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
50% {
|
|
914
|
+
box-shadow: 0 0 0 5px rgba(217, 119, 6, 0);
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/* Movement is the enhancement, never the message: the amber fill, the ring and
|
|
919
|
+
the wording all survive on their own for anyone who asked for stillness. */
|
|
920
|
+
@media (prefers-reduced-motion: reduce) {
|
|
921
|
+
.btnSaveDirty,
|
|
922
|
+
.saveStateDirty::before {
|
|
923
|
+
animation: none;
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
|
|
849
927
|
/* Sits under the question window's Apply, where the misunderstanding happens. */
|
|
850
928
|
.applyHint {
|
|
851
929
|
flex: 1;
|