@striae-org/striae 5.3.1 → 5.4.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 (100) hide show
  1. package/.env.example +3 -0
  2. package/app/components/actions/generate-pdf.ts +22 -0
  3. package/app/components/auth/auth.module.css +531 -0
  4. package/app/components/auth/mfa-enrollment.tsx +132 -79
  5. package/app/components/auth/mfa-totp-enrollment.tsx +231 -0
  6. package/app/components/auth/mfa-verification.tsx +155 -33
  7. package/app/components/{sidebar/cases/cases-modal.tsx → navbar/case-modals/all-cases-modal.tsx} +4 -4
  8. package/app/components/navbar/case-modals/archive-case-modal.tsx +9 -10
  9. package/app/components/navbar/case-modals/case-modal-shared.module.css +88 -0
  10. package/app/components/navbar/case-modals/delete-case-modal.tsx +9 -10
  11. package/app/components/navbar/case-modals/export-case-modal.tsx +9 -10
  12. package/app/components/navbar/case-modals/export-confirmations-modal.tsx +9 -10
  13. package/app/components/navbar/case-modals/open-case-modal.tsx +4 -4
  14. package/app/components/navbar/case-modals/rename-case-modal.tsx +9 -10
  15. package/app/components/navbar/navbar.tsx +1 -1
  16. package/app/components/sidebar/files/delete-files-modal.tsx +3 -3
  17. package/app/components/sidebar/files/files-modal.module.css +29 -0
  18. package/app/components/sidebar/notes/{class-details-fields.tsx → class-details/class-details-fields.tsx} +1 -1
  19. package/app/components/sidebar/notes/{class-details-modal.tsx → class-details/class-details-modal.tsx} +1 -1
  20. package/app/components/sidebar/notes/{class-details-sections.tsx → class-details/class-details-sections.tsx} +1 -1
  21. package/app/components/sidebar/notes/notes-editor-form.tsx +2 -2
  22. package/app/components/sidebar/notes/notes-editor-modal.tsx +6 -6
  23. package/app/components/sidebar/notes/notes.module.css +52 -0
  24. package/app/components/toolbar/toolbar-color-selector.tsx +8 -8
  25. package/app/components/toolbar/toolbar.module.css +181 -2
  26. package/app/components/user/delete-account.tsx +7 -7
  27. package/app/components/user/inactivity-warning.tsx +6 -6
  28. package/app/components/user/manage-profile.tsx +18 -1
  29. package/app/components/user/mfa-enrolled-factors.tsx +117 -0
  30. package/app/components/user/mfa-phone-update.tsx +8 -4
  31. package/app/components/user/mfa-totp-section.tsx +446 -0
  32. package/app/components/user/user.module.css +665 -0
  33. package/app/routes/striae/striae.tsx +1 -1
  34. package/app/services/audit/audit.service.ts +1 -1
  35. package/app/services/audit/builders/audit-event-builders-user-security.ts +4 -2
  36. package/app/services/firebase/errors.ts +2 -0
  37. package/app/utils/auth/mfa.ts +35 -1
  38. package/functions/api/image/[[path]].ts +19 -3
  39. package/package.json +16 -21
  40. package/scripts/deploy-all.sh +166 -0
  41. package/scripts/deploy-config/modules/env-utils.sh +322 -0
  42. package/scripts/deploy-config/modules/keys.sh +404 -0
  43. package/scripts/deploy-config/modules/prompt.sh +375 -0
  44. package/scripts/deploy-config/modules/scaffolding.sh +310 -0
  45. package/scripts/deploy-config/modules/validation.sh +354 -0
  46. package/scripts/deploy-config.sh +236 -0
  47. package/scripts/deploy-pages-secrets.sh +231 -0
  48. package/scripts/deploy-pages.sh +34 -0
  49. package/scripts/deploy-primershear-emails.sh +167 -0
  50. package/scripts/deploy-worker-secrets.sh +385 -0
  51. package/scripts/dev.cjs +23 -0
  52. package/scripts/enable-totp-mfa.mjs +57 -0
  53. package/scripts/install-workers.sh +87 -0
  54. package/scripts/run-eslint.cjs +43 -0
  55. package/scripts/update-compatibility-dates.cjs +124 -0
  56. package/scripts/update-markdown-versions.cjs +43 -0
  57. package/workers/audit-worker/package.json +1 -1
  58. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  59. package/workers/data-worker/package.json +1 -1
  60. package/workers/data-worker/wrangler.jsonc.example +1 -1
  61. package/workers/image-worker/package.json +1 -1
  62. package/workers/image-worker/src/image-worker.example.ts +36 -2
  63. package/workers/image-worker/wrangler.jsonc.example +1 -1
  64. package/workers/pdf-worker/package.json +1 -1
  65. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  66. package/workers/user-worker/package.json +1 -1
  67. package/workers/user-worker/wrangler.jsonc.example +1 -1
  68. package/wrangler.toml.example +1 -1
  69. package/app/components/auth/mfa-enrollment.module.css +0 -276
  70. package/app/components/auth/mfa-verification.module.css +0 -259
  71. package/app/components/navbar/case-modals/archive-case-modal.module.css +0 -34
  72. package/app/components/navbar/case-modals/delete-case-modal.module.css +0 -9
  73. package/app/components/navbar/case-modals/export-case-modal.module.css +0 -27
  74. package/app/components/navbar/case-modals/export-confirmations-modal.module.css +0 -24
  75. package/app/components/navbar/case-modals/open-case-modal.module.css +0 -82
  76. package/app/components/navbar/case-modals/rename-case-modal.module.css +0 -9
  77. package/app/components/sidebar/files/delete-files-modal.module.css +0 -26
  78. package/app/components/sidebar/notes/notes-editor-modal.module.css +0 -49
  79. package/app/components/toolbar/toolbar-color-selector.module.css +0 -171
  80. package/app/components/user/delete-account.module.css +0 -277
  81. package/app/components/user/inactivity-warning.module.css +0 -148
  82. package/app/components/user/manage-profile.module.css +0 -192
  83. package/app/routes/auth/login.module.css +0 -523
  84. package/app/routes/auth/login.tsx +0 -705
  85. /package/app/components/{sidebar → navbar}/case-import/case-import.module.css +0 -0
  86. /package/app/components/{sidebar → navbar}/case-import/case-import.tsx +0 -0
  87. /package/app/components/{sidebar → navbar}/case-import/components/CasePreviewSection.tsx +0 -0
  88. /package/app/components/{sidebar → navbar}/case-import/components/ConfirmationDialog.tsx +0 -0
  89. /package/app/components/{sidebar → navbar}/case-import/components/ConfirmationPreviewSection.tsx +0 -0
  90. /package/app/components/{sidebar → navbar}/case-import/components/ExistingCaseSection.tsx +0 -0
  91. /package/app/components/{sidebar → navbar}/case-import/components/FileSelector.tsx +0 -0
  92. /package/app/components/{sidebar → navbar}/case-import/components/ProgressSection.tsx +0 -0
  93. /package/app/components/{sidebar → navbar}/case-import/hooks/useFilePreview.ts +0 -0
  94. /package/app/components/{sidebar → navbar}/case-import/hooks/useImportExecution.ts +0 -0
  95. /package/app/components/{sidebar → navbar}/case-import/hooks/useImportState.ts +0 -0
  96. /package/app/components/{sidebar → navbar}/case-import/index.ts +0 -0
  97. /package/app/components/{sidebar → navbar}/case-import/utils/file-validation.ts +0 -0
  98. /package/app/components/{sidebar/cases/cases-modal.module.css → navbar/case-modals/all-cases-modal.module.css} +0 -0
  99. /package/app/components/sidebar/notes/{class-details-shared.ts → class-details/class-details-shared.ts} +0 -0
  100. /package/app/components/sidebar/notes/{use-class-details-state.ts → class-details/use-class-details-state.ts} +0 -0
package/.env.example CHANGED
@@ -111,6 +111,9 @@ IMAGES_WORKER_DOMAIN=your_images_worker_domain_here
111
111
  IMAGE_SIGNED_URL_SECRET=your_image_signed_url_secret_here
112
112
  # Optional: defaults to 3600 and max is 86400.
113
113
  IMAGE_SIGNED_URL_TTL_SECONDS=3600
114
+ # Optional: override the base URL used in signed URL responses to route delivery through the Pages proxy.
115
+ # Defaults to the image worker's own origin if unset (exposes worker domain to clients).
116
+ IMAGE_SIGNED_URL_BASE_URL=https://${PAGES_CUSTOM_DOMAIN}/api/image
114
117
 
115
118
  # ================================
116
119
  # PDF WORKER ENVIRONMENT VARIABLES
@@ -64,6 +64,28 @@ const resolvePdfImageUrl = async (selectedImage: string | undefined): Promise<st
64
64
  return await blobToDataUrl(imageBlob);
65
65
  }
66
66
 
67
+ // Signed image URLs routed through the Pages proxy contain a ?st= token.
68
+ // Pre-fetch the image client-side and embed as a data URL so the PDF worker's
69
+ // Puppeteer context doesn't need to make outbound requests for the image.
70
+ if (selectedImage.startsWith('http://') || selectedImage.startsWith('https://')) {
71
+ let parsedUrl: URL;
72
+ try {
73
+ parsedUrl = new URL(selectedImage);
74
+ } catch {
75
+ return selectedImage;
76
+ }
77
+
78
+ if (parsedUrl.searchParams.has('st')) {
79
+ const imageResponse = await fetch(selectedImage);
80
+ if (!imageResponse.ok) {
81
+ throw new Error('Failed to load selected image for PDF generation');
82
+ }
83
+
84
+ const imageBlob = await imageResponse.blob();
85
+ return await blobToDataUrl(imageBlob);
86
+ }
87
+ }
88
+
67
89
  return selectedImage;
68
90
  };
69
91
 
@@ -0,0 +1,531 @@
1
+ /* ─── MFA Enrollment ────────────────────────────────────────────────────── */
2
+
3
+ .overlay {
4
+ position: fixed;
5
+ top: 0;
6
+ left: 0;
7
+ right: 0;
8
+ bottom: 0;
9
+ background-color: rgba(0, 0, 0, 0.75);
10
+ display: flex;
11
+ align-items: center;
12
+ justify-content: center;
13
+ z-index: 1000;
14
+ padding: 1rem;
15
+ cursor: default;
16
+ }
17
+
18
+ .enrollmentModal {
19
+ background: white;
20
+ border-radius: 12px;
21
+ padding: 2rem;
22
+ max-width: 500px;
23
+ width: 100%;
24
+ max-height: 90vh;
25
+ overflow-y: auto;
26
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
27
+ cursor: default;
28
+ }
29
+
30
+ .header {
31
+ text-align: center;
32
+ margin-bottom: 2rem;
33
+ }
34
+
35
+ .header h2 {
36
+ color: #333;
37
+ margin: 0 0 1rem 0;
38
+ font-size: 1.5rem;
39
+ font-weight: 600;
40
+ }
41
+
42
+ .header p {
43
+ color: #666;
44
+ margin: 0;
45
+ line-height: 1.5;
46
+ }
47
+
48
+ .content {
49
+ margin-bottom: 2rem;
50
+ }
51
+
52
+ .phoneStep,
53
+ .codeStep,
54
+ .totpStep {
55
+ text-align: center;
56
+ }
57
+
58
+ .phoneStep h3,
59
+ .codeStep h3,
60
+ .totpStep h3 {
61
+ color: #333;
62
+ margin: 1rem 0 1rem 0;
63
+ font-size: 1.2rem;
64
+ font-weight: 500;
65
+ }
66
+
67
+ .enrollmentInput {
68
+ width: 100%;
69
+ padding: 1rem;
70
+ border: 2px solid #e1e5e9;
71
+ border-radius: 8px;
72
+ font-size: 1rem;
73
+ margin-bottom: 1rem;
74
+ transition: border-color 0.2s ease;
75
+ box-sizing: border-box;
76
+ }
77
+
78
+ .enrollmentInput:focus {
79
+ outline: none;
80
+ border-color: #4285f4;
81
+ box-shadow: 0 0 0 3px rgba(66, 133, 244, 0.1);
82
+ }
83
+
84
+ .enrollmentInput:disabled {
85
+ background-color: #f5f5f5;
86
+ cursor: not-allowed;
87
+ }
88
+
89
+ .note {
90
+ color: #666;
91
+ font-size: 0.9rem;
92
+ margin: 0 0 1.5rem 0;
93
+ line-height: 1.4;
94
+ }
95
+
96
+ .buttonGroup {
97
+ display: flex;
98
+ flex-direction: column;
99
+ gap: 0.75rem;
100
+ }
101
+
102
+ .primaryButton {
103
+ background-color: #4285f4;
104
+ color: white;
105
+ border: none;
106
+ padding: 1rem 2rem;
107
+ border-radius: 8px;
108
+ font-size: 1rem;
109
+ font-weight: 500;
110
+ cursor: pointer;
111
+ transition: background-color 0.2s ease;
112
+ width: 100%;
113
+ box-sizing: border-box;
114
+ }
115
+
116
+ .primaryButton:hover:not(:disabled) {
117
+ background-color: #3367d6;
118
+ }
119
+
120
+ .primaryButton:disabled {
121
+ background-color: #ccc;
122
+ cursor: not-allowed;
123
+ }
124
+
125
+ .enrollmentSecondaryButton {
126
+ background-color: transparent;
127
+ color: #4285f4;
128
+ border: 2px solid #4285f4;
129
+ padding: 0.75rem 1.5rem;
130
+ border-radius: 8px;
131
+ font-size: 0.9rem;
132
+ font-weight: 500;
133
+ cursor: pointer;
134
+ transition: all 0.2s ease;
135
+ width: 100%;
136
+ box-sizing: border-box;
137
+ }
138
+
139
+ .enrollmentSecondaryButton:hover:not(:disabled) {
140
+ background-color: #4285f4;
141
+ color: white;
142
+ }
143
+
144
+ .enrollmentSecondaryButton:disabled {
145
+ border-color: #ccc;
146
+ color: #ccc;
147
+ cursor: not-allowed;
148
+ }
149
+
150
+ .resendTimer {
151
+ color: #999;
152
+ font-size: 0.9rem;
153
+ margin: 0;
154
+ text-align: center;
155
+ }
156
+
157
+ /* ─── Method Choice (TOTP vs SMS) ───────────────────────────────────────── */
158
+
159
+ .methodChoice {
160
+ text-align: center;
161
+ }
162
+
163
+ .methodChoice h3 {
164
+ color: #333;
165
+ margin: 0 0 1.25rem 0;
166
+ font-size: 1.1rem;
167
+ font-weight: 500;
168
+ }
169
+
170
+ .methodButton {
171
+ display: flex;
172
+ flex-direction: column;
173
+ align-items: flex-start;
174
+ width: 100%;
175
+ padding: 1rem 1.25rem;
176
+ margin-bottom: 0.75rem;
177
+ background: #fff;
178
+ border: 2px solid #e1e5e9;
179
+ border-radius: 8px;
180
+ cursor: pointer;
181
+ transition:
182
+ border-color 0.2s ease,
183
+ box-shadow 0.2s ease;
184
+ box-sizing: border-box;
185
+ text-align: left;
186
+ }
187
+
188
+ .methodButton:hover {
189
+ border-color: #4285f4;
190
+ box-shadow: 0 0 0 3px rgba(66, 133, 244, 0.1);
191
+ }
192
+
193
+ .methodButtonTitle {
194
+ font-size: 0.95rem;
195
+ font-weight: 600;
196
+ color: #333;
197
+ margin-bottom: 0.2rem;
198
+ }
199
+
200
+ .methodButtonDesc {
201
+ font-size: 0.8rem;
202
+ color: #666;
203
+ }
204
+
205
+ /* ─── TOTP QR Code ──────────────────────────────────────────────────────── */
206
+
207
+ .qrCodeContainer {
208
+ display: flex;
209
+ flex-direction: column;
210
+ align-items: center;
211
+ margin-bottom: 1rem;
212
+ }
213
+
214
+ .qrCodeImage {
215
+ border: 1px solid #e1e5e9;
216
+ border-radius: 8px;
217
+ padding: 0.5rem;
218
+ background: #fff;
219
+ margin-bottom: 0.5rem;
220
+ }
221
+
222
+ .secretKeyDisplay {
223
+ background: #f5f5f5;
224
+ border: 1px solid #e1e5e9;
225
+ border-radius: 8px;
226
+ padding: 0.75rem 1rem;
227
+ margin-bottom: 1rem;
228
+ text-align: center;
229
+ }
230
+
231
+ .secretKey {
232
+ display: block;
233
+ font-family: monospace;
234
+ font-size: 0.95rem;
235
+ letter-spacing: 0.1em;
236
+ word-break: break-all;
237
+ color: #333;
238
+ margin-top: 0.25rem;
239
+ user-select: all;
240
+ }
241
+
242
+ .footer {
243
+ border-top: 1px solid #e1e5e9;
244
+ padding-top: 1.5rem;
245
+ text-align: center;
246
+ }
247
+
248
+ .skipButton {
249
+ background-color: transparent;
250
+ color: #666;
251
+ border: none;
252
+ padding: 0.75rem 1.5rem;
253
+ border-radius: 8px;
254
+ font-size: 0.9rem;
255
+ cursor: pointer;
256
+ transition: color 0.2s ease;
257
+ }
258
+
259
+ .skipButton:hover:not(:disabled) {
260
+ color: #333;
261
+ text-decoration: underline;
262
+ }
263
+
264
+ .skipButton:disabled {
265
+ color: #ccc;
266
+ cursor: not-allowed;
267
+ }
268
+
269
+ /* ─── MFA Verification ──────────────────────────────────────────────────── */
270
+
271
+ .container {
272
+ position: fixed;
273
+ top: 0;
274
+ left: 0;
275
+ right: 0;
276
+ bottom: 0;
277
+ background: rgba(0, 0, 0, 0.5);
278
+ display: flex;
279
+ justify-content: center;
280
+ align-items: center;
281
+ z-index: 1000;
282
+ cursor: default;
283
+ }
284
+
285
+ .modal {
286
+ background: white;
287
+ border-radius: var(--spaceXS);
288
+ padding: var(--space2XL);
289
+ width: 100%;
290
+ max-width: 400px;
291
+ box-shadow: 0 var(--spaceM) var(--spaceXL) rgba(0, 0, 0, 0.2);
292
+ cursor: default;
293
+ }
294
+
295
+ .title {
296
+ text-align: center;
297
+ color: var(--textTitle);
298
+ margin-bottom: var(--spaceXL);
299
+ font-size: var(--fontSizeH4);
300
+ }
301
+
302
+ .hintSelection {
303
+ margin-bottom: var(--spaceL);
304
+ }
305
+
306
+ .label {
307
+ display: block;
308
+ margin-bottom: var(--spaceS);
309
+ font-weight: 600;
310
+ color: var(--textBody);
311
+ }
312
+
313
+ .select {
314
+ width: 100%;
315
+ padding: var(--spaceM);
316
+ border: 1.5px solid color-mix(in lab, var(--text) 20%, transparent);
317
+ border-radius: var(--spaceXS);
318
+ font-size: var(--fontSizeBodyM);
319
+ background: white;
320
+ transition: border-color var(--durationS) var(--bezierFastoutSlowin);
321
+ box-sizing: border-box;
322
+ }
323
+
324
+ .select:focus {
325
+ outline: none;
326
+ border-color: var(--primary);
327
+ box-shadow: 0 0 0 2px color-mix(in lab, var(--primary) 25%, transparent);
328
+ }
329
+
330
+ .description {
331
+ text-align: center;
332
+ color: var(--textLight);
333
+ margin-bottom: var(--spaceL);
334
+ line-height: 1.5;
335
+ }
336
+
337
+ .sendCode,
338
+ .verifyCode {
339
+ text-align: center;
340
+ }
341
+
342
+ .input {
343
+ width: 100%;
344
+ padding: var(--spaceM);
345
+ border: 1.5px solid color-mix(in lab, var(--text) 20%, transparent);
346
+ border-radius: var(--spaceXS);
347
+ font-size: var(--fontSizeBodyM);
348
+ text-align: center;
349
+ font-family: monospace;
350
+ letter-spacing: 2px;
351
+ margin-bottom: var(--spaceL);
352
+ transition: border-color var(--durationS) var(--bezierFastoutSlowin);
353
+ box-sizing: border-box;
354
+ }
355
+
356
+ .input:focus {
357
+ outline: none;
358
+ border-color: var(--primary);
359
+ box-shadow: 0 0 0 2px color-mix(in lab, var(--primary) 25%, transparent);
360
+ }
361
+
362
+ .button {
363
+ width: 100%;
364
+ padding: var(--spaceM) var(--spaceL);
365
+ border-radius: var(--spaceXS);
366
+ font-size: var(--fontSizeBodyS);
367
+ cursor: pointer;
368
+ background-color: var(--primary);
369
+ color: var(--white);
370
+ border: none;
371
+ transition: all var(--durationS) var(--bezierFastoutSlowin);
372
+ margin-bottom: var(--spaceM);
373
+ box-sizing: border-box;
374
+ }
375
+
376
+ .button:hover:not(:disabled) {
377
+ background-color: color-mix(in lab, var(--primary) 85%, var(--black));
378
+ }
379
+
380
+ .button:disabled {
381
+ background-color: color-mix(in lab, var(--background) 95%, transparent);
382
+ color: var(--textLight);
383
+ cursor: not-allowed;
384
+ }
385
+
386
+ .buttons {
387
+ display: flex;
388
+ flex-direction: column;
389
+ gap: var(--spaceS);
390
+ }
391
+
392
+ .secondaryButton {
393
+ padding: var(--spaceS) var(--spaceM);
394
+ border: 1.5px solid color-mix(in lab, var(--text) 20%, transparent);
395
+ border-radius: var(--spaceXS);
396
+ background: transparent;
397
+ cursor: pointer;
398
+ font-size: var(--fontSizeBodyS);
399
+ color: var(--textBody);
400
+ transition: all var(--durationS) var(--bezierFastoutSlowin);
401
+ box-sizing: border-box;
402
+ width: 100%;
403
+ }
404
+
405
+ .secondaryButton:hover:not(:disabled) {
406
+ background-color: color-mix(in lab, var(--background) 95%, transparent);
407
+ border-color: color-mix(in lab, var(--text) 30%, transparent);
408
+ }
409
+
410
+ .actions {
411
+ margin-top: var(--spaceL);
412
+ text-align: center;
413
+ }
414
+
415
+ .cancelButton {
416
+ padding: var(--spaceS) var(--spaceL);
417
+ border: none;
418
+ background: transparent;
419
+ cursor: pointer;
420
+ font-size: var(--fontSizeBodyS);
421
+ color: var(--textLight);
422
+ transition: color var(--durationS) var(--bezierFastoutSlowin);
423
+ }
424
+
425
+ .cancelButton:hover {
426
+ color: var(--textBody);
427
+ }
428
+
429
+ #recaptcha-container {
430
+ margin-top: var(--spaceM);
431
+ }
432
+
433
+ /* ─── Shared ────────────────────────────────────────────────────────────── */
434
+
435
+ .signOutContainer {
436
+ margin-top: var(--spaceL);
437
+ padding-top: var(--spaceL);
438
+ border-top: 1px solid var(--borderLight);
439
+ text-align: center;
440
+ }
441
+
442
+ .signOutText {
443
+ color: var(--textLight);
444
+ font-size: var(--fontSizeSmall);
445
+ margin-bottom: var(--spaceM);
446
+ }
447
+
448
+ .errorMessage {
449
+ background: linear-gradient(
450
+ 135deg,
451
+ color-mix(in lab, var(--error) 12%, transparent),
452
+ color-mix(in lab, var(--error) 8%, transparent)
453
+ );
454
+ border: 1px solid color-mix(in lab, var(--error) 30%, transparent);
455
+ border-left: 4px solid var(--error);
456
+ color: color-mix(in lab, var(--error) 90%, var(--black));
457
+ padding: var(--spaceL);
458
+ border-radius: var(--spaceS);
459
+ font-size: var(--fontSizeBodyS);
460
+ margin-bottom: var(--spaceL);
461
+ text-align: left;
462
+ line-height: 1.4;
463
+ position: relative;
464
+ overflow: hidden;
465
+ box-shadow: 0 4px 16px color-mix(in lab, var(--text) 10%, transparent);
466
+ animation: slideInError var(--durationM) var(--bezierFastoutSlowin);
467
+ }
468
+
469
+ .errorMessage::before {
470
+ content: "";
471
+ position: absolute;
472
+ top: 0;
473
+ left: 0;
474
+ right: 0;
475
+ height: 2px;
476
+ background: linear-gradient(
477
+ 90deg,
478
+ var(--error),
479
+ color-mix(in lab, var(--error) 60%, transparent)
480
+ );
481
+ animation: shimmer 2s ease-in-out infinite;
482
+ }
483
+
484
+ .errorMessage:empty {
485
+ display: none;
486
+ }
487
+
488
+ /* ─── Animations ────────────────────────────────────────────────────────── */
489
+
490
+ @keyframes slideInError {
491
+ from {
492
+ opacity: 0;
493
+ transform: translateY(calc(-1 * var(--spaceM))) scaleY(0.8);
494
+ max-height: 0;
495
+ padding-top: 0;
496
+ padding-bottom: 0;
497
+ margin-top: 0;
498
+ margin-bottom: 0;
499
+ }
500
+ to {
501
+ opacity: 1;
502
+ transform: translateY(0) scaleY(1);
503
+ max-height: 200px;
504
+ padding-top: var(--spaceL);
505
+ padding-bottom: var(--spaceL);
506
+ margin-top: 0;
507
+ margin-bottom: var(--spaceL);
508
+ }
509
+ }
510
+
511
+ @keyframes shimmer {
512
+ 0%,
513
+ 100% {
514
+ opacity: 0.6;
515
+ transform: translateX(-100%);
516
+ }
517
+ 50% {
518
+ opacity: 1;
519
+ transform: translateX(100%);
520
+ }
521
+ }
522
+
523
+ @media (prefers-reduced-motion: reduce) {
524
+ .errorMessage {
525
+ animation: none;
526
+ }
527
+
528
+ .errorMessage::before {
529
+ animation: none;
530
+ }
531
+ }