@entropy-softworks/ui 2026.8.34 → 2026.8.36

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 (45) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/lib/components/auth/PasswordStrengthMeter.d.ts +9 -0
  3. package/lib/components/auth/PasswordStrengthMeter.d.ts.map +1 -1
  4. package/lib/components/auth/PasswordStrengthMeter.jsx +10 -4
  5. package/lib/components/auth/PasswordStrengthMeter.jsx.map +1 -1
  6. package/lib/components/profile/SecurityTab.d.ts +5 -1
  7. package/lib/components/profile/SecurityTab.d.ts.map +1 -1
  8. package/lib/components/profile/SecurityTab.jsx.map +1 -1
  9. package/lib/components/segmented-toggle.d.ts +9 -0
  10. package/lib/components/segmented-toggle.d.ts.map +1 -1
  11. package/lib/components/segmented-toggle.jsx +4 -0
  12. package/lib/components/segmented-toggle.jsx.map +1 -1
  13. package/lib/components/social-connections.d.ts +22 -5
  14. package/lib/components/social-connections.d.ts.map +1 -1
  15. package/lib/components/social-connections.jsx +37 -11
  16. package/lib/components/social-connections.jsx.map +1 -1
  17. package/lib/context/AppConfigContext.d.ts +27 -0
  18. package/lib/context/AppConfigContext.d.ts.map +1 -1
  19. package/lib/context/AppConfigContext.jsx.map +1 -1
  20. package/lib/demos/extension-popup.d.ts +212 -0
  21. package/lib/demos/extension-popup.d.ts.map +1 -0
  22. package/lib/demos/extension-popup.jsx +1172 -0
  23. package/lib/demos/extension-popup.jsx.map +1 -0
  24. package/lib/demos/index.d.ts +11 -0
  25. package/lib/demos/index.d.ts.map +1 -0
  26. package/lib/demos/index.js +14 -0
  27. package/lib/demos/index.js.map +1 -0
  28. package/lib/screens/auth/AuthScreen.d.ts +75 -1
  29. package/lib/screens/auth/AuthScreen.d.ts.map +1 -1
  30. package/lib/screens/auth/AuthScreen.jsx +356 -122
  31. package/lib/screens/auth/AuthScreen.jsx.map +1 -1
  32. package/lib/services/auth.service.d.ts +41 -1
  33. package/lib/services/auth.service.d.ts.map +1 -1
  34. package/lib/services/auth.service.js +53 -7
  35. package/lib/services/auth.service.js.map +1 -1
  36. package/package.json +7 -1
  37. package/src/components/auth/PasswordStrengthMeter.tsx +25 -3
  38. package/src/components/profile/SecurityTab.tsx +11 -1
  39. package/src/components/segmented-toggle.tsx +13 -0
  40. package/src/components/social-connections.tsx +66 -20
  41. package/src/context/AppConfigContext.tsx +27 -0
  42. package/src/demos/extension-popup.tsx +2051 -0
  43. package/src/demos/index.ts +38 -0
  44. package/src/screens/auth/AuthScreen.tsx +701 -312
  45. package/src/services/auth.service.ts +62 -8
@@ -1,7 +1,7 @@
1
1
  import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
- import { KeyboardAvoidingView, Platform, ScrollView, View } from "react-native";
2
+ import { KeyboardAvoidingView, Platform, ScrollView, useWindowDimensions, View, } from "react-native";
3
3
  import { SafeAreaView } from "react-native-safe-area-context";
4
- import Animated, { Easing, FadeInDown, FadeOutUp, LinearTransition, runOnJS, useAnimatedStyle, useSharedValue, withTiming, } from "react-native-reanimated";
4
+ import Animated, { Easing, LinearTransition, runOnJS, useAnimatedStyle, useSharedValue, withTiming, } from "react-native-reanimated";
5
5
  import { withClickTracking } from "../../services/telemetry";
6
6
  import { StatusBar } from "expo-status-bar";
7
7
  import { Ionicons } from "@expo/vector-icons";
@@ -29,11 +29,37 @@ import { getCachedPolicy } from "../../services/hibp-cache";
29
29
  import { useAppConfig } from "../../context/AppConfigContext";
30
30
  import { useNavigation } from "../../context/NavigationContext";
31
31
  const SCROLL_CONTENT_STYLE = { flexGrow: 1 };
32
+ /** Width of the form column when the aside is showing. */
33
+ /**
34
+ * Width of the form column when the aside is showing.
35
+ *
36
+ * With symmetric padding the card centres in this column, so the gap either
37
+ * side of it is simply `(FORM_PANE_WIDTH - 360) / 2`. 716 puts 178px between
38
+ * the card and the seam, and the same 178px between the card and the window
39
+ * edge — the two gaps match by construction rather than by tuning two numbers
40
+ * against each other.
41
+ */
42
+ const FORM_PANE_WIDTH = 716;
32
43
  const DEFAULT_POLICY = { enabled: false, enforce: false, block_threshold: 0 };
33
44
  const DEFAULT_TRANSITION = {
45
+ // Height is locked to the tallest mode (see the form-box note below), so the
46
+ // layout transition has nothing left to ease. Kept for the cases where the
47
+ // box legitimately grows past the lock — a pwned-password warning, a wrapped
48
+ // error, a larger system font.
34
49
  layout: LinearTransition.duration(280),
35
- entering: FadeInDown.duration(240),
36
- exiting: FadeOutUp.duration(160),
50
+ // No enter/exit animation. Two earlier attempts were both wrong: the
51
+ // directional FadeInDown/FadeOutUp slid fields in from above, and a plain
52
+ // cross-fade still singled out the only fields that actually mount on a mode
53
+ // switch — Display Name, Confirm Password — so they arrived visibly later
54
+ // than the fields around them, which are already on screen and simply stay
55
+ // put. One field fading in while its neighbours do not reads as a glitch, not
56
+ // as a transition. The card's size is locked, so an instant swap has nothing
57
+ // to hide anyway.
58
+ //
59
+ // Overridable: `transition` on the props still accepts both, for a consumer
60
+ // that wants them.
61
+ entering: undefined,
62
+ exiting: undefined,
37
63
  };
38
64
  // Stage morph timing — fade/scale out, swap the stage at zero opacity (so the
39
65
  // centered→centered swap is invisible, no overlap/teleport), then fade/scale in.
@@ -46,13 +72,31 @@ const STAGE_IN_MS = 360;
46
72
  * are themed popups and a connection-settings dialog can be folded in. Preserves
47
73
  * the passkey ceremony + non-PRF unlock and the HIBP/strength checks.
48
74
  */
49
- export function AuthScreen({ passwordLabel = "Password", passwordPlaceholder = "Enter your password", socialAuth, passkeyEnabled = false, passkeyUnlockPlaceholder = "Enter your master password", passkeyUnlockTitle = "Unlock your vault", passkeyUnlockDescription = "Signed in successfully. This passkey can't unlock the vault on its own — enter your master password to decrypt your data.", settings, transition, decoration, welcomeCtaLabel = "Get Started", signUpEnabled = true, requireInviteCode = false, onSignUp, signUpSuccessMessage = "Account created. Check your email to verify the address, then sign in.", showWelcome = true, } = {}) {
75
+ export function AuthScreen({ passwordLabel = "Password", passwordPlaceholder = "Enter your password", socialAuth, passkeyEnabled = false, passkeyUnlockPlaceholder = "Enter your master password", passkeyUnlockTitle = "Unlock your vault", passkeyUnlockDescription = "Signed in successfully. This passkey can't unlock the vault on its own — enter your master password to decrypt your data.", settings, onSettingsPress, transition, decoration, footer, aside, asideBreakpoint = 1024, backdrop, cardBackdrop, cardBackdropBleed = 48, welcomeCtaLabel = "Get Started", onWelcomeDismissed, signUpEnabled = true, requireInviteCode = false, onSignUp, signUpSuccessMessage = "Account created. Check your email to verify the address, then sign in.", showWelcome = true, } = {}) {
50
76
  const { login: authLogin, register: authRegister, loginWithPasskey, unlockWithPassword, } = useAuth();
51
77
  const router = useNavigation();
52
- const { appName, Logo, tagline, routes, termsText, privacyText, logoIsWordmark, logoWidth } = useAppConfig();
78
+ const { appName, Logo, tagline, taglineLayout, routes, termsText, privacyText, logoIsWordmark, logoWidth, } = useAppConfig();
53
79
  const { colorScheme } = useColorScheme();
54
80
  const isDark = colorScheme === "dark";
55
- const t = useMemo(() => ({ ...DEFAULT_TRANSITION, ...transition }), [transition]);
81
+ // Two-pane only when there is a pane to show AND room to show it.
82
+ const { width: viewportWidth } = useWindowDimensions();
83
+ const showAside = !!aside && viewportWidth >= asideBreakpoint;
84
+ // A wordmark has no app-name text to sit beside, so the two layouts that
85
+ // need one degrade to the ruled treatment rather than rendering a pipe, or a
86
+ // lockup, against nothing.
87
+ const inlineTagline = taglineLayout === "inline" && !logoIsWordmark;
88
+ const lockupBrand = taglineLayout === "lockup" && !logoIsWordmark;
89
+ const ruledTagline = taglineLayout !== undefined && !inlineTagline && !lockupBrand;
90
+ // The lockup sizes the mark to the measured height of the name + tagline
91
+ // block, so the three line up however the app scales its type. Measured
92
+ // rather than hard-coded because a fixed px value drifts the moment the type
93
+ // scale or the system font size moves.
94
+ const [brandTextHeight, setBrandTextHeight] = useState(0);
95
+ const onBrandTextLayout = useCallback((e) => {
96
+ const h = Math.round(e.nativeEvent.layout.height);
97
+ setBrandTextHeight((prev) => (Math.abs(prev - h) > 1 ? h : prev));
98
+ }, []);
99
+ const baseTransition = useMemo(() => ({ ...DEFAULT_TRANSITION, ...transition }), [transition]);
56
100
  const [mode, setMode] = useState("signin");
57
101
  const [inviteCode, setInviteCode] = useState("");
58
102
  const [signedUp, setSignedUp] = useState(null);
@@ -124,6 +168,42 @@ export function AuthScreen({ passwordLabel = "Password", passwordPlaceholder = "
124
168
  setErrors({});
125
169
  setPwnedInfo(null);
126
170
  }, []);
171
+ // ── Stable form box ───────────────────────────────────────────────────
172
+ // Sign In and Sign Up mount different field sets (passkey button +
173
+ // separator + "Forgot password?" against Display Name + strength meter +
174
+ // Confirm Password), so the card grew ~70 px on every toggle. On a short
175
+ // viewport the taller Sign Up layout also pushed the page into a scroll,
176
+ // which moved the logo, the footer and the card's own vertical centre —
177
+ // the whole screen appeared to resize, not only the fields.
178
+ //
179
+ // So lock the body to the TALLEST layout and let the shorter mode leave
180
+ // its slack at the bottom of the card. `probeSignup` renders the Sign Up
181
+ // field set for the single frame the form first mounts, purely to measure
182
+ // it: coming from the welcome stage that frame is already at zero opacity
183
+ // (the morph is mid-fade), and it is explicitly hidden anyway for apps
184
+ // that skip the welcome stage. Without that pre-measure the FIRST toggle
185
+ // of every page load would still be the one that grows the card.
186
+ //
187
+ // `minHeight` (not `height`) on purpose: anything that legitimately needs
188
+ // more room later — a pwned-password warning, a wrapped error, a larger
189
+ // system font — still expands the card instead of being clipped.
190
+ const [formMinHeight, setFormMinHeight] = useState(0);
191
+ const [probed, setProbed] = useState(!signUpEnabled);
192
+ const probeSignup = !probed;
193
+ const showSignup = probeSignup || isSignup;
194
+ const onFormLayout = useCallback((e) => {
195
+ const measured = Math.ceil(e.nativeEvent.layout.height);
196
+ setFormMinHeight((prev) => (measured > prev ? measured : prev));
197
+ setProbed(true);
198
+ }, []);
199
+ // No enter/exit animation until the measuring pass is done.
200
+ //
201
+ // When `probed` flips, the probe's Sign Up fields unmount — and an exiting
202
+ // animation on that teardown plays VISIBLY, because the wrapper's opacity is
203
+ // back to 1 by the time it runs. That was a Display Name field blinking into
204
+ // view and fading out on first render, for a frame the user was never meant
205
+ // to see at all.
206
+ const t = useMemo(() => probed ? baseTransition : { ...baseTransition, entering: undefined, exiting: undefined }, [baseTransition, probed]);
127
207
  const clearField = (field) => {
128
208
  if (errors[field]) {
129
209
  setErrors((e) => ({ ...e, [field]: undefined }));
@@ -382,14 +462,46 @@ export function AuthScreen({ passwordLabel = "Password", passwordPlaceholder = "
382
462
  return (<>
383
463
  <SafeAreaView className="flex-1 bg-background dark:bg-black">
384
464
  <StatusBar style={isDark ? "light" : "dark"}/>
385
- <KeyboardAvoidingView behavior={Platform.OS === "ios" ? "padding" : "height"} className="flex-1">
386
- <ScrollView className="flex-1" contentContainerStyle={SCROLL_CONTENT_STYLE} keyboardShouldPersistTaps="handled" showsVerticalScrollIndicator={false}>
387
- <View className="flex-1 justify-between px-6">
465
+ {backdrop ? (<View pointerEvents="none" style={{ position: "absolute", top: 0, left: 0, right: 0, bottom: 0 }}>
466
+ {backdrop}
467
+ </View>) : null}
468
+ <KeyboardAvoidingView behavior={Platform.OS === "ios" ? "padding" : "height"} className="flex-1"
469
+ // Full-bleed two-pane split. The aside is a real panel with its own
470
+ // ground colour reaching the window edge, so it cannot be centred
471
+ // inside a capped container — a floating panel with black either
472
+ // side would read as a mistake. The form column is fixed and the
473
+ // aside takes everything else, which on a typical desktop leaves it
474
+ // roughly two thirds.
475
+ style={showAside ? { flexDirection: "row" } : undefined}>
476
+ {showAside ? (
477
+ // Sits outside the ScrollView on purpose: the pane is scenery and
478
+ // should hold still while the form beside it scrolls.
479
+ <View style={{ flex: 1 }} pointerEvents="box-none">
480
+ {aside}
481
+ </View>) : null}
482
+ <ScrollView className="flex-1"
483
+ // Wide: a fixed column on the right rather than `flex: 1`, so the
484
+ // form keeps a comfortable measure instead of growing with the
485
+ // window and stranding its 360px card in the middle of a wide lane.
486
+ style={showAside ? { flexGrow: 0, flexBasis: FORM_PANE_WIDTH } : undefined} contentContainerStyle={SCROLL_CONTENT_STYLE} keyboardShouldPersistTaps="handled" showsVerticalScrollIndicator={false}>
487
+ <View className="flex-1 justify-between px-6"
488
+ // Asymmetric on the wide layout: more room against the window
489
+ // edge than against the panel seam, so the card sits inside its
490
+ // column rather than pressed to the right of the screen.
491
+ // Symmetric: see FORM_PANE_WIDTH. Asymmetric padding would shift the card
492
+ // off centre and break the matching gaps.
493
+ style={showAside ? { paddingLeft: 64, paddingRight: 64 } : undefined}>
388
494
  {/* top bar: settings gear only — back lives inside the cards
389
495
  (thumb-reachable) so users don't stretch to the corner. */}
390
496
  <View className="mt-4 h-10 flex-row items-center justify-between">
391
497
  <View />
392
- {settings ? (<IconButton variant="ghost" size="sm" telemetryId="auth.auth.settings_open" onPress={() => setSettingsOpen(true)} accessibilityLabel="Connection settings" accessibilityRole="button">
498
+ {settings || onSettingsPress ? (<IconButton variant="ghost" size="sm" telemetryId="auth.auth.settings_open" onPress={() => {
499
+ if (onSettingsPress) {
500
+ onSettingsPress();
501
+ return;
502
+ }
503
+ setSettingsOpen(true);
504
+ }} accessibilityLabel="Connection settings" accessibilityRole="button">
393
505
  <Ionicons name="settings-outline" size={22} color={isDark ? "#A3A3A3" : "#737373"}/>
394
506
  </IconButton>) : (<View />)}
395
507
  </View>
@@ -403,177 +515,287 @@ export function AuthScreen({ passwordLabel = "Password", passwordPlaceholder = "
403
515
  { flex: 1, width: "100%", alignItems: "center", justifyContent: "center" },
404
516
  morphStyle,
405
517
  ]}>
406
- <View className="mb-6 items-center">
518
+ {/* On the wide layout the lockup is constrained to the same
519
+ 360px column as the title and the card and left-aligned with
520
+ them, so all three share one left edge. Centred, it sat at
521
+ its own width and read as indented against the form below
522
+ it. Narrow keeps it centred, where there is no column to
523
+ align to. */}
524
+ <View className={showAside ? "mb-6 w-full max-w-[360px] items-start" : "mb-6 items-center"}>
407
525
  {logoIsWordmark ? (
408
526
  // The mark carries the name; height is left to the
409
527
  // component so a wide wordmark keeps its aspect ratio, and
410
528
  // the ink colour comes from the already-resolved theme.
411
- <Logo width={logoWidth ?? 160} color={isDark ? "#FFFFFF" : "#000000"}/>) : (<View className="flex-row items-center">
529
+ <Logo width={logoWidth ?? 160} color={isDark ? "#FFFFFF" : "#000000"}/>) : lockupBrand ? (
530
+ // Lockup: mark, vertical rule, then the name over the
531
+ // tagline. The mark and the rule are both sized to the
532
+ // MEASURED height of that text block, so all three read as
533
+ // one unit at whatever type scale the app (or the system
534
+ // font size) ends up at. `brandTextHeight || 64` only
535
+ // covers the single frame before the first measurement.
536
+ <View className="flex-row items-center" style={{ gap: 14 }}>
537
+ <Logo width={brandTextHeight || 64} height={brandTextHeight || 64} bleed/>
538
+ <View style={{ width: 1, height: brandTextHeight || 64 }} className="bg-border dark:bg-neutral-700"/>
539
+ <View onLayout={onBrandTextLayout}>
540
+ <Heading className="text-4xl">{appName}</Heading>
541
+ {tagline ? (<MutedText className="font-mono text-base">{tagline}</MutedText>) : null}
542
+ </View>
543
+ </View>) : (<View className="flex-row items-center">
412
544
  <Logo width={72} height={72} style={{ marginRight: -2 }}/>
413
545
  <Heading className="text-4xl">{appName}</Heading>
546
+ {/* Inline tagline sits on the name's baseline, at body
547
+ size — matching the 4xl heading would make the
548
+ category label compete with the brand. */}
549
+ {tagline && inlineTagline ? (<MutedText className="ml-2 font-mono text-base">{`| ${tagline}`}</MutedText>) : null}
414
550
  </View>)}
415
- {tagline ? (<MutedText className="mt-1 font-mono text-base">{tagline}</MutedText>) : null}
551
+ {tagline && !inlineTagline && !lockupBrand ? (<View className="items-center">
552
+ {/* The rule is only drawn for the "ruled" layout. Width
553
+ tracks the row rather than being a fixed px value, so
554
+ it lines up under the logo + name pairing at whatever
555
+ size the app renders them. */}
556
+ {ruledTagline ? (<View className="mt-3 h-px w-full self-stretch bg-border dark:bg-neutral-800"/>) : null}
557
+ <MutedText className={ruledTagline ? "mt-3 font-mono text-base" : "mt-1 font-mono text-base"}>
558
+ {tagline}
559
+ </MutedText>
560
+ </View>) : null}
416
561
  </View>
417
562
 
418
563
  {onWelcome ? (<View style={{ width: "100%", alignItems: "center" }}>
419
564
  {decoration ? (<View className="mb-6 w-full items-center">{decoration}</View>) : null}
420
565
  <View style={{ width: "100%", maxWidth: 320 }}>
421
- <Button className="w-full" telemetryId="auth.auth.stage" onPress={() => setStage("auth")} accessibilityLabel={welcomeCtaLabel} accessibilityRole="button" testID="welcome-get-started">
566
+ <Button className="w-full" telemetryId="auth.auth.stage" onPress={() => {
567
+ setStage("auth");
568
+ onWelcomeDismissed?.();
569
+ }} accessibilityLabel={welcomeCtaLabel} accessibilityRole="button" testID="welcome-get-started">
422
570
  {welcomeCtaLabel}
423
571
  </Button>
424
572
  </View>
425
573
  </View>) : (<View style={{ width: "100%", alignItems: "center" }}>
426
- <Card className="w-full max-w-[360px]">
427
- {displayStage === "forgot" ? (<>
428
- <CardHeader>
429
- <CardTitle>Reset password</CardTitle>
430
- </CardHeader>
431
- <CardContent className="gap-4">
432
- {forgotSent ? (<MutedText className="text-sm leading-5">
433
- If an account exists for that email, we&apos;ve sent a link to reset
434
- your password. Check your inbox.
435
- </MutedText>) : (<>
436
- <MutedText className="text-sm leading-5">
437
- Enter your email and we&apos;ll send you a link to reset your
438
- password.
439
- </MutedText>
440
- <ShakeField shake={emailShake} label="Email">
441
- <EmailInput value={email} onChangeText={(text) => {
574
+ {/* Positioned wrapper so the ambient layer can sit behind
575
+ the card and bleed past its edges. Width tracks the
576
+ card's own max so the glow is centred on the form, not
577
+ the viewport. */}
578
+ {/* No mode heading above the card. The Sign In / Sign Up
579
+ toggle at the top of the card already says which mode
580
+ this is, and a title restating it directly beneath the
581
+ brand lockup made three stacked pieces of naming before
582
+ the first field. The forgot-password card keeps its own
583
+ header title, which is doing real work. */}
584
+ <View style={{ width: "100%", maxWidth: 360, position: "relative" }}>
585
+ {cardBackdrop && !showAside ? (<View pointerEvents="none" style={{
586
+ position: "absolute",
587
+ top: -cardBackdropBleed,
588
+ left: -cardBackdropBleed,
589
+ right: -cardBackdropBleed,
590
+ bottom: -cardBackdropBleed,
591
+ }}>
592
+ {cardBackdrop}
593
+ </View>) : null}
594
+ <Card className="w-full max-w-[360px]">
595
+ {displayStage === "forgot" ? (<>
596
+ <CardHeader>
597
+ <CardTitle>Reset password</CardTitle>
598
+ </CardHeader>
599
+ <CardContent className="gap-4">
600
+ {forgotSent ? (<MutedText className="text-sm leading-5">
601
+ If an account exists for that email, we&apos;ve sent a link to
602
+ reset your password. Check your inbox.
603
+ </MutedText>) : (<>
604
+ <MutedText className="text-sm leading-5">
605
+ Enter your email and we&apos;ll send you a link to reset your
606
+ password.
607
+ </MutedText>
608
+ <ShakeField shake={emailShake} label="Email">
609
+ <EmailInput value={email} onChangeText={(text) => {
442
610
  setEmail(text);
443
611
  clearField("email");
444
612
  }} autoFocus placeholder="you@example.com" autoComplete="email" invalid={!!errors.email} returnKeyType="done" onSubmitEditing={handleForgot} accessibilityLabel="Email address"/>
445
- </ShakeField>
446
- <FormError message={errors.email}/>
447
- </>)}
448
- </CardContent>
449
- <CardFooter className="flex-col items-stretch gap-3 border-t-0 pt-0">
450
- {forgotSent ? (<Button className="w-full" telemetryId="auth.auth.go_auth" onPress={goAuth} accessibilityLabel="Back to sign in">
451
- Back to Sign In
452
- </Button>) : (<>
453
- <Button className="w-full" telemetryId="auth.auth.forgot" onPress={handleForgot} isLoading={isLoading} accessibilityLabel="Send reset link">
454
- Send reset link
455
- </Button>
456
- <MutedText className="mt-1 text-center font-mono text-sm" onPress={withClickTracking("auth.auth.go_auth_2", goAuth)} accessibilityRole="button" accessibilityLabel="Back to sign in">
457
- ← Back
458
- </MutedText>
459
- </>)}
460
- </CardFooter>
461
- </>) : (<>
462
- <CardHeader className="gap-4">
463
- {signUpEnabled ? (<SegmentedToggle value={mode} onChange={switchMode} options={[
613
+ </ShakeField>
614
+ <FormError message={errors.email}/>
615
+ </>)}
616
+ </CardContent>
617
+ <CardFooter className="flex-col items-stretch gap-3 border-t-0 pt-0">
618
+ {forgotSent ? (<Button className="w-full" telemetryId="auth.auth.go_auth" onPress={goAuth} accessibilityLabel="Back to sign in">
619
+ Back to Sign In
620
+ </Button>) : (<>
621
+ <Button className="w-full" telemetryId="auth.auth.forgot" onPress={handleForgot} isLoading={isLoading} accessibilityLabel="Send reset link">
622
+ Send reset link
623
+ </Button>
624
+ <MutedText className="mt-1 text-center font-mono text-sm" onPress={withClickTracking("auth.auth.go_auth_2", goAuth)} accessibilityRole="button" accessibilityLabel="Back to sign in">
625
+ ← Back
626
+ </MutedText>
627
+ </>)}
628
+ </CardFooter>
629
+ </>) : (<>
630
+ {/* `gap-5` under the toggle, not `gap-3`. The
631
+ toggle is the mode switch and the title restates
632
+ the mode, so they were reading as one crowded
633
+ block; a wider gap separates the control from the
634
+ heading it drives and takes some of the slack
635
+ that otherwise all collects above the footer.
636
+ Applies to both modes — the header is shared. */}
637
+ <CardHeader className="gap-5 pb-3 pt-5">
638
+ {signUpEnabled ? (<SegmentedToggle value={mode} onChange={switchMode} options={[
464
639
  { key: "signin", label: "Sign In" },
465
640
  { key: "signup", label: "Sign Up" },
466
641
  ]}/>) : null}
467
- {signedUp ? (<MutedText className="text-sm leading-5">{signedUp}</MutedText>) : null}
468
- <CardTitle>
469
- {isSignup ? "Create your account" : `Sign in to ${appName}`}
470
- </CardTitle>
471
- </CardHeader>
642
+ {signedUp ? (<MutedText className="text-sm leading-5">{signedUp}</MutedText>) : null}
643
+ </CardHeader>
472
644
 
473
- {/* Card height eases as fields appear/disappear. */}
474
- <Animated.View layout={t.layout}>
475
- <CardContent className="gap-3 pb-3">
476
- {!isSignup && passkeyEnabled ? (<Animated.View entering={t.entering} exiting={t.exiting} layout={t.layout}>
477
- <Button variant="outline" className="w-full" telemetryId="auth.auth.passkey_login" onPress={handlePasskeyLogin} isLoading={passkeyStep === "passkey-loading"} accessibilityLabel="Sign in with passkey" accessibilityRole="button" testID="login-passkey-button">
478
- <View className="flex-row items-center justify-center">
479
- <Ionicons name="key-outline" size={20} color={isDark ? "#FFFFFF" : "#000000"} className="mr-2"/>
480
- <Text className="text-base font-medium">
481
- Sign in with passkey
482
- </Text>
645
+ {/* Height is locked to the tallest of the two
646
+ modes (see the form-box note above), so the
647
+ layout transition only ever eases fields in and
648
+ out the card itself no longer moves. Width is
649
+ pinned explicitly so the layout animation has
650
+ nothing to interpolate horizontally either. */}
651
+ <Animated.View layout={t.layout} onLayout={onFormLayout} pointerEvents={probeSignup ? "none" : "auto"} style={{
652
+ width: "100%",
653
+ minHeight: formMinHeight || undefined,
654
+ opacity: probeSignup ? 0 : 1,
655
+ }}>
656
+ {/* `flex: 1` with the default top alignment, so the
657
+ fields keep their natural rhythm and ALL the
658
+ locked height's slack collects in one place —
659
+ between the last field and the footer.
660
+ That pins the submit button, the OR divider and
661
+ the social row to the same y in both modes, so
662
+ they hold still while toggling instead of
663
+ jumping up and down by the height of two
664
+ fields.
665
+ Note this is not the earlier attempt: spreading
666
+ the slack BETWEEN the rows (`space-between`)
667
+ looked worse than the gap it removed. Same
668
+ total space, collected instead of distributed. */}
669
+ <CardContent className="gap-2.5 pb-2" style={{ flex: 1 }}>
670
+ {!showSignup && passkeyEnabled ? (<Animated.View entering={t.entering} exiting={t.exiting} layout={t.layout}>
671
+ <Button variant="outline" className="w-full" telemetryId="auth.auth.passkey_login" onPress={handlePasskeyLogin} isLoading={passkeyStep === "passkey-loading"} accessibilityLabel="Sign in with passkey" accessibilityRole="button" testID="login-passkey-button">
672
+ <View className="flex-row items-center justify-center">
673
+ <Ionicons name="key-outline" size={20} color={isDark ? "#FFFFFF" : "#000000"} className="mr-2"/>
674
+ <Text className="text-base font-medium">
675
+ Sign in with passkey
676
+ </Text>
677
+ </View>
678
+ </Button>
679
+ <View className="mt-3">
680
+ <SeparatorWithText />
483
681
  </View>
484
- </Button>
485
- <View className="mt-3">
486
- <SeparatorWithText />
487
- </View>
488
- </Animated.View>) : null}
682
+ </Animated.View>) : null}
489
683
 
490
- <ShakeField shake={emailShake} label="Email">
491
- <EmailInput value={email} onChangeText={(text) => {
684
+ <ShakeField shake={emailShake} label="Email">
685
+ <EmailInput value={email} onChangeText={(text) => {
492
686
  setEmail(text);
493
687
  clearField("email");
494
688
  }} autoFocus placeholder="you@example.com" autoComplete="email" invalid={!!errors.email} accessibilityLabel="Email address" testID="auth-email-input"/>
495
- </ShakeField>
689
+ </ShakeField>
496
690
 
497
- {isSignup ? (<Animated.View key="displayName" entering={t.entering} exiting={t.exiting} layout={t.layout}>
498
- <ShakeField shake={displayNameShake} label="Display Name">
499
- <Input value={displayName} onChangeText={(text) => {
691
+ {showSignup ? (<Animated.View key="displayName" entering={t.entering} exiting={t.exiting} layout={t.layout}>
692
+ <ShakeField shake={displayNameShake} label="Display Name">
693
+ <Input value={displayName} onChangeText={(text) => {
500
694
  setDisplayName(text);
501
695
  clearField("displayName");
502
696
  }} placeholder="Your display name" leftIcon={<Ionicons name="person-outline" size={20} color={isDark ? "#A3A3A3" : "#737373"}/>} autoCapitalize="words" autoCorrect={false} maxLength={100} invalid={!!errors.displayName} accessibilityLabel="Display name" testID="auth-displayname-input"/>
503
- </ShakeField>
504
- </Animated.View>) : null}
697
+ </ShakeField>
698
+ </Animated.View>) : null}
505
699
 
506
- <ShakeField shake={passwordShake} label={passwordLabel}>
507
- <PasswordInput value={password} onChangeText={(text) => {
700
+ <ShakeField shake={passwordShake} label={passwordLabel}>
701
+ <PasswordInput value={password} onChangeText={(text) => {
508
702
  setPassword(text);
509
703
  clearField("password");
510
704
  if (isSignup) {
511
705
  checkPasswordDebounced(text);
512
706
  }
513
707
  }} placeholder={passwordPlaceholder} autoComplete={isSignup ? "password-new" : "password"} invalid={!!errors.password} returnKeyType={isSignup ? "next" : "done"} onSubmitEditing={isSignup ? undefined : handleSubmit} accessibilityLabel={passwordLabel} testID="auth-password-input"/>
514
- </ShakeField>
708
+ </ShakeField>
515
709
 
516
- {isSignup ? (<Animated.View key="strength" entering={t.entering} exiting={t.exiting} layout={t.layout}>
517
- <PasswordStrengthMeter password={password}/>
518
- <PwnedPasswordWarning pwnedInfo={pwnedInfo}/>
519
- </Animated.View>) : null}
710
+ {/* Rendered only when it has something to show.
711
+ An empty wrapper still consumes one of
712
+ CardContent's `gap-3` slots, which put 12px
713
+ more space above Confirm Password than
714
+ between any other pair of fields — visible as
715
+ a stray gap. `probeSignup` keeps it in the
716
+ measuring pass so the locked height still
717
+ accounts for the meter. */}
718
+ {showSignup && (probeSignup || password.length > 0 || pwnedInfo) ? (<Animated.View key="strength" entering={t.entering} exiting={t.exiting} layout={t.layout}>
719
+ {/* `reserveSpace` only during the probe.
720
+ Keeping it on permanently held an empty
721
+ 28px block between Password and Confirm
722
+ Password, which reads as a stray gap.
723
+ Reserving it just for the measurement
724
+ means the locked height still accounts
725
+ for the meter, so it can appear later
726
+ without the card growing. */}
727
+ <PasswordStrengthMeter password={password} reserveSpace={probeSignup}/>
728
+ <PwnedPasswordWarning pwnedInfo={pwnedInfo}/>
729
+ </Animated.View>) : null}
520
730
 
521
- {isSignup ? (<Animated.View key="confirm" entering={t.entering} exiting={t.exiting} layout={t.layout}>
522
- <ShakeField shake={confirmShake} label="Confirm Password">
523
- <PasswordInput value={confirmPassword} onChangeText={(text) => {
731
+ {showSignup ? (<Animated.View key="confirm" entering={t.entering} exiting={t.exiting} layout={t.layout}>
732
+ <ShakeField shake={confirmShake} label="Confirm Password">
733
+ <PasswordInput value={confirmPassword} onChangeText={(text) => {
524
734
  setConfirmPassword(text);
525
735
  clearField("confirmPassword");
526
736
  }} placeholder="Re-enter your password" autoComplete="password-new" returnKeyType="done" onSubmitEditing={handleSubmit} invalid={!!errors.confirmPassword} accessibilityLabel="Confirm password" testID="auth-confirm-password-input"/>
527
- </ShakeField>
528
- {requireInviteCode ? (<View className="mt-3">
529
- <ShakeField shake={confirmShake} label="Invite code">
530
- <Input value={inviteCode} onChangeText={(text) => {
737
+ </ShakeField>
738
+ {requireInviteCode ? (<View className="mt-3">
739
+ <ShakeField shake={confirmShake} label="Invite code">
740
+ <Input value={inviteCode} onChangeText={(text) => {
531
741
  setInviteCode(text);
532
742
  clearField("inviteCode");
533
743
  }} placeholder="Paste your invite code" autoCapitalize="none" autoCorrect={false} returnKeyType="done" onSubmitEditing={handleSubmit} invalid={!!errors.inviteCode} accessibilityLabel="Invite code" testID="auth-invite-code-input"/>
534
- </ShakeField>
535
- <FormError message={errors.inviteCode}/>
536
- </View>) : null}
537
- </Animated.View>) : (<Animated.View key="forgot" entering={t.entering} exiting={t.exiting} layout={t.layout} className="items-end">
538
- <Text className="font-mono text-sm underline" onPress={withClickTracking("auth.auth.stage_2", () => {
744
+ </ShakeField>
745
+ <FormError message={errors.inviteCode}/>
746
+ </View>) : null}
747
+ </Animated.View>) : (<Animated.View key="forgot" entering={t.entering} exiting={t.exiting} layout={t.layout} className="items-end">
748
+ <Text className="font-mono text-sm underline" onPress={withClickTracking("auth.auth.stage_2", () => {
539
749
  setStage("forgot");
540
750
  setForgotSent(false);
541
751
  setErrors({});
542
752
  })} accessibilityRole="link" accessibilityLabel="Forgot password">
543
- Forgot password?
544
- </Text>
545
- </Animated.View>)}
546
- </CardContent>
753
+ Forgot password?
754
+ </Text>
755
+ </Animated.View>)}
756
+ </CardContent>
547
757
 
548
- <CardFooter className="flex-col items-stretch gap-3 border-t-0 pt-0">
549
- <FormError message={firstError}/>
758
+ <CardFooter className="flex-col items-stretch gap-2.5 border-t-0 pb-5 pt-0">
759
+ <FormError message={firstError}/>
550
760
 
551
- <Button className="w-full" telemetryId="auth.auth.submit" onPress={handleSubmit} isLoading={isLoading} accessibilityLabel={isSignup ? "Create account" : "Sign in"} accessibilityRole="button" testID="auth-submit">
552
- {isSignup ? "Create Account" : "Sign In"}
553
- </Button>
761
+ <Button className="w-full" telemetryId="auth.auth.submit" onPress={handleSubmit} isLoading={isLoading} accessibilityLabel={isSignup ? "Create account" : "Sign in"} accessibilityRole="button" testID="auth-submit">
762
+ {isSignup ? "Create Account" : "Sign In"}
763
+ </Button>
554
764
 
555
- {socialAuth ? (<>
556
- <SeparatorWithText />
557
- {socialAuth}
558
- </>) : null}
765
+ {socialAuth ? (<>
766
+ <SeparatorWithText />
767
+ {socialAuth}
768
+ </>) : null}
559
769
 
560
- {/* In-card back — thumb-reachable, so users don't
561
- have to stretch to the top-left corner arrow.
562
- Hidden when there is no welcome stage behind it. */}
563
- <Text className={showWelcome
770
+ {/* In-card back — thumb-reachable, so users don't
771
+ have to stretch to the top-left corner arrow.
772
+ Hidden when there is no welcome stage behind it. */}
773
+ <Text className={showWelcome
564
774
  ? "mt-1 text-center font-mono text-sm text-muted-foreground"
565
775
  : "hidden"} onPress={withClickTracking("auth.auth.stage_3", () => setStage("welcome"))} accessibilityRole="button" accessibilityLabel="Back">
566
- ← Back
567
- </Text>
568
- </CardFooter>
569
- </Animated.View>
570
- </>)}
571
- </Card>
776
+ ← Back
777
+ </Text>
778
+ </CardFooter>
779
+ </Animated.View>
780
+ </>)}
781
+ </Card>
782
+ </View>
572
783
  </View>)}
573
784
  </Animated.View>
574
785
 
575
- {/* legal footer — Terms/Privacy as popups, no routes */}
576
- <View className="mb-6 mt-4">
786
+ {/* Legal footer — Terms/Privacy as popups, no routes.
787
+ Welcome stage ONLY. Pressing "Get Started" and proceeding IS
788
+ the acknowledgement, so the line belongs beside that button
789
+ and nowhere else; repeating it under the sign-in and sign-up
790
+ forms restates a consent the user already gave and crowds two
791
+ screens that are busier than the welcome. When there is no
792
+ welcome stage at all (`showWelcome={false}`) it stays on the
793
+ form. Note there is deliberately NO "but show it on the form
794
+ if there is no welcome stage" fallback: `showWelcome` is false
795
+ on every launch after the first, so such a fallback puts the
796
+ line back under the form permanently — which is exactly the
797
+ bug it was written to avoid. */}
798
+ <View className={onWelcome ? "mb-6 mt-4" : "hidden"}>
577
799
  <MutedText className="text-center text-xs leading-5">
578
800
  By continuing, you agree to our{" "}
579
801
  <Text className="text-xs text-foreground underline" onPress={withClickTracking("auth.auth.legal", () => setLegal("terms"))} accessibilityRole="link" accessibilityLabel="Terms of Service">
@@ -585,9 +807,21 @@ export function AuthScreen({ passwordLabel = "Password", passwordPlaceholder = "
585
807
  </Text>
586
808
  </MutedText>
587
809
  </View>
810
+
811
+ {footer && !showAside ? <View className="mb-6">{footer}</View> : null}
588
812
  </View>
589
813
  </ScrollView>
590
814
  </KeyboardAvoidingView>
815
+ {/* Wide only. Screen-level and absolutely placed, so it centres on the
816
+ PAGE rather than under the form column, and drawn after the panes so
817
+ it sits above them. `pointerEvents="box-none"` keeps it from
818
+ intercepting a tap meant for anything behind it.
819
+ On a narrow layout it is rendered IN FLOW instead (below): the card
820
+ reaches the bottom of a phone screen, and an absolute footer simply
821
+ lands on top of the card's own back link. */}
822
+ {footer && showAside ? (<View pointerEvents="box-none" style={{ position: "absolute", left: 0, right: 0, bottom: 16, alignItems: "center" }}>
823
+ {footer}
824
+ </View>) : null}
591
825
  </SafeAreaView>
592
826
 
593
827
  {/* Passkey unlock dialog — non-PRF authenticators. */}