about summary refs log tree commit diff
path: root/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'src/components')
-rw-r--r--src/components/Button.tsx75
-rw-r--r--src/components/Divider.tsx10
-rw-r--r--src/components/Link.tsx182
-rw-r--r--src/components/Portal.tsx87
-rw-r--r--src/components/RichText.tsx131
-rw-r--r--src/components/Typography.tsx96
-rw-r--r--src/components/forms/TextField.tsx2
-rw-r--r--src/components/forms/Toggle.tsx8
-rw-r--r--src/components/forms/ToggleButton.tsx8
-rw-r--r--src/components/icons/ArrowRotateCounterClockwise.tsx6
-rw-r--r--src/components/icons/At.tsx5
-rw-r--r--src/components/icons/Check.tsx5
-rw-r--r--src/components/icons/Chevron.tsx9
-rw-r--r--src/components/icons/CircleInfo.tsx5
-rw-r--r--src/components/icons/Emoji.tsx5
-rw-r--r--src/components/icons/EyeSlash.tsx5
-rw-r--r--src/components/icons/FilterTimeline.tsx5
-rw-r--r--src/components/icons/Growth.tsx5
-rw-r--r--src/components/icons/Hashtag.tsx5
-rw-r--r--src/components/icons/ListMagnifyingGlass.tsx5
-rw-r--r--src/components/icons/ListSparkle.tsx5
-rw-r--r--src/components/icons/News2.tsx5
-rw-r--r--src/components/icons/Plus.tsx5
-rw-r--r--src/components/icons/Trending2.tsx5
24 files changed, 542 insertions, 137 deletions
diff --git a/src/components/Button.tsx b/src/components/Button.tsx
index 7c682ac1a..f88fbcbde 100644
--- a/src/components/Button.tsx
+++ b/src/components/Button.tsx
@@ -9,10 +9,11 @@ import {
   View,
   TextStyle,
   StyleSheet,
+  StyleProp,
 } from 'react-native'
 import LinearGradient from 'react-native-linear-gradient'
 
-import {useTheme, atoms as a, tokens, web, native} from '#/alf'
+import {useTheme, atoms as a, tokens, android, flatten} from '#/alf'
 import {Props as SVGIconProps} from '#/components/icons/common'
 
 export type ButtonVariant = 'solid' | 'outline' | 'ghost' | 'gradient'
@@ -27,6 +28,7 @@ export type ButtonColor =
   | 'gradient_nordic'
   | 'gradient_bonfire'
 export type ButtonSize = 'small' | 'large'
+export type ButtonShape = 'round' | 'square' | 'default'
 export type VariantProps = {
   /**
    * The style variation of the button
@@ -40,6 +42,10 @@ export type VariantProps = {
    * The size of the button
    */
   size?: ButtonSize
+  /**
+   * The shape of the button
+   */
+  shape?: ButtonShape
 }
 
 export type ButtonProps = React.PropsWithChildren<
@@ -47,6 +53,7 @@ export type ButtonProps = React.PropsWithChildren<
     AccessibilityProps &
     VariantProps & {
       label: string
+      style?: StyleProp<ViewStyle>
     }
 >
 export type ButtonTextProps = TextProps & VariantProps & {disabled?: boolean}
@@ -74,8 +81,10 @@ export function Button({
   variant,
   color,
   size,
+  shape = 'default',
   label,
   disabled = false,
+  style,
   ...rest
 }: ButtonProps) {
   const t = useTheme()
@@ -175,18 +184,18 @@ export function Button({
         if (!disabled) {
           baseStyles.push({
             backgroundColor: light
-              ? tokens.color.gray_100
+              ? tokens.color.gray_50
               : tokens.color.gray_900,
           })
           hoverStyles.push({
             backgroundColor: light
-              ? tokens.color.gray_200
+              ? tokens.color.gray_100
               : tokens.color.gray_950,
           })
         } else {
           baseStyles.push({
             backgroundColor: light
-              ? tokens.color.gray_300
+              ? tokens.color.gray_200
               : tokens.color.gray_950,
           })
         }
@@ -197,7 +206,7 @@ export function Button({
 
         if (!disabled) {
           baseStyles.push(a.border, {
-            borderColor: light ? tokens.color.gray_500 : tokens.color.gray_500,
+            borderColor: light ? tokens.color.gray_300 : tokens.color.gray_700,
           })
           hoverStyles.push(a.border, t.atoms.bg_contrast_50)
         } else {
@@ -262,10 +271,28 @@ export function Button({
       }
     }
 
-    if (size === 'large') {
-      baseStyles.push({paddingVertical: 15}, a.px_2xl, a.rounded_sm, a.gap_sm)
-    } else if (size === 'small') {
-      baseStyles.push({paddingVertical: 9}, a.px_md, a.rounded_sm, a.gap_sm)
+    if (shape === 'default') {
+      if (size === 'large') {
+        baseStyles.push({paddingVertical: 15}, a.px_2xl, a.rounded_sm, a.gap_md)
+      } else if (size === 'small') {
+        baseStyles.push({paddingVertical: 9}, a.px_lg, a.rounded_sm, a.gap_sm)
+      }
+    } else if (shape === 'round' || shape === 'square') {
+      if (size === 'large') {
+        if (shape === 'round') {
+          baseStyles.push({height: 54, width: 54})
+        } else {
+          baseStyles.push({height: 50, width: 50})
+        }
+      } else if (size === 'small') {
+        baseStyles.push({height: 40, width: 40})
+      }
+
+      if (shape === 'round') {
+        baseStyles.push(a.rounded_full)
+      } else if (shape === 'square') {
+        baseStyles.push(a.rounded_sm)
+      }
     }
 
     return {
@@ -278,7 +305,7 @@ export function Button({
         } as ViewStyle,
       ],
     }
-  }, [t, variant, color, size, disabled])
+  }, [t, variant, color, size, shape, disabled])
 
   const {gradientColors, gradientHoverColors, gradientLocations} =
     React.useMemo(() => {
@@ -334,8 +361,10 @@ export function Button({
         disabled: disabled || false,
       }}
       style={[
+        flatten(style),
         a.flex_row,
         a.align_center,
+        a.justify_center,
         a.overflow_hidden,
         a.justify_center,
         ...baseStyles,
@@ -462,17 +491,9 @@ export function useSharedButtonTextStyles() {
     }
 
     if (size === 'large') {
-      baseStyles.push(
-        a.text_md,
-        web({paddingBottom: 1}),
-        native({marginTop: 2}),
-      )
+      baseStyles.push(a.text_md, android({paddingBottom: 1}))
     } else {
-      baseStyles.push(
-        a.text_md,
-        web({paddingBottom: 1}),
-        native({marginTop: 2}),
-      )
+      baseStyles.push(a.text_sm, android({paddingBottom: 1}))
     }
 
     return StyleSheet.flatten(baseStyles)
@@ -491,14 +512,24 @@ export function ButtonText({children, style, ...rest}: ButtonTextProps) {
 
 export function ButtonIcon({
   icon: Comp,
+  position,
 }: {
   icon: React.ComponentType<SVGIconProps>
+  position?: 'left' | 'right'
 }) {
-  const {size} = useButtonContext()
+  const {size, disabled} = useButtonContext()
   const textStyles = useSharedButtonTextStyles()
 
   return (
-    <View style={[a.z_20]}>
+    <View
+      style={[
+        a.z_20,
+        {
+          opacity: disabled ? 0.7 : 1,
+          marginLeft: position === 'left' ? -2 : 0,
+          marginRight: position === 'right' ? -2 : 0,
+        },
+      ]}>
       <Comp
         size={size === 'large' ? 'md' : 'sm'}
         style={[{color: textStyles.color, pointerEvents: 'none'}]}
diff --git a/src/components/Divider.tsx b/src/components/Divider.tsx
new file mode 100644
index 000000000..9b8f79fd0
--- /dev/null
+++ b/src/components/Divider.tsx
@@ -0,0 +1,10 @@
+import React from 'react'
+import {View} from 'react-native'
+import {atoms as a, useTheme} from '#/alf'
+import {ViewStyleProp} from '#/alf'
+
+export function Divider({style}: ViewStyleProp) {
+  const t = useTheme()
+
+  return <View style={[a.w_full, a.border_t, t.atoms.border, style]} />
+}
diff --git a/src/components/Link.tsx b/src/components/Link.tsx
index 8f686f3c4..63b0c73f1 100644
--- a/src/components/Link.tsx
+++ b/src/components/Link.tsx
@@ -1,10 +1,8 @@
 import React from 'react'
 import {
-  Text,
-  TextStyle,
-  StyleProp,
   GestureResponderEvent,
   Linking,
+  TouchableWithoutFeedback,
 } from 'react-native'
 import {
   useLinkProps,
@@ -13,9 +11,10 @@ import {
 } from '@react-navigation/native'
 import {sanitizeUrl} from '@braintree/sanitize-url'
 
+import {useInteractionState} from '#/components/hooks/useInteractionState'
 import {isWeb} from '#/platform/detection'
-import {useTheme, web, flatten} from '#/alf'
-import {Button, ButtonProps, useButtonContext} from '#/components/Button'
+import {useTheme, web, flatten, TextStyleProp} from '#/alf'
+import {Button, ButtonProps} from '#/components/Button'
 import {AllNavigatorParams, NavigationProp} from '#/lib/routes/types'
 import {
   convertBskyAppUrlIfNeeded,
@@ -24,43 +23,39 @@ import {
 } from '#/lib/strings/url-helpers'
 import {useModalControls} from '#/state/modals'
 import {router} from '#/routes'
+import {Text} from '#/components/Typography'
 
-export type LinkProps = Omit<
-  ButtonProps,
-  'style' | 'onPress' | 'disabled' | 'label'
+/**
+ * Only available within a `Link`, since that inherits from `Button`.
+ * `InlineLink` provides no context.
+ */
+export {useButtonContext as useLinkContext} from '#/components/Button'
+
+type BaseLinkProps = Pick<
+  Parameters<typeof useLinkProps<AllNavigatorParams>>[0],
+  'to'
 > & {
   /**
-   * `TextStyle` to apply to the anchor element itself. Does not apply to any children.
-   */
-  style?: StyleProp<TextStyle>
-  /**
    * The React Navigation `StackAction` to perform when the link is pressed.
    */
   action?: 'push' | 'replace' | 'navigate'
+
   /**
-   * If true, will warn the user if the link text does not match the href. Only
-   * works for Links with children that are strings i.e. text links.
+   * If true, will warn the user if the link text does not match the href.
+   *
+   * Note: atm this only works for `InlineLink`s with a string child.
    */
   warnOnMismatchingTextChild?: boolean
-  label?: ButtonProps['label']
-} & Pick<Parameters<typeof useLinkProps<AllNavigatorParams>>[0], 'to'>
+}
 
-/**
- * A interactive element that renders as a `<a>` tag on the web. On mobile it
- * will translate the `href` to navigator screens and params and dispatch a
- * navigation action.
- *
- * Intended to behave as a web anchor tag. For more complex routing, use a
- * `Button`.
- */
-export function Link({
-  children,
+export function useLink({
   to,
+  displayText,
   action = 'push',
   warnOnMismatchingTextChild,
-  style,
-  ...rest
-}: LinkProps) {
+}: BaseLinkProps & {
+  displayText: string
+}) {
   const navigation = useNavigation<NavigationProp>()
   const {href} = useLinkProps<AllNavigatorParams>({
     to:
@@ -68,14 +63,14 @@ export function Link({
   })
   const isExternal = isExternalUrl(href)
   const {openModal, closeModal} = useModalControls()
+
   const onPress = React.useCallback(
     (e: GestureResponderEvent) => {
-      const stringChildren = typeof children === 'string' ? children : ''
       const requiresWarning = Boolean(
         warnOnMismatchingTextChild &&
-          stringChildren &&
+          displayText &&
           isExternal &&
-          linkRequiresWarning(href, stringChildren),
+          linkRequiresWarning(href, displayText),
       )
 
       if (requiresWarning) {
@@ -83,7 +78,7 @@ export function Link({
 
         openModal({
           name: 'link-warning',
-          text: stringChildren,
+          text: displayText,
           href: href,
         })
       } else {
@@ -134,12 +129,42 @@ export function Link({
       warnOnMismatchingTextChild,
       navigation,
       action,
-      children,
+      displayText,
       closeModal,
       openModal,
     ],
   )
 
+  return {
+    isExternal,
+    href,
+    onPress,
+  }
+}
+
+export type LinkProps = Omit<BaseLinkProps, 'warnOnMismatchingTextChild'> &
+  Omit<ButtonProps, 'style' | 'onPress' | 'disabled' | 'label'> & {
+    /**
+     * Label for a11y. Defaults to the href.
+     */
+    label?: string
+  }
+
+/**
+ * A interactive element that renders as a `<a>` tag on the web. On mobile it
+ * will translate the `href` to navigator screens and params and dispatch a
+ * navigation action.
+ *
+ * Intended to behave as a web anchor tag. For more complex routing, use a
+ * `Button`.
+ */
+export function Link({children, to, action = 'push', ...rest}: LinkProps) {
+  const {href, isExternal, onPress} = useLink({
+    to,
+    displayText: typeof children === 'string' ? children : '',
+    action,
+  })
+
   return (
     <Button
       label={href}
@@ -158,34 +183,81 @@ export function Link({
           noUnderline: '1',
         },
       })}>
-      {typeof children === 'string' ? (
-        <LinkText style={style}>{children}</LinkText>
-      ) : (
-        children
-      )}
+      {children}
     </Button>
   )
 }
 
-function LinkText({
+export type InlineLinkProps = React.PropsWithChildren<
+  BaseLinkProps &
+    TextStyleProp & {
+      /**
+       * Label for a11y. Defaults to the href.
+       */
+      label?: string
+    }
+>
+
+export function InlineLink({
   children,
+  to,
+  action = 'push',
+  warnOnMismatchingTextChild,
   style,
-}: React.PropsWithChildren<{
-  style?: StyleProp<TextStyle>
-}>) {
+  ...rest
+}: InlineLinkProps) {
   const t = useTheme()
-  const {hovered} = useButtonContext()
+  const stringChildren = typeof children === 'string'
+  const {href, isExternal, onPress} = useLink({
+    to,
+    displayText: stringChildren ? children : '',
+    action,
+    warnOnMismatchingTextChild,
+  })
+  const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
+  const {
+    state: pressed,
+    onIn: onPressIn,
+    onOut: onPressOut,
+  } = useInteractionState()
+
   return (
-    <Text
-      style={[
-        {color: t.palette.primary_500},
-        hovered && {
-          textDecorationLine: 'underline',
-          textDecorationColor: t.palette.primary_500,
-        },
-        flatten(style),
-      ]}>
-      {children as string}
-    </Text>
+    <TouchableWithoutFeedback
+      accessibilityRole="button"
+      onPress={onPress}
+      onPressIn={onPressIn}
+      onPressOut={onPressOut}
+      onFocus={onFocus}
+      onBlur={onBlur}>
+      <Text
+        label={href}
+        {...rest}
+        style={[
+          {color: t.palette.primary_500},
+          (focused || pressed) && {
+            outline: 0,
+            textDecorationLine: 'underline',
+            textDecorationColor: t.palette.primary_500,
+          },
+          flatten(style),
+        ]}
+        role="link"
+        accessibilityRole="link"
+        href={href}
+        {...web({
+          hrefAttrs: {
+            target: isExternal ? 'blank' : undefined,
+            rel: isExternal ? 'noopener noreferrer' : undefined,
+          },
+          dataSet: stringChildren
+            ? {}
+            : {
+                // default to no underline, apply this ourselves
+                noUnderline: '1',
+              },
+        })}>
+        {children}
+      </Text>
+    </TouchableWithoutFeedback>
   )
 }
diff --git a/src/components/Portal.tsx b/src/components/Portal.tsx
index 1813d9e05..d696f986b 100644
--- a/src/components/Portal.tsx
+++ b/src/components/Portal.tsx
@@ -12,45 +12,54 @@ type ComponentMap = {
   [id: string]: Component
 }
 
-export const Context = React.createContext<ContextType>({
-  outlet: null,
-  append: () => {},
-  remove: () => {},
-})
-
-export function Provider(props: React.PropsWithChildren<{}>) {
-  const map = React.useRef<ComponentMap>({})
-  const [outlet, setOutlet] = React.useState<ContextType['outlet']>(null)
-
-  const append = React.useCallback<ContextType['append']>((id, component) => {
-    if (map.current[id]) return
-    map.current[id] = <React.Fragment key={id}>{component}</React.Fragment>
-    setOutlet(<>{Object.values(map.current)}</>)
-  }, [])
-
-  const remove = React.useCallback<ContextType['remove']>(id => {
-    delete map.current[id]
-    setOutlet(<>{Object.values(map.current)}</>)
-  }, [])
-
-  return (
-    <Context.Provider value={{outlet, append, remove}}>
-      {props.children}
-    </Context.Provider>
-  )
-}
+export function createPortalGroup() {
+  const Context = React.createContext<ContextType>({
+    outlet: null,
+    append: () => {},
+    remove: () => {},
+  })
 
-export function Outlet() {
-  const ctx = React.useContext(Context)
-  return ctx.outlet
-}
+  function Provider(props: React.PropsWithChildren<{}>) {
+    const map = React.useRef<ComponentMap>({})
+    const [outlet, setOutlet] = React.useState<ContextType['outlet']>(null)
+
+    const append = React.useCallback<ContextType['append']>((id, component) => {
+      if (map.current[id]) return
+      map.current[id] = <React.Fragment key={id}>{component}</React.Fragment>
+      setOutlet(<>{Object.values(map.current)}</>)
+    }, [])
+
+    const remove = React.useCallback<ContextType['remove']>(id => {
+      delete map.current[id]
+      setOutlet(<>{Object.values(map.current)}</>)
+    }, [])
 
-export function Portal({children}: React.PropsWithChildren<{}>) {
-  const {append, remove} = React.useContext(Context)
-  const id = React.useId()
-  React.useEffect(() => {
-    append(id, children as Component)
-    return () => remove(id)
-  }, [id, children, append, remove])
-  return null
+    return (
+      <Context.Provider value={{outlet, append, remove}}>
+        {props.children}
+      </Context.Provider>
+    )
+  }
+
+  function Outlet() {
+    const ctx = React.useContext(Context)
+    return ctx.outlet
+  }
+
+  function Portal({children}: React.PropsWithChildren<{}>) {
+    const {append, remove} = React.useContext(Context)
+    const id = React.useId()
+    React.useEffect(() => {
+      append(id, children as Component)
+      return () => remove(id)
+    }, [id, children, append, remove])
+    return null
+  }
+
+  return {Provider, Outlet, Portal}
 }
+
+const DefaultPortal = createPortalGroup()
+export const Provider = DefaultPortal.Provider
+export const Outlet = DefaultPortal.Outlet
+export const Portal = DefaultPortal.Portal
diff --git a/src/components/RichText.tsx b/src/components/RichText.tsx
new file mode 100644
index 000000000..068ee99e0
--- /dev/null
+++ b/src/components/RichText.tsx
@@ -0,0 +1,131 @@
+import React from 'react'
+import {RichText as RichTextAPI, AppBskyRichtextFacet} from '@atproto/api'
+
+import {atoms as a, TextStyleProp} from '#/alf'
+import {InlineLink} from '#/components/Link'
+import {Text} from '#/components/Typography'
+import {toShortUrl} from 'lib/strings/url-helpers'
+import {getAgent} from '#/state/session'
+
+const WORD_WRAP = {wordWrap: 1}
+
+export function RichText({
+  testID,
+  value,
+  style,
+  numberOfLines,
+  disableLinks,
+  resolveFacets = false,
+}: TextStyleProp & {
+  value: RichTextAPI | string
+  testID?: string
+  numberOfLines?: number
+  disableLinks?: boolean
+  resolveFacets?: boolean
+}) {
+  const detected = React.useRef(false)
+  const [richText, setRichText] = React.useState<RichTextAPI>(() =>
+    value instanceof RichTextAPI ? value : new RichTextAPI({text: value}),
+  )
+  const styles = [a.leading_normal, style]
+
+  React.useEffect(() => {
+    if (!resolveFacets) return
+
+    async function detectFacets() {
+      const rt = new RichTextAPI({text: richText.text})
+      await rt.detectFacets(getAgent())
+      setRichText(rt)
+    }
+
+    if (!detected.current) {
+      detected.current = true
+      detectFacets()
+    }
+  }, [richText, setRichText, resolveFacets])
+
+  const {text, facets} = richText
+
+  if (!facets?.length) {
+    if (text.length <= 5 && /^\p{Extended_Pictographic}+$/u.test(text)) {
+      return (
+        <Text
+          testID={testID}
+          style={[
+            {
+              fontSize: 26,
+              lineHeight: 30,
+            },
+          ]}
+          // @ts-ignore web only -prf
+          dataSet={WORD_WRAP}>
+          {text}
+        </Text>
+      )
+    }
+    return (
+      <Text
+        testID={testID}
+        style={styles}
+        numberOfLines={numberOfLines}
+        // @ts-ignore web only -prf
+        dataSet={WORD_WRAP}>
+        {text}
+      </Text>
+    )
+  }
+
+  const els = []
+  let key = 0
+  // N.B. must access segments via `richText.segments`, not via destructuring
+  for (const segment of richText.segments()) {
+    const link = segment.link
+    const mention = segment.mention
+    if (
+      mention &&
+      AppBskyRichtextFacet.validateMention(mention).success &&
+      !disableLinks
+    ) {
+      els.push(
+        <InlineLink
+          key={key}
+          to={`/profile/${mention.did}`}
+          style={[...styles, {pointerEvents: 'auto'}]}
+          // @ts-ignore TODO
+          dataSet={WORD_WRAP}>
+          {segment.text}
+        </InlineLink>,
+      )
+    } else if (link && AppBskyRichtextFacet.validateLink(link).success) {
+      if (disableLinks) {
+        els.push(toShortUrl(segment.text))
+      } else {
+        els.push(
+          <InlineLink
+            key={key}
+            to={link.uri}
+            style={[...styles, {pointerEvents: 'auto'}]}
+            // @ts-ignore TODO
+            dataSet={WORD_WRAP}
+            warnOnMismatchingLabel>
+            {toShortUrl(segment.text)}
+          </InlineLink>,
+        )
+      }
+    } else {
+      els.push(segment.text)
+    }
+    key++
+  }
+
+  return (
+    <Text
+      testID={testID}
+      style={styles}
+      numberOfLines={numberOfLines}
+      // @ts-ignore web only -prf
+      dataSet={WORD_WRAP}>
+      {els}
+    </Text>
+  )
+}
diff --git a/src/components/Typography.tsx b/src/components/Typography.tsx
index 66cf0720d..64aa6d1a4 100644
--- a/src/components/Typography.tsx
+++ b/src/components/Typography.tsx
@@ -1,11 +1,50 @@
 import React from 'react'
-import {Text as RNText, TextProps} from 'react-native'
+import {Text as RNText, TextStyle, TextProps} from 'react-native'
 
 import {useTheme, atoms, web, flatten} from '#/alf'
 
+/**
+ * Util to calculate lineHeight from a text size atom and a leading atom
+ *
+ * Example:
+ *   `leading(atoms.text_md, atoms.leading_normal)` // => 24
+ */
+export function leading<
+  Size extends {fontSize?: number},
+  Leading extends {lineHeight?: number},
+>(textSize: Size, leading: Leading) {
+  const size = textSize?.fontSize || atoms.text_md.fontSize
+  const lineHeight = leading?.lineHeight || atoms.leading_normal.lineHeight
+  return size * lineHeight
+}
+
+/**
+ * Ensures that `lineHeight` defaults to a relative value of `1`, or applies
+ * other relative leading atoms.
+ *
+ * If the `lineHeight` value is > 2, we assume it's an absolute value and
+ * returns it as-is.
+ */
+function normalizeTextStyles(styles: TextStyle[]) {
+  const s = flatten(styles)
+  // should always be defined on these components
+  const fontSize = s.fontSize || atoms.text_md.fontSize
+
+  if (s?.lineHeight) {
+    if (s.lineHeight <= 2) {
+      s.lineHeight = fontSize * s.lineHeight
+    }
+  } else {
+    s.lineHeight = fontSize
+  }
+
+  return s
+}
+
 export function Text({style, ...rest}: TextProps) {
   const t = useTheme()
-  return <RNText style={[atoms.text_sm, t.atoms.text, style]} {...rest} />
+  const s = normalizeTextStyles([atoms.text_sm, t.atoms.text, flatten(style)])
+  return <RNText style={s} {...rest} />
 }
 
 export function H1({style, ...rest}: TextProps) {
@@ -19,7 +58,12 @@ export function H1({style, ...rest}: TextProps) {
     <RNText
       {...attr}
       {...rest}
-      style={[atoms.text_5xl, atoms.font_bold, t.atoms.text, flatten(style)]}
+      style={normalizeTextStyles([
+        atoms.text_5xl,
+        atoms.font_bold,
+        t.atoms.text,
+        flatten(style),
+      ])}
     />
   )
 }
@@ -35,7 +79,12 @@ export function H2({style, ...rest}: TextProps) {
     <RNText
       {...attr}
       {...rest}
-      style={[atoms.text_4xl, atoms.font_bold, t.atoms.text, flatten(style)]}
+      style={normalizeTextStyles([
+        atoms.text_4xl,
+        atoms.font_bold,
+        t.atoms.text,
+        flatten(style),
+      ])}
     />
   )
 }
@@ -51,7 +100,12 @@ export function H3({style, ...rest}: TextProps) {
     <RNText
       {...attr}
       {...rest}
-      style={[atoms.text_3xl, atoms.font_bold, t.atoms.text, flatten(style)]}
+      style={normalizeTextStyles([
+        atoms.text_3xl,
+        atoms.font_bold,
+        t.atoms.text,
+        flatten(style),
+      ])}
     />
   )
 }
@@ -67,7 +121,12 @@ export function H4({style, ...rest}: TextProps) {
     <RNText
       {...attr}
       {...rest}
-      style={[atoms.text_2xl, atoms.font_bold, t.atoms.text, flatten(style)]}
+      style={normalizeTextStyles([
+        atoms.text_2xl,
+        atoms.font_bold,
+        t.atoms.text,
+        flatten(style),
+      ])}
     />
   )
 }
@@ -83,7 +142,12 @@ export function H5({style, ...rest}: TextProps) {
     <RNText
       {...attr}
       {...rest}
-      style={[atoms.text_xl, atoms.font_bold, t.atoms.text, flatten(style)]}
+      style={normalizeTextStyles([
+        atoms.text_xl,
+        atoms.font_bold,
+        t.atoms.text,
+        flatten(style),
+      ])}
     />
   )
 }
@@ -99,7 +163,12 @@ export function H6({style, ...rest}: TextProps) {
     <RNText
       {...attr}
       {...rest}
-      style={[atoms.text_lg, atoms.font_bold, t.atoms.text, flatten(style)]}
+      style={normalizeTextStyles([
+        atoms.text_lg,
+        atoms.font_bold,
+        t.atoms.text,
+        flatten(style),
+      ])}
     />
   )
 }
@@ -110,15 +179,16 @@ export function P({style, ...rest}: TextProps) {
     web({
       role: 'paragraph',
     }) || {}
-  const _style = flatten(style)
-  const lineHeight =
-    (_style?.lineHeight || atoms.text_md.lineHeight) *
-    atoms.leading_normal.lineHeight
   return (
     <RNText
       {...attr}
       {...rest}
-      style={[atoms.text_md, t.atoms.text, _style, {lineHeight}]}
+      style={normalizeTextStyles([
+        atoms.text_md,
+        atoms.leading_normal,
+        t.atoms.text,
+        flatten(style),
+      ])}
     />
   )
 }
diff --git a/src/components/forms/TextField.tsx b/src/components/forms/TextField.tsx
index 1ee58303a..67515049c 100644
--- a/src/components/forms/TextField.tsx
+++ b/src/components/forms/TextField.tsx
@@ -208,7 +208,7 @@ export function createInput(Component: typeof TextInput) {
               paddingBottom: 2,
             }),
             {
-              lineHeight: a.text_md.lineHeight * 1.1875,
+              lineHeight: a.text_md.fontSize * 1.1875,
               textAlignVertical: rest.multiline ? 'top' : undefined,
               minHeight: rest.multiline ? 60 : undefined,
             },
diff --git a/src/components/forms/Toggle.tsx b/src/components/forms/Toggle.tsx
index ad82bdff5..d3c034246 100644
--- a/src/components/forms/Toggle.tsx
+++ b/src/components/forms/Toggle.tsx
@@ -2,7 +2,7 @@ import React from 'react'
 import {Pressable, View, ViewStyle} from 'react-native'
 
 import {HITSLOP_10} from 'lib/constants'
-import {useTheme, atoms as a, web, native} from '#/alf'
+import {useTheme, atoms as a, web, native, flatten, ViewStyleProp} from '#/alf'
 import {Text} from '#/components/Typography'
 import {useInteractionState} from '#/components/hooks/useInteractionState'
 
@@ -49,7 +49,7 @@ export type GroupProps = React.PropsWithChildren<{
   label: string
 }>
 
-export type ItemProps = {
+export type ItemProps = ViewStyleProp & {
   type?: 'radio' | 'checkbox'
   name: string
   label: string
@@ -57,7 +57,6 @@ export type ItemProps = {
   disabled?: boolean
   onChange?: (selected: boolean) => void
   isInvalid?: boolean
-  style?: (state: ItemState) => ViewStyle
   children: ((props: ItemState) => React.ReactNode) | React.ReactNode
 }
 
@@ -125,6 +124,7 @@ export function Group({
   return (
     <GroupContext.Provider value={context}>
       <View
+        style={[a.w_full]}
         role={groupRole}
         {...(groupRole === 'radiogroup'
           ? {
@@ -224,7 +224,7 @@ export function Item({
           a.align_center,
           a.gap_sm,
           focused ? web({outline: 'none'}) : {},
-          style?.(state),
+          flatten(style),
         ]}>
         {typeof children === 'function' ? children(state) : children}
       </Pressable>
diff --git a/src/components/forms/ToggleButton.tsx b/src/components/forms/ToggleButton.tsx
index 615fedae8..5cd51d794 100644
--- a/src/components/forms/ToggleButton.tsx
+++ b/src/components/forms/ToggleButton.tsx
@@ -20,6 +20,7 @@ export function Group({children, multiple, ...props}: GroupProps) {
     <Toggle.Group type={multiple ? 'checkbox' : 'radio'} {...props}>
       <View
         style={[
+          a.w_full,
           a.flex_row,
           a.border,
           a.rounded_sm,
@@ -34,7 +35,7 @@ export function Group({children, multiple, ...props}: GroupProps) {
 
 export function Button({children, ...props}: ItemProps) {
   return (
-    <Toggle.Item {...props}>
+    <Toggle.Item {...props} style={[a.flex_grow]}>
       <ButtonInner>{children}</ButtonInner>
     </Toggle.Item>
   )
@@ -95,11 +96,12 @@ function ButtonInner({children}: React.PropsWithChildren<{}>) {
           borderLeftWidth: 1,
           marginLeft: -1,
         },
-        a.px_lg,
+        a.flex_grow,
         a.py_md,
         native({
-          paddingTop: 14,
+          paddingBottom: 10,
         }),
+        a.px_sm,
         t.atoms.bg,
         t.atoms.border,
         baseStyles,
diff --git a/src/components/icons/ArrowRotateCounterClockwise.tsx b/src/components/icons/ArrowRotateCounterClockwise.tsx
new file mode 100644
index 000000000..35cd23a97
--- /dev/null
+++ b/src/components/icons/ArrowRotateCounterClockwise.tsx
@@ -0,0 +1,6 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded =
+  createSinglePathSVG({
+    path: 'M5 3a1 1 0 0 1 1 1v1.423c.498-.46 1.02-.869 1.58-1.213C8.863 3.423 10.302 3 12.028 3a9 9 0 1 1-8.487 12 1 1 0 0 1 1.885-.667A7 7 0 1 0 12.028 5c-1.37 0-2.444.327-3.402.915-.474.29-.93.652-1.383 1.085H9a1 1 0 0 1 0 2H5a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1Z',
+  })
diff --git a/src/components/icons/At.tsx b/src/components/icons/At.tsx
new file mode 100644
index 000000000..248725054
--- /dev/null
+++ b/src/components/icons/At.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const At_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M12 4a8 8 0 1 0 4.21 14.804 1 1 0 0 1 1.054 1.7A9.958 9.958 0 0 1 12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10c0 1.104-.27 2.31-.949 3.243-.716.984-1.849 1.6-3.331 1.465a4.207 4.207 0 0 1-2.93-1.585c-.94 1.21-2.388 1.94-3.985 1.715-2.53-.356-4.04-2.91-3.682-5.458.358-2.547 2.514-4.586 5.044-4.23.905.127 1.68.536 2.286 1.126a1 1 0 0 1 1.964.368l-.515 3.545v.002a2.222 2.222 0 0 0 1.999 2.526c.75.068 1.212-.21 1.533-.65.358-.493.566-1.245.566-2.067a8 8 0 0 0-8-8Zm-.112 5.13c-1.195-.168-2.544.819-2.784 2.529-.24 1.71.784 3.03 1.98 3.198 1.195.168 2.543-.819 2.784-2.529.24-1.71-.784-3.03-1.98-3.198Z',
+})
diff --git a/src/components/icons/Check.tsx b/src/components/icons/Check.tsx
new file mode 100644
index 000000000..24316c784
--- /dev/null
+++ b/src/components/icons/Check.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const Check_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M21.59 3.193a1 1 0 0 1 .217 1.397l-11.706 16a1 1 0 0 1-1.429.193l-6.294-5a1 1 0 1 1 1.244-1.566l5.48 4.353 11.09-15.16a1 1 0 0 1 1.398-.217Z',
+})
diff --git a/src/components/icons/Chevron.tsx b/src/components/icons/Chevron.tsx
new file mode 100644
index 000000000..b1a9deea0
--- /dev/null
+++ b/src/components/icons/Chevron.tsx
@@ -0,0 +1,9 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const ChevronLeft_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M15.707 3.293a1 1 0 0 1 0 1.414L8.414 12l7.293 7.293a1 1 0 0 1-1.414 1.414l-8-8a1 1 0 0 1 0-1.414l8-8a1 1 0 0 1 1.414 0Z',
+})
+
+export const ChevronRight_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M8.293 3.293a1 1 0 0 1 1.414 0l8 8a1 1 0 0 1 0 1.414l-8 8a1 1 0 0 1-1.414-1.414L15.586 12 8.293 4.707a1 1 0 0 1 0-1.414Z',
+})
diff --git a/src/components/icons/CircleInfo.tsx b/src/components/icons/CircleInfo.tsx
new file mode 100644
index 000000000..cc3813bf3
--- /dev/null
+++ b/src/components/icons/CircleInfo.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const CircleInfo_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16ZM2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm8-1a1 1 0 0 1 1-1h1a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0v-4a1 1 0 0 1-1-1Zm1-3a1 1 0 1 0 2 0 1 1 0 0 0-2 0Z',
+})
diff --git a/src/components/icons/Emoji.tsx b/src/components/icons/Emoji.tsx
new file mode 100644
index 000000000..568cd71e6
--- /dev/null
+++ b/src/components/icons/Emoji.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const EmojiSad_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M6.343 6.343a8 8 0 1 1 11.314 11.314A8 8 0 0 1 6.343 6.343ZM19.071 4.93c-3.905-3.905-10.237-3.905-14.142 0-3.905 3.905-3.905 10.237 0 14.142 3.905 3.905 10.237 3.905 14.142 0 3.905-3.905 3.905-10.237 0-14.142Zm-3.537 9.535a5 5 0 0 0-7.07 0 1 1 0 1 0 1.413 1.415 3 3 0 0 1 4.243 0 1 1 0 0 0 1.414-1.415ZM16 9.5c0 .828-.56 1.5-1.25 1.5s-1.25-.672-1.25-1.5.56-1.5 1.25-1.5S16 8.672 16 9.5ZM9.25 11c.69 0 1.25-.672 1.25-1.5S9.94 8 9.25 8 8 8.672 8 9.5 8.56 11 9.25 11Z',
+})
diff --git a/src/components/icons/EyeSlash.tsx b/src/components/icons/EyeSlash.tsx
new file mode 100644
index 000000000..a936a1c71
--- /dev/null
+++ b/src/components/icons/EyeSlash.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const EyeSlash_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M2.293 2.293a1 1 0 0 1 1.414 0L7.335 5.92l.03.03 3.22 3.222 4.243 4.242 3.22 3.22.03.03 3.63 3.629a1 1 0 0 1-1.415 1.414l-3.09-3.09c-2.65 1.478-5.625 1.778-8.421.869-3.039-.987-5.779-3.37-7.67-7.027a1 1 0 0 1 0-.918c1.086-2.1 2.452-3.78 3.996-5.019L2.293 3.707a1 1 0 0 1 0-1.414Zm4.24 5.654 2.021 2.021a4 4 0 0 0 5.478 5.478l1.688 1.688c-2.042.982-4.246 1.124-6.32.45-2.34-.76-4.594-2.586-6.265-5.584.97-1.739 2.135-3.083 3.398-4.053Zm3.535 3.535 2.45 2.45a2 2 0 0 1-2.45-2.45Zm.81-5.405c3.573-.49 7.45 1.369 9.987 5.923a14.797 14.797 0 0 1-1.347 2.02 1 1 0 1 0 1.564 1.247 17.078 17.078 0 0 0 1.806-2.808 1 1 0 0 0 0-.918c-2.833-5.479-7.584-8.088-12.281-7.446a1 1 0 0 0 .271 1.982Z',
+})
diff --git a/src/components/icons/FilterTimeline.tsx b/src/components/icons/FilterTimeline.tsx
new file mode 100644
index 000000000..ea11a429c
--- /dev/null
+++ b/src/components/icons/FilterTimeline.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const FilterTimeline_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M7.002 5a1 1 0 0 0-2 0v11.587l-1.295-1.294a1 1 0 0 0-1.414 1.414l3.002 3a1 1 0 0 0 1.414 0l2.998-3a1 1 0 0 0-1.414-1.414l-1.291 1.292V5ZM16 16a1 1 0 1 0 0 2h4a1 1 0 1 0 0-2h-4Zm-3-4a1 1 0 0 1 1-1h6a1 1 0 1 1 0 2h-6a1 1 0 0 1-1-1Zm-1-6a1 1 0 1 0 0 2h8a1 1 0 1 0 0-2h-8Z',
+})
diff --git a/src/components/icons/Growth.tsx b/src/components/icons/Growth.tsx
new file mode 100644
index 000000000..ab5684a57
--- /dev/null
+++ b/src/components/icons/Growth.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const Growth_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M3 4a1 1 0 0 1 1-1h1a8.003 8.003 0 0 1 7.75 6.006A7.985 7.985 0 0 1 19 6h1a1 1 0 0 1 1 1v1a8 8 0 0 1-8 8v4a1 1 0 1 1-2 0v-7a8 8 0 0 1-8-8V4Zm2 1a6 6 0 0 1 6 6 6 6 0 0 1-6-6Zm8 9a6 6 0 0 1 6-6 6 6 0 0 1-6 6Z',
+})
diff --git a/src/components/icons/Hashtag.tsx b/src/components/icons/Hashtag.tsx
new file mode 100644
index 000000000..668ed9256
--- /dev/null
+++ b/src/components/icons/Hashtag.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const Hashtag_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M9.124 3.008a1 1 0 0 1 .868 1.116L9.632 7h5.985l.39-3.124a1 1 0 0 1 1.985.248L17.632 7H20a1 1 0 1 1 0 2h-2.617l-.75 6H20a1 1 0 1 1 0 2h-3.617l-.39 3.124a1 1 0 1 1-1.985-.248l.36-2.876H8.382l-.39 3.124a1 1 0 1 1-1.985-.248L6.368 17H4a1 1 0 1 1 0-2h2.617l.75-6H4a1 1 0 1 1 0-2h3.617l.39-3.124a1 1 0 0 1 1.117-.868ZM9.383 9l-.75 6h5.984l.75-6H9.383Z',
+})
diff --git a/src/components/icons/ListMagnifyingGlass.tsx b/src/components/icons/ListMagnifyingGlass.tsx
new file mode 100644
index 000000000..a897fe853
--- /dev/null
+++ b/src/components/icons/ListMagnifyingGlass.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const ListMagnifyingGlass_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M3 4a1 1 0 0 1 1-1h13a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1Zm1 4a1 1 0 0 0 0 2h5a1 1 0 0 0 0-2H4Zm-1 7a1 1 0 0 1 1-1h5a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1Zm0 5a1 1 0 0 1 1-1h13a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1Zm9-8a4 4 0 1 1 7.446 2.032l.99.989a1 1 0 1 1-1.415 1.414l-.99-.989A4 4 0 0 1 12 12Zm4-2a2 2 0 1 0 0 4 2 2 0 0 0 0-4Z',
+})
diff --git a/src/components/icons/ListSparkle.tsx b/src/components/icons/ListSparkle.tsx
new file mode 100644
index 000000000..4d472465d
--- /dev/null
+++ b/src/components/icons/ListSparkle.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const ListSparkle_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M4 5a1 1 0 0 0 0 2h16a1 1 0 1 0 0-2H4Zm0 12a1 1 0 1 0 0 2h3a1 1 0 1 0 0-2H4Zm-1-5a1 1 0 0 1 1-1h5a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1Zm14-3a1 1 0 0 1 .92.606l1.342 3.132 3.132 1.343a1 1 0 0 1 0 1.838l-3.132 1.343-1.343 3.132a1 1 0 0 1-1.838 0l-1.343-3.132-3.132-1.343a1 1 0 0 1 0-1.838l3.132-1.343 1.343-3.132A1 1 0 0 1 17 9Zm0 3.539-.58 1.355a1 1 0 0 1-.526.525L14.539 15l1.355.58a1 1 0 0 1 .525.526L17 17.461l.58-1.355a1 1 0 0 1 .526-.525L19.461 15l-1.355-.58a1 1 0 0 1-.525-.526L17 12.539Z',
+})
diff --git a/src/components/icons/News2.tsx b/src/components/icons/News2.tsx
new file mode 100644
index 000000000..f2124e7b8
--- /dev/null
+++ b/src/components/icons/News2.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const News2_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M1 5a1 1 0 0 1 1-1h7a3.99 3.99 0 0 1 3 1.354A3.99 3.99 0 0 1 15 4h7a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1h-6.723c-.52 0-1 .125-1.4.372-.421.26-.761.633-.983 1.075a1 1 0 0 1-1.788 0 2.664 2.664 0 0 0-.983-1.075c-.4-.247-.88-.372-1.4-.372H2a1 1 0 0 1-1-1V5Zm10 3a2 2 0 0 0-2-2H3v12h5.723c.776 0 1.564.173 2.277.569V8Zm2 10.569V8a2 2 0 0 1 2-2h6v12h-5.723c-.776 0-1.564.173-2.277.569Z',
+})
diff --git a/src/components/icons/Plus.tsx b/src/components/icons/Plus.tsx
new file mode 100644
index 000000000..d0698f7f4
--- /dev/null
+++ b/src/components/icons/Plus.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const PlusLarge_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M12 3a1 1 0 0 1 1 1v7h7a1 1 0 1 1 0 2h-7v7a1 1 0 1 1-2 0v-7H4a1 1 0 1 1 0-2h7V4a1 1 0 0 1 1-1Z',
+})
diff --git a/src/components/icons/Trending2.tsx b/src/components/icons/Trending2.tsx
new file mode 100644
index 000000000..5fba4167b
--- /dev/null
+++ b/src/components/icons/Trending2.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const Trending2_Stroke2_Corner2_Rounded = createSinglePathSVG({
+  path: 'm18.192 5.004 1.864 5.31a1 1 0 0 0 1.887-.662L20.08 4.34c-.665-1.893-3.378-1.741-3.834.207l-3.381 14.449-2.985-9.605C9.3 7.531 6.684 7.506 6.07 9.355l-1.18 3.56-.969-2.312a1 1 0 0 0-1.844.772l.97 2.315c.715 1.71 3.159 1.613 3.741-.144l1.18-3.56 2.985 9.605c.607 1.952 3.392 1.848 3.857-.138l3.381-14.449Z',
+})