From 9f5289a1017856c303d7fefe28327b30eac3a909 Mon Sep 17 00:00:00 2001
From: Samuel Newman <mozzius@protonmail.com>
Date: Wed, 13 Mar 2024 23:34:01 +0000
Subject: alf the login form

---
 src/components/forms/TextField.tsx | 8 +++++++-
 1 file changed, 7 insertions(+), 1 deletion(-)

(limited to 'src/components/forms/TextField.tsx')

diff --git a/src/components/forms/TextField.tsx b/src/components/forms/TextField.tsx
index b37f4bfae..3cffe5b2b 100644
--- a/src/components/forms/TextField.tsx
+++ b/src/components/forms/TextField.tsx
@@ -14,6 +14,7 @@ import {useTheme, atoms as a, web, android} from '#/alf'
 import {Text} from '#/components/Typography'
 import {useInteractionState} from '#/components/hooks/useInteractionState'
 import {Props as SVGIconProps} from '#/components/icons/common'
+import {mergeRefs} from '#/lib/merge-refs'
 
 const Context = React.createContext<{
   inputRef: React.RefObject<TextInput> | null
@@ -128,6 +129,7 @@ export type InputProps = Omit<TextInputProps, 'value' | 'onChangeText'> & {
   value: string
   onChangeText: (value: string) => void
   isInvalid?: boolean
+  inputRef?: React.RefObject<TextInput>
 }
 
 export function createInput(Component: typeof TextInput) {
@@ -137,6 +139,7 @@ export function createInput(Component: typeof TextInput) {
     value,
     onChangeText,
     isInvalid,
+    inputRef,
     ...rest
   }: InputProps) {
     const t = useTheme()
@@ -161,19 +164,22 @@ export function createInput(Component: typeof TextInput) {
       )
     }
 
+    const refs = mergeRefs([ctx.inputRef, inputRef!].filter(Boolean))
+
     return (
       <>
         <Component
           accessibilityHint={undefined}
           {...rest}
           accessibilityLabel={label}
-          ref={ctx.inputRef}
+          ref={refs}
           value={value}
           onChangeText={onChangeText}
           onFocus={ctx.onFocus}
           onBlur={ctx.onBlur}
           placeholder={placeholder || label}
           placeholderTextColor={t.palette.contrast_500}
+          keyboardAppearance={t.name === 'light' ? 'light' : 'dark'}
           hitSlop={HITSLOP_20}
           style={[
             a.relative,
-- 
cgit 1.4.1


From a1c4f19731878f7026d398d28e475bbeb7de824a Mon Sep 17 00:00:00 2001
From: Hailey <me@haileyok.com>
Date: Tue, 19 Mar 2024 12:47:46 -0700
Subject: Use ALF for signup flow, improve a11y of signup (#3151)

* Use ALF for signup flow, improve a11y of signup

* adjust padding

* rm log

* org imports

* clarify allowance of hyphens

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* fix a few accessibility items

* Standardise date input across platforms (#3223)

* make the date input consistent across platforms

* integrate into new signup form

* rm log

* add transitions

* show correct # of steps

* use `FormError`

* animate buttons

* use `ScreenTransition`

* fix android text overflow via flex -> flex_1

* change button color

* (android) make date input the same height as others

* fix deps

* fix deps

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
---
 assets/icons/calendar_stroke2_corner0_rounded.svg |   1 +
 assets/icons/envelope_stroke2_corner0_rounded.svg |   1 +
 assets/icons/lock_stroke2_corner0_rounded.svg     |   1 +
 assets/icons/pencil_stroke2_corner0_rounded.svg   |   1 +
 src/components/forms/DateField/index.android.tsx  |  73 ++---
 src/components/forms/DateField/index.shared.tsx   |  99 +++++++
 src/components/forms/DateField/index.tsx          |  59 +++-
 src/components/forms/DateField/index.web.tsx      |   4 +
 src/components/forms/DateField/types.ts           |   1 +
 src/components/forms/TextField.tsx                |   6 +-
 src/components/icons/Calendar.tsx                 |   5 +
 src/components/icons/Envelope.tsx                 |   5 +
 src/lib/strings/handles.ts                        |   2 +
 src/screens/Signup/StepCaptcha.tsx                |  94 +++++++
 src/screens/Signup/StepHandle.tsx                 | 134 +++++++++
 src/screens/Signup/StepInfo.tsx                   | 145 ++++++++++
 src/screens/Signup/index.tsx                      | 225 +++++++++++++++
 src/screens/Signup/state.ts                       | 320 ++++++++++++++++++++++
 src/view/com/auth/LoggedOut.tsx                   |   4 +-
 src/view/com/auth/create/CaptchaWebView.tsx       |  14 +-
 src/view/com/auth/create/CreateAccount.tsx        | 230 ----------------
 src/view/com/auth/create/Policies.tsx             |  14 +-
 src/view/com/auth/create/Step1.tsx                | 261 ------------------
 src/view/com/auth/create/Step2.tsx                | 140 ----------
 src/view/com/auth/create/Step3.tsx                | 114 --------
 src/view/com/auth/create/StepHeader.tsx           |  44 ---
 26 files changed, 1126 insertions(+), 871 deletions(-)
 create mode 100644 assets/icons/calendar_stroke2_corner0_rounded.svg
 create mode 100644 assets/icons/envelope_stroke2_corner0_rounded.svg
 create mode 100644 assets/icons/lock_stroke2_corner0_rounded.svg
 create mode 100644 assets/icons/pencil_stroke2_corner0_rounded.svg
 create mode 100644 src/components/forms/DateField/index.shared.tsx
 create mode 100644 src/components/icons/Calendar.tsx
 create mode 100644 src/components/icons/Envelope.tsx
 create mode 100644 src/screens/Signup/StepCaptcha.tsx
 create mode 100644 src/screens/Signup/StepHandle.tsx
 create mode 100644 src/screens/Signup/StepInfo.tsx
 create mode 100644 src/screens/Signup/index.tsx
 create mode 100644 src/screens/Signup/state.ts
 delete mode 100644 src/view/com/auth/create/CreateAccount.tsx
 delete mode 100644 src/view/com/auth/create/Step1.tsx
 delete mode 100644 src/view/com/auth/create/Step2.tsx
 delete mode 100644 src/view/com/auth/create/Step3.tsx
 delete mode 100644 src/view/com/auth/create/StepHeader.tsx

(limited to 'src/components/forms/TextField.tsx')

diff --git a/assets/icons/calendar_stroke2_corner0_rounded.svg b/assets/icons/calendar_stroke2_corner0_rounded.svg
new file mode 100644
index 000000000..703f389db
--- /dev/null
+++ b/assets/icons/calendar_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M8 2a1 1 0 0 1 1 1v1h6V3a1 1 0 1 1 2 0v1h2a2 2 0 0 1 2 2v13a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2V3a1 1 0 0 1 1-1ZM5 6v3h14V6H5Zm14 5H5v8h14v-8Z" clip-rule="evenodd"/></svg>
\ No newline at end of file
diff --git a/assets/icons/envelope_stroke2_corner0_rounded.svg b/assets/icons/envelope_stroke2_corner0_rounded.svg
new file mode 100644
index 000000000..c3ab45980
--- /dev/null
+++ b/assets/icons/envelope_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M4.568 4h14.864c.252 0 .498 0 .706.017.229.019.499.063.77.201a2 2 0 0 1 .874.874c.138.271.182.541.201.77.017.208.017.454.017.706v10.864c0 .252 0 .498-.017.706a2.022 2.022 0 0 1-.201.77 2 2 0 0 1-.874.874 2.022 2.022 0 0 1-.77.201c-.208.017-.454.017-.706.017H4.568c-.252 0-.498 0-.706-.017a2.022 2.022 0 0 1-.77-.201 2 2 0 0 1-.874-.874 2.022 2.022 0 0 1-.201-.77C2 17.93 2 17.684 2 17.432V6.568c0-.252 0-.498.017-.706.019-.229.063-.499.201-.77a2 2 0 0 1 .874-.874c.271-.138.541-.182.77-.201C4.07 4 4.316 4 4.568 4Zm.456 2L12 11.708 18.976 6H5.024ZM20 7.747l-6.733 5.509a2 2 0 0 1-2.534 0L4 7.746V17.4a8.187 8.187 0 0 0 .011.589h.014c.116.01.278.011.575.011h14.8a8.207 8.207 0 0 0 .589-.012v-.013c.01-.116.011-.279.011-.575V7.747Z" clip-rule="evenodd"/></svg>
\ No newline at end of file
diff --git a/assets/icons/lock_stroke2_corner0_rounded.svg b/assets/icons/lock_stroke2_corner0_rounded.svg
new file mode 100644
index 000000000..8b094ba5e
--- /dev/null
+++ b/assets/icons/lock_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M7 7a5 5 0 0 1 10 0v2h1a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-9a2 2 0 0 1 2-2h1V7Zm-1 4v9h12v-9H6Zm9-2H9V7a3 3 0 1 1 6 0v2Zm-3 4a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0v-3a1 1 0 0 1 1-1Z" clip-rule="evenodd"/></svg>
\ No newline at end of file
diff --git a/assets/icons/pencil_stroke2_corner0_rounded.svg b/assets/icons/pencil_stroke2_corner0_rounded.svg
new file mode 100644
index 000000000..734198989
--- /dev/null
+++ b/assets/icons/pencil_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 21 21"><path fill="#000" fill-rule="evenodd" d="M13.586 1.5a2 2 0 0 1 2.828 0L19.5 4.586a2 2 0 0 1 0 2.828l-13 13A2 2 0 0 1 5.086 21H1a1 1 0 0 1-1-1v-4.086A2 2 0 0 1 .586 14.5l13-13ZM15 2.914l-13 13V19h3.086l13-13L15 2.914ZM11 20a1 1 0 0 1 1-1h7a1 1 0 1 1 0 2h-7a1 1 0 0 1-1-1Z" clip-rule="evenodd"/></svg>
\ No newline at end of file
diff --git a/src/components/forms/DateField/index.android.tsx b/src/components/forms/DateField/index.android.tsx
index 451810a5e..35c2459f0 100644
--- a/src/components/forms/DateField/index.android.tsx
+++ b/src/components/forms/DateField/index.android.tsx
@@ -1,19 +1,12 @@
 import React from 'react'
-import {View, Pressable} from 'react-native'
 
-import {useTheme, atoms} from '#/alf'
-import {Text} from '#/components/Typography'
-import {useInteractionState} from '#/components/hooks/useInteractionState'
+import {useTheme} from '#/alf'
 import * as TextField from '#/components/forms/TextField'
-import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays'
-
 import {DateFieldProps} from '#/components/forms/DateField/types'
-import {
-  localizeDate,
-  toSimpleDateString,
-} from '#/components/forms/DateField/utils'
+import {toSimpleDateString} from '#/components/forms/DateField/utils'
 import DatePicker from 'react-native-date-picker'
 import {isAndroid} from 'platform/detection'
+import {DateFieldButton} from './index.shared'
 
 export * as utils from '#/components/forms/DateField/utils'
 export const Label = TextField.Label
@@ -24,18 +17,10 @@ export function DateField({
   label,
   isInvalid,
   testID,
+  accessibilityHint,
 }: DateFieldProps) {
   const t = useTheme()
   const [open, setOpen] = React.useState(false)
-  const {
-    state: pressed,
-    onIn: onPressIn,
-    onOut: onPressOut,
-  } = useInteractionState()
-  const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
-
-  const {chromeFocus, chromeError, chromeErrorHover} =
-    TextField.useSharedInputStyles()
 
   const onChangeInternal = React.useCallback(
     (date: Date) => {
@@ -47,45 +32,23 @@ export function DateField({
     [onChangeDate, setOpen],
   )
 
+  const onPress = React.useCallback(() => {
+    setOpen(true)
+  }, [])
+
   const onCancel = React.useCallback(() => {
     setOpen(false)
   }, [])
 
   return (
-    <View style={[atoms.relative, atoms.w_full]}>
-      <Pressable
-        aria-label={label}
-        accessibilityLabel={label}
-        accessibilityHint={undefined}
-        onPress={() => setOpen(true)}
-        onPressIn={onPressIn}
-        onPressOut={onPressOut}
-        onFocus={onFocus}
-        onBlur={onBlur}
-        style={[
-          {
-            paddingTop: 16,
-            paddingBottom: 16,
-            borderColor: 'transparent',
-            borderWidth: 2,
-          },
-          atoms.flex_row,
-          atoms.flex_1,
-          atoms.w_full,
-          atoms.px_lg,
-          atoms.rounded_sm,
-          t.atoms.bg_contrast_50,
-          focused || pressed ? chromeFocus : {},
-          isInvalid ? chromeError : {},
-          isInvalid && (focused || pressed) ? chromeErrorHover : {},
-        ]}>
-        <TextField.Icon icon={CalendarDays} />
-
-        <Text
-          style={[atoms.text_md, atoms.pl_xs, t.atoms.text, {paddingTop: 3}]}>
-          {localizeDate(value)}
-        </Text>
-      </Pressable>
+    <>
+      <DateFieldButton
+        label={label}
+        value={value}
+        onPress={onPress}
+        isInvalid={isInvalid}
+        accessibilityHint={accessibilityHint}
+      />
 
       {open && (
         <DatePicker
@@ -99,9 +62,9 @@ export function DateField({
           testID={`${testID}-datepicker`}
           aria-label={label}
           accessibilityLabel={label}
-          accessibilityHint={undefined}
+          accessibilityHint={accessibilityHint}
         />
       )}
-    </View>
+    </>
   )
 }
diff --git a/src/components/forms/DateField/index.shared.tsx b/src/components/forms/DateField/index.shared.tsx
new file mode 100644
index 000000000..29b3e8cb6
--- /dev/null
+++ b/src/components/forms/DateField/index.shared.tsx
@@ -0,0 +1,99 @@
+import React from 'react'
+import {View, Pressable} from 'react-native'
+
+import {atoms as a, android, useTheme, web} from '#/alf'
+import {Text} from '#/components/Typography'
+import {useInteractionState} from '#/components/hooks/useInteractionState'
+import * as TextField from '#/components/forms/TextField'
+import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays'
+import {localizeDate} from './utils'
+
+// looks like a TextField.Input, but is just a button. It'll do something different on each platform on press
+// iOS: open a dialog with an inline date picker
+// Android: open the date picker modal
+
+export function DateFieldButton({
+  label,
+  value,
+  onPress,
+  isInvalid,
+  accessibilityHint,
+}: {
+  label: string
+  value: string
+  onPress: () => void
+  isInvalid?: boolean
+  accessibilityHint?: string
+}) {
+  const t = useTheme()
+
+  const {
+    state: pressed,
+    onIn: onPressIn,
+    onOut: onPressOut,
+  } = useInteractionState()
+  const {
+    state: hovered,
+    onIn: onHoverIn,
+    onOut: onHoverOut,
+  } = useInteractionState()
+  const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
+
+  const {chromeHover, chromeFocus, chromeError, chromeErrorHover} =
+    TextField.useSharedInputStyles()
+
+  return (
+    <View
+      style={[a.relative, a.w_full]}
+      {...web({
+        onMouseOver: onHoverIn,
+        onMouseOut: onHoverOut,
+      })}>
+      <Pressable
+        aria-label={label}
+        accessibilityLabel={label}
+        accessibilityHint={accessibilityHint}
+        onPress={onPress}
+        onPressIn={onPressIn}
+        onPressOut={onPressOut}
+        onFocus={onFocus}
+        onBlur={onBlur}
+        style={[
+          {
+            paddingTop: 12,
+            paddingBottom: 12,
+            paddingLeft: 14,
+            paddingRight: 14,
+            borderColor: 'transparent',
+            borderWidth: 2,
+          },
+          android({
+            minHeight: 57.5,
+          }),
+          a.flex_row,
+          a.flex_1,
+          a.w_full,
+          a.rounded_sm,
+          t.atoms.bg_contrast_25,
+          a.align_center,
+          hovered ? chromeHover : {},
+          focused || pressed ? chromeFocus : {},
+          isInvalid || isInvalid ? chromeError : {},
+          (isInvalid || isInvalid) && (hovered || focused)
+            ? chromeErrorHover
+            : {},
+        ]}>
+        <TextField.Icon icon={CalendarDays} />
+        <Text
+          style={[
+            a.text_md,
+            a.pl_xs,
+            t.atoms.text,
+            {lineHeight: a.text_md.fontSize * 1.1875},
+          ]}>
+          {localizeDate(value)}
+        </Text>
+      </Pressable>
+    </View>
+  )
+}
diff --git a/src/components/forms/DateField/index.tsx b/src/components/forms/DateField/index.tsx
index 49e47a01e..22fa3a9f5 100644
--- a/src/components/forms/DateField/index.tsx
+++ b/src/components/forms/DateField/index.tsx
@@ -1,11 +1,16 @@
 import React from 'react'
 import {View} from 'react-native'
 
-import {useTheme, atoms} from '#/alf'
+import {useTheme, atoms as a} from '#/alf'
 import * as TextField from '#/components/forms/TextField'
 import {toSimpleDateString} from '#/components/forms/DateField/utils'
 import {DateFieldProps} from '#/components/forms/DateField/types'
 import DatePicker from 'react-native-date-picker'
+import * as Dialog from '#/components/Dialog'
+import {DateFieldButton} from './index.shared'
+import {Button, ButtonText} from '#/components/Button'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 
 export * as utils from '#/components/forms/DateField/utils'
 export const Label = TextField.Label
@@ -22,8 +27,12 @@ export function DateField({
   onChangeDate,
   testID,
   label,
+  isInvalid,
+  accessibilityHint,
 }: DateFieldProps) {
+  const {_} = useLingui()
   const t = useTheme()
+  const control = Dialog.useDialogControl()
 
   const onChangeInternal = React.useCallback(
     (date: Date | undefined) => {
@@ -36,17 +45,43 @@ export function DateField({
   )
 
   return (
-    <View style={[atoms.relative, atoms.w_full]}>
-      <DatePicker
-        theme={t.name === 'light' ? 'light' : 'dark'}
-        date={new Date(value)}
-        onDateChange={onChangeInternal}
-        mode="date"
-        testID={`${testID}-datepicker`}
-        aria-label={label}
-        accessibilityLabel={label}
-        accessibilityHint={undefined}
+    <>
+      <DateFieldButton
+        label={label}
+        value={value}
+        onPress={control.open}
+        isInvalid={isInvalid}
+        accessibilityHint={accessibilityHint}
       />
-    </View>
+      <Dialog.Outer control={control} testID={testID}>
+        <Dialog.Handle />
+        <Dialog.Inner label={label}>
+          <View style={a.gap_lg}>
+            <View style={[a.relative, a.w_full, a.align_center]}>
+              <DatePicker
+                theme={t.name === 'light' ? 'light' : 'dark'}
+                date={new Date(value)}
+                onDateChange={onChangeInternal}
+                mode="date"
+                testID={`${testID}-datepicker`}
+                aria-label={label}
+                accessibilityLabel={label}
+                accessibilityHint={accessibilityHint}
+              />
+            </View>
+            <Button
+              label={_(msg`Done`)}
+              onPress={() => control.close()}
+              size="medium"
+              color="primary"
+              variant="solid">
+              <ButtonText>
+                <Trans>Done</Trans>
+              </ButtonText>
+            </Button>
+          </View>
+        </Dialog.Inner>
+      </Dialog.Outer>
+    </>
   )
 }
diff --git a/src/components/forms/DateField/index.web.tsx b/src/components/forms/DateField/index.web.tsx
index 32f38a5d1..a3aa302f9 100644
--- a/src/components/forms/DateField/index.web.tsx
+++ b/src/components/forms/DateField/index.web.tsx
@@ -2,6 +2,7 @@ import React from 'react'
 import {TextInput, TextInputProps, StyleSheet} from 'react-native'
 // @ts-ignore
 import {unstable_createElement} from 'react-native-web'
+import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays'
 
 import * as TextField from '#/components/forms/TextField'
 import {toSimpleDateString} from '#/components/forms/DateField/utils'
@@ -37,6 +38,7 @@ export function DateField({
   label,
   isInvalid,
   testID,
+  accessibilityHint,
 }: DateFieldProps) {
   const handleOnChange = React.useCallback(
     (e: any) => {
@@ -52,12 +54,14 @@ export function DateField({
 
   return (
     <TextField.Root isInvalid={isInvalid}>
+      <TextField.Icon icon={CalendarDays} />
       <Input
         value={value}
         label={label}
         onChange={handleOnChange}
         onChangeText={() => {}}
         testID={testID}
+        accessibilityHint={accessibilityHint}
       />
     </TextField.Root>
   )
diff --git a/src/components/forms/DateField/types.ts b/src/components/forms/DateField/types.ts
index 129f5672d..5400cf903 100644
--- a/src/components/forms/DateField/types.ts
+++ b/src/components/forms/DateField/types.ts
@@ -4,4 +4,5 @@ export type DateFieldProps = {
   label: string
   isInvalid?: boolean
   testID?: string
+  accessibilityHint?: string
 }
diff --git a/src/components/forms/TextField.tsx b/src/components/forms/TextField.tsx
index 3cffe5b2b..376883c9d 100644
--- a/src/components/forms/TextField.tsx
+++ b/src/components/forms/TextField.tsx
@@ -126,8 +126,8 @@ export function useSharedInputStyles() {
 
 export type InputProps = Omit<TextInputProps, 'value' | 'onChangeText'> & {
   label: string
-  value: string
-  onChangeText: (value: string) => void
+  value?: string
+  onChangeText?: (value: string) => void
   isInvalid?: boolean
   inputRef?: React.RefObject<TextInput>
 }
@@ -277,7 +277,7 @@ export function Icon({icon: Comp}: {icon: React.ComponentType<SVGIconProps>}) {
       <Comp
         size="md"
         style={[
-          {color: t.palette.contrast_500, pointerEvents: 'none'},
+          {color: t.palette.contrast_500, pointerEvents: 'none', flexShrink: 0},
           ctx.hovered ? hover : {},
           ctx.focused ? focus : {},
           ctx.isInvalid && ctx.hovered ? errorHover : {},
diff --git a/src/components/icons/Calendar.tsx b/src/components/icons/Calendar.tsx
new file mode 100644
index 000000000..b3816f28b
--- /dev/null
+++ b/src/components/icons/Calendar.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const Calendar_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M8 2a1 1 0 0 1 1 1v1h6V3a1 1 0 1 1 2 0v1h2a2 2 0 0 1 2 2v13a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2V3a1 1 0 0 1 1-1ZM5 6v3h14V6H5Zm14 5H5v8h14v-8Z',
+})
diff --git a/src/components/icons/Envelope.tsx b/src/components/icons/Envelope.tsx
new file mode 100644
index 000000000..8e40346cd
--- /dev/null
+++ b/src/components/icons/Envelope.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const Envelope_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M4.568 4h14.864c.252 0 .498 0 .706.017.229.019.499.063.77.201a2 2 0 0 1 .874.874c.138.271.182.541.201.77.017.208.017.454.017.706v10.864c0 .252 0 .498-.017.706a2.022 2.022 0 0 1-.201.77 2 2 0 0 1-.874.874 2.022 2.022 0 0 1-.77.201c-.208.017-.454.017-.706.017H4.568c-.252 0-.498 0-.706-.017a2.022 2.022 0 0 1-.77-.201 2 2 0 0 1-.874-.874 2.022 2.022 0 0 1-.201-.77C2 17.93 2 17.684 2 17.432V6.568c0-.252 0-.498.017-.706.019-.229.063-.499.201-.77a2 2 0 0 1 .874-.874c.271-.138.541-.182.77-.201C4.07 4 4.316 4 4.568 4Zm.456 2L12 11.708 18.976 6H5.024ZM20 7.747l-6.733 5.509a2 2 0 0 1-2.534 0L4 7.746V17.4a8.187 8.187 0 0 0 .011.589h.014c.116.01.278.011.575.011h14.8a8.207 8.207 0 0 0 .589-.012v-.013c.01-.116.011-.279.011-.575V7.747Z',
+})
diff --git a/src/lib/strings/handles.ts b/src/lib/strings/handles.ts
index a18fef453..bc07b32ec 100644
--- a/src/lib/strings/handles.ts
+++ b/src/lib/strings/handles.ts
@@ -27,6 +27,7 @@ export function sanitizeHandle(handle: string, prefix = ''): string {
 
 export interface IsValidHandle {
   handleChars: boolean
+  hyphenStartOrEnd: boolean
   frontLength: boolean
   totalLength: boolean
   overall: boolean
@@ -39,6 +40,7 @@ export function validateHandle(str: string, userDomain: string): IsValidHandle {
   const results = {
     handleChars:
       !str || (VALIDATE_REGEX.test(fullHandle) && !str.includes('.')),
+    hyphenStartOrEnd: !str.startsWith('-') && !str.endsWith('-'),
     frontLength: str.length >= 3,
     totalLength: fullHandle.length <= 253,
   }
diff --git a/src/screens/Signup/StepCaptcha.tsx b/src/screens/Signup/StepCaptcha.tsx
new file mode 100644
index 000000000..c4181e552
--- /dev/null
+++ b/src/screens/Signup/StepCaptcha.tsx
@@ -0,0 +1,94 @@
+import React from 'react'
+import {ActivityIndicator, StyleSheet, View} from 'react-native'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {nanoid} from 'nanoid/non-secure'
+import {useSignupContext, useSubmitSignup} from '#/screens/Signup/state'
+import {CaptchaWebView} from 'view/com/auth/create/CaptchaWebView'
+import {createFullHandle} from 'lib/strings/handles'
+import {isWeb} from 'platform/detection'
+import {atoms as a, useTheme} from '#/alf'
+import {FormError} from '#/components/forms/FormError'
+import {ScreenTransition} from '#/screens/Login/ScreenTransition'
+
+const CAPTCHA_PATH = '/gate/signup'
+
+export function StepCaptcha() {
+  const {_} = useLingui()
+  const theme = useTheme()
+  const {state, dispatch} = useSignupContext()
+  const submit = useSubmitSignup({state, dispatch})
+
+  const [completed, setCompleted] = React.useState(false)
+
+  const stateParam = React.useMemo(() => nanoid(15), [])
+  const url = React.useMemo(() => {
+    const newUrl = new URL(state.serviceUrl)
+    newUrl.pathname = CAPTCHA_PATH
+    newUrl.searchParams.set(
+      'handle',
+      createFullHandle(state.handle, state.userDomain),
+    )
+    newUrl.searchParams.set('state', stateParam)
+    newUrl.searchParams.set('colorScheme', theme.name)
+
+    return newUrl.href
+  }, [state.serviceUrl, state.handle, state.userDomain, stateParam, theme.name])
+
+  const onSuccess = React.useCallback(
+    (code: string) => {
+      setCompleted(true)
+      submit(code)
+    },
+    [submit],
+  )
+
+  const onError = React.useCallback(() => {
+    dispatch({
+      type: 'setError',
+      value: _(msg`Error receiving captcha response.`),
+    })
+  }, [_, dispatch])
+
+  return (
+    <ScreenTransition>
+      <View style={[a.gap_lg]}>
+        <View style={[styles.container, completed && styles.center]}>
+          {!completed ? (
+            <CaptchaWebView
+              url={url}
+              stateParam={stateParam}
+              state={state}
+              onSuccess={onSuccess}
+              onError={onError}
+            />
+          ) : (
+            <ActivityIndicator size="large" />
+          )}
+        </View>
+        <FormError error={state.error} />
+      </View>
+    </ScreenTransition>
+  )
+}
+
+const styles = StyleSheet.create({
+  error: {
+    borderRadius: 6,
+    marginTop: 10,
+  },
+  // @ts-expect-error: Suppressing error due to incomplete `ViewStyle` type definition in react-native-web, missing `cursor` prop as discussed in https://github.com/necolas/react-native-web/issues/832.
+  touchable: {
+    ...(isWeb && {cursor: 'pointer'}),
+  },
+  container: {
+    minHeight: 500,
+    width: '100%',
+    paddingBottom: 20,
+    overflow: 'hidden',
+  },
+  center: {
+    alignItems: 'center',
+    justifyContent: 'center',
+  },
+})
diff --git a/src/screens/Signup/StepHandle.tsx b/src/screens/Signup/StepHandle.tsx
new file mode 100644
index 000000000..e0a79e8fb
--- /dev/null
+++ b/src/screens/Signup/StepHandle.tsx
@@ -0,0 +1,134 @@
+import React from 'react'
+import {View} from 'react-native'
+import {useFocusEffect} from '@react-navigation/native'
+import {useLingui} from '@lingui/react'
+import {msg, Trans} from '@lingui/macro'
+import {TimesLarge_Stroke2_Corner0_Rounded as Times} from '#/components/icons/Times'
+import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
+import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At'
+import * as TextField from '#/components/forms/TextField'
+import {useSignupContext} from '#/screens/Signup/state'
+import {Text} from '#/components/Typography'
+import {atoms as a, useTheme} from '#/alf'
+import {
+  createFullHandle,
+  IsValidHandle,
+  validateHandle,
+} from 'lib/strings/handles'
+import {ScreenTransition} from '#/screens/Login/ScreenTransition'
+
+export function StepHandle() {
+  const {_} = useLingui()
+  const t = useTheme()
+  const {state, dispatch} = useSignupContext()
+
+  const [validCheck, setValidCheck] = React.useState<IsValidHandle>({
+    handleChars: false,
+    hyphenStartOrEnd: false,
+    frontLength: false,
+    totalLength: true,
+    overall: false,
+  })
+
+  useFocusEffect(
+    React.useCallback(() => {
+      console.log('run')
+      setValidCheck(validateHandle(state.handle, state.userDomain))
+    }, [state.handle, state.userDomain]),
+  )
+
+  const onHandleChange = React.useCallback(
+    (value: string) => {
+      if (state.error) {
+        dispatch({type: 'setError', value: ''})
+      }
+
+      dispatch({
+        type: 'setHandle',
+        value,
+      })
+    },
+    [dispatch, state.error],
+  )
+
+  return (
+    <ScreenTransition>
+      <View style={[a.gap_lg]}>
+        <View>
+          <TextField.Root>
+            <TextField.Icon icon={At} />
+            <TextField.Input
+              onChangeText={onHandleChange}
+              label={_(msg`Input your user handle`)}
+              defaultValue={state.handle}
+              autoCapitalize="none"
+              autoCorrect={false}
+              autoFocus
+              autoComplete="off"
+            />
+          </TextField.Root>
+        </View>
+        <Text style={[a.text_md]}>
+          <Trans>Your full handle will be</Trans>{' '}
+          <Text style={[a.text_md, a.font_bold]}>
+            @{createFullHandle(state.handle, state.userDomain)}
+          </Text>
+        </Text>
+
+        <View
+          style={[
+            a.w_full,
+            a.rounded_sm,
+            a.border,
+            a.p_md,
+            a.gap_sm,
+            t.atoms.border_contrast_low,
+          ]}>
+          {state.error ? (
+            <View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}>
+              <IsValidIcon valid={false} />
+              <Text style={[a.text_md, a.flex_1]}>{state.error}</Text>
+            </View>
+          ) : undefined}
+          {validCheck.hyphenStartOrEnd ? (
+            <View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}>
+              <IsValidIcon valid={validCheck.handleChars} />
+              <Text style={[a.text_md, a.flex_1]}>
+                <Trans>Only contains letters, numbers, and hyphens</Trans>
+              </Text>
+            </View>
+          ) : (
+            <View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}>
+              <IsValidIcon valid={validCheck.hyphenStartOrEnd} />
+              <Text style={[a.text_md, a.flex_1]}>
+                <Trans>Doesn't begin or end with a hyphen</Trans>
+              </Text>
+            </View>
+          )}
+          <View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}>
+            <IsValidIcon
+              valid={validCheck.frontLength && validCheck.totalLength}
+            />
+            {!validCheck.totalLength ? (
+              <Text style={[a.text_md, a.flex_1]}>
+                <Trans>No longer than 253 characters</Trans>
+              </Text>
+            ) : (
+              <Text style={[a.text_md, a.flex_1]}>
+                <Trans>At least 3 characters</Trans>
+              </Text>
+            )}
+          </View>
+        </View>
+      </View>
+    </ScreenTransition>
+  )
+}
+
+function IsValidIcon({valid}: {valid: boolean}) {
+  const t = useTheme()
+  if (!valid) {
+    return <Times size="md" style={{color: t.palette.negative_500}} />
+  }
+  return <Check size="md" style={{color: t.palette.positive_700}} />
+}
diff --git a/src/screens/Signup/StepInfo.tsx b/src/screens/Signup/StepInfo.tsx
new file mode 100644
index 000000000..30a31884a
--- /dev/null
+++ b/src/screens/Signup/StepInfo.tsx
@@ -0,0 +1,145 @@
+import React from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {atoms as a} from '#/alf'
+import * as TextField from '#/components/forms/TextField'
+import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '#/components/icons/Envelope'
+import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock'
+import {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticket'
+import {is13, is18, useSignupContext} from '#/screens/Signup/state'
+import * as DateField from '#/components/forms/DateField'
+import {logger} from '#/logger'
+import {Loader} from '#/components/Loader'
+import {Policies} from 'view/com/auth/create/Policies'
+import {HostingProvider} from '#/components/forms/HostingProvider'
+import {FormError} from '#/components/forms/FormError'
+import {ScreenTransition} from '#/screens/Login/ScreenTransition'
+
+function sanitizeDate(date: Date): Date {
+  if (!date || date.toString() === 'Invalid Date') {
+    logger.error(`Create account: handled invalid date for birthDate`, {
+      hasDate: !!date,
+    })
+    return new Date()
+  }
+  return date
+}
+
+export function StepInfo() {
+  const {_} = useLingui()
+  const {state, dispatch} = useSignupContext()
+
+  return (
+    <ScreenTransition>
+      <View style={[a.gap_lg]}>
+        <FormError error={state.error} />
+        <View>
+          <TextField.Label>
+            <Trans>Hosting provider</Trans>
+          </TextField.Label>
+          <HostingProvider
+            serviceUrl={state.serviceUrl}
+            onSelectServiceUrl={v =>
+              dispatch({type: 'setServiceUrl', value: v})
+            }
+          />
+        </View>
+        {state.isLoading ? (
+          <View style={[a.align_center]}>
+            <Loader size="xl" />
+          </View>
+        ) : state.serviceDescription ? (
+          <>
+            {state.serviceDescription.inviteCodeRequired && (
+              <View>
+                <TextField.Label>
+                  <Trans>Invite code</Trans>
+                </TextField.Label>
+                <TextField.Root>
+                  <TextField.Icon icon={Ticket} />
+                  <TextField.Input
+                    onChangeText={value => {
+                      dispatch({
+                        type: 'setInviteCode',
+                        value: value.trim(),
+                      })
+                    }}
+                    label={_(msg`Required for this provider`)}
+                    defaultValue={state.inviteCode}
+                    autoCapitalize="none"
+                    autoComplete="email"
+                    keyboardType="email-address"
+                  />
+                </TextField.Root>
+              </View>
+            )}
+            <View>
+              <TextField.Label>
+                <Trans>Email</Trans>
+              </TextField.Label>
+              <TextField.Root>
+                <TextField.Icon icon={Envelope} />
+                <TextField.Input
+                  onChangeText={value => {
+                    dispatch({
+                      type: 'setEmail',
+                      value: value.trim(),
+                    })
+                  }}
+                  label={_(msg`Enter your email address`)}
+                  defaultValue={state.email}
+                  autoCapitalize="none"
+                  autoComplete="email"
+                  keyboardType="email-address"
+                />
+              </TextField.Root>
+            </View>
+            <View>
+              <TextField.Label>
+                <Trans>Password</Trans>
+              </TextField.Label>
+              <TextField.Root>
+                <TextField.Icon icon={Lock} />
+                <TextField.Input
+                  onChangeText={value => {
+                    dispatch({
+                      type: 'setPassword',
+                      value,
+                    })
+                  }}
+                  label={_(msg`Choose your password`)}
+                  defaultValue={state.password}
+                  secureTextEntry
+                  autoComplete="new-password"
+                />
+              </TextField.Root>
+            </View>
+            <View>
+              <DateField.Label>
+                <Trans>Your birth date</Trans>
+              </DateField.Label>
+              <DateField.DateField
+                testID="date"
+                value={DateField.utils.toSimpleDateString(state.dateOfBirth)}
+                onChangeDate={date => {
+                  dispatch({
+                    type: 'setDateOfBirth',
+                    value: sanitizeDate(new Date(date)),
+                  })
+                }}
+                label={_(msg`Date of birth`)}
+                accessibilityHint={_(msg`Select your date of birth`)}
+              />
+            </View>
+            <Policies
+              serviceDescription={state.serviceDescription}
+              needsGuardian={!is18(state.dateOfBirth)}
+              under13={!is13(state.dateOfBirth)}
+            />
+          </>
+        ) : undefined}
+      </View>
+    </ScreenTransition>
+  )
+}
diff --git a/src/screens/Signup/index.tsx b/src/screens/Signup/index.tsx
new file mode 100644
index 000000000..b1acbbdf0
--- /dev/null
+++ b/src/screens/Signup/index.tsx
@@ -0,0 +1,225 @@
+import React from 'react'
+import {ScrollView, View} from 'react-native'
+import {useLingui} from '@lingui/react'
+import {msg, Trans} from '@lingui/macro'
+import {
+  initialState,
+  reducer,
+  SignupContext,
+  SignupStep,
+  useSubmitSignup,
+} from '#/screens/Signup/state'
+import {StepInfo} from '#/screens/Signup/StepInfo'
+import {StepHandle} from '#/screens/Signup/StepHandle'
+import {StepCaptcha} from '#/screens/Signup/StepCaptcha'
+import {atoms as a, useTheme} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
+import {Text} from '#/components/Typography'
+import {LoggedOutLayout} from 'view/com/util/layouts/LoggedOutLayout'
+import {FEEDBACK_FORM_URL} from 'lib/constants'
+import {InlineLink} from '#/components/Link'
+import {useServiceQuery} from 'state/queries/service'
+import {getAgent} from 'state/session'
+import {createFullHandle} from 'lib/strings/handles'
+import {useAnalytics} from 'lib/analytics/analytics'
+
+export function Signup({onPressBack}: {onPressBack: () => void}) {
+  const {_} = useLingui()
+  const t = useTheme()
+  const {screen} = useAnalytics()
+  const [state, dispatch] = React.useReducer(reducer, initialState)
+  const submit = useSubmitSignup({state, dispatch})
+
+  const {
+    data: serviceInfo,
+    isFetching,
+    isError,
+    refetch,
+  } = useServiceQuery(state.serviceUrl)
+
+  React.useEffect(() => {
+    screen('CreateAccount')
+  }, [screen])
+
+  React.useEffect(() => {
+    if (isFetching) {
+      dispatch({type: 'setIsLoading', value: true})
+    } else if (!isFetching) {
+      dispatch({type: 'setIsLoading', value: false})
+    }
+  }, [isFetching])
+
+  React.useEffect(() => {
+    if (isError) {
+      dispatch({type: 'setServiceDescription', value: undefined})
+      dispatch({
+        type: 'setError',
+        value: _(
+          msg`Unable to contact your service. Please check your Internet connection.`,
+        ),
+      })
+    } else if (serviceInfo) {
+      dispatch({type: 'setServiceDescription', value: serviceInfo})
+      dispatch({type: 'setError', value: ''})
+    }
+  }, [_, serviceInfo, isError])
+
+  const onNextPress = React.useCallback(async () => {
+    if (state.activeStep === SignupStep.HANDLE) {
+      try {
+        dispatch({type: 'setIsLoading', value: true})
+
+        const res = await getAgent().resolveHandle({
+          handle: createFullHandle(state.handle, state.userDomain),
+        })
+
+        if (res.data.did) {
+          dispatch({
+            type: 'setError',
+            value: _(msg`That handle is already taken.`),
+          })
+          return
+        }
+      } catch (e) {
+        // Don't have to handle
+      } finally {
+        dispatch({type: 'setIsLoading', value: false})
+      }
+    }
+
+    // phoneVerificationRequired is actually whether a captcha is required
+    if (
+      state.activeStep === SignupStep.HANDLE &&
+      !state.serviceDescription?.phoneVerificationRequired
+    ) {
+      submit()
+      return
+    }
+
+    dispatch({type: 'next'})
+  }, [
+    _,
+    state.activeStep,
+    state.handle,
+    state.serviceDescription?.phoneVerificationRequired,
+    state.userDomain,
+    submit,
+  ])
+
+  const onBackPress = React.useCallback(() => {
+    if (state.activeStep !== SignupStep.INFO) {
+      dispatch({type: 'prev'})
+    } else {
+      onPressBack()
+    }
+  }, [onPressBack, state.activeStep])
+
+  return (
+    <SignupContext.Provider value={{state, dispatch}}>
+      <LoggedOutLayout
+        leadin=""
+        title={_(msg`Create Account`)}
+        description={_(msg`We're so excited to have you join us!`)}>
+        <ScrollView
+          testID="createAccount"
+          keyboardShouldPersistTaps="handled"
+          style={a.h_full}
+          keyboardDismissMode="on-drag">
+          <View
+            style={[
+              a.flex_1,
+              a.px_xl,
+              a.gap_3xl,
+              a.pt_2xl,
+              {paddingBottom: 100},
+            ]}>
+            <View style={[a.gap_sm]}>
+              <Text style={[a.text_lg, t.atoms.text_contrast_medium]}>
+                <Trans>Step</Trans> {state.activeStep + 1} <Trans>of</Trans>{' '}
+                {state.serviceDescription &&
+                !state.serviceDescription.phoneVerificationRequired
+                  ? '2'
+                  : '3'}
+              </Text>
+              <Text style={[a.text_3xl, a.font_bold]}>
+                {state.activeStep === SignupStep.INFO ? (
+                  <Trans>Your account</Trans>
+                ) : state.activeStep === SignupStep.HANDLE ? (
+                  <Trans>Your user handle</Trans>
+                ) : (
+                  <Trans>Complete the challenge</Trans>
+                )}
+              </Text>
+            </View>
+            <View>
+              {state.activeStep === SignupStep.INFO ? (
+                <StepInfo />
+              ) : state.activeStep === SignupStep.HANDLE ? (
+                <StepHandle />
+              ) : (
+                <StepCaptcha />
+              )}
+            </View>
+
+            <View style={[a.flex_row, a.justify_between]}>
+              <Button
+                label="Back"
+                variant="solid"
+                color="secondary"
+                size="small"
+                onPress={onBackPress}>
+                Back
+              </Button>
+              {state.activeStep !== SignupStep.CAPTCHA && (
+                <>
+                  {isError ? (
+                    <Button
+                      label="Retry"
+                      variant="solid"
+                      color="primary"
+                      size="small"
+                      disabled={state.isLoading}
+                      onPress={() => refetch()}>
+                      Retry
+                    </Button>
+                  ) : (
+                    <Button
+                      label="Next"
+                      variant="solid"
+                      color={
+                        !state.canNext || state.isLoading
+                          ? 'secondary'
+                          : 'primary'
+                      }
+                      size="small"
+                      disabled={!state.canNext || state.isLoading}
+                      onPress={onNextPress}>
+                      <ButtonText>Next</ButtonText>
+                    </Button>
+                  )}
+                </>
+              )}
+            </View>
+            <View
+              style={[
+                a.w_full,
+                a.py_lg,
+                a.px_md,
+                a.rounded_sm,
+                t.atoms.bg_contrast_25,
+              ]}>
+              <Text style={[a.text_md, t.atoms.text_contrast_medium]}>
+                <Trans>Having trouble?</Trans>{' '}
+                <InlineLink
+                  style={[a.text_md]}
+                  to={FEEDBACK_FORM_URL({email: state.email})}>
+                  <Trans>Contact support</Trans>
+                </InlineLink>
+              </Text>
+            </View>
+          </View>
+        </ScrollView>
+      </LoggedOutLayout>
+    </SignupContext.Provider>
+  )
+}
diff --git a/src/screens/Signup/state.ts b/src/screens/Signup/state.ts
new file mode 100644
index 000000000..1ae43612e
--- /dev/null
+++ b/src/screens/Signup/state.ts
@@ -0,0 +1,320 @@
+import React, {useCallback} from 'react'
+import {LayoutAnimation} from 'react-native'
+import * as EmailValidator from 'email-validator'
+import {useLingui} from '@lingui/react'
+import {msg} from '@lingui/macro'
+import {cleanError} from 'lib/strings/errors'
+import {
+  ComAtprotoServerCreateAccount,
+  ComAtprotoServerDescribeServer,
+} from '@atproto/api'
+
+import {logger} from '#/logger'
+import {DEFAULT_SERVICE, IS_PROD_SERVICE} from 'lib/constants'
+import {createFullHandle, validateHandle} from 'lib/strings/handles'
+import {getAge} from 'lib/strings/time'
+import {useSessionApi} from 'state/session'
+import {
+  DEFAULT_PROD_FEEDS,
+  usePreferencesSetBirthDateMutation,
+  useSetSaveFeedsMutation,
+} from 'state/queries/preferences'
+import {useOnboardingDispatch} from 'state/shell'
+
+export type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
+
+const DEFAULT_DATE = new Date(Date.now() - 60e3 * 60 * 24 * 365 * 20) // default to 20 years ago
+
+export enum SignupStep {
+  INFO,
+  HANDLE,
+  CAPTCHA,
+}
+
+export type SignupState = {
+  hasPrev: boolean
+  canNext: boolean
+  activeStep: SignupStep
+
+  serviceUrl: string
+  serviceDescription?: ServiceDescription
+  userDomain: string
+  dateOfBirth: Date
+  email: string
+  password: string
+  inviteCode: string
+  handle: string
+
+  error: string
+  isLoading: boolean
+}
+
+export type SignupAction =
+  | {type: 'prev'}
+  | {type: 'next'}
+  | {type: 'finish'}
+  | {type: 'setStep'; value: SignupStep}
+  | {type: 'setServiceUrl'; value: string}
+  | {type: 'setServiceDescription'; value: ServiceDescription | undefined}
+  | {type: 'setEmail'; value: string}
+  | {type: 'setPassword'; value: string}
+  | {type: 'setDateOfBirth'; value: Date}
+  | {type: 'setInviteCode'; value: string}
+  | {type: 'setHandle'; value: string}
+  | {type: 'setVerificationCode'; value: string}
+  | {type: 'setError'; value: string}
+  | {type: 'setCanNext'; value: boolean}
+  | {type: 'setIsLoading'; value: boolean}
+
+export const initialState: SignupState = {
+  hasPrev: false,
+  canNext: false,
+  activeStep: SignupStep.INFO,
+
+  serviceUrl: DEFAULT_SERVICE,
+  serviceDescription: undefined,
+  userDomain: '',
+  dateOfBirth: DEFAULT_DATE,
+  email: '',
+  password: '',
+  handle: '',
+  inviteCode: '',
+
+  error: '',
+  isLoading: false,
+}
+
+export function is13(date: Date) {
+  return getAge(date) >= 13
+}
+
+export function is18(date: Date) {
+  return getAge(date) >= 18
+}
+
+export function reducer(s: SignupState, a: SignupAction): SignupState {
+  let next = {...s}
+
+  switch (a.type) {
+    case 'prev': {
+      if (s.activeStep !== SignupStep.INFO) {
+        LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
+        next.activeStep--
+        next.error = ''
+      }
+      break
+    }
+    case 'next': {
+      if (s.activeStep !== SignupStep.CAPTCHA) {
+        LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
+        next.activeStep++
+        next.error = ''
+      }
+      break
+    }
+    case 'setStep': {
+      next.activeStep = a.value
+      break
+    }
+    case 'setServiceUrl': {
+      next.serviceUrl = a.value
+      break
+    }
+    case 'setServiceDescription': {
+      LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
+
+      next.serviceDescription = a.value
+      next.userDomain = a.value?.availableUserDomains[0] ?? ''
+      next.isLoading = false
+      break
+    }
+
+    case 'setEmail': {
+      next.email = a.value
+      break
+    }
+    case 'setPassword': {
+      next.password = a.value
+      break
+    }
+    case 'setDateOfBirth': {
+      next.dateOfBirth = a.value
+      break
+    }
+    case 'setInviteCode': {
+      next.inviteCode = a.value
+      break
+    }
+    case 'setHandle': {
+      next.handle = a.value
+      break
+    }
+    case 'setCanNext': {
+      next.canNext = a.value
+      break
+    }
+    case 'setIsLoading': {
+      next.isLoading = a.value
+      break
+    }
+    case 'setError': {
+      next.error = a.value
+      break
+    }
+  }
+
+  next.hasPrev = next.activeStep !== SignupStep.INFO
+
+  switch (next.activeStep) {
+    case SignupStep.INFO: {
+      const isValidEmail = EmailValidator.validate(next.email)
+      next.canNext =
+        !!(next.email && next.password && next.dateOfBirth) &&
+        (!next.serviceDescription?.inviteCodeRequired || !!next.inviteCode) &&
+        is13(next.dateOfBirth) &&
+        isValidEmail
+      break
+    }
+    case SignupStep.HANDLE: {
+      next.canNext =
+        !!next.handle && validateHandle(next.handle, next.userDomain).overall
+      break
+    }
+  }
+
+  logger.debug('signup', next)
+
+  if (s.activeStep !== next.activeStep) {
+    logger.debug('signup: step changed', {activeStep: next.activeStep})
+  }
+
+  return next
+}
+
+interface IContext {
+  state: SignupState
+  dispatch: React.Dispatch<SignupAction>
+}
+export const SignupContext = React.createContext<IContext>({} as IContext)
+export const useSignupContext = () => React.useContext(SignupContext)
+
+export function useSubmitSignup({
+  state,
+  dispatch,
+}: {
+  state: SignupState
+  dispatch: (action: SignupAction) => void
+}) {
+  const {_} = useLingui()
+  const {createAccount} = useSessionApi()
+  const {mutate: setBirthDate} = usePreferencesSetBirthDateMutation()
+  const {mutate: setSavedFeeds} = useSetSaveFeedsMutation()
+  const onboardingDispatch = useOnboardingDispatch()
+
+  return useCallback(
+    async (verificationCode?: string) => {
+      if (!state.email) {
+        dispatch({type: 'setStep', value: SignupStep.INFO})
+        return dispatch({
+          type: 'setError',
+          value: _(msg`Please enter your email.`),
+        })
+      }
+      if (!EmailValidator.validate(state.email)) {
+        dispatch({type: 'setStep', value: SignupStep.INFO})
+        return dispatch({
+          type: 'setError',
+          value: _(msg`Your email appears to be invalid.`),
+        })
+      }
+      if (!state.password) {
+        dispatch({type: 'setStep', value: SignupStep.INFO})
+        return dispatch({
+          type: 'setError',
+          value: _(msg`Please choose your password.`),
+        })
+      }
+      if (!state.handle) {
+        dispatch({type: 'setStep', value: SignupStep.HANDLE})
+        return dispatch({
+          type: 'setError',
+          value: _(msg`Please choose your handle.`),
+        })
+      }
+      if (
+        state.serviceDescription?.phoneVerificationRequired &&
+        !verificationCode
+      ) {
+        dispatch({type: 'setStep', value: SignupStep.CAPTCHA})
+        return dispatch({
+          type: 'setError',
+          value: _(msg`Please complete the verification captcha.`),
+        })
+      }
+      dispatch({type: 'setError', value: ''})
+      dispatch({type: 'setIsLoading', value: true})
+
+      try {
+        onboardingDispatch({type: 'start'}) // start now to avoid flashing the wrong view
+        await createAccount({
+          service: state.serviceUrl,
+          email: state.email,
+          handle: createFullHandle(state.handle, state.userDomain),
+          password: state.password,
+          inviteCode: state.inviteCode.trim(),
+          verificationCode: verificationCode,
+        })
+        setBirthDate({birthDate: state.dateOfBirth})
+        if (IS_PROD_SERVICE(state.serviceUrl)) {
+          setSavedFeeds(DEFAULT_PROD_FEEDS)
+        }
+      } catch (e: any) {
+        onboardingDispatch({type: 'skip'}) // undo starting the onboard
+        let errMsg = e.toString()
+        if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) {
+          dispatch({
+            type: 'setError',
+            value: _(
+              msg`Invite code not accepted. Check that you input it correctly and try again.`,
+            ),
+          })
+          dispatch({type: 'setStep', value: SignupStep.INFO})
+          return
+        }
+
+        if ([400, 429].includes(e.status)) {
+          logger.warn('Failed to create account', {message: e})
+        } else {
+          logger.error(`Failed to create account (${e.status} status)`, {
+            message: e,
+          })
+        }
+
+        const error = cleanError(errMsg)
+        const isHandleError = error.toLowerCase().includes('handle')
+
+        dispatch({type: 'setIsLoading', value: false})
+        dispatch({type: 'setError', value: cleanError(errMsg)})
+        dispatch({type: 'setStep', value: isHandleError ? 2 : 1})
+      } finally {
+        dispatch({type: 'setIsLoading', value: false})
+      }
+    },
+    [
+      state.email,
+      state.password,
+      state.handle,
+      state.serviceDescription?.phoneVerificationRequired,
+      state.serviceUrl,
+      state.userDomain,
+      state.inviteCode,
+      state.dateOfBirth,
+      dispatch,
+      _,
+      onboardingDispatch,
+      createAccount,
+      setBirthDate,
+      setSavedFeeds,
+    ],
+  )
+}
diff --git a/src/view/com/auth/LoggedOut.tsx b/src/view/com/auth/LoggedOut.tsx
index 58604ec9e..b22bbb7fe 100644
--- a/src/view/com/auth/LoggedOut.tsx
+++ b/src/view/com/auth/LoggedOut.tsx
@@ -7,7 +7,7 @@ import {useNavigation} from '@react-navigation/native'
 
 import {isIOS, isNative} from '#/platform/detection'
 import {Login} from '#/screens/Login'
-import {CreateAccount} from '#/view/com/auth/create/CreateAccount'
+import {Signup} from '#/screens/Signup'
 import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
 import {s} from '#/lib/styles'
 import {usePalette} from '#/lib/hooks/usePalette'
@@ -148,7 +148,7 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
           />
         ) : undefined}
         {screenState === ScreenState.S_CreateAccount ? (
-          <CreateAccount
+          <Signup
             onPressBack={() =>
               setScreenState(ScreenState.S_LoginOrCreateAccount)
             }
diff --git a/src/view/com/auth/create/CaptchaWebView.tsx b/src/view/com/auth/create/CaptchaWebView.tsx
index b0de8b4a4..06b605e4d 100644
--- a/src/view/com/auth/create/CaptchaWebView.tsx
+++ b/src/view/com/auth/create/CaptchaWebView.tsx
@@ -2,7 +2,7 @@ import React from 'react'
 import {WebView, WebViewNavigation} from 'react-native-webview'
 import {ShouldStartLoadRequest} from 'react-native-webview/lib/WebViewTypes'
 import {StyleSheet} from 'react-native'
-import {CreateAccountState} from 'view/com/auth/create/state'
+import {SignupState} from '#/screens/Signup/state'
 
 const ALLOWED_HOSTS = [
   'bsky.social',
@@ -17,24 +17,24 @@ const ALLOWED_HOSTS = [
 export function CaptchaWebView({
   url,
   stateParam,
-  uiState,
+  state,
   onSuccess,
   onError,
 }: {
   url: string
   stateParam: string
-  uiState?: CreateAccountState
+  state?: SignupState
   onSuccess: (code: string) => void
   onError: () => void
 }) {
   const redirectHost = React.useMemo(() => {
-    if (!uiState?.serviceUrl) return 'bsky.app'
+    if (!state?.serviceUrl) return 'bsky.app'
 
-    return uiState?.serviceUrl &&
-      new URL(uiState?.serviceUrl).host === 'staging.bsky.dev'
+    return state?.serviceUrl &&
+      new URL(state?.serviceUrl).host === 'staging.bsky.dev'
       ? 'staging.bsky.app'
       : 'bsky.app'
-  }, [uiState?.serviceUrl])
+  }, [state?.serviceUrl])
 
   const wasSuccessful = React.useRef(false)
 
diff --git a/src/view/com/auth/create/CreateAccount.tsx b/src/view/com/auth/create/CreateAccount.tsx
deleted file mode 100644
index d193802fe..000000000
--- a/src/view/com/auth/create/CreateAccount.tsx
+++ /dev/null
@@ -1,230 +0,0 @@
-import React from 'react'
-import {
-  ActivityIndicator,
-  ScrollView,
-  StyleSheet,
-  TouchableOpacity,
-  View,
-} from 'react-native'
-import {useAnalytics} from 'lib/analytics/analytics'
-import {Text} from '../../util/text/Text'
-import {LoggedOutLayout} from 'view/com/util/layouts/LoggedOutLayout'
-import {s} from 'lib/styles'
-import {usePalette} from 'lib/hooks/usePalette'
-import {msg, Trans} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import {useCreateAccount, useSubmitCreateAccount} from './state'
-import {useServiceQuery} from '#/state/queries/service'
-import {FEEDBACK_FORM_URL, HITSLOP_10} from '#/lib/constants'
-
-import {Step1} from './Step1'
-import {Step2} from './Step2'
-import {Step3} from './Step3'
-import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
-import {TextLink} from '../../util/Link'
-import {getAgent} from 'state/session'
-import {createFullHandle, validateHandle} from 'lib/strings/handles'
-
-export function CreateAccount({onPressBack}: {onPressBack: () => void}) {
-  const {screen} = useAnalytics()
-  const pal = usePalette('default')
-  const {_} = useLingui()
-  const [uiState, uiDispatch] = useCreateAccount()
-  const {isTabletOrDesktop} = useWebMediaQueries()
-  const submit = useSubmitCreateAccount(uiState, uiDispatch)
-
-  React.useEffect(() => {
-    screen('CreateAccount')
-  }, [screen])
-
-  // fetch service info
-  // =
-
-  const {
-    data: serviceInfo,
-    isFetching: serviceInfoIsFetching,
-    error: serviceInfoError,
-    refetch: refetchServiceInfo,
-  } = useServiceQuery(uiState.serviceUrl)
-
-  React.useEffect(() => {
-    if (serviceInfo) {
-      uiDispatch({type: 'set-service-description', value: serviceInfo})
-      uiDispatch({type: 'set-error', value: ''})
-    } else if (serviceInfoError) {
-      uiDispatch({
-        type: 'set-error',
-        value: _(
-          msg`Unable to contact your service. Please check your Internet connection.`,
-        ),
-      })
-    }
-  }, [_, uiDispatch, serviceInfo, serviceInfoError])
-
-  // event handlers
-  // =
-
-  const onPressBackInner = React.useCallback(() => {
-    if (uiState.canBack) {
-      uiDispatch({type: 'back'})
-    } else {
-      onPressBack()
-    }
-  }, [uiState, uiDispatch, onPressBack])
-
-  const onPressNext = React.useCallback(async () => {
-    if (!uiState.canNext) {
-      return
-    }
-
-    if (uiState.step === 2) {
-      if (!validateHandle(uiState.handle, uiState.userDomain).overall) {
-        return
-      }
-
-      uiDispatch({type: 'set-processing', value: true})
-      try {
-        const res = await getAgent().resolveHandle({
-          handle: createFullHandle(uiState.handle, uiState.userDomain),
-        })
-
-        if (res.data.did) {
-          uiDispatch({
-            type: 'set-error',
-            value: _(msg`That handle is already taken.`),
-          })
-          return
-        }
-      } catch (e) {
-        // Don't need to handle
-      } finally {
-        uiDispatch({type: 'set-processing', value: false})
-      }
-
-      if (!uiState.isCaptchaRequired) {
-        try {
-          await submit()
-        } catch {
-          // dont need to handle here
-        }
-        // We don't need to go to the next page if there wasn't a captcha required
-        return
-      }
-    }
-
-    uiDispatch({type: 'next'})
-  }, [
-    uiState.canNext,
-    uiState.step,
-    uiState.isCaptchaRequired,
-    uiState.handle,
-    uiState.userDomain,
-    uiDispatch,
-    _,
-    submit,
-  ])
-
-  // rendering
-  // =
-
-  return (
-    <LoggedOutLayout
-      leadin=""
-      title={_(msg`Create Account`)}
-      description={_(msg`We're so excited to have you join us!`)}>
-      <ScrollView
-        testID="createAccount"
-        style={pal.view}
-        keyboardShouldPersistTaps="handled"
-        keyboardDismissMode="on-drag">
-        <View style={styles.stepContainer}>
-          {uiState.step === 1 && (
-            <Step1 uiState={uiState} uiDispatch={uiDispatch} />
-          )}
-          {uiState.step === 2 && (
-            <Step2 uiState={uiState} uiDispatch={uiDispatch} />
-          )}
-          {uiState.step === 3 && (
-            <Step3 uiState={uiState} uiDispatch={uiDispatch} />
-          )}
-        </View>
-        <View style={[s.flexRow, s.pl20, s.pr20]}>
-          <TouchableOpacity
-            onPress={onPressBackInner}
-            testID="backBtn"
-            accessibilityRole="button"
-            hitSlop={HITSLOP_10}>
-            <Text type="xl" style={pal.link}>
-              <Trans>Back</Trans>
-            </Text>
-          </TouchableOpacity>
-          <View style={s.flex1} />
-          {uiState.canNext ? (
-            <TouchableOpacity
-              testID="nextBtn"
-              onPress={onPressNext}
-              accessibilityRole="button"
-              hitSlop={HITSLOP_10}>
-              {uiState.isProcessing ? (
-                <ActivityIndicator />
-              ) : (
-                <Text type="xl-bold" style={[pal.link, s.pr5]}>
-                  <Trans>Next</Trans>
-                </Text>
-              )}
-            </TouchableOpacity>
-          ) : serviceInfoError ? (
-            <TouchableOpacity
-              testID="retryConnectBtn"
-              onPress={() => refetchServiceInfo()}
-              accessibilityRole="button"
-              accessibilityLabel={_(msg`Retry`)}
-              accessibilityHint=""
-              accessibilityLiveRegion="polite"
-              hitSlop={HITSLOP_10}>
-              <Text type="xl-bold" style={[pal.link, s.pr5]}>
-                <Trans>Retry</Trans>
-              </Text>
-            </TouchableOpacity>
-          ) : serviceInfoIsFetching ? (
-            <>
-              <ActivityIndicator color="#fff" />
-              <Text type="xl" style={[pal.text, s.pr5]}>
-                <Trans>Connecting...</Trans>
-              </Text>
-            </>
-          ) : undefined}
-        </View>
-
-        <View style={styles.stepContainer}>
-          <View
-            style={[
-              s.flexRow,
-              s.alignCenter,
-              pal.viewLight,
-              {borderRadius: 8, paddingHorizontal: 14, paddingVertical: 12},
-            ]}>
-            <Text type="md" style={pal.textLight}>
-              <Trans>Having trouble?</Trans>{' '}
-            </Text>
-            <TextLink
-              type="md"
-              style={pal.link}
-              text={_(msg`Contact support`)}
-              href={FEEDBACK_FORM_URL({email: uiState.email})}
-            />
-          </View>
-        </View>
-
-        <View style={{height: isTabletOrDesktop ? 50 : 400}} />
-      </ScrollView>
-    </LoggedOutLayout>
-  )
-}
-
-const styles = StyleSheet.create({
-  stepContainer: {
-    paddingHorizontal: 20,
-    paddingVertical: 20,
-  },
-})
diff --git a/src/view/com/auth/create/Policies.tsx b/src/view/com/auth/create/Policies.tsx
index 2c7d60818..dc3c9c174 100644
--- a/src/view/com/auth/create/Policies.tsx
+++ b/src/view/com/auth/create/Policies.tsx
@@ -1,5 +1,5 @@
 import React from 'react'
-import {StyleSheet, View} from 'react-native'
+import {Linking, StyleSheet, View} from 'react-native'
 import {
   FontAwesomeIcon,
   FontAwesomeIconStyle,
@@ -15,9 +15,11 @@ type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
 export const Policies = ({
   serviceDescription,
   needsGuardian,
+  under13,
 }: {
   serviceDescription: ServiceDescription
   needsGuardian: boolean
+  under13: boolean
 }) => {
   const pal = usePalette('default')
   if (!serviceDescription) {
@@ -53,6 +55,7 @@ export const Policies = ({
         href={tos}
         text="Terms of Service"
         style={[pal.link, s.underline]}
+        onPress={() => Linking.openURL(tos)}
       />,
     )
   }
@@ -63,6 +66,7 @@ export const Policies = ({
         href={pp}
         text="Privacy Policy"
         style={[pal.link, s.underline]}
+        onPress={() => Linking.openURL(pp)}
       />,
     )
   }
@@ -81,12 +85,16 @@ export const Policies = ({
       <Text style={pal.textLight}>
         By creating an account you agree to the {els}.
       </Text>
-      {needsGuardian && (
+      {under13 ? (
+        <Text style={[pal.textLight, s.bold]}>
+          You must be 13 years of age or older to sign up.
+        </Text>
+      ) : needsGuardian ? (
         <Text style={[pal.textLight, s.bold]}>
           If you are not yet an adult according to the laws of your country,
           your parent or legal guardian must read these Terms on your behalf.
         </Text>
-      )}
+      ) : undefined}
     </View>
   )
 }
diff --git a/src/view/com/auth/create/Step1.tsx b/src/view/com/auth/create/Step1.tsx
deleted file mode 100644
index 1f6852f8c..000000000
--- a/src/view/com/auth/create/Step1.tsx
+++ /dev/null
@@ -1,261 +0,0 @@
-import React from 'react'
-import {
-  ActivityIndicator,
-  Keyboard,
-  StyleSheet,
-  TouchableOpacity,
-  View,
-} from 'react-native'
-import {CreateAccountState, CreateAccountDispatch, is18} from './state'
-import {Text} from 'view/com/util/text/Text'
-import {DateInput} from 'view/com/util/forms/DateInput'
-import {StepHeader} from './StepHeader'
-import {s} from 'lib/styles'
-import {usePalette} from 'lib/hooks/usePalette'
-import {TextInput} from '../util/TextInput'
-import {Policies} from './Policies'
-import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
-import {isWeb} from 'platform/detection'
-import {Trans, msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import {logger} from '#/logger'
-import {
-  FontAwesomeIcon,
-  FontAwesomeIconStyle,
-} from '@fortawesome/react-native-fontawesome'
-import {useDialogControl} from '#/components/Dialog'
-
-import {ServerInputDialog} from '../server-input'
-import {toNiceDomain} from '#/lib/strings/url-helpers'
-
-function sanitizeDate(date: Date): Date {
-  if (!date || date.toString() === 'Invalid Date') {
-    logger.error(`Create account: handled invalid date for birthDate`, {
-      hasDate: !!date,
-    })
-    return new Date()
-  }
-  return date
-}
-
-export function Step1({
-  uiState,
-  uiDispatch,
-}: {
-  uiState: CreateAccountState
-  uiDispatch: CreateAccountDispatch
-}) {
-  const pal = usePalette('default')
-  const {_} = useLingui()
-  const serverInputControl = useDialogControl()
-
-  const onPressSelectService = React.useCallback(() => {
-    serverInputControl.open()
-    Keyboard.dismiss()
-  }, [serverInputControl])
-
-  const birthDate = React.useMemo(() => {
-    return sanitizeDate(uiState.birthDate)
-  }, [uiState.birthDate])
-
-  return (
-    <View>
-      <ServerInputDialog
-        control={serverInputControl}
-        onSelect={url => uiDispatch({type: 'set-service-url', value: url})}
-      />
-      <StepHeader uiState={uiState} title={_(msg`Your account`)} />
-
-      {uiState.error ? (
-        <ErrorMessage message={uiState.error} style={styles.error} />
-      ) : undefined}
-
-      <View style={s.pb20}>
-        <Text type="md-medium" style={[pal.text, s.mb2]}>
-          <Trans>Hosting provider</Trans>
-        </Text>
-        <View style={[pal.border, {borderWidth: 1, borderRadius: 6}]}>
-          <View
-            style={[
-              pal.borderDark,
-              {flexDirection: 'row', alignItems: 'center'},
-            ]}>
-            <FontAwesomeIcon
-              icon="globe"
-              style={[pal.textLight, {marginLeft: 14}]}
-            />
-            <TouchableOpacity
-              testID="selectServiceButton"
-              style={{
-                flexDirection: 'row',
-                flex: 1,
-                alignItems: 'center',
-              }}
-              onPress={onPressSelectService}
-              accessibilityRole="button"
-              accessibilityLabel={_(msg`Select service`)}
-              accessibilityHint={_(msg`Sets server for the Bluesky client`)}>
-              <Text
-                type="xl"
-                style={[
-                  pal.text,
-                  {
-                    flex: 1,
-                    paddingVertical: 10,
-                    paddingRight: 12,
-                    paddingLeft: 10,
-                  },
-                ]}>
-                {toNiceDomain(uiState.serviceUrl)}
-              </Text>
-              <View
-                style={[
-                  pal.btn,
-                  {
-                    flexDirection: 'row',
-                    alignItems: 'center',
-                    borderRadius: 6,
-                    paddingVertical: 6,
-                    paddingHorizontal: 8,
-                    marginHorizontal: 6,
-                  },
-                ]}>
-                <FontAwesomeIcon
-                  icon="pen"
-                  size={12}
-                  style={pal.textLight as FontAwesomeIconStyle}
-                />
-              </View>
-            </TouchableOpacity>
-          </View>
-        </View>
-      </View>
-
-      {!uiState.serviceDescription ? (
-        <ActivityIndicator />
-      ) : (
-        <>
-          {uiState.isInviteCodeRequired && (
-            <View style={s.pb20}>
-              <Text type="md-medium" style={[pal.text, s.mb2]}>
-                <Trans>Invite code</Trans>
-              </Text>
-              <TextInput
-                testID="inviteCodeInput"
-                icon="ticket"
-                placeholder={_(msg`Required for this provider`)}
-                value={uiState.inviteCode}
-                editable
-                onChange={value => uiDispatch({type: 'set-invite-code', value})}
-                accessibilityLabel={_(msg`Invite code`)}
-                accessibilityHint={_(msg`Input invite code to proceed`)}
-                autoCapitalize="none"
-                autoComplete="off"
-                autoCorrect={false}
-                autoFocus={true}
-              />
-            </View>
-          )}
-
-          {!uiState.isInviteCodeRequired || uiState.inviteCode ? (
-            <>
-              <View style={s.pb20}>
-                <Text
-                  type="md-medium"
-                  style={[pal.text, s.mb2]}
-                  nativeID="email">
-                  <Trans>Email address</Trans>
-                </Text>
-                <TextInput
-                  testID="emailInput"
-                  icon="envelope"
-                  placeholder={_(msg`Enter your email address`)}
-                  value={uiState.email}
-                  editable
-                  onChange={value => uiDispatch({type: 'set-email', value})}
-                  accessibilityLabel={_(msg`Email`)}
-                  accessibilityHint={_(msg`Input email for Bluesky account`)}
-                  accessibilityLabelledBy="email"
-                  autoCapitalize="none"
-                  autoComplete="email"
-                  autoCorrect={false}
-                  autoFocus={!uiState.isInviteCodeRequired}
-                />
-              </View>
-
-              <View style={s.pb20}>
-                <Text
-                  type="md-medium"
-                  style={[pal.text, s.mb2]}
-                  nativeID="password">
-                  <Trans>Password</Trans>
-                </Text>
-                <TextInput
-                  testID="passwordInput"
-                  icon="lock"
-                  placeholder={_(msg`Choose your password`)}
-                  value={uiState.password}
-                  editable
-                  secureTextEntry
-                  onChange={value => uiDispatch({type: 'set-password', value})}
-                  accessibilityLabel={_(msg`Password`)}
-                  accessibilityHint={_(msg`Set password`)}
-                  accessibilityLabelledBy="password"
-                  autoCapitalize="none"
-                  autoComplete="new-password"
-                  autoCorrect={false}
-                />
-              </View>
-
-              <View style={s.pb20}>
-                <Text
-                  type="md-medium"
-                  style={[pal.text, s.mb2]}
-                  nativeID="birthDate">
-                  <Trans>Your birth date</Trans>
-                </Text>
-                <DateInput
-                  handleAsUTC
-                  testID="birthdayInput"
-                  value={birthDate}
-                  onChange={value =>
-                    uiDispatch({type: 'set-birth-date', value})
-                  }
-                  buttonType="default-light"
-                  buttonStyle={[pal.border, styles.dateInputButton]}
-                  buttonLabelType="lg"
-                  accessibilityLabel={_(msg`Birthday`)}
-                  accessibilityHint={_(msg`Enter your birth date`)}
-                  accessibilityLabelledBy="birthDate"
-                />
-              </View>
-
-              {uiState.serviceDescription && (
-                <Policies
-                  serviceDescription={uiState.serviceDescription}
-                  needsGuardian={!is18(uiState)}
-                />
-              )}
-            </>
-          ) : undefined}
-        </>
-      )}
-    </View>
-  )
-}
-
-const styles = StyleSheet.create({
-  error: {
-    borderRadius: 6,
-    marginBottom: 10,
-  },
-  dateInputButton: {
-    borderWidth: 1,
-    borderRadius: 6,
-    paddingVertical: 14,
-  },
-  // @ts-expect-error: Suppressing error due to incomplete `ViewStyle` type definition in react-native-web, missing `cursor` prop as discussed in https://github.com/necolas/react-native-web/issues/832.
-  touchable: {
-    ...(isWeb && {cursor: 'pointer'}),
-  },
-})
diff --git a/src/view/com/auth/create/Step2.tsx b/src/view/com/auth/create/Step2.tsx
deleted file mode 100644
index 5c262977f..000000000
--- a/src/view/com/auth/create/Step2.tsx
+++ /dev/null
@@ -1,140 +0,0 @@
-import React from 'react'
-import {View} from 'react-native'
-import {CreateAccountState, CreateAccountDispatch} from './state'
-import {Text} from 'view/com/util/text/Text'
-import {StepHeader} from './StepHeader'
-import {s} from 'lib/styles'
-import {TextInput} from '../util/TextInput'
-import {
-  createFullHandle,
-  IsValidHandle,
-  validateHandle,
-} from 'lib/strings/handles'
-import {usePalette} from 'lib/hooks/usePalette'
-import {msg, Trans} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import {atoms as a, useTheme} from '#/alf'
-import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
-import {TimesLarge_Stroke2_Corner0_Rounded as Times} from '#/components/icons/Times'
-import {useFocusEffect} from '@react-navigation/native'
-
-/** STEP 3: Your user handle
- * @field User handle
- */
-export function Step2({
-  uiState,
-  uiDispatch,
-}: {
-  uiState: CreateAccountState
-  uiDispatch: CreateAccountDispatch
-}) {
-  const pal = usePalette('default')
-  const {_} = useLingui()
-  const t = useTheme()
-
-  const [validCheck, setValidCheck] = React.useState<IsValidHandle>({
-    handleChars: false,
-    frontLength: false,
-    totalLength: true,
-    overall: false,
-  })
-
-  useFocusEffect(
-    React.useCallback(() => {
-      setValidCheck(validateHandle(uiState.handle, uiState.userDomain))
-
-      // Disabling this, because we only want to run this when we focus the screen
-      // eslint-disable-next-line react-hooks/exhaustive-deps
-    }, []),
-  )
-
-  const onHandleChange = React.useCallback(
-    (value: string) => {
-      if (uiState.error) {
-        uiDispatch({type: 'set-error', value: ''})
-      }
-
-      setValidCheck(validateHandle(value, uiState.userDomain))
-      uiDispatch({type: 'set-handle', value})
-    },
-    [uiDispatch, uiState.error, uiState.userDomain],
-  )
-
-  return (
-    <View>
-      <StepHeader uiState={uiState} title={_(msg`Your user handle`)} />
-      <View style={s.pb10}>
-        <View style={s.mb20}>
-          <TextInput
-            testID="handleInput"
-            icon="at"
-            placeholder="e.g. alice"
-            value={uiState.handle}
-            editable
-            autoFocus
-            autoComplete="off"
-            autoCorrect={false}
-            onChange={onHandleChange}
-            // TODO: Add explicit text label
-            accessibilityLabel={_(msg`User handle`)}
-            accessibilityHint={_(msg`Input your user handle`)}
-          />
-          <Text type="lg" style={[pal.text, s.pl5, s.pt10]}>
-            <Trans>Your full handle will be</Trans>{' '}
-            <Text type="lg-bold" style={pal.text}>
-              @{createFullHandle(uiState.handle, uiState.userDomain)}
-            </Text>
-          </Text>
-        </View>
-        <View
-          style={[
-            a.w_full,
-            a.rounded_sm,
-            a.border,
-            a.p_md,
-            a.gap_sm,
-            t.atoms.border_contrast_low,
-          ]}>
-          {uiState.error ? (
-            <View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}>
-              <IsValidIcon valid={false} />
-              <Text style={[t.atoms.text, a.text_md, a.flex]}>
-                {uiState.error}
-              </Text>
-            </View>
-          ) : undefined}
-          <View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}>
-            <IsValidIcon valid={validCheck.handleChars} />
-            <Text style={[t.atoms.text, a.text_md, a.flex]}>
-              <Trans>May only contain letters and numbers</Trans>
-            </Text>
-          </View>
-          <View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}>
-            <IsValidIcon
-              valid={validCheck.frontLength && validCheck.totalLength}
-            />
-            {!validCheck.totalLength ? (
-              <Text style={[t.atoms.text]}>
-                <Trans>May not be longer than 253 characters</Trans>
-              </Text>
-            ) : (
-              <Text style={[t.atoms.text, a.text_md]}>
-                <Trans>Must be at least 3 characters</Trans>
-              </Text>
-            )}
-          </View>
-        </View>
-      </View>
-    </View>
-  )
-}
-
-function IsValidIcon({valid}: {valid: boolean}) {
-  const t = useTheme()
-
-  if (!valid) {
-    return <Times size="md" style={{color: t.palette.negative_500}} />
-  }
-
-  return <Check size="md" style={{color: t.palette.positive_700}} />
-}
diff --git a/src/view/com/auth/create/Step3.tsx b/src/view/com/auth/create/Step3.tsx
deleted file mode 100644
index 53fdfdde8..000000000
--- a/src/view/com/auth/create/Step3.tsx
+++ /dev/null
@@ -1,114 +0,0 @@
-import React from 'react'
-import {ActivityIndicator, StyleSheet, View} from 'react-native'
-import {
-  CreateAccountState,
-  CreateAccountDispatch,
-  useSubmitCreateAccount,
-} from './state'
-import {StepHeader} from './StepHeader'
-import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
-import {isWeb} from 'platform/detection'
-import {msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-
-import {nanoid} from 'nanoid/non-secure'
-import {CaptchaWebView} from 'view/com/auth/create/CaptchaWebView'
-import {useTheme} from 'lib/ThemeContext'
-import {createFullHandle} from 'lib/strings/handles'
-
-const CAPTCHA_PATH = '/gate/signup'
-
-export function Step3({
-  uiState,
-  uiDispatch,
-}: {
-  uiState: CreateAccountState
-  uiDispatch: CreateAccountDispatch
-}) {
-  const {_} = useLingui()
-  const theme = useTheme()
-  const submit = useSubmitCreateAccount(uiState, uiDispatch)
-
-  const [completed, setCompleted] = React.useState(false)
-
-  const stateParam = React.useMemo(() => nanoid(15), [])
-  const url = React.useMemo(() => {
-    const newUrl = new URL(uiState.serviceUrl)
-    newUrl.pathname = CAPTCHA_PATH
-    newUrl.searchParams.set(
-      'handle',
-      createFullHandle(uiState.handle, uiState.userDomain),
-    )
-    newUrl.searchParams.set('state', stateParam)
-    newUrl.searchParams.set('colorScheme', theme.colorScheme)
-
-    console.log(newUrl)
-
-    return newUrl.href
-  }, [
-    uiState.serviceUrl,
-    uiState.handle,
-    uiState.userDomain,
-    stateParam,
-    theme.colorScheme,
-  ])
-
-  const onSuccess = React.useCallback(
-    (code: string) => {
-      setCompleted(true)
-      submit(code)
-    },
-    [submit],
-  )
-
-  const onError = React.useCallback(() => {
-    uiDispatch({
-      type: 'set-error',
-      value: _(msg`Error receiving captcha response.`),
-    })
-  }, [_, uiDispatch])
-
-  return (
-    <View>
-      <StepHeader uiState={uiState} title={_(msg`Complete the challenge`)} />
-      <View style={[styles.container, completed && styles.center]}>
-        {!completed ? (
-          <CaptchaWebView
-            url={url}
-            stateParam={stateParam}
-            uiState={uiState}
-            onSuccess={onSuccess}
-            onError={onError}
-          />
-        ) : (
-          <ActivityIndicator size="large" />
-        )}
-      </View>
-
-      {uiState.error ? (
-        <ErrorMessage message={uiState.error} style={styles.error} />
-      ) : undefined}
-    </View>
-  )
-}
-
-const styles = StyleSheet.create({
-  error: {
-    borderRadius: 6,
-    marginTop: 10,
-  },
-  // @ts-expect-error: Suppressing error due to incomplete `ViewStyle` type definition in react-native-web, missing `cursor` prop as discussed in https://github.com/necolas/react-native-web/issues/832.
-  touchable: {
-    ...(isWeb && {cursor: 'pointer'}),
-  },
-  container: {
-    minHeight: 500,
-    width: '100%',
-    paddingBottom: 20,
-    overflow: 'hidden',
-  },
-  center: {
-    alignItems: 'center',
-    justifyContent: 'center',
-  },
-})
diff --git a/src/view/com/auth/create/StepHeader.tsx b/src/view/com/auth/create/StepHeader.tsx
deleted file mode 100644
index a98b392d8..000000000
--- a/src/view/com/auth/create/StepHeader.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-import React from 'react'
-import {StyleSheet, View} from 'react-native'
-import {Text} from 'view/com/util/text/Text'
-import {usePalette} from 'lib/hooks/usePalette'
-import {Trans} from '@lingui/macro'
-import {CreateAccountState} from './state'
-
-export function StepHeader({
-  uiState,
-  title,
-  children,
-}: React.PropsWithChildren<{uiState: CreateAccountState; title: string}>) {
-  const pal = usePalette('default')
-  const numSteps = 3
-  return (
-    <View style={styles.container}>
-      <View>
-        <Text type="lg" style={[pal.textLight]}>
-          {uiState.step === 3 ? (
-            <Trans>Last step!</Trans>
-          ) : (
-            <Trans>
-              Step {uiState.step} of {numSteps}
-            </Trans>
-          )}
-        </Text>
-
-        <Text style={[pal.text]} type="title-xl">
-          {title}
-        </Text>
-      </View>
-      {children}
-    </View>
-  )
-}
-
-const styles = StyleSheet.create({
-  container: {
-    flexDirection: 'row',
-    justifyContent: 'space-between',
-    alignItems: 'center',
-    marginBottom: 20,
-  },
-})
-- 
cgit 1.4.1

