@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,10 +1,15 @@
1
1
  import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
- import { KeyboardAvoidingView, Platform, ScrollView, View } from "react-native";
2
+ import {
3
+ KeyboardAvoidingView,
4
+ type LayoutChangeEvent,
5
+ Platform,
6
+ ScrollView,
7
+ useWindowDimensions,
8
+ View,
9
+ } from "react-native";
3
10
  import { SafeAreaView } from "react-native-safe-area-context";
4
11
  import Animated, {
5
12
  Easing,
6
- FadeInDown,
7
- FadeOutUp,
8
13
  LinearTransition,
9
14
  runOnJS,
10
15
  useAnimatedStyle,
@@ -40,6 +45,19 @@ import { useAppConfig } from "../../context/AppConfigContext";
40
45
  import { useNavigation } from "../../context/NavigationContext";
41
46
 
42
47
  const SCROLL_CONTENT_STYLE = { flexGrow: 1 } as const;
48
+
49
+ /** Width of the form column when the aside is showing. */
50
+ /**
51
+ * Width of the form column when the aside is showing.
52
+ *
53
+ * With symmetric padding the card centres in this column, so the gap either
54
+ * side of it is simply `(FORM_PANE_WIDTH - 360) / 2`. 716 puts 178px between
55
+ * the card and the seam, and the same 178px between the card and the window
56
+ * edge — the two gaps match by construction rather than by tuning two numbers
57
+ * against each other.
58
+ */
59
+ const FORM_PANE_WIDTH = 716;
60
+
43
61
  const DEFAULT_POLICY = { enabled: false, enforce: false, block_threshold: 0 };
44
62
 
45
63
  type Mode = "signin" | "signup";
@@ -63,10 +81,28 @@ export interface AuthFormTransition {
63
81
  exiting?: AnimatedViewProps["exiting"];
64
82
  }
65
83
 
66
- const DEFAULT_TRANSITION: Required<AuthFormTransition> = {
84
+ /** `Required` would forbid the `undefined` the defaults deliberately use. */
85
+ type ResolvedTransition = AuthFormTransition;
86
+
87
+ const DEFAULT_TRANSITION: ResolvedTransition = {
88
+ // Height is locked to the tallest mode (see the form-box note below), so the
89
+ // layout transition has nothing left to ease. Kept for the cases where the
90
+ // box legitimately grows past the lock — a pwned-password warning, a wrapped
91
+ // error, a larger system font.
67
92
  layout: LinearTransition.duration(280),
68
- entering: FadeInDown.duration(240),
69
- exiting: FadeOutUp.duration(160),
93
+ // No enter/exit animation. Two earlier attempts were both wrong: the
94
+ // directional FadeInDown/FadeOutUp slid fields in from above, and a plain
95
+ // cross-fade still singled out the only fields that actually mount on a mode
96
+ // switch — Display Name, Confirm Password — so they arrived visibly later
97
+ // than the fields around them, which are already on screen and simply stay
98
+ // put. One field fading in while its neighbours do not reads as a glitch, not
99
+ // as a transition. The card's size is locked, so an instant swap has nothing
100
+ // to hide anyway.
101
+ //
102
+ // Overridable: `transition` on the props still accepts both, for a consumer
103
+ // that wants them.
104
+ entering: undefined,
105
+ exiting: undefined,
70
106
  };
71
107
 
72
108
  // Stage morph timing — fade/scale out, swap the stage at zero opacity (so the
@@ -92,8 +128,23 @@ export interface AuthScreenProps {
92
128
  /**
93
129
  * Connection / server-settings dialog, folded into this single page (no
94
130
  * route). When provided, a gear button is shown top-right that toggles it.
131
+ *
132
+ * Mutually exclusive with `onSettingsPress`, which is the better fit where
133
+ * the settings surface deserves a whole screen rather than a dialog.
95
134
  */
96
135
  settings?: (props: { visible: boolean; onClose: () => void }) => React.ReactNode;
136
+ /**
137
+ * Show the gear, but navigate instead of opening a dialog.
138
+ *
139
+ * Takes precedence over `settings`. A connection screen that has to explain
140
+ * offline against cloud, official against self-hosted, and show a live health
141
+ * check is too much for a dialog on a phone — it wants a real screen with
142
+ * room and a back button. Web, by contrast, passes NEITHER: a browser reached
143
+ * the app by loading a particular server's URL, so offering to point it
144
+ * somewhere else is answering a question the page's own origin already
145
+ * settled.
146
+ */
147
+ onSettingsPress?: () => void;
97
148
  /** Override the animations. Merged over the defaults. */
98
149
  transition?: AuthFormTransition;
99
150
  /**
@@ -102,8 +153,67 @@ export interface AuthScreenProps {
102
153
  * simple-finance's AssetCarousel, …). Omit for none.
103
154
  */
104
155
  decoration?: React.ReactNode;
156
+ /**
157
+ * Full-bleed layer behind everything on the screen — a texture, an ambient
158
+ * gradient. Rendered absolutely-filled and `pointerEvents="none"`, below the
159
+ * morph, so a welcome→form stage change does not fade it.
160
+ *
161
+ * Pairs with `cardBackdrop`: this is the ground, that is the accent hugging
162
+ * the form. A backdrop drawn here sits UNDER the card backdrop, so a card
163
+ * glow still reads over an opaque texture.
164
+ */
165
+ backdrop?: React.ReactNode;
166
+ /**
167
+ * Persistent footer under the form — copyright, build, which server the
168
+ * client is pointed at. Always visible, unlike the legal line, which is
169
+ * welcome-stage only.
170
+ */
171
+ footer?: React.ReactNode;
172
+ /**
173
+ * Companion pane shown BESIDE the form on a wide viewport, and not rendered
174
+ * at all on a narrow one.
175
+ *
176
+ * This is the difference between a mobile layout stretched across a monitor
177
+ * and something that looks built for a desktop. A single centred card on a
178
+ * 1440px screen leaves most of the window empty; giving the brand, the
179
+ * product story and the ambient motion their own half turns that emptiness
180
+ * into the composition.
181
+ *
182
+ * Rendered inside the same `KeyboardAvoidingView` as the form but outside its
183
+ * scroll view, so it holds still while the form scrolls.
184
+ */
185
+ aside?: React.ReactNode;
186
+ /**
187
+ * Viewport width, in px, at which `aside` appears. Defaults to 1024 — below
188
+ * that a two-pane split squeezes the form narrower than its own 360px card.
189
+ */
190
+ asideBreakpoint?: number;
191
+ /**
192
+ * Ambient layer immediately behind the auth card — a soft glow, a tint.
193
+ * Rendered absolutely, `pointerEvents="none"`, and bled a little past the
194
+ * card's edges so the effect hugs the form rather than washing the screen.
195
+ *
196
+ * Deliberately scoped to the card. A full-bleed version of this reads as a
197
+ * coloured background, which is a much louder decision than an accent behind
198
+ * the one thing the user is looking at.
199
+ *
200
+ * Distinct from `decoration`, which is content INSIDE the welcome view's
201
+ * flow and moves with it.
202
+ */
203
+ cardBackdrop?: React.ReactNode;
204
+ /** How far the card backdrop bleeds past the card, in px. Defaults to 48. */
205
+ cardBackdropBleed?: number;
105
206
  /** Welcome-view tagline/subtitle. Defaults to AppConfig `tagline`. */
106
207
  welcomeCtaLabel?: string;
208
+ /**
209
+ * Called the first time the user leaves the welcome stage.
210
+ *
211
+ * The consumer persists this — an introduction repeated on every launch is an
212
+ * obstacle, not an introduction. Kept as a callback rather than handled here
213
+ * because this screen is shared across apps and has no business knowing where
214
+ * any of them keep their preferences.
215
+ */
216
+ onWelcomeDismissed?: () => void;
107
217
  /**
108
218
  * Show the Sign Up half of the toggle. Defaults to `true`.
109
219
  *
@@ -165,9 +275,17 @@ export function AuthScreen({
165
275
  passkeyUnlockTitle = "Unlock your vault",
166
276
  passkeyUnlockDescription = "Signed in successfully. This passkey can't unlock the vault on its own — enter your master password to decrypt your data.",
167
277
  settings,
278
+ onSettingsPress,
168
279
  transition,
169
280
  decoration,
281
+ footer,
282
+ aside,
283
+ asideBreakpoint = 1024,
284
+ backdrop,
285
+ cardBackdrop,
286
+ cardBackdropBleed = 48,
170
287
  welcomeCtaLabel = "Get Started",
288
+ onWelcomeDismissed,
171
289
  signUpEnabled = true,
172
290
  requireInviteCode = false,
173
291
  onSignUp,
@@ -181,12 +299,42 @@ export function AuthScreen({
181
299
  unlockWithPassword,
182
300
  } = useAuth();
183
301
  const router = useNavigation();
184
- const { appName, Logo, tagline, routes, termsText, privacyText, logoIsWordmark, logoWidth } =
185
- useAppConfig();
302
+ const {
303
+ appName,
304
+ Logo,
305
+ tagline,
306
+ taglineLayout,
307
+ routes,
308
+ termsText,
309
+ privacyText,
310
+ logoIsWordmark,
311
+ logoWidth,
312
+ } = useAppConfig();
186
313
 
187
314
  const { colorScheme } = useColorScheme();
188
315
  const isDark = colorScheme === "dark";
189
- const t = useMemo(() => ({ ...DEFAULT_TRANSITION, ...transition }), [transition]);
316
+
317
+ // Two-pane only when there is a pane to show AND room to show it.
318
+ const { width: viewportWidth } = useWindowDimensions();
319
+ const showAside = !!aside && viewportWidth >= asideBreakpoint;
320
+
321
+ // A wordmark has no app-name text to sit beside, so the two layouts that
322
+ // need one degrade to the ruled treatment rather than rendering a pipe, or a
323
+ // lockup, against nothing.
324
+ const inlineTagline = taglineLayout === "inline" && !logoIsWordmark;
325
+ const lockupBrand = taglineLayout === "lockup" && !logoIsWordmark;
326
+ const ruledTagline = taglineLayout !== undefined && !inlineTagline && !lockupBrand;
327
+
328
+ // The lockup sizes the mark to the measured height of the name + tagline
329
+ // block, so the three line up however the app scales its type. Measured
330
+ // rather than hard-coded because a fixed px value drifts the moment the type
331
+ // scale or the system font size moves.
332
+ const [brandTextHeight, setBrandTextHeight] = useState(0);
333
+ const onBrandTextLayout = useCallback((e: LayoutChangeEvent) => {
334
+ const h = Math.round(e.nativeEvent.layout.height);
335
+ setBrandTextHeight((prev) => (Math.abs(prev - h) > 1 ? h : prev));
336
+ }, []);
337
+ const baseTransition = useMemo(() => ({ ...DEFAULT_TRANSITION, ...transition }), [transition]);
190
338
 
191
339
  const [mode, setMode] = useState<Mode>("signin");
192
340
  const [inviteCode, setInviteCode] = useState("");
@@ -291,6 +439,49 @@ export function AuthScreen({
291
439
  setPwnedInfo(null);
292
440
  }, []);
293
441
 
442
+ // ── Stable form box ───────────────────────────────────────────────────
443
+ // Sign In and Sign Up mount different field sets (passkey button +
444
+ // separator + "Forgot password?" against Display Name + strength meter +
445
+ // Confirm Password), so the card grew ~70 px on every toggle. On a short
446
+ // viewport the taller Sign Up layout also pushed the page into a scroll,
447
+ // which moved the logo, the footer and the card's own vertical centre —
448
+ // the whole screen appeared to resize, not only the fields.
449
+ //
450
+ // So lock the body to the TALLEST layout and let the shorter mode leave
451
+ // its slack at the bottom of the card. `probeSignup` renders the Sign Up
452
+ // field set for the single frame the form first mounts, purely to measure
453
+ // it: coming from the welcome stage that frame is already at zero opacity
454
+ // (the morph is mid-fade), and it is explicitly hidden anyway for apps
455
+ // that skip the welcome stage. Without that pre-measure the FIRST toggle
456
+ // of every page load would still be the one that grows the card.
457
+ //
458
+ // `minHeight` (not `height`) on purpose: anything that legitimately needs
459
+ // more room later — a pwned-password warning, a wrapped error, a larger
460
+ // system font — still expands the card instead of being clipped.
461
+ const [formMinHeight, setFormMinHeight] = useState(0);
462
+ const [probed, setProbed] = useState(!signUpEnabled);
463
+ const probeSignup = !probed;
464
+ const showSignup = probeSignup || isSignup;
465
+
466
+ const onFormLayout = useCallback((e: LayoutChangeEvent) => {
467
+ const measured = Math.ceil(e.nativeEvent.layout.height);
468
+ setFormMinHeight((prev) => (measured > prev ? measured : prev));
469
+ setProbed(true);
470
+ }, []);
471
+
472
+ // No enter/exit animation until the measuring pass is done.
473
+ //
474
+ // When `probed` flips, the probe's Sign Up fields unmount — and an exiting
475
+ // animation on that teardown plays VISIBLY, because the wrapper's opacity is
476
+ // back to 1 by the time it runs. That was a Display Name field blinking into
477
+ // view and fading out on first render, for a frame the user was never meant
478
+ // to see at all.
479
+ const t = useMemo(
480
+ () =>
481
+ probed ? baseTransition : { ...baseTransition, entering: undefined, exiting: undefined },
482
+ [baseTransition, probed]
483
+ );
484
+
294
485
  const clearField = (field: keyof typeof errors) => {
295
486
  if (errors[field]) {
296
487
  setErrors((e) => ({ ...e, [field]: undefined }));
@@ -545,27 +736,67 @@ export function AuthScreen({
545
736
  <>
546
737
  <SafeAreaView className="flex-1 bg-background dark:bg-black">
547
738
  <StatusBar style={isDark ? "light" : "dark"} />
739
+ {backdrop ? (
740
+ <View
741
+ pointerEvents="none"
742
+ style={{ position: "absolute", top: 0, left: 0, right: 0, bottom: 0 }}
743
+ >
744
+ {backdrop}
745
+ </View>
746
+ ) : null}
548
747
  <KeyboardAvoidingView
549
748
  behavior={Platform.OS === "ios" ? "padding" : "height"}
550
749
  className="flex-1"
750
+ // Full-bleed two-pane split. The aside is a real panel with its own
751
+ // ground colour reaching the window edge, so it cannot be centred
752
+ // inside a capped container — a floating panel with black either
753
+ // side would read as a mistake. The form column is fixed and the
754
+ // aside takes everything else, which on a typical desktop leaves it
755
+ // roughly two thirds.
756
+ style={showAside ? { flexDirection: "row" } : undefined}
551
757
  >
758
+ {showAside ? (
759
+ // Sits outside the ScrollView on purpose: the pane is scenery and
760
+ // should hold still while the form beside it scrolls.
761
+ <View style={{ flex: 1 }} pointerEvents="box-none">
762
+ {aside}
763
+ </View>
764
+ ) : null}
552
765
  <ScrollView
553
766
  className="flex-1"
767
+ // Wide: a fixed column on the right rather than `flex: 1`, so the
768
+ // form keeps a comfortable measure instead of growing with the
769
+ // window and stranding its 360px card in the middle of a wide lane.
770
+ style={showAside ? { flexGrow: 0, flexBasis: FORM_PANE_WIDTH } : undefined}
554
771
  contentContainerStyle={SCROLL_CONTENT_STYLE}
555
772
  keyboardShouldPersistTaps="handled"
556
773
  showsVerticalScrollIndicator={false}
557
774
  >
558
- <View className="flex-1 justify-between px-6">
775
+ <View
776
+ className="flex-1 justify-between px-6"
777
+ // Asymmetric on the wide layout: more room against the window
778
+ // edge than against the panel seam, so the card sits inside its
779
+ // column rather than pressed to the right of the screen.
780
+ // Symmetric: see FORM_PANE_WIDTH. Asymmetric padding would shift the card
781
+ // off centre and break the matching gaps.
782
+ style={showAside ? { paddingLeft: 64, paddingRight: 64 } : undefined}
783
+ >
559
784
  {/* top bar: settings gear only — back lives inside the cards
560
785
  (thumb-reachable) so users don't stretch to the corner. */}
561
786
  <View className="mt-4 h-10 flex-row items-center justify-between">
562
787
  <View />
563
- {settings ? (
788
+ {settings || onSettingsPress ? (
564
789
  <IconButton
565
790
  variant="ghost"
566
791
  size="sm"
567
792
  telemetryId="auth.auth.settings_open"
568
- onPress={() => setSettingsOpen(true)}
793
+ onPress={() => {
794
+ if (onSettingsPress) {
795
+ onSettingsPress();
796
+ return;
797
+ }
798
+ setSettingsOpen(true);
799
+ }}
569
800
  accessibilityLabel="Connection settings"
570
801
  accessibilityRole="button"
571
802
  >
@@ -591,20 +822,71 @@ export function AuthScreen({
591
822
  morphStyle,
592
823
  ]}
593
824
  >
594
- <View className="mb-6 items-center">
825
+ {/* On the wide layout the lockup is constrained to the same
826
+ 360px column as the title and the card and left-aligned with
827
+ them, so all three share one left edge. Centred, it sat at
828
+ its own width and read as indented against the form below
829
+ it. Narrow keeps it centred, where there is no column to
830
+ align to. */}
831
+ <View
832
+ className={
833
+ showAside ? "mb-6 w-full max-w-[360px] items-start" : "mb-6 items-center"
834
+ }
835
+ >
595
836
  {logoIsWordmark ? (
596
837
  // The mark carries the name; height is left to the
597
838
  // component so a wide wordmark keeps its aspect ratio, and
598
839
  // the ink colour comes from the already-resolved theme.
599
840
  <Logo width={logoWidth ?? 160} color={isDark ? "#FFFFFF" : "#000000"} />
841
+ ) : lockupBrand ? (
842
+ // Lockup: mark, vertical rule, then the name over the
843
+ // tagline. The mark and the rule are both sized to the
844
+ // MEASURED height of that text block, so all three read as
845
+ // one unit at whatever type scale the app (or the system
846
+ // font size) ends up at. `brandTextHeight || 64` only
847
+ // covers the single frame before the first measurement.
848
+ <View className="flex-row items-center" style={{ gap: 14 }}>
849
+ <Logo width={brandTextHeight || 64} height={brandTextHeight || 64} bleed />
850
+ <View
851
+ style={{ width: 1, height: brandTextHeight || 64 }}
852
+ className="bg-border dark:bg-neutral-700"
853
+ />
854
+ <View onLayout={onBrandTextLayout}>
855
+ <Heading className="text-4xl">{appName}</Heading>
856
+ {tagline ? (
857
+ <MutedText className="font-mono text-base">{tagline}</MutedText>
858
+ ) : null}
859
+ </View>
860
+ </View>
600
861
  ) : (
601
862
  <View className="flex-row items-center">
602
863
  <Logo width={72} height={72} style={{ marginRight: -2 }} />
603
864
  <Heading className="text-4xl">{appName}</Heading>
865
+ {/* Inline tagline sits on the name's baseline, at body
866
+ size — matching the 4xl heading would make the
867
+ category label compete with the brand. */}
868
+ {tagline && inlineTagline ? (
869
+ <MutedText className="ml-2 font-mono text-base">{`| ${tagline}`}</MutedText>
870
+ ) : null}
604
871
  </View>
605
872
  )}
606
- {tagline ? (
607
- <MutedText className="mt-1 font-mono text-base">{tagline}</MutedText>
873
+ {tagline && !inlineTagline && !lockupBrand ? (
874
+ <View className="items-center">
875
+ {/* The rule is only drawn for the "ruled" layout. Width
876
+ tracks the row rather than being a fixed px value, so
877
+ it lines up under the logo + name pairing at whatever
878
+ size the app renders them. */}
879
+ {ruledTagline ? (
880
+ <View className="mt-3 h-px w-full self-stretch bg-border dark:bg-neutral-800" />
881
+ ) : null}
882
+ <MutedText
883
+ className={
884
+ ruledTagline ? "mt-3 font-mono text-base" : "mt-1 font-mono text-base"
885
+ }
886
+ >
887
+ {tagline}
888
+ </MutedText>
889
+ </View>
608
890
  ) : null}
609
891
  </View>
610
892
 
@@ -617,7 +899,10 @@ export function AuthScreen({
617
899
  <Button
618
900
  className="w-full"
619
901
  telemetryId="auth.auth.stage"
620
- onPress={() => setStage("auth")}
902
+ onPress={() => {
903
+ setStage("auth");
904
+ onWelcomeDismissed?.();
905
+ }}
621
906
  accessibilityLabel={welcomeCtaLabel}
622
907
  accessibilityRole="button"
623
908
  testID="welcome-get-started"
@@ -628,24 +913,191 @@ export function AuthScreen({
628
913
  </View>
629
914
  ) : (
630
915
  <View style={{ width: "100%", alignItems: "center" }}>
631
- <Card className="w-full max-w-[360px]">
632
- {displayStage === "forgot" ? (
633
- <>
634
- <CardHeader>
635
- <CardTitle>Reset password</CardTitle>
636
- </CardHeader>
637
- <CardContent className="gap-4">
638
- {forgotSent ? (
639
- <MutedText className="text-sm leading-5">
640
- If an account exists for that email, we&apos;ve sent a link to reset
641
- your password. Check your inbox.
642
- </MutedText>
643
- ) : (
644
- <>
916
+ {/* Positioned wrapper so the ambient layer can sit behind
917
+ the card and bleed past its edges. Width tracks the
918
+ card's own max so the glow is centred on the form, not
919
+ the viewport. */}
920
+ {/* No mode heading above the card. The Sign In / Sign Up
921
+ toggle at the top of the card already says which mode
922
+ this is, and a title restating it directly beneath the
923
+ brand lockup made three stacked pieces of naming before
924
+ the first field. The forgot-password card keeps its own
925
+ header title, which is doing real work. */}
926
+ <View style={{ width: "100%", maxWidth: 360, position: "relative" }}>
927
+ {cardBackdrop && !showAside ? (
928
+ <View
929
+ pointerEvents="none"
930
+ style={{
931
+ position: "absolute",
932
+ top: -cardBackdropBleed,
933
+ left: -cardBackdropBleed,
934
+ right: -cardBackdropBleed,
935
+ bottom: -cardBackdropBleed,
936
+ }}
937
+ >
938
+ {cardBackdrop}
939
+ </View>
940
+ ) : null}
941
+ <Card className="w-full max-w-[360px]">
942
+ {displayStage === "forgot" ? (
943
+ <>
944
+ <CardHeader>
945
+ <CardTitle>Reset password</CardTitle>
946
+ </CardHeader>
947
+ <CardContent className="gap-4">
948
+ {forgotSent ? (
645
949
  <MutedText className="text-sm leading-5">
646
- Enter your email and we&apos;ll send you a link to reset your
647
- password.
950
+ If an account exists for that email, we&apos;ve sent a link to
951
+ reset your password. Check your inbox.
648
952
  </MutedText>
953
+ ) : (
954
+ <>
955
+ <MutedText className="text-sm leading-5">
956
+ Enter your email and we&apos;ll send you a link to reset your
957
+ password.
958
+ </MutedText>
959
+ <ShakeField shake={emailShake} label="Email">
960
+ <EmailInput
961
+ value={email}
962
+ onChangeText={(text) => {
963
+ setEmail(text);
964
+ clearField("email");
965
+ }}
966
+ autoFocus
967
+ placeholder="you@example.com"
968
+ autoComplete="email"
969
+ invalid={!!errors.email}
970
+ returnKeyType="done"
971
+ onSubmitEditing={handleForgot}
972
+ accessibilityLabel="Email address"
973
+ />
974
+ </ShakeField>
975
+ <FormError message={errors.email} />
976
+ </>
977
+ )}
978
+ </CardContent>
979
+ <CardFooter className="flex-col items-stretch gap-3 border-t-0 pt-0">
980
+ {forgotSent ? (
981
+ <Button
982
+ className="w-full"
983
+ telemetryId="auth.auth.go_auth"
984
+ onPress={goAuth}
985
+ accessibilityLabel="Back to sign in"
986
+ >
987
+ Back to Sign In
988
+ </Button>
989
+ ) : (
990
+ <>
991
+ <Button
992
+ className="w-full"
993
+ telemetryId="auth.auth.forgot"
994
+ onPress={handleForgot}
995
+ isLoading={isLoading}
996
+ accessibilityLabel="Send reset link"
997
+ >
998
+ Send reset link
999
+ </Button>
1000
+ <MutedText
1001
+ className="mt-1 text-center font-mono text-sm"
1002
+ onPress={withClickTracking("auth.auth.go_auth_2", goAuth)}
1003
+ accessibilityRole="button"
1004
+ accessibilityLabel="Back to sign in"
1005
+ >
1006
+ ← Back
1007
+ </MutedText>
1008
+ </>
1009
+ )}
1010
+ </CardFooter>
1011
+ </>
1012
+ ) : (
1013
+ <>
1014
+ {/* `gap-5` under the toggle, not `gap-3`. The
1015
+ toggle is the mode switch and the title restates
1016
+ the mode, so they were reading as one crowded
1017
+ block; a wider gap separates the control from the
1018
+ heading it drives and takes some of the slack
1019
+ that otherwise all collects above the footer.
1020
+ Applies to both modes — the header is shared. */}
1021
+ <CardHeader className="gap-5 pb-3 pt-5">
1022
+ {signUpEnabled ? (
1023
+ <SegmentedToggle<Mode>
1024
+ value={mode}
1025
+ onChange={switchMode}
1026
+ options={[
1027
+ { key: "signin", label: "Sign In" },
1028
+ { key: "signup", label: "Sign Up" },
1029
+ ]}
1030
+ />
1031
+ ) : null}
1032
+ {signedUp ? (
1033
+ <MutedText className="text-sm leading-5">{signedUp}</MutedText>
1034
+ ) : null}
1035
+ </CardHeader>
1036
+
1037
+ {/* Height is locked to the tallest of the two
1038
+ modes (see the form-box note above), so the
1039
+ layout transition only ever eases fields in and
1040
+ out — the card itself no longer moves. Width is
1041
+ pinned explicitly so the layout animation has
1042
+ nothing to interpolate horizontally either. */}
1043
+ <Animated.View
1044
+ layout={t.layout}
1045
+ onLayout={onFormLayout}
1046
+ pointerEvents={probeSignup ? "none" : "auto"}
1047
+ style={{
1048
+ width: "100%",
1049
+ minHeight: formMinHeight || undefined,
1050
+ opacity: probeSignup ? 0 : 1,
1051
+ }}
1052
+ >
1053
+ {/* `flex: 1` with the default top alignment, so the
1054
+ fields keep their natural rhythm and ALL the
1055
+ locked height's slack collects in one place —
1056
+ between the last field and the footer.
1057
+ That pins the submit button, the OR divider and
1058
+ the social row to the same y in both modes, so
1059
+ they hold still while toggling instead of
1060
+ jumping up and down by the height of two
1061
+ fields.
1062
+ Note this is not the earlier attempt: spreading
1063
+ the slack BETWEEN the rows (`space-between`)
1064
+ looked worse than the gap it removed. Same
1065
+ total space, collected instead of distributed. */}
1066
+ <CardContent className="gap-2.5 pb-2" style={{ flex: 1 }}>
1067
+ {!showSignup && passkeyEnabled ? (
1068
+ <Animated.View
1069
+ entering={t.entering}
1070
+ exiting={t.exiting}
1071
+ layout={t.layout}
1072
+ >
1073
+ <Button
1074
+ variant="outline"
1075
+ className="w-full"
1076
+ telemetryId="auth.auth.passkey_login"
1077
+ onPress={handlePasskeyLogin}
1078
+ isLoading={passkeyStep === "passkey-loading"}
1079
+ accessibilityLabel="Sign in with passkey"
1080
+ accessibilityRole="button"
1081
+ testID="login-passkey-button"
1082
+ >
1083
+ <View className="flex-row items-center justify-center">
1084
+ <Ionicons
1085
+ name="key-outline"
1086
+ size={20}
1087
+ color={isDark ? "#FFFFFF" : "#000000"}
1088
+ className="mr-2"
1089
+ />
1090
+ <Text className="text-base font-medium">
1091
+ Sign in with passkey
1092
+ </Text>
1093
+ </View>
1094
+ </Button>
1095
+ <View className="mt-3">
1096
+ <SeparatorWithText />
1097
+ </View>
1098
+ </Animated.View>
1099
+ ) : null}
1100
+
649
1101
  <ShakeField shake={emailShake} label="Email">
650
1102
  <EmailInput
651
1103
  value={email}
@@ -657,307 +1109,227 @@ export function AuthScreen({
657
1109
  placeholder="you@example.com"
658
1110
  autoComplete="email"
659
1111
  invalid={!!errors.email}
660
- returnKeyType="done"
661
- onSubmitEditing={handleForgot}
662
1112
  accessibilityLabel="Email address"
1113
+ testID="auth-email-input"
663
1114
  />
664
1115
  </ShakeField>
665
- <FormError message={errors.email} />
666
- </>
667
- )}
668
- </CardContent>
669
- <CardFooter className="flex-col items-stretch gap-3 border-t-0 pt-0">
670
- {forgotSent ? (
671
- <Button
672
- className="w-full"
673
- telemetryId="auth.auth.go_auth"
674
- onPress={goAuth}
675
- accessibilityLabel="Back to sign in"
676
- >
677
- Back to Sign In
678
- </Button>
679
- ) : (
680
- <>
681
- <Button
682
- className="w-full"
683
- telemetryId="auth.auth.forgot"
684
- onPress={handleForgot}
685
- isLoading={isLoading}
686
- accessibilityLabel="Send reset link"
687
- >
688
- Send reset link
689
- </Button>
690
- <MutedText
691
- className="mt-1 text-center font-mono text-sm"
692
- onPress={withClickTracking("auth.auth.go_auth_2", goAuth)}
693
- accessibilityRole="button"
694
- accessibilityLabel="Back to sign in"
695
- >
696
- ← Back
697
- </MutedText>
698
- </>
699
- )}
700
- </CardFooter>
701
- </>
702
- ) : (
703
- <>
704
- <CardHeader className="gap-4">
705
- {signUpEnabled ? (
706
- <SegmentedToggle<Mode>
707
- value={mode}
708
- onChange={switchMode}
709
- options={[
710
- { key: "signin", label: "Sign In" },
711
- { key: "signup", label: "Sign Up" },
712
- ]}
713
- />
714
- ) : null}
715
- {signedUp ? (
716
- <MutedText className="text-sm leading-5">{signedUp}</MutedText>
717
- ) : null}
718
- <CardTitle>
719
- {isSignup ? "Create your account" : `Sign in to ${appName}`}
720
- </CardTitle>
721
- </CardHeader>
722
1116
 
723
- {/* Card height eases as fields appear/disappear. */}
724
- <Animated.View layout={t.layout}>
725
- <CardContent className="gap-3 pb-3">
726
- {!isSignup && passkeyEnabled ? (
727
- <Animated.View
728
- entering={t.entering}
729
- exiting={t.exiting}
730
- layout={t.layout}
731
- >
732
- <Button
733
- variant="outline"
734
- className="w-full"
735
- telemetryId="auth.auth.passkey_login"
736
- onPress={handlePasskeyLogin}
737
- isLoading={passkeyStep === "passkey-loading"}
738
- accessibilityLabel="Sign in with passkey"
739
- accessibilityRole="button"
740
- testID="login-passkey-button"
1117
+ {showSignup ? (
1118
+ <Animated.View
1119
+ key="displayName"
1120
+ entering={t.entering}
1121
+ exiting={t.exiting}
1122
+ layout={t.layout}
741
1123
  >
742
- <View className="flex-row items-center justify-center">
743
- <Ionicons
744
- name="key-outline"
745
- size={20}
746
- color={isDark ? "#FFFFFF" : "#000000"}
747
- className="mr-2"
1124
+ <ShakeField shake={displayNameShake} label="Display Name">
1125
+ <Input
1126
+ value={displayName}
1127
+ onChangeText={(text) => {
1128
+ setDisplayName(text);
1129
+ clearField("displayName");
1130
+ }}
1131
+ placeholder="Your display name"
1132
+ leftIcon={
1133
+ <Ionicons
1134
+ name="person-outline"
1135
+ size={20}
1136
+ color={isDark ? "#A3A3A3" : "#737373"}
1137
+ />
1138
+ }
1139
+ autoCapitalize="words"
1140
+ autoCorrect={false}
1141
+ maxLength={100}
1142
+ invalid={!!errors.displayName}
1143
+ accessibilityLabel="Display name"
1144
+ testID="auth-displayname-input"
748
1145
  />
749
- <Text className="text-base font-medium">
750
- Sign in with passkey
751
- </Text>
752
- </View>
753
- </Button>
754
- <View className="mt-3">
755
- <SeparatorWithText />
756
- </View>
757
- </Animated.View>
758
- ) : null}
759
-
760
- <ShakeField shake={emailShake} label="Email">
761
- <EmailInput
762
- value={email}
763
- onChangeText={(text) => {
764
- setEmail(text);
765
- clearField("email");
766
- }}
767
- autoFocus
768
- placeholder="you@example.com"
769
- autoComplete="email"
770
- invalid={!!errors.email}
771
- accessibilityLabel="Email address"
772
- testID="auth-email-input"
773
- />
774
- </ShakeField>
1146
+ </ShakeField>
1147
+ </Animated.View>
1148
+ ) : null}
775
1149
 
776
- {isSignup ? (
777
- <Animated.View
778
- key="displayName"
779
- entering={t.entering}
780
- exiting={t.exiting}
781
- layout={t.layout}
782
- >
783
- <ShakeField shake={displayNameShake} label="Display Name">
784
- <Input
785
- value={displayName}
786
- onChangeText={(text) => {
787
- setDisplayName(text);
788
- clearField("displayName");
789
- }}
790
- placeholder="Your display name"
791
- leftIcon={
792
- <Ionicons
793
- name="person-outline"
794
- size={20}
795
- color={isDark ? "#A3A3A3" : "#737373"}
796
- />
1150
+ <ShakeField shake={passwordShake} label={passwordLabel}>
1151
+ <PasswordInput
1152
+ value={password}
1153
+ onChangeText={(text) => {
1154
+ setPassword(text);
1155
+ clearField("password");
1156
+ if (isSignup) {
1157
+ checkPasswordDebounced(text);
797
1158
  }
798
- autoCapitalize="words"
799
- autoCorrect={false}
800
- maxLength={100}
801
- invalid={!!errors.displayName}
802
- accessibilityLabel="Display name"
803
- testID="auth-displayname-input"
804
- />
805
- </ShakeField>
806
- </Animated.View>
807
- ) : null}
808
-
809
- <ShakeField shake={passwordShake} label={passwordLabel}>
810
- <PasswordInput
811
- value={password}
812
- onChangeText={(text) => {
813
- setPassword(text);
814
- clearField("password");
815
- if (isSignup) {
816
- checkPasswordDebounced(text);
817
- }
818
- }}
819
- placeholder={passwordPlaceholder}
820
- autoComplete={isSignup ? "password-new" : "password"}
821
- invalid={!!errors.password}
822
- returnKeyType={isSignup ? "next" : "done"}
823
- onSubmitEditing={isSignup ? undefined : handleSubmit}
824
- accessibilityLabel={passwordLabel}
825
- testID="auth-password-input"
826
- />
827
- </ShakeField>
828
-
829
- {isSignup ? (
830
- <Animated.View
831
- key="strength"
832
- entering={t.entering}
833
- exiting={t.exiting}
834
- layout={t.layout}
835
- >
836
- <PasswordStrengthMeter password={password} />
837
- <PwnedPasswordWarning pwnedInfo={pwnedInfo} />
838
- </Animated.View>
839
- ) : null}
1159
+ }}
1160
+ placeholder={passwordPlaceholder}
1161
+ autoComplete={isSignup ? "password-new" : "password"}
1162
+ invalid={!!errors.password}
1163
+ returnKeyType={isSignup ? "next" : "done"}
1164
+ onSubmitEditing={isSignup ? undefined : handleSubmit}
1165
+ accessibilityLabel={passwordLabel}
1166
+ testID="auth-password-input"
1167
+ />
1168
+ </ShakeField>
840
1169
 
841
- {isSignup ? (
842
- <Animated.View
843
- key="confirm"
844
- entering={t.entering}
845
- exiting={t.exiting}
846
- layout={t.layout}
847
- >
848
- <ShakeField shake={confirmShake} label="Confirm Password">
849
- <PasswordInput
850
- value={confirmPassword}
851
- onChangeText={(text) => {
852
- setConfirmPassword(text);
853
- clearField("confirmPassword");
854
- }}
855
- placeholder="Re-enter your password"
856
- autoComplete="password-new"
857
- returnKeyType="done"
858
- onSubmitEditing={handleSubmit}
859
- invalid={!!errors.confirmPassword}
860
- accessibilityLabel="Confirm password"
861
- testID="auth-confirm-password-input"
1170
+ {/* Rendered only when it has something to show.
1171
+ An empty wrapper still consumes one of
1172
+ CardContent's `gap-3` slots, which put 12px
1173
+ more space above Confirm Password than
1174
+ between any other pair of fields — visible as
1175
+ a stray gap. `probeSignup` keeps it in the
1176
+ measuring pass so the locked height still
1177
+ accounts for the meter. */}
1178
+ {showSignup && (probeSignup || password.length > 0 || pwnedInfo) ? (
1179
+ <Animated.View
1180
+ key="strength"
1181
+ entering={t.entering}
1182
+ exiting={t.exiting}
1183
+ layout={t.layout}
1184
+ >
1185
+ {/* `reserveSpace` only during the probe.
1186
+ Keeping it on permanently held an empty
1187
+ 28px block between Password and Confirm
1188
+ Password, which reads as a stray gap.
1189
+ Reserving it just for the measurement
1190
+ means the locked height still accounts
1191
+ for the meter, so it can appear later
1192
+ without the card growing. */}
1193
+ <PasswordStrengthMeter
1194
+ password={password}
1195
+ reserveSpace={probeSignup}
862
1196
  />
863
- </ShakeField>
864
- {requireInviteCode ? (
865
- <View className="mt-3">
866
- <ShakeField shake={confirmShake} label="Invite code">
867
- <Input
868
- value={inviteCode}
869
- onChangeText={(text) => {
870
- setInviteCode(text);
871
- clearField("inviteCode");
872
- }}
873
- placeholder="Paste your invite code"
874
- autoCapitalize="none"
875
- autoCorrect={false}
876
- returnKeyType="done"
877
- onSubmitEditing={handleSubmit}
878
- invalid={!!errors.inviteCode}
879
- accessibilityLabel="Invite code"
880
- testID="auth-invite-code-input"
881
- />
882
- </ShakeField>
883
- <FormError message={errors.inviteCode} />
884
- </View>
885
- ) : null}
886
- </Animated.View>
887
- ) : (
888
- <Animated.View
889
- key="forgot"
890
- entering={t.entering}
891
- exiting={t.exiting}
892
- layout={t.layout}
893
- className="items-end"
894
- >
895
- <Text
896
- className="font-mono text-sm underline"
897
- onPress={withClickTracking("auth.auth.stage_2", () => {
898
- setStage("forgot");
899
- setForgotSent(false);
900
- setErrors({});
901
- })}
902
- accessibilityRole="link"
903
- accessibilityLabel="Forgot password"
1197
+ <PwnedPasswordWarning pwnedInfo={pwnedInfo} />
1198
+ </Animated.View>
1199
+ ) : null}
1200
+
1201
+ {showSignup ? (
1202
+ <Animated.View
1203
+ key="confirm"
1204
+ entering={t.entering}
1205
+ exiting={t.exiting}
1206
+ layout={t.layout}
904
1207
  >
905
- Forgot password?
906
- </Text>
907
- </Animated.View>
908
- )}
909
- </CardContent>
1208
+ <ShakeField shake={confirmShake} label="Confirm Password">
1209
+ <PasswordInput
1210
+ value={confirmPassword}
1211
+ onChangeText={(text) => {
1212
+ setConfirmPassword(text);
1213
+ clearField("confirmPassword");
1214
+ }}
1215
+ placeholder="Re-enter your password"
1216
+ autoComplete="password-new"
1217
+ returnKeyType="done"
1218
+ onSubmitEditing={handleSubmit}
1219
+ invalid={!!errors.confirmPassword}
1220
+ accessibilityLabel="Confirm password"
1221
+ testID="auth-confirm-password-input"
1222
+ />
1223
+ </ShakeField>
1224
+ {requireInviteCode ? (
1225
+ <View className="mt-3">
1226
+ <ShakeField shake={confirmShake} label="Invite code">
1227
+ <Input
1228
+ value={inviteCode}
1229
+ onChangeText={(text) => {
1230
+ setInviteCode(text);
1231
+ clearField("inviteCode");
1232
+ }}
1233
+ placeholder="Paste your invite code"
1234
+ autoCapitalize="none"
1235
+ autoCorrect={false}
1236
+ returnKeyType="done"
1237
+ onSubmitEditing={handleSubmit}
1238
+ invalid={!!errors.inviteCode}
1239
+ accessibilityLabel="Invite code"
1240
+ testID="auth-invite-code-input"
1241
+ />
1242
+ </ShakeField>
1243
+ <FormError message={errors.inviteCode} />
1244
+ </View>
1245
+ ) : null}
1246
+ </Animated.View>
1247
+ ) : (
1248
+ <Animated.View
1249
+ key="forgot"
1250
+ entering={t.entering}
1251
+ exiting={t.exiting}
1252
+ layout={t.layout}
1253
+ className="items-end"
1254
+ >
1255
+ <Text
1256
+ className="font-mono text-sm underline"
1257
+ onPress={withClickTracking("auth.auth.stage_2", () => {
1258
+ setStage("forgot");
1259
+ setForgotSent(false);
1260
+ setErrors({});
1261
+ })}
1262
+ accessibilityRole="link"
1263
+ accessibilityLabel="Forgot password"
1264
+ >
1265
+ Forgot password?
1266
+ </Text>
1267
+ </Animated.View>
1268
+ )}
1269
+ </CardContent>
910
1270
 
911
- <CardFooter className="flex-col items-stretch gap-3 border-t-0 pt-0">
912
- <FormError message={firstError} />
1271
+ <CardFooter className="flex-col items-stretch gap-2.5 border-t-0 pb-5 pt-0">
1272
+ <FormError message={firstError} />
913
1273
 
914
- <Button
915
- className="w-full"
916
- telemetryId="auth.auth.submit"
917
- onPress={handleSubmit}
918
- isLoading={isLoading}
919
- accessibilityLabel={isSignup ? "Create account" : "Sign in"}
920
- accessibilityRole="button"
921
- testID="auth-submit"
922
- >
923
- {isSignup ? "Create Account" : "Sign In"}
924
- </Button>
1274
+ <Button
1275
+ className="w-full"
1276
+ telemetryId="auth.auth.submit"
1277
+ onPress={handleSubmit}
1278
+ isLoading={isLoading}
1279
+ accessibilityLabel={isSignup ? "Create account" : "Sign in"}
1280
+ accessibilityRole="button"
1281
+ testID="auth-submit"
1282
+ >
1283
+ {isSignup ? "Create Account" : "Sign In"}
1284
+ </Button>
925
1285
 
926
- {socialAuth ? (
927
- <>
928
- <SeparatorWithText />
929
- {socialAuth}
930
- </>
931
- ) : null}
1286
+ {socialAuth ? (
1287
+ <>
1288
+ <SeparatorWithText />
1289
+ {socialAuth}
1290
+ </>
1291
+ ) : null}
932
1292
 
933
- {/* In-card back — thumb-reachable, so users don't
1293
+ {/* In-card back — thumb-reachable, so users don't
934
1294
  have to stretch to the top-left corner arrow.
935
1295
  Hidden when there is no welcome stage behind it. */}
936
- <Text
937
- className={
938
- showWelcome
939
- ? "mt-1 text-center font-mono text-sm text-muted-foreground"
940
- : "hidden"
941
- }
942
- onPress={withClickTracking("auth.auth.stage_3", () =>
943
- setStage("welcome")
944
- )}
945
- accessibilityRole="button"
946
- accessibilityLabel="Back"
947
- >
948
- ← Back
949
- </Text>
950
- </CardFooter>
951
- </Animated.View>
952
- </>
953
- )}
954
- </Card>
1296
+ <Text
1297
+ className={
1298
+ showWelcome
1299
+ ? "mt-1 text-center font-mono text-sm text-muted-foreground"
1300
+ : "hidden"
1301
+ }
1302
+ onPress={withClickTracking("auth.auth.stage_3", () =>
1303
+ setStage("welcome")
1304
+ )}
1305
+ accessibilityRole="button"
1306
+ accessibilityLabel="Back"
1307
+ >
1308
+ ← Back
1309
+ </Text>
1310
+ </CardFooter>
1311
+ </Animated.View>
1312
+ </>
1313
+ )}
1314
+ </Card>
1315
+ </View>
955
1316
  </View>
956
1317
  )}
957
1318
  </Animated.View>
958
1319
 
959
- {/* legal footer — Terms/Privacy as popups, no routes */}
960
- <View className="mb-6 mt-4">
1320
+ {/* Legal footer — Terms/Privacy as popups, no routes.
1321
+ Welcome stage ONLY. Pressing "Get Started" and proceeding IS
1322
+ the acknowledgement, so the line belongs beside that button
1323
+ and nowhere else; repeating it under the sign-in and sign-up
1324
+ forms restates a consent the user already gave and crowds two
1325
+ screens that are busier than the welcome. When there is no
1326
+ welcome stage at all (`showWelcome={false}`) it stays on the
1327
+ form. Note there is deliberately NO "but show it on the form
1328
+ if there is no welcome stage" fallback: `showWelcome` is false
1329
+ on every launch after the first, so such a fallback puts the
1330
+ line back under the form permanently — which is exactly the
1331
+ bug it was written to avoid. */}
1332
+ <View className={onWelcome ? "mb-6 mt-4" : "hidden"}>
961
1333
  <MutedText className="text-center text-xs leading-5">
962
1334
  By continuing, you agree to our{" "}
963
1335
  <Text
@@ -979,9 +1351,26 @@ export function AuthScreen({
979
1351
  </Text>
980
1352
  </MutedText>
981
1353
  </View>
1354
+
1355
+ {footer && !showAside ? <View className="mb-6">{footer}</View> : null}
982
1356
  </View>
983
1357
  </ScrollView>
984
1358
  </KeyboardAvoidingView>
1359
+ {/* Wide only. Screen-level and absolutely placed, so it centres on the
1360
+ PAGE rather than under the form column, and drawn after the panes so
1361
+ it sits above them. `pointerEvents="box-none"` keeps it from
1362
+ intercepting a tap meant for anything behind it.
1363
+ On a narrow layout it is rendered IN FLOW instead (below): the card
1364
+ reaches the bottom of a phone screen, and an absolute footer simply
1365
+ lands on top of the card's own back link. */}
1366
+ {footer && showAside ? (
1367
+ <View
1368
+ pointerEvents="box-none"
1369
+ style={{ position: "absolute", left: 0, right: 0, bottom: 16, alignItems: "center" }}
1370
+ >
1371
+ {footer}
1372
+ </View>
1373
+ ) : null}
985
1374
  </SafeAreaView>
986
1375
 
987
1376
  {/* Passkey unlock dialog — non-PRF authenticators. */}