@striae-org/striae 5.4.5 → 5.5.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.
@@ -1,4 +1,4 @@
1
- import { useState, useEffect } from 'react';
1
+ import { useState, useEffect, useCallback, useLayoutEffect } from 'react';
2
2
  import type { User } from 'firebase/auth';
3
3
  import { ColorSelector } from '~/components/colors/colors';
4
4
  import { AddlNotesModal } from './addl-notes-modal';
@@ -18,13 +18,71 @@ interface NotesEditorFormProps {
18
18
  originalFileName?: string;
19
19
  isUploading?: boolean;
20
20
  showNotification?: (message: string, type: 'success' | 'error' | 'warning') => void;
21
+ onDirtyChange?: (isDirty: boolean) => void;
22
+ onRegisterSaveHandler?: (saveHandler: (() => Promise<boolean>) | null) => void;
21
23
  }
22
24
 
23
25
  type SupportLevel = 'ID' | 'Exclusion' | 'Inconclusive';
24
26
  type ClassType = 'Bullet' | 'Cartridge Case' | 'Shotshell' | 'Other';
25
27
  type IndexType = 'number' | 'color';
26
28
 
27
- export const NotesEditorForm = ({ currentCase, user, imageId, onAnnotationRefresh, originalFileName, isUploading = false, showNotification: externalShowNotification }: NotesEditorFormProps) => {
29
+ interface NotesFormSnapshot {
30
+ leftCase: string;
31
+ rightCase: string;
32
+ leftItem: string;
33
+ rightItem: string;
34
+ caseFontColor: string;
35
+ classType: ClassType | '';
36
+ customClass: string;
37
+ classNote: string;
38
+ hasSubclass: boolean;
39
+ bulletData: BulletAnnotationData | undefined;
40
+ cartridgeCaseData: CartridgeCaseAnnotationData | undefined;
41
+ shotshellData: ShotshellAnnotationData | undefined;
42
+ indexType: IndexType;
43
+ indexNumber: string;
44
+ indexColor: string;
45
+ supportLevel: SupportLevel | '';
46
+ includeConfirmation: boolean;
47
+ additionalNotes: string;
48
+ }
49
+
50
+ const hasMeaningfulValue = (value: unknown): boolean => {
51
+ if (value === undefined || value === null) {
52
+ return false;
53
+ }
54
+
55
+ if (Array.isArray(value)) {
56
+ return value.some(hasMeaningfulValue);
57
+ }
58
+
59
+ if (typeof value === 'object') {
60
+ return Object.values(value as Record<string, unknown>).some(hasMeaningfulValue);
61
+ }
62
+
63
+ return true;
64
+ };
65
+
66
+ const normalizeNestedAnnotationData = <T extends object>(data: T | undefined): T | undefined => {
67
+ if (data === undefined || data === null) {
68
+ return undefined;
69
+ }
70
+
71
+ return hasMeaningfulValue(data) ? data : undefined;
72
+ };
73
+
74
+ const normalizeNotesSnapshot = (snapshot: NotesFormSnapshot): NotesFormSnapshot => ({
75
+ ...snapshot,
76
+ bulletData: normalizeNestedAnnotationData(snapshot.bulletData),
77
+ cartridgeCaseData: normalizeNestedAnnotationData(snapshot.cartridgeCaseData),
78
+ shotshellData: normalizeNestedAnnotationData(snapshot.shotshellData),
79
+ });
80
+
81
+ const serializeNotesSnapshot = (snapshot: NotesFormSnapshot): string => JSON.stringify(normalizeNotesSnapshot(snapshot));
82
+ const DIRTY_CHECK_DEBOUNCE_MS = 180;
83
+ const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
84
+
85
+ export const NotesEditorForm = ({ currentCase, user, imageId, onAnnotationRefresh, originalFileName, isUploading = false, showNotification: externalShowNotification, onDirtyChange, onRegisterSaveHandler }: NotesEditorFormProps) => {
28
86
  // Loading/Saving Notes States
29
87
  const [isLoading, setIsLoading] = useState(false);
30
88
  const [loadError, setLoadError] = useState<string>();
@@ -64,13 +122,72 @@ export const NotesEditorForm = ({ currentCase, user, imageId, onAnnotationRefres
64
122
  const [isClassOpen, setIsClassOpen] = useState(true);
65
123
  const [isIndexOpen, setIsIndexOpen] = useState(true);
66
124
  const [isSupportOpen, setIsSupportOpen] = useState(true);
125
+ const [savedSnapshot, setSavedSnapshot] = useState<string>('');
126
+ const [hasLoadedSnapshot, setHasLoadedSnapshot] = useState(false);
127
+ const [isDirty, setIsDirty] = useState(false);
67
128
  const areInputsDisabled = isUploading || isConfirmedImage;
68
129
 
69
- const notificationHandler = (message: string, type: 'success' | 'error' | 'warning' = 'success') => {
130
+ const notificationHandler = useCallback((message: string, type: 'success' | 'error' | 'warning' = 'success') => {
70
131
  if (externalShowNotification) {
71
132
  externalShowNotification(message, type);
72
133
  }
73
- };
134
+ }, [externalShowNotification]);
135
+
136
+ useEffect(() => {
137
+ if (!hasLoadedSnapshot) {
138
+ return;
139
+ }
140
+
141
+ const timeoutId = window.setTimeout(() => {
142
+ const nextSnapshot = serializeNotesSnapshot({
143
+ leftCase,
144
+ rightCase,
145
+ leftItem,
146
+ rightItem,
147
+ caseFontColor,
148
+ classType,
149
+ customClass,
150
+ classNote,
151
+ hasSubclass,
152
+ bulletData,
153
+ cartridgeCaseData,
154
+ shotshellData,
155
+ indexType,
156
+ indexNumber,
157
+ indexColor,
158
+ supportLevel,
159
+ includeConfirmation,
160
+ additionalNotes,
161
+ });
162
+
163
+ setIsDirty(nextSnapshot !== savedSnapshot);
164
+ }, DIRTY_CHECK_DEBOUNCE_MS);
165
+
166
+ return () => {
167
+ window.clearTimeout(timeoutId);
168
+ };
169
+ }, [
170
+ additionalNotes,
171
+ bulletData,
172
+ cartridgeCaseData,
173
+ caseFontColor,
174
+ classNote,
175
+ classType,
176
+ customClass,
177
+ hasLoadedSnapshot,
178
+ hasSubclass,
179
+ includeConfirmation,
180
+ indexColor,
181
+ indexNumber,
182
+ indexType,
183
+ leftCase,
184
+ leftItem,
185
+ rightCase,
186
+ rightItem,
187
+ savedSnapshot,
188
+ shotshellData,
189
+ supportLevel,
190
+ ]);
74
191
 
75
192
  useEffect(() => {
76
193
  const loadExistingNotes = async () => {
@@ -79,6 +196,9 @@ export const NotesEditorForm = ({ currentCase, user, imageId, onAnnotationRefres
79
196
  setIsLoading(true);
80
197
  setLoadError(undefined);
81
198
  setIsConfirmedImage(false);
199
+ setHasLoadedSnapshot(false);
200
+ setIsDirty(false);
201
+ onDirtyChange?.(false);
82
202
 
83
203
  try {
84
204
  const existingNotes = await getNotes(user, currentCase, imageId);
@@ -106,19 +226,66 @@ export const NotesEditorForm = ({ currentCase, user, imageId, onAnnotationRefres
106
226
  setSupportLevel(existingNotes.supportLevel || '');
107
227
  setIncludeConfirmation(existingNotes.includeConfirmation);
108
228
  setAdditionalNotes(existingNotes.additionalNotes || '');
229
+
230
+ setSavedSnapshot(serializeNotesSnapshot({
231
+ leftCase: existingNotes.leftCase || '',
232
+ rightCase: existingNotes.rightCase || '',
233
+ leftItem: existingNotes.leftItem || '',
234
+ rightItem: existingNotes.rightItem || '',
235
+ caseFontColor: existingNotes.caseFontColor || '',
236
+ classType: existingNotes.classType || '',
237
+ customClass: existingNotes.customClass || '',
238
+ classNote: existingNotes.classNote || '',
239
+ hasSubclass: existingNotes.hasSubclass ?? false,
240
+ bulletData: existingNotes.bulletData,
241
+ cartridgeCaseData: existingNotes.cartridgeCaseData,
242
+ shotshellData: existingNotes.shotshellData,
243
+ indexType: existingNotes.indexType || 'color',
244
+ indexNumber: existingNotes.indexNumber || '',
245
+ indexColor: existingNotes.indexColor || '',
246
+ supportLevel: existingNotes.supportLevel || '',
247
+ includeConfirmation: existingNotes.includeConfirmation,
248
+ additionalNotes: existingNotes.additionalNotes || ''
249
+ }));
109
250
  } else {
110
251
  setIsConfirmedImage(false);
252
+
253
+ setSavedSnapshot(serializeNotesSnapshot({
254
+ leftCase: '',
255
+ rightCase: '',
256
+ leftItem: '',
257
+ rightItem: '',
258
+ caseFontColor: '',
259
+ classType: '',
260
+ customClass: '',
261
+ classNote: '',
262
+ hasSubclass: false,
263
+ bulletData: undefined,
264
+ cartridgeCaseData: undefined,
265
+ shotshellData: undefined,
266
+ indexType: 'color',
267
+ indexNumber: '',
268
+ indexColor: '',
269
+ supportLevel: '',
270
+ includeConfirmation: false,
271
+ additionalNotes: ''
272
+ }));
111
273
  }
112
274
  } catch (error) {
113
275
  setLoadError('Failed to load existing notes');
114
276
  console.error('Error loading notes:', error);
115
277
  } finally {
116
278
  setIsLoading(false);
279
+ setHasLoadedSnapshot(true);
117
280
  }
118
281
  };
119
282
 
120
283
  loadExistingNotes();
121
- }, [imageId, currentCase, user]);
284
+ }, [imageId, currentCase, onDirtyChange, user]);
285
+
286
+ useIsomorphicLayoutEffect(() => {
287
+ onDirtyChange?.(isDirty);
288
+ }, [isDirty, onDirtyChange]);
122
289
 
123
290
  useEffect(() => {
124
291
  if (useCurrentCaseLeft) {
@@ -129,11 +296,11 @@ export const NotesEditorForm = ({ currentCase, user, imageId, onAnnotationRefres
129
296
  }
130
297
  }, [useCurrentCaseLeft, useCurrentCaseRight, currentCase]);
131
298
 
132
- const handleSave = async () => {
299
+ const handleSave = useCallback(async (): Promise<boolean> => {
133
300
 
134
301
  if (!imageId) {
135
302
  console.error('No image selected');
136
- return;
303
+ return false;
137
304
  }
138
305
 
139
306
  let existingData: AnnotationData | null = null;
@@ -145,8 +312,12 @@ export const NotesEditorForm = ({ currentCase, user, imageId, onAnnotationRefres
145
312
  if (existingData?.confirmationData) {
146
313
  setIsConfirmedImage(true);
147
314
  notificationHandler('This image is confirmed. Notes cannot be modified.', 'error');
148
- return;
315
+ return false;
149
316
  }
317
+
318
+ const normalizedBulletData = normalizeNestedAnnotationData(bulletData);
319
+ const normalizedCartridgeCaseData = normalizeNestedAnnotationData(cartridgeCaseData);
320
+ const normalizedShotshellData = normalizeNestedAnnotationData(shotshellData);
150
321
 
151
322
  // Create updated annotation data, preserving box annotations and earliest timestamp
152
323
  const now = new Date().toISOString();
@@ -163,9 +334,9 @@ export const NotesEditorForm = ({ currentCase, user, imageId, onAnnotationRefres
163
334
  customClass: customClass,
164
335
  classNote: classNote || undefined,
165
336
  hasSubclass: hasSubclass,
166
- bulletData: bulletData,
167
- cartridgeCaseData: cartridgeCaseData,
168
- shotshellData: shotshellData,
337
+ bulletData: normalizedBulletData,
338
+ cartridgeCaseData: normalizedCartridgeCaseData,
339
+ shotshellData: normalizedShotshellData,
169
340
 
170
341
  // Index Information
171
342
  indexType: indexType,
@@ -207,11 +378,36 @@ export const NotesEditorForm = ({ currentCase, user, imageId, onAnnotationRefres
207
378
  );
208
379
 
209
380
  notificationHandler('Notes saved successfully.', 'success');
381
+
382
+ setSavedSnapshot(serializeNotesSnapshot({
383
+ leftCase,
384
+ rightCase,
385
+ leftItem,
386
+ rightItem,
387
+ caseFontColor,
388
+ classType,
389
+ customClass,
390
+ classNote,
391
+ hasSubclass,
392
+ bulletData,
393
+ cartridgeCaseData,
394
+ shotshellData,
395
+ indexType,
396
+ indexNumber,
397
+ indexColor,
398
+ supportLevel,
399
+ includeConfirmation,
400
+ additionalNotes,
401
+ }));
402
+ setIsDirty(false);
403
+ onDirtyChange?.(false);
210
404
 
211
405
  // Refresh annotation data after saving notes
212
406
  if (onAnnotationRefresh) {
213
407
  onAnnotationRefresh();
214
408
  }
409
+
410
+ return true;
215
411
  } catch (error) {
216
412
  console.error('Failed to save notes:', error);
217
413
  const errorMessage = error instanceof Error ? error.message : '';
@@ -237,8 +433,44 @@ export const NotesEditorForm = ({ currentCase, user, imageId, onAnnotationRefres
237
433
  } catch (auditError) {
238
434
  console.error('Failed to log annotation edit audit:', auditError);
239
435
  }
436
+
437
+ return false;
240
438
  }
241
- };
439
+ }, [
440
+ additionalNotes,
441
+ bulletData,
442
+ cartridgeCaseData,
443
+ caseFontColor,
444
+ classNote,
445
+ classType,
446
+ currentCase,
447
+ customClass,
448
+ hasSubclass,
449
+ imageId,
450
+ includeConfirmation,
451
+ indexColor,
452
+ indexNumber,
453
+ indexType,
454
+ leftCase,
455
+ leftItem,
456
+ notificationHandler,
457
+ onAnnotationRefresh,
458
+ onDirtyChange,
459
+ originalFileName,
460
+ rightCase,
461
+ rightItem,
462
+ shotshellData,
463
+ supportLevel,
464
+ user,
465
+ ]);
466
+
467
+ useEffect(() => {
468
+ onRegisterSaveHandler?.(handleSave);
469
+
470
+ return () => {
471
+ onRegisterSaveHandler?.(null);
472
+ };
473
+ }, [handleSave, onRegisterSaveHandler]);
242
474
 
243
475
  return (
244
476
  <div className={`${styles.notesEditorForm} ${styles.editorLayout}`}>
@@ -1,4 +1,5 @@
1
1
  import type { User } from 'firebase/auth';
2
+ import { useCallback, useEffect, useId, useRef, useState } from 'react';
2
3
  import { useOverlayDismiss } from '~/hooks/useOverlayDismiss';
3
4
  import { NotesEditorForm } from './notes-editor-form';
4
5
  import styles from './notes.module.css';
@@ -26,21 +27,151 @@ export const NotesEditorModal = ({
26
27
  isUploading = false,
27
28
  showNotification,
28
29
  }: NotesEditorModalProps) => {
30
+ const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
31
+ const [isCloseAlertOpen, setIsCloseAlertOpen] = useState(false);
32
+ const [isSavingBeforeClose, setIsSavingBeforeClose] = useState(false);
33
+ const [saveHandler, setSaveHandler] = useState<(() => Promise<boolean>) | null>(null);
34
+ const closeAlertRef = useRef<HTMLDivElement | null>(null);
35
+ const saveAndCloseButtonRef = useRef<HTMLButtonElement | null>(null);
36
+ const closeAlertTitleId = useId();
37
+ const closeAlertDescriptionId = useId();
38
+
39
+ const handleCloseAttempt = useCallback(() => {
40
+ if (hasUnsavedChanges) {
41
+ setIsCloseAlertOpen(true);
42
+ return;
43
+ }
44
+
45
+ onClose();
46
+ }, [hasUnsavedChanges, onClose]);
47
+
48
+ const handleDirtyChange = useCallback((isDirty: boolean) => {
49
+ setHasUnsavedChanges((previous) => (previous === isDirty ? previous : isDirty));
50
+ }, []);
51
+
52
+ const handleRegisterSaveHandler = useCallback((handler: (() => Promise<boolean>) | null) => {
53
+ setSaveHandler((previous) => (previous === handler ? previous : handler));
54
+ }, []);
55
+
29
56
  const {
30
57
  overlayProps,
31
58
  getCloseButtonProps,
32
59
  } = useOverlayDismiss({
33
60
  isOpen,
34
- onClose,
61
+ onClose: handleCloseAttempt,
62
+ closeOnEscape: !isCloseAlertOpen,
35
63
  });
36
64
 
65
+ const handleDiscardAndClose = () => {
66
+ setHasUnsavedChanges(false);
67
+ setIsCloseAlertOpen(false);
68
+ onClose();
69
+ };
70
+
71
+ const handleCancelClose = () => {
72
+ setIsCloseAlertOpen(false);
73
+ };
74
+
75
+ const canSaveBeforeClose = !!saveHandler && !isSavingBeforeClose;
76
+
77
+ const handleSaveBeforeClose = async () => {
78
+ if (!saveHandler) {
79
+ showNotification?.('Save is not ready yet. Please wait a moment and try again.', 'warning');
80
+ return;
81
+ }
82
+
83
+ setIsSavingBeforeClose(true);
84
+ let didSave = false;
85
+
86
+ try {
87
+ didSave = await saveHandler();
88
+ } catch (error) {
89
+ console.error('Failed to save notes before closing:', error);
90
+ showNotification?.('Failed to save notes. Please try again.', 'error');
91
+ didSave = false;
92
+ } finally {
93
+ setIsSavingBeforeClose(false);
94
+ }
95
+
96
+ if (!didSave) {
97
+ return;
98
+ }
99
+
100
+ setHasUnsavedChanges(false);
101
+ setIsCloseAlertOpen(false);
102
+ onClose();
103
+ };
104
+
105
+ useEffect(() => {
106
+ if (!isCloseAlertOpen) {
107
+ return;
108
+ }
109
+
110
+ const previouslyFocused = document.activeElement as HTMLElement | null;
111
+ saveAndCloseButtonRef.current?.focus();
112
+
113
+ const handleAlertKeyDown = (event: KeyboardEvent) => {
114
+ if (event.key === 'Escape') {
115
+ event.preventDefault();
116
+ setIsCloseAlertOpen(false);
117
+ return;
118
+ }
119
+
120
+ if (event.key !== 'Tab') {
121
+ return;
122
+ }
123
+
124
+ const focusContainer = closeAlertRef.current;
125
+ if (!focusContainer) {
126
+ return;
127
+ }
128
+
129
+ const focusableElements = Array.from(
130
+ focusContainer.querySelectorAll<HTMLElement>(
131
+ 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
132
+ )
133
+ );
134
+
135
+ if (focusableElements.length === 0) {
136
+ event.preventDefault();
137
+ return;
138
+ }
139
+
140
+ const first = focusableElements[0];
141
+ const last = focusableElements[focusableElements.length - 1];
142
+ const active = document.activeElement as HTMLElement | null;
143
+
144
+ if (event.shiftKey && active === first) {
145
+ event.preventDefault();
146
+ last.focus();
147
+ } else if (!event.shiftKey && active === last) {
148
+ event.preventDefault();
149
+ first.focus();
150
+ }
151
+ };
152
+
153
+ document.addEventListener('keydown', handleAlertKeyDown);
154
+
155
+ return () => {
156
+ document.removeEventListener('keydown', handleAlertKeyDown);
157
+ previouslyFocused?.focus();
158
+ };
159
+ }, [isCloseAlertOpen]);
160
+
37
161
  if (!isOpen) {
38
162
  return null;
39
163
  }
40
164
 
41
165
  return (
42
166
  <div className={styles.overlay} aria-label="Close image notes dialog" {...overlayProps}>
43
- <div className={styles.editorModal} role="dialog" aria-modal="true" aria-label="Image Notes">
167
+ <div
168
+ className={styles.editorModal}
169
+ role="dialog"
170
+ aria-modal={isCloseAlertOpen ? undefined : true}
171
+ aria-label="Image Notes"
172
+ aria-hidden={isCloseAlertOpen}
173
+ inert={isCloseAlertOpen}
174
+ >
44
175
  <div className={styles.editorModalHeader}>
45
176
  <h2 className={styles.editorModalTitle}>Image Notes</h2>
46
177
  <button className={styles.editorModalCloseButton} {...getCloseButtonProps({ ariaLabel: 'Close image notes dialog' })}>
@@ -56,9 +187,57 @@ export const NotesEditorModal = ({
56
187
  originalFileName={originalFileName}
57
188
  isUploading={isUploading}
58
189
  showNotification={showNotification}
190
+ onDirtyChange={handleDirtyChange}
191
+ onRegisterSaveHandler={handleRegisterSaveHandler}
59
192
  />
60
193
  </div>
61
194
  </div>
195
+ {isCloseAlertOpen && (
196
+ <div className={styles.modalOverlay}>
197
+ <div
198
+ className={`${styles.modal} ${styles.unsavedChangesModal}`}
199
+ ref={closeAlertRef}
200
+ role="alertdialog"
201
+ aria-modal="true"
202
+ aria-labelledby={closeAlertTitleId}
203
+ aria-describedby={closeAlertDescriptionId}
204
+ >
205
+ <h3 id={closeAlertTitleId} className={styles.modalTitle}>You have unsaved notes!</h3>
206
+ <p id={closeAlertDescriptionId} className={styles.unsavedChangesMessage}>
207
+ You have unsaved changes to notes and data. Save before closing?
208
+ </p>
209
+ <div className={styles.unsavedChangesActions}>
210
+ <div className={styles.unsavedChangesPrimaryRow}>
211
+ <button
212
+ ref={saveAndCloseButtonRef}
213
+ type="button"
214
+ onClick={handleSaveBeforeClose}
215
+ className={`${styles.saveButton} ${styles.unsavedChangesPrimaryAction}`}
216
+ disabled={!canSaveBeforeClose}
217
+ >
218
+ {isSavingBeforeClose ? 'Saving...' : 'Save and Close'}
219
+ </button>
220
+ <button
221
+ type="button"
222
+ onClick={handleDiscardAndClose}
223
+ className={`${styles.cancelButton} ${styles.unsavedChangesPrimaryAction}`}
224
+ disabled={isSavingBeforeClose}
225
+ >
226
+ Close Without Saving
227
+ </button>
228
+ </div>
229
+ <button
230
+ type="button"
231
+ onClick={handleCancelClose}
232
+ className={styles.secondaryButton}
233
+ disabled={isSavingBeforeClose}
234
+ >
235
+ Continue Editing
236
+ </button>
237
+ </div>
238
+ </div>
239
+ </div>
240
+ )}
62
241
  </div>
63
242
  );
64
243
  };
@@ -688,6 +688,76 @@ textarea:focus {
688
688
  justify-content: flex-end;
689
689
  }
690
690
 
691
+ .modalButtons .saveButton,
692
+ .modalButtons .cancelButton {
693
+ min-height: 42px;
694
+ margin: 0;
695
+ display: inline-flex;
696
+ align-items: center;
697
+ justify-content: center;
698
+ }
699
+
700
+ .classDetailsModalButtons .classDetailsModalAction {
701
+ min-height: 42px;
702
+ margin: 0;
703
+ display: inline-flex;
704
+ align-items: center;
705
+ justify-content: center;
706
+ }
707
+
708
+ .unsavedChangesModal {
709
+ max-width: 520px;
710
+ }
711
+
712
+ .unsavedChangesModal .modalTitle {
713
+ margin-top: 0;
714
+ }
715
+
716
+ .unsavedChangesMessage {
717
+ margin: 0.75rem;
718
+ color: #495057;
719
+ line-height: 1.5;
720
+ }
721
+
722
+ .unsavedChangesActions {
723
+ display: flex;
724
+ flex-direction: column;
725
+ gap: 0.75rem;
726
+ }
727
+
728
+ .unsavedChangesPrimaryRow {
729
+ display: grid;
730
+ grid-template-columns: repeat(2, minmax(0, 1fr));
731
+ gap: 0.75rem;
732
+ }
733
+
734
+ .unsavedChangesPrimaryAction {
735
+ width: 100%;
736
+ margin: 0;
737
+ }
738
+
739
+ .unsavedChangesPrimaryRow .cancelButton,
740
+ .unsavedChangesPrimaryRow .saveButton {
741
+ width: 100%;
742
+ margin: 0;
743
+ }
744
+
745
+ .secondaryButton {
746
+ width: 100%;
747
+ padding: 0.75rem;
748
+ border: 1.5px solid #ced4da;
749
+ border-radius: 6px;
750
+ background: white;
751
+ color: #495057;
752
+ font-weight: 500;
753
+ cursor: pointer;
754
+ transition: all 0.2s;
755
+ }
756
+
757
+ .secondaryButton:hover {
758
+ background-color: #f8f9fa;
759
+ }
760
+
691
761
  .cancelButton {
692
762
  width: 30%;
693
763
  padding: 0.75rem;
@@ -705,6 +775,13 @@ textarea:focus {
705
775
  background-color: #bd2130;
706
776
  }
707
777
 
778
+ .cancelButton:disabled,
779
+ .saveButton:disabled,
780
+ .secondaryButton:disabled {
781
+ opacity: 0.65;
782
+ cursor: not-allowed;
783
+ }
784
+
708
785
  .saveButton {
709
786
  width: 100%;
710
787
  padding: 0.75rem;
@@ -730,7 +807,13 @@ textarea:focus {
730
807
  position: sticky;
731
808
  bottom: 0;
732
809
  padding: 0.75rem 0 0.25rem;
733
- background: linear-gradient(to top, white 72%, rgba(255, 255, 255, 0));
810
+ background: linear-gradient(
811
+ to bottom,
812
+ rgba(255, 255, 255, 0),
813
+ white 28%,
814
+ white 72%,
815
+ rgba(255, 255, 255, 0)
816
+ );
734
817
  }
735
818
 
736
819
  .saveButton:hover {
@@ -156,7 +156,7 @@
156
156
  .sectionLabel {
157
157
  display: block;
158
158
  font-weight: 600;
159
- color: var(--textBody);
159
+ color: var(--textTitle);
160
160
  margin-bottom: var(--spaceS);
161
161
  }
162
162