@insymetri/styleguide 0.1.26 → 0.1.28

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,968 @@
1
+ <script lang="ts">
2
+ import {onMount} from 'svelte'
3
+ import DensityProvider from '../DensityProvider/DensityProvider.svelte'
4
+ import IIInput from '../IIInput/IIInput.svelte'
5
+ import IIButton from '../IIButton/IIButton.svelte'
6
+ import IIAlert from '../IIAlert/IIAlert.svelte'
7
+ import IIDateInput from '../IIDateInput/IIDateInput.svelte'
8
+ import IIDropdownInput from '../IIDropdownInput/IIDropdownInput.svelte'
9
+ import IISwitch from '../IISwitch/IISwitch.svelte'
10
+ import IISegmentedControl from '../IISegmentedControl/IISegmentedControl.svelte'
11
+
12
+ // --- Flow state ---
13
+ type Step = 'email' | 'otp' | 'profile' | 'about' | 'financial' | 'review'
14
+ let step: Step = $state('email')
15
+ let transitioning = $state(false)
16
+ const FADE_DURATION = 200
17
+
18
+ function handleFocusIn(e: FocusEvent) {
19
+ const t = e.target as HTMLElement
20
+ if (t.tagName === 'INPUT' || t.tagName === 'SELECT' || t.tagName === 'BUTTON') {
21
+ setTimeout(() => t.scrollIntoView({block: 'center', behavior: 'smooth'}), 100)
22
+ }
23
+ }
24
+
25
+ function handleFocusTrap(e: KeyboardEvent) {
26
+ if (e.key !== 'Tab') return
27
+ const container = e.currentTarget as HTMLElement
28
+ const focusable = container.querySelectorAll<HTMLElement>(
29
+ 'input:not([disabled]), select:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex="-1"])'
30
+ )
31
+ if (focusable.length === 0) return
32
+ const first = focusable[0]
33
+ const last = focusable[focusable.length - 1]
34
+ if (e.shiftKey && document.activeElement === first) {
35
+ e.preventDefault()
36
+ last.focus()
37
+ } else if (!e.shiftKey && document.activeElement === last) {
38
+ e.preventDefault()
39
+ first.focus()
40
+ }
41
+ }
42
+
43
+ function transitionTo(newStep: Step) {
44
+ transitioning = true
45
+ formScrolled = false
46
+ setTimeout(() => {
47
+ step = newStep
48
+ transitioning = false
49
+ }, FADE_DURATION)
50
+ }
51
+
52
+ // --- Email state ---
53
+ let email = $state('')
54
+ let emailError = $state('')
55
+ let emailLoading = $state(false)
56
+
57
+ // --- OTP state ---
58
+ let digits = $state<string[]>(['', '', '', '', '', ''])
59
+ let inputs: HTMLInputElement[] = []
60
+ let otpError = $state('')
61
+ let verifying = $state(false)
62
+ let isResending = $state(false)
63
+
64
+ let deliveryStatus = $state<'idle' | 'sending' | 'sent' | 'delivered' | 'bounced' | 'failed'>('delivered')
65
+ let deliveryError = $state('')
66
+
67
+ const code = $derived(digits.join(''))
68
+ const isComplete = $derived(code.length === 6 && digits.every((d) => d !== ''))
69
+ const hasFailed = $derived(deliveryStatus === 'bounced' || deliveryStatus === 'failed')
70
+ const sentComplete = $derived(deliveryStatus !== 'idle')
71
+ const deliveredComplete = $derived(deliveryStatus === 'delivered')
72
+ const isInFlight = $derived(deliveryStatus === 'sent' || deliveryStatus === 'sending')
73
+
74
+ function handleOtpInput(index: number, event: Event) {
75
+ const target = event.target as HTMLInputElement
76
+ const val = target.value.replace(/[^a-zA-Z0-9]/g, '').toUpperCase()
77
+ digits[index] = val.slice(-1)
78
+ target.value = digits[index]
79
+ if (digits[index] && index < 5) {
80
+ inputs[index + 1]?.focus()
81
+ }
82
+ }
83
+
84
+ function handleOtpKeydown(index: number, event: KeyboardEvent) {
85
+ if (event.key === 'Backspace' && !digits[index] && index > 0) {
86
+ inputs[index - 1]?.focus()
87
+ }
88
+ }
89
+
90
+ function handleOtpPaste(event: ClipboardEvent) {
91
+ event.preventDefault()
92
+ const paste = event.clipboardData
93
+ ?.getData('text')
94
+ ?.replace(/[^a-zA-Z0-9]/g, '')
95
+ ?.toUpperCase()
96
+ ?.slice(0, 6)
97
+ if (paste) {
98
+ for (let i = 0; i < 6; i++) {
99
+ digits[i] = paste[i] || ''
100
+ }
101
+ const nextEmpty = paste.length < 6 ? paste.length : 5
102
+ inputs[nextEmpty]?.focus()
103
+ }
104
+ }
105
+
106
+ // --- Profile state ---
107
+ let firstName = $state('')
108
+ let lastName = $state('')
109
+ let dob = $state<any>(undefined)
110
+ let phone = $state('')
111
+ let accountUpdates = $state(true)
112
+ let promotional = $state(true)
113
+ let streetAddress = $state('')
114
+ let city = $state('')
115
+ let stateValue = $state<string | undefined>(undefined)
116
+ let zip = $state('')
117
+ let formScrolled = $state(false)
118
+
119
+ // --- About You state ---
120
+ let loanAmount = $state('')
121
+ let bestTimeToCall = $state<string | undefined>(undefined)
122
+ let ssn = $state('')
123
+ let idType = $state('dl')
124
+ let idNumber = $state('')
125
+ let issuingState = $state<string | undefined>(undefined)
126
+ let militaryStatus = $state<string | undefined>(undefined)
127
+ let timeAtAddress = $state<string | undefined>(undefined)
128
+ let ownOrRent = $state('rent')
129
+
130
+ // --- Financial state ---
131
+ let incomeType = $state<string | undefined>(undefined)
132
+ let annualIncome = $state('')
133
+ let payFrequency = $state('biweekly')
134
+ let payType = $state('direct-deposit')
135
+ let bankName = $state('')
136
+ let routingNumber = $state('')
137
+ let accountNumber = $state('')
138
+ let accountType = $state('checking')
139
+ let accountAge = $state<string | undefined>(undefined)
140
+
141
+ const CALL_TIMES = [
142
+ {value: 'morning', label: 'Morning (8am-12pm)'}, {value: 'afternoon', label: 'Afternoon (12pm-5pm)'},
143
+ {value: 'evening', label: 'Evening (5pm-8pm)'}, {value: 'anytime', label: 'Anytime'},
144
+ ]
145
+ const ID_TYPES = [{value: 'dl', label: "Driver's License"}, {value: 'stId', label: "State ID"}]
146
+ const MILITARY_OPTIONS = [
147
+ {value: 'no', label: 'No Military Affiliation'}, {value: 'active-duty', label: 'Active Duty Military'},
148
+ {value: 'veteran', label: 'Veteran'}, {value: 'dependent', label: 'Military Dependent'},
149
+ ]
150
+ const TIME_AT_ADDRESS = [
151
+ {value: 'less-than-1-year', label: 'Less than 1 year'}, {value: '1-2-years', label: '1-2 years'},
152
+ {value: '2-5-years', label: '2-5 years'}, {value: '5-10-years', label: '5-10 years'},
153
+ {value: 'more-than-10-years', label: 'More than 10 years'},
154
+ ]
155
+ const OWN_RENT = [{value: 'rent', label: 'Rent'}, {value: 'own', label: 'Own'}, {value: 'other', label: 'Other'}]
156
+ const INCOME_TYPES = [
157
+ {value: 'employment', label: 'Employment'}, {value: 'self-employed', label: 'Self-Employed'},
158
+ {value: 'social-security', label: 'Social Security'}, {value: 'disability', label: 'Disability'},
159
+ {value: 'retirement', label: 'Retirement'}, {value: 'other', label: 'Other'},
160
+ ]
161
+ const PAY_FREQUENCIES = [
162
+ {value: 'biweekly', label: 'Bi-wk'}, {value: 'weekly', label: 'Wk'},
163
+ {value: 'half-month', label: '2x/mo'}, {value: 'monthly', label: 'Mo'},
164
+ ]
165
+ const PAY_TYPES = [{value: 'direct-deposit', label: 'Direct Dep.'}, {value: 'check', label: 'Check'}, {value: 'cash', label: 'Cash'}]
166
+ const ACCOUNT_TYPES = [{value: 'checking', label: 'Checking'}, {value: 'savings', label: 'Savings'}]
167
+ const ACCOUNT_AGES = [
168
+ {value: 'less-than-6-months', label: 'Less than 6 months'}, {value: '6-12-months', label: '6-12 months'},
169
+ {value: '1-2-years', label: '1-2 years'}, {value: '2-5-years', label: '2-5 years'},
170
+ {value: 'more-than-5-years', label: 'More than 5 years'},
171
+ ]
172
+
173
+ const US_STATES = [
174
+ {value: 'AL', label: 'Alabama'}, {value: 'AK', label: 'Alaska'}, {value: 'AZ', label: 'Arizona'},
175
+ {value: 'AR', label: 'Arkansas'}, {value: 'CA', label: 'California'}, {value: 'CO', label: 'Colorado'},
176
+ {value: 'CT', label: 'Connecticut'}, {value: 'DE', label: 'Delaware'}, {value: 'FL', label: 'Florida'},
177
+ {value: 'GA', label: 'Georgia'}, {value: 'HI', label: 'Hawaii'}, {value: 'ID', label: 'Idaho'},
178
+ {value: 'IL', label: 'Illinois'}, {value: 'IN', label: 'Indiana'}, {value: 'IA', label: 'Iowa'},
179
+ {value: 'KS', label: 'Kansas'}, {value: 'KY', label: 'Kentucky'}, {value: 'LA', label: 'Louisiana'},
180
+ {value: 'ME', label: 'Maine'}, {value: 'MD', label: 'Maryland'}, {value: 'MA', label: 'Massachusetts'},
181
+ {value: 'MI', label: 'Michigan'}, {value: 'MN', label: 'Minnesota'}, {value: 'MS', label: 'Mississippi'},
182
+ {value: 'MO', label: 'Missouri'}, {value: 'MT', label: 'Montana'}, {value: 'NE', label: 'Nebraska'},
183
+ {value: 'NV', label: 'Nevada'}, {value: 'NH', label: 'New Hampshire'}, {value: 'NJ', label: 'New Jersey'},
184
+ {value: 'NM', label: 'New Mexico'}, {value: 'NY', label: 'New York'}, {value: 'NC', label: 'North Carolina'},
185
+ {value: 'ND', label: 'North Dakota'}, {value: 'OH', label: 'Ohio'}, {value: 'OK', label: 'Oklahoma'},
186
+ {value: 'OR', label: 'Oregon'}, {value: 'PA', label: 'Pennsylvania'}, {value: 'RI', label: 'Rhode Island'},
187
+ {value: 'SC', label: 'South Carolina'}, {value: 'SD', label: 'South Dakota'}, {value: 'TN', label: 'Tennessee'},
188
+ {value: 'TX', label: 'Texas'}, {value: 'UT', label: 'Utah'}, {value: 'VT', label: 'Vermont'},
189
+ {value: 'VA', label: 'Virginia'}, {value: 'WA', label: 'Washington'}, {value: 'WV', label: 'West Virginia'},
190
+ {value: 'WI', label: 'Wisconsin'}, {value: 'WY', label: 'Wyoming'},
191
+ ]
192
+
193
+ $effect(() => {
194
+ if (step === 'otp') {
195
+ requestAnimationFrame(() => {
196
+ requestAnimationFrame(() => {
197
+ inputs[0]?.focus()
198
+ })
199
+ })
200
+ }
201
+ if (step in APP_STEP_MAP) {
202
+ requestAnimationFrame(() => {
203
+ requestAnimationFrame(() => {
204
+ const form = document.querySelector(`[id^="alt-${step}"]`) || document.querySelector('.step-content')
205
+ const first = form?.querySelector<HTMLElement>('input:not([disabled]), select:not([disabled]), button:not([disabled]), [tabindex="0"]')
206
+ first?.focus()
207
+ })
208
+ })
209
+ }
210
+ })
211
+
212
+ // --- Application flow (after login) ---
213
+ const APP_STEPS = [
214
+ {label: 'Profile'},
215
+ {label: 'About You'},
216
+ {label: 'Financial Info'},
217
+ {label: 'Review'},
218
+ ]
219
+ const APP_STEP_MAP: Record<string, number> = {profile: 0, about: 1, financial: 2, review: 3}
220
+ const isAppFlow = $derived(step in APP_STEP_MAP)
221
+ const appStepIndex = $derived(APP_STEP_MAP[step] ?? 0)
222
+
223
+ // --- Responsive density ---
224
+ let innerWidth = $state(0)
225
+ const densityValue = $derived(innerWidth >= 640 ? 'comfortable' : 'mobile') as 'comfortable' | 'mobile'
226
+
227
+ // --- Step indicator ---
228
+ const loginStepIndex = $derived(step === 'email' ? 0 : 1)
229
+ let mounted = $state(false)
230
+ onMount(() => {
231
+ requestAnimationFrame(() => {
232
+ mounted = true
233
+ })
234
+ })
235
+ </script>
236
+
237
+ <svelte:window bind:innerWidth />
238
+
239
+ <div class="flex flex-col h-screen overflow-hidden">
240
+ <!-- Outer wrapper -->
241
+ <div class="dot-grid flex flex-1 min-h-0 flex-col min-[640px]:items-center {isAppFlow ? 'min-[640px]:pt-[8vh] min-[640px]:px-24 min-[640px]:h-screen' : 'min-[640px]:p-24 min-[640px]:flex-initial min-[640px]:h-auto min-[640px]:min-h-screen min-[640px]:justify-center'} min-[640px]:relative">
242
+ <!-- Desktop page-level logo — app flow only -->
243
+ {#if isAppFlow}
244
+ <div class="hidden min-[640px]:flex items-center gap-8 absolute top-24 left-24 row-enter row-delay-1">
245
+ <svg width="28" height="28" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
246
+ <path d="M16 2L4 9v14l12 7 12-7V9L16 2z" fill="var(--ii-primary)" />
247
+ <path d="M16 6l-8 4.5v9L16 24l8-4.5v-9L16 6z" fill="white" />
248
+ <path d="M16 10l-4 2.25v4.5L16 19l4-2.25v-4.5L16 10z" fill="var(--ii-primary)" />
249
+ </svg>
250
+ <span class="text-h3 text-body">Meridian Finance</span>
251
+ </div>
252
+ {/if}
253
+ <DensityProvider density={densityValue}>
254
+ <div class="flex flex-1 min-h-0 flex-col min-[640px]:relative min-[640px]:flex-initial">
255
+ <!-- Desktop vertical stepper — app flow only, positioned to left of card -->
256
+ {#if isAppFlow}
257
+ <div class="hidden min-[1040px]:flex flex-col gap-0 absolute right-full mr-32 pt-48 w-[104px]">
258
+ {#each APP_STEPS as appStep, i}
259
+ <div class="row-enter flex items-start gap-12" style="animation-delay: {80 + i * 70}ms">
260
+ <!-- Dot + line -->
261
+ <div class="flex flex-col items-center">
262
+ <div
263
+ class="rounded-full shrink-0 w-12 h-12 transition-all duration-300 ease-in-out
264
+ {i <= appStepIndex ? 'bg-primary' : 'bg-gray-300'}"
265
+ style="transition-delay: {i <= appStepIndex && i > 0 ? '400ms' : '0ms'}"
266
+ ></div>
267
+ {#if i < APP_STEPS.length - 1}
268
+ <div class="w-[2px] h-32 relative overflow-hidden bg-gray-200">
269
+ <div
270
+ class="absolute inset-x-0 top-0 bg-primary transition-all duration-500 ease-in-out"
271
+ style="height: {i < appStepIndex ? '100%' : '0%'}; transition-delay: {i < appStepIndex ? '100ms' : '0ms'}"
272
+ ></div>
273
+ </div>
274
+ {/if}
275
+ </div>
276
+ <!-- Label -->
277
+ <span class="text-small font-medium -mt-2 whitespace-nowrap transition-colors duration-300 {i === appStepIndex ? 'text-body' : i < appStepIndex ? 'text-secondary' : 'text-tertiary'}">
278
+ {appStep.label}
279
+ </span>
280
+ </div>
281
+ {/each}
282
+ </div>
283
+ {/if}
284
+
285
+ <div class="flex min-w-0 min-h-0 flex-1 flex-col min-[640px]:flex-initial min-[640px]:rounded-16 {!isAppFlow ? 'min-[640px]:w-[440px] card-border min-[640px]:h-[574px]' : 'overflow-hidden card-border-tl min-[640px]:flex-1 card-width-responsive'}">
286
+ <!-- Form side -->
287
+ <div class="flex w-full min-w-0 min-h-0 flex-1 flex-col {isAppFlow ? 'min-[640px]:pt-40' : 'px-16 min-[375px]:px-24 min-[640px]:p-40'}">
288
+ <!-- Persistent logo + step indicator for app flow (doesn't fade) -->
289
+ {#if isAppFlow}
290
+ <div class="flex shrink-0 items-center gap-8 pt-24 min-[640px]:hidden px-24">
291
+ <svg width="24" height="24" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
292
+ <path d="M16 2L4 9v14l12 7 12-7V9L16 2z" fill="var(--ii-primary)" />
293
+ <path d="M16 6l-8 4.5v9L16 24l8-4.5v-9L16 6z" fill="white" />
294
+ <path d="M16 10l-4 2.25v4.5L16 19l4-2.25v-4.5L16 10z" fill="var(--ii-primary)" />
295
+ </svg>
296
+ <span class="text-emphasis text-body">Meridian Finance</span>
297
+ </div>
298
+ <div class="flex items-center justify-center gap-8 pt-16 min-[1040px]:hidden px-24">
299
+ {#each APP_STEPS as _, i}
300
+ <div class="h-6 rounded-full" style="background: {i === appStepIndex ? 'var(--ii-primary)' : 'var(--ii-gray-300)'}; width: {i === appStepIndex ? '36px' : '16px'}; transition: width 0.4s ease, background-color 0.4s ease;"></div>
301
+ {/each}
302
+ </div>
303
+ {/if}
304
+
305
+ <!-- Content that fades between steps -->
306
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
307
+ <div class="step-content flex flex-1 min-h-0 flex-col" class:fade-out={transitioning} class:overflow-y-auto={!isAppFlow} onkeydown={isAppFlow ? handleFocusTrap : undefined} onfocusin={handleFocusIn}>
308
+ <!-- Push content toward center on mobile (login flow only) -->
309
+ {#if !isAppFlow}
310
+ <div class="h-[12vh] min-[640px]:hidden"></div>
311
+ {/if}
312
+
313
+ {#if step === 'email'}
314
+ <!-- Logo -->
315
+ <div class="row-enter row-delay-1 flex h-56 shrink-0 items-center justify-center gap-8 pt-24 min-[640px]:pt-0">
316
+ <svg width="28" height="28" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
317
+ <path d="M16 2L4 9v14l12 7 12-7V9L16 2z" fill="var(--ii-primary)" />
318
+ <path d="M16 6l-8 4.5v9L16 24l8-4.5v-9L16 6z" fill="white" />
319
+ <path d="M16 10l-4 2.25v4.5L16 19l4-2.25v-4.5L16 10z" fill="var(--ii-primary)" />
320
+ </svg>
321
+ <span class="text-h3 text-body">Meridian Finance</span>
322
+ </div>
323
+
324
+ <!-- Header -->
325
+ <div class="row-enter row-delay-2 pt-48 pb-16 text-center mx-auto w-full max-w-[320px]">
326
+ <div class="text-h2 text-body m-0">Your Loan Application</div>
327
+ <p class="text-m3-emphasis text-tertiary mt-4 m-0">
328
+ Enter your email to get started or continue
329
+ </p>
330
+ </div>
331
+
332
+ <!-- Input -->
333
+ <form id="alt-email-form" class="row-enter row-delay-3 flex flex-col mx-auto w-full max-w-[320px] p-4 -m-4" onsubmit={(e) => { e.preventDefault(); emailLoading = true; setTimeout(() => { emailLoading = false; transitionTo('otp') }, 800) }}>
334
+ {#if emailError}
335
+ <div class="mb-16">
336
+ <IIAlert variant="error" dismissible onDismiss={() => (emailError = '')}>
337
+ {#snippet children()}
338
+ {emailError}
339
+ {/snippet}
340
+ </IIAlert>
341
+ </div>
342
+ {/if}
343
+
344
+ <IIInput
345
+ type="email"
346
+ placeholder="name@example.com"
347
+ bind:value={email}
348
+ inputmode="email"
349
+ autocomplete="email"
350
+ error={!!emailError}
351
+ autofocus
352
+ />
353
+ </form>
354
+
355
+ <!-- Info section -->
356
+ <div class="row-enter row-delay-4 mb-24 mt-auto pt-24 mx-auto w-full max-w-[320px]">
357
+ <p class="text-emphasis text-body m-0 mb-12">Here's what happens next:</p>
358
+ <ul class="m-0 list-none p-0 flex flex-col gap-8">
359
+ <li class="relative pl-20 text-default text-secondary">
360
+ <span class="absolute left-0 text-accent">&#x2713;</span>
361
+ New here? We'll help you get started
362
+ </li>
363
+ <li class="relative pl-20 text-default text-secondary">
364
+ <span class="absolute left-0 text-accent">&#x2713;</span>
365
+ Already applied? Pick up right where you left off
366
+ </li>
367
+ <li class="relative pl-20 text-default text-secondary">
368
+ <span class="absolute left-0 text-accent">&#x2713;</span>
369
+ All done? Check your loan status
370
+ </li>
371
+ </ul>
372
+ </div>
373
+
374
+ {:else if step === 'otp'}
375
+ <!-- Back button -->
376
+ <div class="row-enter row-delay-1 flex h-56 shrink-0 items-center justify-center gap-10 pt-24 min-[640px]:pt-0">
377
+ <IIButton variant="ghost" size="sm" onclick={() => transitionTo('email')}>
378
+ {#snippet children()}
379
+ &larr; Back
380
+ {/snippet}
381
+ </IIButton>
382
+ </div>
383
+
384
+ <!-- Header -->
385
+ <div class="row-enter row-delay-2 pt-48 pb-16 text-center mx-auto w-full max-w-[320px]">
386
+ <div class="text-h2 text-body m-0">Enter verification code</div>
387
+ <p class="text-m3-emphasis text-tertiary mt-4 m-0">
388
+ We sent a 6-digit code to <strong class="text-body">john@example.com</strong>
389
+ </p>
390
+ </div>
391
+
392
+ <!-- OTP Input -->
393
+ <form id="alt-otp-form" class="row-enter row-delay-3 flex flex-col w-full" onsubmit={(e) => { e.preventDefault(); verifying = true; setTimeout(() => { verifying = false; transitionTo('profile') }, 800) }}>
394
+ {#if otpError}
395
+ <div class="mb-16">
396
+ <IIAlert variant="error" dismissible onDismiss={() => (otpError = '')}>
397
+ {#snippet children()}
398
+ {otpError}
399
+ {/snippet}
400
+ </IIAlert>
401
+ </div>
402
+ {/if}
403
+
404
+ <div class="mb-16">
405
+ <div class="flex gap-8 max-w-[320px] mx-auto" role="group" aria-label="Verification code">
406
+ {#each digits as digit, i}
407
+ <input
408
+ bind:this={inputs[i]}
409
+ bind:value={digits[i]}
410
+ type="text"
411
+ inputmode="text"
412
+ autocomplete={i === 0 ? 'one-time-code' : 'off'}
413
+ maxlength="1"
414
+ pattern="[a-zA-Z0-9]"
415
+ class="w-full h-auto aspect-square text-center !text-m1 font-medium text-body bg-input-bg border-2 rounded-8 outline-none transition-all duration-fast
416
+ {otpError
417
+ ? 'border-error'
418
+ : digit
419
+ ? 'border-primary'
420
+ : 'border-input-border'}
421
+ focus:border-primary focus:ring-2 focus:ring-primary/20"
422
+ oninput={(e) => handleOtpInput(i, e)}
423
+ onkeydown={(e) => handleOtpKeydown(i, e)}
424
+ onpaste={handleOtpPaste}
425
+ aria-label="Digit {i + 1}"
426
+ />
427
+ {/each}
428
+ </div>
429
+ </div>
430
+
431
+ <!-- Delivery Status -->
432
+ <div class="mb-16 flex justify-center">
433
+ <div class="flex justify-center gap-0" role="status" aria-label="Code delivery status">
434
+ <!-- Sent -->
435
+ <div class="flex flex-col items-center" style="min-width: 56px">
436
+ <div
437
+ class="w-24 h-24 rounded-full flex items-center justify-center shrink-0
438
+ {hasFailed
439
+ ? 'bg-error text-inverse'
440
+ : sentComplete
441
+ ? 'bg-primary text-inverse'
442
+ : 'bg-muted text-secondary'}
443
+ {deliveryStatus === 'sending' ? ' animate-pulse' : ''}"
444
+ >
445
+ {#if sentComplete && !hasFailed}
446
+ <svg width="12" height="12" viewBox="0 0 256 256" fill="currentColor">
447
+ <path d="m229.66 77.66l-128 128a8 8 0 0 1-11.32 0l-56-56a8 8 0 0 1 11.32-11.32L96 188.69L218.34 66.34a8 8 0 0 1 11.32 11.32" />
448
+ </svg>
449
+ {:else if hasFailed}
450
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
451
+ <line x1="18" y1="6" x2="6" y2="18"></line>
452
+ <line x1="6" y1="6" x2="18" y2="18"></line>
453
+ </svg>
454
+ {/if}
455
+ </div>
456
+ <span class="mt-4 text-[10px] text-secondary">Sent</span>
457
+ </div>
458
+
459
+ <!-- Connector -->
460
+ <div class="flex items-start pt-12">
461
+ <div
462
+ class="mx-6 h-[2px] w-80 transition-colors duration-500
463
+ {hasFailed ? 'bg-error' : deliveredComplete ? 'bg-primary' : 'bg-muted'}"
464
+ ></div>
465
+ </div>
466
+
467
+ <!-- Delivered -->
468
+ <div class="flex flex-col items-center" style="min-width: 56px">
469
+ <div
470
+ class="w-24 h-24 rounded-full flex items-center justify-center shrink-0
471
+ {hasFailed
472
+ ? 'bg-error text-inverse'
473
+ : deliveredComplete
474
+ ? 'bg-primary text-inverse'
475
+ : isInFlight
476
+ ? 'bg-primary text-inverse animate-pulse'
477
+ : 'bg-muted text-secondary'}"
478
+ >
479
+ {#if deliveredComplete}
480
+ <svg width="12" height="12" viewBox="0 0 256 256" fill="currentColor">
481
+ <path d="m229.66 77.66l-128 128a8 8 0 0 1-11.32 0l-56-56a8 8 0 0 1 11.32-11.32L96 188.69L218.34 66.34a8 8 0 0 1 11.32 11.32" />
482
+ </svg>
483
+ {:else if hasFailed}
484
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
485
+ <line x1="18" y1="6" x2="6" y2="18"></line>
486
+ <line x1="6" y1="6" x2="18" y2="18"></line>
487
+ </svg>
488
+ {/if}
489
+ </div>
490
+ <span class="mt-4 text-[10px] text-secondary">
491
+ {#if hasFailed}Failed{:else}Delivered{/if}
492
+ </span>
493
+ </div>
494
+ </div>
495
+ </div>
496
+
497
+ {#if deliveryError}
498
+ <p class="mt-0 mb-16 p-0 text-center text-default text-error">{deliveryError}</p>
499
+ {/if}
500
+ </form>
501
+
502
+ <!-- Help text -->
503
+ <div class="row-enter row-delay-4 mb-24 mt-auto mx-auto w-full max-w-[320px] text-center">
504
+ <p class="m-0 text-tiny text-tertiary leading-relaxed">
505
+ Didn't receive the code?
506
+ <button
507
+ type="button"
508
+ class="cursor-default border-0 bg-transparent p-0 text-tiny text-accent underline outline-none focus-visible:ring-3 focus-visible:ring-primary rounded-4"
509
+ disabled={isResending}
510
+ onclick={() => {
511
+ isResending = true
512
+ deliveryStatus = 'sending'
513
+ setTimeout(() => (deliveryStatus = 'sent'), 800)
514
+ setTimeout(() => { deliveryStatus = 'delivered'; isResending = false }, 2000)
515
+ }}
516
+ >
517
+ {isResending ? 'Sending...' : 'Resend code'}
518
+ </button>
519
+ or
520
+ <button
521
+ type="button"
522
+ class="cursor-default border-0 bg-transparent p-0 text-tiny text-accent underline outline-none focus-visible:ring-3 focus-visible:ring-primary rounded-4"
523
+ onclick={() => transitionTo('email')}
524
+ >
525
+ try a different email
526
+ </button>
527
+ </p>
528
+ </div>
529
+ {:else if step === 'profile'}
530
+ <!-- Sticky header -->
531
+ <div class="row-enter row-delay-2 pt-24 min-[1040px]:pt-0 pb-16 shrink-0 px-24 min-[640px]:px-40">
532
+ <div class="text-h2 text-body m-0">Update Your Info</div>
533
+ <p class="text-m3-emphasis text-tertiary mt-4 m-0">
534
+ Please provide your information to continue
535
+ </p>
536
+ </div>
537
+
538
+ <!-- Scrollable form area with top fade mask on scroll -->
539
+ <div
540
+ class="flex-1 min-h-0 overflow-y-scroll pl-24 pr-16 min-[640px]:pl-40 form-scroll-fade form-pr-responsive"
541
+ class:form-scroll-mask={formScrolled}
542
+ onscroll={(e) => { formScrolled = (e.currentTarget as HTMLElement).scrollTop > 0 }}
543
+ onfocusin={handleFocusIn}
544
+ >
545
+ <form id="alt-profile-form" class="row-enter row-delay-3 flex flex-col gap-16 pb-16" onsubmit={(e) => { e.preventDefault(); transitionTo('about') }}>
546
+ <div class="flex flex-col min-[640px]:flex-row gap-16">
547
+ <div class="flex-1"><IIInput label="First Name" bind:value={firstName} placeholder="George" autofocus /></div>
548
+ <div class="flex-1"><IIInput label="Last Name" bind:value={lastName} placeholder="Jones" /></div>
549
+ </div>
550
+ <IIDateInput label="Date of Birth" bind:value={dob} />
551
+ <IIInput label="Phone Number" bind:value={phone} placeholder="(555) 555-5555" type="tel" inputmode="tel" />
552
+
553
+ <!-- SMS Preferences -->
554
+ <div class="flex flex-col gap-12 py-8">
555
+ <div class="flex items-center justify-between gap-16">
556
+ <div>
557
+ <p class="text-emphasis text-body m-0">Account Updates</p>
558
+ <p class="text-small text-tertiary m-0">Payment reminders and notifications via SMS</p>
559
+ </div>
560
+ <IISwitch checked={accountUpdates} onCheckedChange={(v) => (accountUpdates = v)} />
561
+ </div>
562
+ <div class="flex items-center justify-between gap-16">
563
+ <div>
564
+ <p class="text-emphasis text-body m-0">Promotional</p>
565
+ <p class="text-small text-tertiary m-0">Special offers and news via SMS</p>
566
+ </div>
567
+ <IISwitch checked={promotional} onCheckedChange={(v) => (promotional = v)} />
568
+ </div>
569
+ </div>
570
+
571
+ <IIInput label="Street Address" bind:value={streetAddress} placeholder="123 West Elm" />
572
+ <IIInput label="City" bind:value={city} placeholder="Saratoga Springs" />
573
+ <IIDropdownInput
574
+ matchTriggerWidth
575
+ label="State"
576
+ placeholder="Select state"
577
+ items={US_STATES}
578
+ bind:value={stateValue}
579
+ />
580
+ <IIInput label="ZIP Code" bind:value={zip} placeholder="84045" inputmode="numeric" />
581
+ </form>
582
+
583
+ <!-- Cancel link -->
584
+ <div class="row-enter row-delay-4 mt-8 mb-16 flex justify-center">
585
+ <IIButton variant="ghost" onclick={() => transitionTo('email')}>
586
+ {#snippet children()}
587
+ Cancel
588
+ {/snippet}
589
+ </IIButton>
590
+ </div>
591
+
592
+ <div class="row-enter" style="animation-delay: 330ms">
593
+ <IIButton
594
+ variant="primary"
595
+ class="w-full"
596
+ type="submit"
597
+ form="alt-profile-form"
598
+ >
599
+ {#snippet children()}
600
+ Continue
601
+ {/snippet}
602
+ </IIButton>
603
+ </div>
604
+ <div class="pb-48"></div>
605
+ </div>
606
+
607
+ {:else if step === 'about'}
608
+ <!-- Header -->
609
+ <div class="row-enter row-delay-2 pt-24 min-[1040px]:pt-0 pb-16 shrink-0 px-24 min-[640px]:px-40">
610
+ <div class="text-h2 text-body m-0">About You</div>
611
+ <p class="text-m3-emphasis text-tertiary mt-4 m-0">Tell us a bit more about yourself</p>
612
+ </div>
613
+ <!-- Scrollable form -->
614
+ <div
615
+ class="flex-1 min-h-0 overflow-y-scroll pl-24 pr-16 min-[640px]:pl-40 form-scroll-fade form-pr-responsive"
616
+ class:form-scroll-mask={formScrolled}
617
+ onscroll={(e) => { formScrolled = (e.currentTarget as HTMLElement).scrollTop > 0 }}
618
+ onfocusin={handleFocusIn}
619
+ >
620
+ <form id="alt-about-form" class="row-enter row-delay-3 flex flex-col gap-16 pb-16" onsubmit={(e) => { e.preventDefault(); transitionTo('financial') }}>
621
+ <!-- Loan -->
622
+ <IIInput label="Loan Amount" bind:value={loanAmount} placeholder="$5,000" inputmode="numeric" autofocus />
623
+ <!-- Personal -->
624
+ <div class="border-t border-gray-200 pt-16"></div>
625
+ <IIDropdownInput matchTriggerWidth label="Best Time to Call" placeholder="Select time" items={CALL_TIMES} bind:value={bestTimeToCall} />
626
+ <IIInput label="Social Security Number" bind:value={ssn} placeholder="XXX-XX-XXXX" />
627
+ <div class="flex flex-col gap-4">
628
+ <span class="text-small-emphasis text-secondary">ID Type</span>
629
+ <IISegmentedControl items={ID_TYPES} bind:value={idType} />
630
+ </div>
631
+ <IIInput label="ID Number" bind:value={idNumber} placeholder="ID number" />
632
+ <IIDropdownInput matchTriggerWidth label="Issuing State" placeholder="Select state" items={US_STATES} bind:value={issuingState} />
633
+ <!-- Military -->
634
+ <div class="border-t border-gray-200 pt-16"></div>
635
+ <IIDropdownInput matchTriggerWidth label="Military Service" placeholder="Select option" items={MILITARY_OPTIONS} bind:value={militaryStatus} />
636
+ <!-- Housing -->
637
+ <div class="border-t border-gray-200 pt-16"></div>
638
+ <IIDropdownInput matchTriggerWidth label="Time at Current Address" placeholder="Select duration" items={TIME_AT_ADDRESS} bind:value={timeAtAddress} />
639
+ <div class="flex flex-col gap-4">
640
+ <span class="text-small-emphasis text-secondary">Do you own or rent?</span>
641
+ <IISegmentedControl items={OWN_RENT} bind:value={ownOrRent} />
642
+ </div>
643
+ </form>
644
+ <div class="row-enter row-delay-4 mt-8 mb-16 flex justify-center">
645
+ <IIButton variant="ghost" onclick={() => transitionTo('profile')}>
646
+ {#snippet children()}Back{/snippet}
647
+ </IIButton>
648
+ </div>
649
+ <div class="row-enter" style="animation-delay: 330ms">
650
+ <IIButton variant="primary" class="w-full" type="submit" form="alt-about-form">
651
+ {#snippet children()}Continue{/snippet}
652
+ </IIButton>
653
+ </div>
654
+ <div class="pb-48"></div>
655
+ </div>
656
+
657
+ {:else if step === 'financial'}
658
+ <!-- Header -->
659
+ <div class="row-enter row-delay-2 pt-24 min-[1040px]:pt-0 pb-16 shrink-0 px-24 min-[640px]:px-40">
660
+ <div class="text-h2 text-body m-0">Financial Info</div>
661
+ <p class="text-m3-emphasis text-tertiary mt-4 m-0">Income and banking details</p>
662
+ </div>
663
+ <!-- Scrollable form -->
664
+ <div
665
+ class="flex-1 min-h-0 overflow-y-scroll pl-24 pr-16 min-[640px]:pl-40 form-scroll-fade form-pr-responsive"
666
+ class:form-scroll-mask={formScrolled}
667
+ onscroll={(e) => { formScrolled = (e.currentTarget as HTMLElement).scrollTop > 0 }}
668
+ onfocusin={handleFocusIn}
669
+ >
670
+ <form id="alt-financial-form" class="row-enter row-delay-3 flex flex-col gap-16 pb-16" onsubmit={(e) => { e.preventDefault(); transitionTo('review') }}>
671
+ <!-- Income -->
672
+ <IIDropdownInput matchTriggerWidth label="Income Type" placeholder="Select income type" items={INCOME_TYPES} bind:value={incomeType} />
673
+ <IIInput label="Annual Gross Income" bind:value={annualIncome} placeholder="$50,000" inputmode="numeric" />
674
+ <div class="flex flex-col gap-4">
675
+ <span class="text-small-emphasis text-secondary">Pay Frequency</span>
676
+ <IISegmentedControl items={PAY_FREQUENCIES} bind:value={payFrequency} />
677
+ </div>
678
+ <div class="flex flex-col gap-4">
679
+ <span class="text-small-emphasis text-secondary">Pay Type</span>
680
+ <IISegmentedControl items={PAY_TYPES} bind:value={payType} />
681
+ </div>
682
+ <!-- Banking -->
683
+ <div class="border-t border-gray-200 pt-16"></div>
684
+ <IIInput label="Bank Name" bind:value={bankName} placeholder="Bank name" />
685
+ <div class="flex flex-col min-[640px]:flex-row gap-16">
686
+ <div class="flex-1"><IIInput label="Routing Number" bind:value={routingNumber} placeholder="9 digits" inputmode="numeric" /></div>
687
+ <div class="flex-1"><IIInput label="Account Number" bind:value={accountNumber} placeholder="Account number" /></div>
688
+ </div>
689
+ <div class="flex flex-col gap-4">
690
+ <span class="text-small-emphasis text-secondary">Account Type</span>
691
+ <IISegmentedControl items={ACCOUNT_TYPES} bind:value={accountType} />
692
+ </div>
693
+ <IIDropdownInput matchTriggerWidth label="Account Age" placeholder="Select duration" items={ACCOUNT_AGES} bind:value={accountAge} />
694
+ </form>
695
+ <div class="row-enter row-delay-4 mt-8 mb-16 flex justify-center">
696
+ <IIButton variant="ghost" onclick={() => transitionTo('about')}>
697
+ {#snippet children()}Back{/snippet}
698
+ </IIButton>
699
+ </div>
700
+ <div class="row-enter" style="animation-delay: 330ms">
701
+ <IIButton variant="primary" class="w-full" type="submit" form="alt-financial-form">
702
+ {#snippet children()}Continue{/snippet}
703
+ </IIButton>
704
+ </div>
705
+ <div class="pb-48"></div>
706
+ </div>
707
+
708
+ {:else if step === 'review'}
709
+ <!-- Header -->
710
+ <div class="row-enter row-delay-2 pt-24 min-[1040px]:pt-0 pb-16 shrink-0 px-24 min-[640px]:px-40">
711
+ <div class="text-h2 text-body m-0">Review</div>
712
+ <p class="text-m3-emphasis text-tertiary mt-4 m-0">Please review your information before submitting</p>
713
+ </div>
714
+ <!-- Scrollable review -->
715
+ <div
716
+ class="flex-1 min-h-0 overflow-y-scroll pl-24 pr-16 min-[640px]:pl-40 form-scroll-fade form-pr-responsive"
717
+ class:form-scroll-mask={formScrolled}
718
+ onscroll={(e) => { formScrolled = (e.currentTarget as HTMLElement).scrollTop > 0 }}
719
+ onfocusin={handleFocusIn}
720
+ >
721
+ <div class="row-enter row-delay-3 flex flex-col gap-24 pb-16">
722
+ <!-- Personal -->
723
+ <div>
724
+ <div class="flex items-center justify-between mb-8">
725
+ <h3 class="text-emphasis text-body m-0">Personal Information</h3>
726
+ <button class="text-tiny text-accent border-0 bg-transparent p-0 cursor-default underline outline-none focus-visible:ring-3 focus-visible:ring-primary rounded-4" onclick={() => transitionTo('profile')}>Edit</button>
727
+ </div>
728
+ <div class="flex flex-col gap-4 text-small text-secondary">
729
+ <p class="m-0">{firstName || '—'} {lastName || '—'}</p>
730
+ <p class="m-0">{phone || '—'}</p>
731
+ <p class="m-0">{email || '—'}</p>
732
+ </div>
733
+ </div>
734
+ <!-- Address -->
735
+ <div>
736
+ <div class="flex items-center justify-between mb-8">
737
+ <h3 class="text-emphasis text-body m-0">Address</h3>
738
+ <button class="text-tiny text-accent border-0 bg-transparent p-0 cursor-default underline outline-none focus-visible:ring-3 focus-visible:ring-primary rounded-4" onclick={() => transitionTo('profile')}>Edit</button>
739
+ </div>
740
+ <div class="flex flex-col gap-4 text-small text-secondary">
741
+ <p class="m-0">{streetAddress || '—'}</p>
742
+ <p class="m-0">{city || '—'}{stateValue ? `, ${stateValue}` : ''} {zip || ''}</p>
743
+ </div>
744
+ </div>
745
+ <!-- About You -->
746
+ <div>
747
+ <div class="flex items-center justify-between mb-8">
748
+ <h3 class="text-emphasis text-body m-0">About You</h3>
749
+ <button class="text-tiny text-accent border-0 bg-transparent p-0 cursor-default underline outline-none focus-visible:ring-3 focus-visible:ring-primary rounded-4" onclick={() => transitionTo('about')}>Edit</button>
750
+ </div>
751
+ <div class="flex flex-col gap-4 text-small text-secondary">
752
+ <p class="m-0">Loan Amount: {loanAmount || '—'}</p>
753
+ <p class="m-0">SSN: {ssn ? '***-**-' + ssn.slice(-4) : '—'}</p>
754
+ <p class="m-0">Military: {MILITARY_OPTIONS.find(o => o.value === militaryStatus)?.label || '—'}</p>
755
+ <p class="m-0">Housing: {OWN_RENT.find(o => o.value === ownOrRent)?.label || '—'}</p>
756
+ </div>
757
+ </div>
758
+ <!-- Financial -->
759
+ <div>
760
+ <div class="flex items-center justify-between mb-8">
761
+ <h3 class="text-emphasis text-body m-0">Financial Info</h3>
762
+ <button class="text-tiny text-accent border-0 bg-transparent p-0 cursor-default underline outline-none focus-visible:ring-3 focus-visible:ring-primary rounded-4" onclick={() => transitionTo('financial')}>Edit</button>
763
+ </div>
764
+ <div class="flex flex-col gap-4 text-small text-secondary">
765
+ <p class="m-0">Income: {INCOME_TYPES.find(o => o.value === incomeType)?.label || '—'} — {annualIncome || '—'}/yr</p>
766
+ <p class="m-0">Pay: {PAY_FREQUENCIES.find(o => o.value === payFrequency)?.label || '—'}, {PAY_TYPES.find(o => o.value === payType)?.label || '—'}</p>
767
+ <p class="m-0">Bank: {bankName || '—'} ({ACCOUNT_TYPES.find(o => o.value === accountType)?.label || '—'})</p>
768
+ <p class="m-0">Routing: {routingNumber ? '***' + routingNumber.slice(-4) : '—'} / Account: {accountNumber ? '***' + accountNumber.slice(-4) : '—'}</p>
769
+ </div>
770
+ </div>
771
+ </div>
772
+ <div class="row-enter row-delay-4 mt-8 mb-16 flex justify-center">
773
+ <IIButton variant="ghost" onclick={() => transitionTo('financial')}>
774
+ {#snippet children()}Back{/snippet}
775
+ </IIButton>
776
+ </div>
777
+ <div class="row-enter" style="animation-delay: 330ms">
778
+ <IIButton variant="primary" class="w-full">
779
+ {#snippet children()}Submit Application{/snippet}
780
+ </IIButton>
781
+ </div>
782
+ <div class="pb-48"></div>
783
+ </div>
784
+ {/if}
785
+
786
+ </div>
787
+
788
+ <!-- Persistent bottom area — step indicator + button (login flow only) -->
789
+ <div class="pb-24 min-[640px]:pb-0 mx-auto w-full max-w-[320px]" class:hidden={isAppFlow}>
790
+ <!-- Login step indicator (email/otp only) -->
791
+ {#if !isAppFlow}
792
+ <div class="flex items-center justify-center gap-8 mb-24 mt-24">
793
+ {#each Array(2) as _, i}
794
+ <div
795
+ class="h-6 rounded-full"
796
+ style="
797
+ background: {mounted && i === loginStepIndex ? 'var(--ii-primary)' : 'var(--ii-gray-300)'};
798
+ width: {mounted && i === loginStepIndex ? '36px' : '16px'};
799
+ transition: width 0.4s ease, background-color 0.4s ease;
800
+ "
801
+ ></div>
802
+ {/each}
803
+ </div>
804
+ {:else}
805
+ <div class="mt-24"></div>
806
+ {/if}
807
+
808
+ {#if step === 'email'}
809
+ <IIButton
810
+ variant="primary"
811
+ class="w-full"
812
+ type="submit"
813
+ form="alt-email-form"
814
+ loading={emailLoading}
815
+ >
816
+ {#snippet children()}
817
+ Continue
818
+ {/snippet}
819
+ </IIButton>
820
+ {:else if step === 'otp' && hasFailed}
821
+ <IIButton
822
+ variant="primary"
823
+ class="w-full"
824
+ type="button"
825
+ onclick={() => transitionTo('email')}
826
+ >
827
+ {#snippet children()}
828
+ Use Different Email
829
+ {/snippet}
830
+ </IIButton>
831
+ {:else if step === 'otp'}
832
+ <IIButton
833
+ variant="primary"
834
+ class="w-full"
835
+ type="submit"
836
+ form="alt-otp-form"
837
+ disabled={!isComplete || verifying}
838
+ loading={verifying}
839
+ >
840
+ {#snippet children()}
841
+ Verify
842
+ {/snippet}
843
+ </IIButton>
844
+ {/if}
845
+ </div>
846
+ </div>
847
+
848
+ </div>
849
+ </div>
850
+ </DensityProvider>
851
+ </div>
852
+ </div>
853
+
854
+ <style>
855
+ .dot-grid {
856
+ background-image:
857
+ linear-gradient(160deg, transparent 30%, white 55%),
858
+ radial-gradient(circle, color-mix(in srgb, var(--ii-primary) 13%, transparent) 1.5px, transparent 1.5px);
859
+ background-size: 100% 100%, 24px 24px;
860
+ background-color: var(--ii-gray-50, #f9fafb);
861
+ }
862
+
863
+
864
+
865
+ .card-border {
866
+ position: relative;
867
+ background: linear-gradient(160deg, rgba(255, 255, 255, 0.45) 0%, rgba(255, 255, 255, 0) 35%);
868
+ }
869
+
870
+ .card-border::before {
871
+ content: '';
872
+ position: absolute;
873
+ inset: 0;
874
+ border-radius: inherit;
875
+ border: 1px solid var(--ii-gray-200, #e5e7eb);
876
+ pointer-events: none;
877
+ -webkit-mask-image: linear-gradient(160deg, black 20%, transparent 60%);
878
+ mask-image: linear-gradient(160deg, black 20%, transparent 60%);
879
+ }
880
+
881
+
882
+ .card-border-tl {
883
+ position: relative;
884
+ background: linear-gradient(160deg, rgba(255, 255, 255, 0.45) 0%, rgba(255, 255, 255, 0) 35%);
885
+ }
886
+
887
+ .card-border-tl::before,
888
+ .card-border-tl::after {
889
+ content: '';
890
+ position: absolute;
891
+ inset: 0;
892
+ border-radius: inherit;
893
+ pointer-events: none;
894
+ }
895
+
896
+ /* Top border — fades out sooner */
897
+ .card-border-tl::before {
898
+ border-top: 1px solid var(--ii-gray-200, #e5e7eb);
899
+ -webkit-mask-image: linear-gradient(to right, black 0%, transparent 90%);
900
+ mask-image: linear-gradient(to right, black 0%, transparent 90%);
901
+ }
902
+
903
+ /* Left border — fades out later */
904
+ .card-border-tl::after {
905
+ border-left: 1px solid var(--ii-gray-200, #e5e7eb);
906
+ -webkit-mask-image: linear-gradient(to bottom, black 0%, transparent 90%);
907
+ mask-image: linear-gradient(to bottom, black 0%, transparent 90%);
908
+ }
909
+
910
+ @keyframes row-fade-in {
911
+ from {
912
+ opacity: 0;
913
+ transform: translateY(-8px);
914
+ }
915
+ to {
916
+ opacity: 1;
917
+ transform: translateY(0);
918
+ }
919
+ }
920
+
921
+ .row-enter {
922
+ animation: row-fade-in 400ms ease both;
923
+ }
924
+
925
+ .row-delay-1 { animation-delay: 50ms; }
926
+ .row-delay-2 { animation-delay: 120ms; }
927
+ .row-delay-3 { animation-delay: 190ms; }
928
+ .row-delay-4 { animation-delay: 260ms; }
929
+
930
+ @media (min-width: 640px) {
931
+ .card-width-responsive {
932
+ width: 440px;
933
+ }
934
+
935
+ .form-pr-responsive {
936
+ padding-right: 24px;
937
+ }
938
+ }
939
+
940
+ @media (min-width: 1040px) {
941
+ .card-width-responsive {
942
+ width: 720px;
943
+ }
944
+
945
+ .form-pr-responsive {
946
+ padding-right: clamp(32px, 10vw, 140px);
947
+ }
948
+ }
949
+
950
+ .form-scroll-fade {
951
+ -webkit-mask-image: linear-gradient(to bottom, black calc(100% - 48px), rgba(0,0,0,0.1) 100%);
952
+ mask-image: linear-gradient(to bottom, black calc(100% - 48px), rgba(0,0,0,0.1) 100%);
953
+ }
954
+
955
+ .form-scroll-fade.form-scroll-mask {
956
+ -webkit-mask-image: linear-gradient(to bottom, transparent 0px, black 24px, black calc(100% - 48px), rgba(0,0,0,0.1) 100%);
957
+ mask-image: linear-gradient(to bottom, transparent 0px, black 24px, black calc(100% - 48px), rgba(0,0,0,0.1) 100%);
958
+ }
959
+
960
+ .step-content {
961
+ opacity: 1;
962
+ transition: opacity 200ms ease;
963
+ }
964
+
965
+ .step-content.fade-out {
966
+ opacity: 0;
967
+ }
968
+ </style>