about summary refs log tree commit diff
path: root/src/alf
diff options
context:
space:
mode:
Diffstat (limited to 'src/alf')
-rw-r--r--src/alf/README.md56
-rw-r--r--src/alf/atoms.ts702
-rw-r--r--src/alf/index.tsx93
-rw-r--r--src/alf/themes.ts320
-rw-r--r--src/alf/tokens.ts168
-rw-r--r--src/alf/types.ts16
-rw-r--r--src/alf/util/flatten.ts3
-rw-r--r--src/alf/util/platform.ts25
-rw-r--r--src/alf/util/useColorModeTheme.ts10
9 files changed, 1393 insertions, 0 deletions
diff --git a/src/alf/README.md b/src/alf/README.md
new file mode 100644
index 000000000..aa31bcf98
--- /dev/null
+++ b/src/alf/README.md
@@ -0,0 +1,56 @@
+# Application Layout Framework (ALF)
+
+A set of UI primitives and components.
+
+## Usage
+
+Naming conventions follow Tailwind — delimited with a `_` instead of `-` to
+enable object access — with a couple exceptions:
+
+**Spacing**
+
+Uses "t-shirt" sizes `xxs`, `xs`, `sm`, `md`, `lg`, `xl` and `xxl` instead of
+increments of 4px. We only use a few common spacings, and otherwise typically
+rely on many one-off values.
+
+**Text Size**
+
+Uses "t-shirt" sizes `xxs`, `xs`, `sm`, `md`, `lg`, `xl` and `xxl` to match our
+type scale.
+
+**Line Height**
+
+The text size atoms also apply a line-height with the same value as the size,
+for a 1:1 ratio. `tight` and `normal` are retained for use in the few places
+where we need leading.
+
+### Atoms
+
+An (mostly-complete) set of style definitions that match Tailwind CSS selectors.
+These are static and reused throughout the app.
+
+```tsx
+import { atoms } from '#/alf'
+
+<View style={[atoms.flex_row]} />
+```
+
+### Theme
+
+Any values that rely on the theme, namely colors.
+
+```tsx
+const t = useTheme()
+
+<View style={[atoms.flex_row, t.atoms.bg]} />
+```
+
+### Breakpoints
+
+```tsx
+const b = useBreakpoints()
+
+if (b.gtMobile) {
+  // render tablet or desktop UI
+}
+```
diff --git a/src/alf/atoms.ts b/src/alf/atoms.ts
new file mode 100644
index 000000000..203c2f282
--- /dev/null
+++ b/src/alf/atoms.ts
@@ -0,0 +1,702 @@
+import * as tokens from '#/alf/tokens'
+
+export const atoms = {
+  /*
+   * Positioning
+   */
+  fixed: {
+    position: 'fixed',
+  },
+  absolute: {
+    position: 'absolute',
+  },
+  relative: {
+    position: 'relative',
+  },
+  inset_0: {
+    top: 0,
+    left: 0,
+    right: 0,
+    bottom: 0,
+  },
+  z_10: {
+    zIndex: 10,
+  },
+  z_20: {
+    zIndex: 20,
+  },
+  z_30: {
+    zIndex: 30,
+  },
+  z_40: {
+    zIndex: 40,
+  },
+  z_50: {
+    zIndex: 50,
+  },
+
+  overflow_hidden: {
+    overflow: 'hidden',
+  },
+
+  /*
+   * Width
+   */
+  w_full: {
+    width: '100%',
+  },
+  h_full: {
+    height: '100%',
+  },
+
+  /*
+   * Border radius
+   */
+  rounded_2xs: {
+    borderRadius: tokens.borderRadius._2xs,
+  },
+  rounded_xs: {
+    borderRadius: tokens.borderRadius.xs,
+  },
+  rounded_sm: {
+    borderRadius: tokens.borderRadius.sm,
+  },
+  rounded_md: {
+    borderRadius: tokens.borderRadius.md,
+  },
+  rounded_full: {
+    borderRadius: tokens.borderRadius.full,
+  },
+
+  /*
+   * Flex
+   */
+  gap_2xs: {
+    gap: tokens.space._2xs,
+  },
+  gap_xs: {
+    gap: tokens.space.xs,
+  },
+  gap_sm: {
+    gap: tokens.space.sm,
+  },
+  gap_md: {
+    gap: tokens.space.md,
+  },
+  gap_lg: {
+    gap: tokens.space.lg,
+  },
+  gap_xl: {
+    gap: tokens.space.xl,
+  },
+  gap_2xl: {
+    gap: tokens.space._2xl,
+  },
+  gap_3xl: {
+    gap: tokens.space._3xl,
+  },
+  gap_4xl: {
+    gap: tokens.space._4xl,
+  },
+  gap_5xl: {
+    gap: tokens.space._5xl,
+  },
+  flex: {
+    display: 'flex',
+  },
+  flex_row: {
+    flexDirection: 'row',
+  },
+  flex_wrap: {
+    flexWrap: 'wrap',
+  },
+  flex_1: {
+    flex: 1,
+  },
+  flex_grow: {
+    flexGrow: 1,
+  },
+  flex_shrink: {
+    flexShrink: 1,
+  },
+  justify_center: {
+    justifyContent: 'center',
+  },
+  justify_between: {
+    justifyContent: 'space-between',
+  },
+  justify_end: {
+    justifyContent: 'flex-end',
+  },
+  align_center: {
+    alignItems: 'center',
+  },
+  align_start: {
+    alignItems: 'flex-start',
+  },
+  align_end: {
+    alignItems: 'flex-end',
+  },
+
+  /*
+   * Text
+   */
+  text_center: {
+    textAlign: 'center',
+  },
+  text_right: {
+    textAlign: 'right',
+  },
+  text_2xs: {
+    fontSize: tokens.fontSize._2xs,
+    lineHeight: tokens.fontSize._2xs,
+  },
+  text_xs: {
+    fontSize: tokens.fontSize.xs,
+    lineHeight: tokens.fontSize.xs,
+  },
+  text_sm: {
+    fontSize: tokens.fontSize.sm,
+    lineHeight: tokens.fontSize.sm,
+  },
+  text_md: {
+    fontSize: tokens.fontSize.md,
+    lineHeight: tokens.fontSize.md,
+  },
+  text_lg: {
+    fontSize: tokens.fontSize.lg,
+    lineHeight: tokens.fontSize.lg,
+  },
+  text_xl: {
+    fontSize: tokens.fontSize.xl,
+    lineHeight: tokens.fontSize.xl,
+  },
+  text_2xl: {
+    fontSize: tokens.fontSize._2xl,
+    lineHeight: tokens.fontSize._2xl,
+  },
+  text_3xl: {
+    fontSize: tokens.fontSize._3xl,
+    lineHeight: tokens.fontSize._3xl,
+  },
+  text_4xl: {
+    fontSize: tokens.fontSize._4xl,
+    lineHeight: tokens.fontSize._4xl,
+  },
+  text_5xl: {
+    fontSize: tokens.fontSize._5xl,
+    lineHeight: tokens.fontSize._5xl,
+  },
+  leading_tight: {
+    lineHeight: 1.25,
+  },
+  leading_normal: {
+    lineHeight: 1.5,
+  },
+  font_normal: {
+    fontWeight: tokens.fontWeight.normal,
+  },
+  font_bold: {
+    fontWeight: tokens.fontWeight.semibold,
+  },
+
+  /*
+   * Border
+   */
+  border: {
+    borderWidth: 1,
+  },
+  border_t: {
+    borderTopWidth: 1,
+  },
+  border_b: {
+    borderBottomWidth: 1,
+  },
+
+  /*
+   * Shadow
+   */
+  shadow_sm: {
+    shadowRadius: 8,
+    shadowOpacity: 0.1,
+    elevation: 8,
+  },
+  shadow_md: {
+    shadowRadius: 16,
+    shadowOpacity: 0.1,
+    elevation: 16,
+  },
+  shadow_lg: {
+    shadowRadius: 32,
+    shadowOpacity: 0.1,
+    elevation: 24,
+  },
+
+  /*
+   * Padding
+   */
+  p_2xs: {
+    padding: tokens.space._2xs,
+  },
+  p_xs: {
+    padding: tokens.space.xs,
+  },
+  p_sm: {
+    padding: tokens.space.sm,
+  },
+  p_md: {
+    padding: tokens.space.md,
+  },
+  p_lg: {
+    padding: tokens.space.lg,
+  },
+  p_xl: {
+    padding: tokens.space.xl,
+  },
+  p_2xl: {
+    padding: tokens.space._2xl,
+  },
+  p_3xl: {
+    padding: tokens.space._3xl,
+  },
+  p_4xl: {
+    padding: tokens.space._4xl,
+  },
+  p_5xl: {
+    padding: tokens.space._5xl,
+  },
+  px_2xs: {
+    paddingLeft: tokens.space._2xs,
+    paddingRight: tokens.space._2xs,
+  },
+  px_xs: {
+    paddingLeft: tokens.space.xs,
+    paddingRight: tokens.space.xs,
+  },
+  px_sm: {
+    paddingLeft: tokens.space.sm,
+    paddingRight: tokens.space.sm,
+  },
+  px_md: {
+    paddingLeft: tokens.space.md,
+    paddingRight: tokens.space.md,
+  },
+  px_lg: {
+    paddingLeft: tokens.space.lg,
+    paddingRight: tokens.space.lg,
+  },
+  px_xl: {
+    paddingLeft: tokens.space.xl,
+    paddingRight: tokens.space.xl,
+  },
+  px_2xl: {
+    paddingLeft: tokens.space._2xl,
+    paddingRight: tokens.space._2xl,
+  },
+  px_3xl: {
+    paddingLeft: tokens.space._3xl,
+    paddingRight: tokens.space._3xl,
+  },
+  px_4xl: {
+    paddingLeft: tokens.space._4xl,
+    paddingRight: tokens.space._4xl,
+  },
+  px_5xl: {
+    paddingLeft: tokens.space._5xl,
+    paddingRight: tokens.space._5xl,
+  },
+  py_2xs: {
+    paddingTop: tokens.space._2xs,
+    paddingBottom: tokens.space._2xs,
+  },
+  py_xs: {
+    paddingTop: tokens.space.xs,
+    paddingBottom: tokens.space.xs,
+  },
+  py_sm: {
+    paddingTop: tokens.space.sm,
+    paddingBottom: tokens.space.sm,
+  },
+  py_md: {
+    paddingTop: tokens.space.md,
+    paddingBottom: tokens.space.md,
+  },
+  py_lg: {
+    paddingTop: tokens.space.lg,
+    paddingBottom: tokens.space.lg,
+  },
+  py_xl: {
+    paddingTop: tokens.space.xl,
+    paddingBottom: tokens.space.xl,
+  },
+  py_2xl: {
+    paddingTop: tokens.space._2xl,
+    paddingBottom: tokens.space._2xl,
+  },
+  py_3xl: {
+    paddingTop: tokens.space._3xl,
+    paddingBottom: tokens.space._3xl,
+  },
+  py_4xl: {
+    paddingTop: tokens.space._4xl,
+    paddingBottom: tokens.space._4xl,
+  },
+  py_5xl: {
+    paddingTop: tokens.space._5xl,
+    paddingBottom: tokens.space._5xl,
+  },
+  pt_2xs: {
+    paddingTop: tokens.space._2xs,
+  },
+  pt_xs: {
+    paddingTop: tokens.space.xs,
+  },
+  pt_sm: {
+    paddingTop: tokens.space.sm,
+  },
+  pt_md: {
+    paddingTop: tokens.space.md,
+  },
+  pt_lg: {
+    paddingTop: tokens.space.lg,
+  },
+  pt_xl: {
+    paddingTop: tokens.space.xl,
+  },
+  pt_2xl: {
+    paddingTop: tokens.space._2xl,
+  },
+  pt_3xl: {
+    paddingTop: tokens.space._3xl,
+  },
+  pt_4xl: {
+    paddingTop: tokens.space._4xl,
+  },
+  pt_5xl: {
+    paddingTop: tokens.space._5xl,
+  },
+  pb_2xs: {
+    paddingBottom: tokens.space._2xs,
+  },
+  pb_xs: {
+    paddingBottom: tokens.space.xs,
+  },
+  pb_sm: {
+    paddingBottom: tokens.space.sm,
+  },
+  pb_md: {
+    paddingBottom: tokens.space.md,
+  },
+  pb_lg: {
+    paddingBottom: tokens.space.lg,
+  },
+  pb_xl: {
+    paddingBottom: tokens.space.xl,
+  },
+  pb_2xl: {
+    paddingBottom: tokens.space._2xl,
+  },
+  pb_3xl: {
+    paddingBottom: tokens.space._3xl,
+  },
+  pb_4xl: {
+    paddingBottom: tokens.space._4xl,
+  },
+  pb_5xl: {
+    paddingBottom: tokens.space._5xl,
+  },
+  pl_2xs: {
+    paddingLeft: tokens.space._2xs,
+  },
+  pl_xs: {
+    paddingLeft: tokens.space.xs,
+  },
+  pl_sm: {
+    paddingLeft: tokens.space.sm,
+  },
+  pl_md: {
+    paddingLeft: tokens.space.md,
+  },
+  pl_lg: {
+    paddingLeft: tokens.space.lg,
+  },
+  pl_xl: {
+    paddingLeft: tokens.space.xl,
+  },
+  pl_2xl: {
+    paddingLeft: tokens.space._2xl,
+  },
+  pl_3xl: {
+    paddingLeft: tokens.space._3xl,
+  },
+  pl_4xl: {
+    paddingLeft: tokens.space._4xl,
+  },
+  pl_5xl: {
+    paddingLeft: tokens.space._5xl,
+  },
+  pr_2xs: {
+    paddingRight: tokens.space._2xs,
+  },
+  pr_xs: {
+    paddingRight: tokens.space.xs,
+  },
+  pr_sm: {
+    paddingRight: tokens.space.sm,
+  },
+  pr_md: {
+    paddingRight: tokens.space.md,
+  },
+  pr_lg: {
+    paddingRight: tokens.space.lg,
+  },
+  pr_xl: {
+    paddingRight: tokens.space.xl,
+  },
+  pr_2xl: {
+    paddingRight: tokens.space._2xl,
+  },
+  pr_3xl: {
+    paddingRight: tokens.space._3xl,
+  },
+  pr_4xl: {
+    paddingRight: tokens.space._4xl,
+  },
+  pr_5xl: {
+    paddingRight: tokens.space._5xl,
+  },
+
+  /*
+   * Margin
+   */
+  m_2xs: {
+    margin: tokens.space._2xs,
+  },
+  m_xs: {
+    margin: tokens.space.xs,
+  },
+  m_sm: {
+    margin: tokens.space.sm,
+  },
+  m_md: {
+    margin: tokens.space.md,
+  },
+  m_lg: {
+    margin: tokens.space.lg,
+  },
+  m_xl: {
+    margin: tokens.space.xl,
+  },
+  m_2xl: {
+    margin: tokens.space._2xl,
+  },
+  m_3xl: {
+    margin: tokens.space._3xl,
+  },
+  m_4xl: {
+    margin: tokens.space._4xl,
+  },
+  m_5xl: {
+    margin: tokens.space._5xl,
+  },
+  mx_2xs: {
+    marginLeft: tokens.space._2xs,
+    marginRight: tokens.space._2xs,
+  },
+  mx_xs: {
+    marginLeft: tokens.space.xs,
+    marginRight: tokens.space.xs,
+  },
+  mx_sm: {
+    marginLeft: tokens.space.sm,
+    marginRight: tokens.space.sm,
+  },
+  mx_md: {
+    marginLeft: tokens.space.md,
+    marginRight: tokens.space.md,
+  },
+  mx_lg: {
+    marginLeft: tokens.space.lg,
+    marginRight: tokens.space.lg,
+  },
+  mx_xl: {
+    marginLeft: tokens.space.xl,
+    marginRight: tokens.space.xl,
+  },
+  mx_2xl: {
+    marginLeft: tokens.space._2xl,
+    marginRight: tokens.space._2xl,
+  },
+  mx_3xl: {
+    marginLeft: tokens.space._3xl,
+    marginRight: tokens.space._3xl,
+  },
+  mx_4xl: {
+    marginLeft: tokens.space._4xl,
+    marginRight: tokens.space._4xl,
+  },
+  mx_5xl: {
+    marginLeft: tokens.space._5xl,
+    marginRight: tokens.space._5xl,
+  },
+  my_2xs: {
+    marginTop: tokens.space._2xs,
+    marginBottom: tokens.space._2xs,
+  },
+  my_xs: {
+    marginTop: tokens.space.xs,
+    marginBottom: tokens.space.xs,
+  },
+  my_sm: {
+    marginTop: tokens.space.sm,
+    marginBottom: tokens.space.sm,
+  },
+  my_md: {
+    marginTop: tokens.space.md,
+    marginBottom: tokens.space.md,
+  },
+  my_lg: {
+    marginTop: tokens.space.lg,
+    marginBottom: tokens.space.lg,
+  },
+  my_xl: {
+    marginTop: tokens.space.xl,
+    marginBottom: tokens.space.xl,
+  },
+  my_2xl: {
+    marginTop: tokens.space._2xl,
+    marginBottom: tokens.space._2xl,
+  },
+  my_3xl: {
+    marginTop: tokens.space._3xl,
+    marginBottom: tokens.space._3xl,
+  },
+  my_4xl: {
+    marginTop: tokens.space._4xl,
+    marginBottom: tokens.space._4xl,
+  },
+  my_5xl: {
+    marginTop: tokens.space._5xl,
+    marginBottom: tokens.space._5xl,
+  },
+  mt_2xs: {
+    marginTop: tokens.space._2xs,
+  },
+  mt_xs: {
+    marginTop: tokens.space.xs,
+  },
+  mt_sm: {
+    marginTop: tokens.space.sm,
+  },
+  mt_md: {
+    marginTop: tokens.space.md,
+  },
+  mt_lg: {
+    marginTop: tokens.space.lg,
+  },
+  mt_xl: {
+    marginTop: tokens.space.xl,
+  },
+  mt_2xl: {
+    marginTop: tokens.space._2xl,
+  },
+  mt_3xl: {
+    marginTop: tokens.space._3xl,
+  },
+  mt_4xl: {
+    marginTop: tokens.space._4xl,
+  },
+  mt_5xl: {
+    marginTop: tokens.space._5xl,
+  },
+  mb_2xs: {
+    marginBottom: tokens.space._2xs,
+  },
+  mb_xs: {
+    marginBottom: tokens.space.xs,
+  },
+  mb_sm: {
+    marginBottom: tokens.space.sm,
+  },
+  mb_md: {
+    marginBottom: tokens.space.md,
+  },
+  mb_lg: {
+    marginBottom: tokens.space.lg,
+  },
+  mb_xl: {
+    marginBottom: tokens.space.xl,
+  },
+  mb_2xl: {
+    marginBottom: tokens.space._2xl,
+  },
+  mb_3xl: {
+    marginBottom: tokens.space._3xl,
+  },
+  mb_4xl: {
+    marginBottom: tokens.space._4xl,
+  },
+  mb_5xl: {
+    marginBottom: tokens.space._5xl,
+  },
+  ml_2xs: {
+    marginLeft: tokens.space._2xs,
+  },
+  ml_xs: {
+    marginLeft: tokens.space.xs,
+  },
+  ml_sm: {
+    marginLeft: tokens.space.sm,
+  },
+  ml_md: {
+    marginLeft: tokens.space.md,
+  },
+  ml_lg: {
+    marginLeft: tokens.space.lg,
+  },
+  ml_xl: {
+    marginLeft: tokens.space.xl,
+  },
+  ml_2xl: {
+    marginLeft: tokens.space._2xl,
+  },
+  ml_3xl: {
+    marginLeft: tokens.space._3xl,
+  },
+  ml_4xl: {
+    marginLeft: tokens.space._4xl,
+  },
+  ml_5xl: {
+    marginLeft: tokens.space._5xl,
+  },
+  mr_2xs: {
+    marginRight: tokens.space._2xs,
+  },
+  mr_xs: {
+    marginRight: tokens.space.xs,
+  },
+  mr_sm: {
+    marginRight: tokens.space.sm,
+  },
+  mr_md: {
+    marginRight: tokens.space.md,
+  },
+  mr_lg: {
+    marginRight: tokens.space.lg,
+  },
+  mr_xl: {
+    marginRight: tokens.space.xl,
+  },
+  mr_2xl: {
+    marginRight: tokens.space._2xl,
+  },
+  mr_3xl: {
+    marginRight: tokens.space._3xl,
+  },
+  mr_4xl: {
+    marginRight: tokens.space._4xl,
+  },
+  mr_5xl: {
+    marginRight: tokens.space._5xl,
+  },
+} as const
diff --git a/src/alf/index.tsx b/src/alf/index.tsx
new file mode 100644
index 000000000..69a879853
--- /dev/null
+++ b/src/alf/index.tsx
@@ -0,0 +1,93 @@
+import React from 'react'
+import {Dimensions} from 'react-native'
+import * as themes from '#/alf/themes'
+
+export * as tokens from '#/alf/tokens'
+export {atoms} from '#/alf/atoms'
+export * from '#/alf/util/platform'
+export * from '#/alf/util/flatten'
+
+type BreakpointName = keyof typeof breakpoints
+
+/*
+ * Breakpoints
+ */
+const breakpoints: {
+  [key: string]: number
+} = {
+  gtMobile: 800,
+  gtTablet: 1200,
+}
+function getActiveBreakpoints({width}: {width: number}) {
+  const active: (keyof typeof breakpoints)[] = Object.keys(breakpoints).filter(
+    breakpoint => width >= breakpoints[breakpoint],
+  )
+
+  return {
+    active: active[active.length - 1],
+    gtMobile: active.includes('gtMobile'),
+    gtTablet: active.includes('gtTablet'),
+  }
+}
+
+/*
+ * Context
+ */
+export const Context = React.createContext<{
+  themeName: themes.ThemeName
+  theme: themes.Theme
+  breakpoints: {
+    active: BreakpointName | undefined
+    gtMobile: boolean
+    gtTablet: boolean
+  }
+}>({
+  themeName: 'light',
+  theme: themes.light,
+  breakpoints: {
+    active: undefined,
+    gtMobile: false,
+    gtTablet: false,
+  },
+})
+
+export function ThemeProvider({
+  children,
+  theme: themeName,
+}: React.PropsWithChildren<{theme: themes.ThemeName}>) {
+  const theme = themes[themeName]
+  const [breakpoints, setBreakpoints] = React.useState(() =>
+    getActiveBreakpoints({width: Dimensions.get('window').width}),
+  )
+
+  React.useEffect(() => {
+    const listener = Dimensions.addEventListener('change', ({window}) => {
+      const bp = getActiveBreakpoints({width: window.width})
+      if (bp.active !== breakpoints.active) setBreakpoints(bp)
+    })
+
+    return listener.remove
+  }, [breakpoints, setBreakpoints])
+
+  return (
+    <Context.Provider
+      value={React.useMemo(
+        () => ({
+          themeName: themeName,
+          theme: theme,
+          breakpoints,
+        }),
+        [theme, themeName, breakpoints],
+      )}>
+      {children}
+    </Context.Provider>
+  )
+}
+
+export function useTheme() {
+  return React.useContext(Context).theme
+}
+
+export function useBreakpoints() {
+  return React.useContext(Context).breakpoints
+}
diff --git a/src/alf/themes.ts b/src/alf/themes.ts
new file mode 100644
index 000000000..7c6b7dab4
--- /dev/null
+++ b/src/alf/themes.ts
@@ -0,0 +1,320 @@
+import * as tokens from '#/alf/tokens'
+import type {Mutable} from '#/alf/types'
+import {atoms} from '#/alf/atoms'
+
+export type ThemeName = 'light' | 'dim' | 'dark'
+export type ReadonlyTheme = typeof light
+export type Theme = Mutable<ReadonlyTheme>
+export type ReadonlyPalette = typeof lightPalette
+export type Palette = Mutable<ReadonlyPalette>
+
+export const lightPalette = {
+  white: tokens.color.gray_0,
+  black: tokens.color.gray_1000,
+
+  contrast_25: tokens.color.gray_25,
+  contrast_50: tokens.color.gray_50,
+  contrast_100: tokens.color.gray_100,
+  contrast_200: tokens.color.gray_200,
+  contrast_300: tokens.color.gray_300,
+  contrast_400: tokens.color.gray_400,
+  contrast_500: tokens.color.gray_500,
+  contrast_600: tokens.color.gray_600,
+  contrast_700: tokens.color.gray_700,
+  contrast_800: tokens.color.gray_800,
+  contrast_900: tokens.color.gray_900,
+  contrast_950: tokens.color.gray_950,
+  contrast_975: tokens.color.gray_975,
+
+  primary_25: tokens.color.blue_25,
+  primary_50: tokens.color.blue_50,
+  primary_100: tokens.color.blue_100,
+  primary_200: tokens.color.blue_200,
+  primary_300: tokens.color.blue_300,
+  primary_400: tokens.color.blue_400,
+  primary_500: tokens.color.blue_500,
+  primary_600: tokens.color.blue_600,
+  primary_700: tokens.color.blue_700,
+  primary_800: tokens.color.blue_800,
+  primary_900: tokens.color.blue_900,
+  primary_950: tokens.color.blue_950,
+  primary_975: tokens.color.blue_975,
+
+  positive_25: tokens.color.green_25,
+  positive_50: tokens.color.green_50,
+  positive_100: tokens.color.green_100,
+  positive_200: tokens.color.green_200,
+  positive_300: tokens.color.green_300,
+  positive_400: tokens.color.green_400,
+  positive_500: tokens.color.green_500,
+  positive_600: tokens.color.green_600,
+  positive_700: tokens.color.green_700,
+  positive_800: tokens.color.green_800,
+  positive_900: tokens.color.green_900,
+  positive_950: tokens.color.green_950,
+  positive_975: tokens.color.green_975,
+
+  negative_25: tokens.color.red_25,
+  negative_50: tokens.color.red_50,
+  negative_100: tokens.color.red_100,
+  negative_200: tokens.color.red_200,
+  negative_300: tokens.color.red_300,
+  negative_400: tokens.color.red_400,
+  negative_500: tokens.color.red_500,
+  negative_600: tokens.color.red_600,
+  negative_700: tokens.color.red_700,
+  negative_800: tokens.color.red_800,
+  negative_900: tokens.color.red_900,
+  negative_950: tokens.color.red_950,
+  negative_975: tokens.color.red_975,
+} as const
+
+export const darkPalette: Palette = {
+  white: tokens.color.gray_0,
+  black: tokens.color.gray_1000,
+
+  contrast_25: tokens.color.gray_975,
+  contrast_50: tokens.color.gray_950,
+  contrast_100: tokens.color.gray_900,
+  contrast_200: tokens.color.gray_800,
+  contrast_300: tokens.color.gray_700,
+  contrast_400: tokens.color.gray_600,
+  contrast_500: tokens.color.gray_500,
+  contrast_600: tokens.color.gray_400,
+  contrast_700: tokens.color.gray_300,
+  contrast_800: tokens.color.gray_200,
+  contrast_900: tokens.color.gray_100,
+  contrast_950: tokens.color.gray_50,
+  contrast_975: tokens.color.gray_25,
+
+  primary_25: tokens.color.blue_25,
+  primary_50: tokens.color.blue_50,
+  primary_100: tokens.color.blue_100,
+  primary_200: tokens.color.blue_200,
+  primary_300: tokens.color.blue_300,
+  primary_400: tokens.color.blue_400,
+  primary_500: tokens.color.blue_500,
+  primary_600: tokens.color.blue_600,
+  primary_700: tokens.color.blue_700,
+  primary_800: tokens.color.blue_800,
+  primary_900: tokens.color.blue_900,
+  primary_950: tokens.color.blue_950,
+  primary_975: tokens.color.blue_975,
+
+  positive_25: tokens.color.green_25,
+  positive_50: tokens.color.green_50,
+  positive_100: tokens.color.green_100,
+  positive_200: tokens.color.green_200,
+  positive_300: tokens.color.green_300,
+  positive_400: tokens.color.green_400,
+  positive_500: tokens.color.green_500,
+  positive_600: tokens.color.green_600,
+  positive_700: tokens.color.green_700,
+  positive_800: tokens.color.green_800,
+  positive_900: tokens.color.green_900,
+  positive_950: tokens.color.green_950,
+  positive_975: tokens.color.green_975,
+
+  negative_25: tokens.color.red_25,
+  negative_50: tokens.color.red_50,
+  negative_100: tokens.color.red_100,
+  negative_200: tokens.color.red_200,
+  negative_300: tokens.color.red_300,
+  negative_400: tokens.color.red_400,
+  negative_500: tokens.color.red_500,
+  negative_600: tokens.color.red_600,
+  negative_700: tokens.color.red_700,
+  negative_800: tokens.color.red_800,
+  negative_900: tokens.color.red_900,
+  negative_950: tokens.color.red_950,
+  negative_975: tokens.color.red_975,
+} as const
+
+export const light = {
+  name: 'light',
+  palette: lightPalette,
+  atoms: {
+    text: {
+      color: lightPalette.black,
+    },
+    text_contrast_700: {
+      color: lightPalette.contrast_700,
+    },
+    text_contrast_600: {
+      color: lightPalette.contrast_600,
+    },
+    text_contrast_500: {
+      color: lightPalette.contrast_500,
+    },
+    text_contrast_400: {
+      color: lightPalette.contrast_400,
+    },
+    text_inverted: {
+      color: lightPalette.white,
+    },
+    bg: {
+      backgroundColor: lightPalette.white,
+    },
+    bg_contrast_25: {
+      backgroundColor: lightPalette.contrast_25,
+    },
+    bg_contrast_50: {
+      backgroundColor: lightPalette.contrast_50,
+    },
+    bg_contrast_100: {
+      backgroundColor: lightPalette.contrast_100,
+    },
+    bg_contrast_200: {
+      backgroundColor: lightPalette.contrast_200,
+    },
+    bg_contrast_300: {
+      backgroundColor: lightPalette.contrast_300,
+    },
+    border: {
+      borderColor: lightPalette.contrast_100,
+    },
+    border_contrast: {
+      borderColor: lightPalette.contrast_400,
+    },
+    shadow_sm: {
+      ...atoms.shadow_sm,
+      shadowColor: lightPalette.black,
+    },
+    shadow_md: {
+      ...atoms.shadow_md,
+      shadowColor: lightPalette.black,
+    },
+    shadow_lg: {
+      ...atoms.shadow_lg,
+      shadowColor: lightPalette.black,
+    },
+  },
+}
+
+export const dim: Theme = {
+  name: 'dim',
+  palette: darkPalette,
+  atoms: {
+    text: {
+      color: darkPalette.white,
+    },
+    text_contrast_700: {
+      color: darkPalette.contrast_800,
+    },
+    text_contrast_600: {
+      color: darkPalette.contrast_700,
+    },
+    text_contrast_500: {
+      color: darkPalette.contrast_600,
+    },
+    text_contrast_400: {
+      color: darkPalette.contrast_500,
+    },
+    text_inverted: {
+      color: darkPalette.black,
+    },
+    bg: {
+      backgroundColor: darkPalette.contrast_50,
+    },
+    bg_contrast_25: {
+      backgroundColor: darkPalette.contrast_100,
+    },
+    bg_contrast_50: {
+      backgroundColor: darkPalette.contrast_200,
+    },
+    bg_contrast_100: {
+      backgroundColor: darkPalette.contrast_300,
+    },
+    bg_contrast_200: {
+      backgroundColor: darkPalette.contrast_400,
+    },
+    bg_contrast_300: {
+      backgroundColor: darkPalette.contrast_500,
+    },
+    border: {
+      borderColor: darkPalette.contrast_200,
+    },
+    border_contrast: {
+      borderColor: darkPalette.contrast_400,
+    },
+    shadow_sm: {
+      ...atoms.shadow_sm,
+      shadowOpacity: 0.7,
+      shadowColor: tokens.color.trueBlack,
+    },
+    shadow_md: {
+      ...atoms.shadow_md,
+      shadowOpacity: 0.7,
+      shadowColor: tokens.color.trueBlack,
+    },
+    shadow_lg: {
+      ...atoms.shadow_lg,
+      shadowOpacity: 0.7,
+      shadowColor: tokens.color.trueBlack,
+    },
+  },
+}
+
+export const dark: Theme = {
+  name: 'dark',
+  palette: darkPalette,
+  atoms: {
+    text: {
+      color: darkPalette.white,
+    },
+    text_contrast_700: {
+      color: darkPalette.contrast_700,
+    },
+    text_contrast_600: {
+      color: darkPalette.contrast_600,
+    },
+    text_contrast_500: {
+      color: darkPalette.contrast_500,
+    },
+    text_contrast_400: {
+      color: darkPalette.contrast_400,
+    },
+    text_inverted: {
+      color: darkPalette.black,
+    },
+    bg: {
+      backgroundColor: darkPalette.black,
+    },
+    bg_contrast_25: {
+      backgroundColor: darkPalette.contrast_50,
+    },
+    bg_contrast_50: {
+      backgroundColor: darkPalette.contrast_100,
+    },
+    bg_contrast_100: {
+      backgroundColor: darkPalette.contrast_200,
+    },
+    bg_contrast_200: {
+      backgroundColor: darkPalette.contrast_300,
+    },
+    bg_contrast_300: {
+      backgroundColor: darkPalette.contrast_400,
+    },
+    border: {
+      borderColor: darkPalette.contrast_100,
+    },
+    border_contrast: {
+      borderColor: darkPalette.contrast_300,
+    },
+    shadow_sm: {
+      ...atoms.shadow_sm,
+      shadowOpacity: 0.7,
+      shadowColor: tokens.color.trueBlack,
+    },
+    shadow_md: {
+      ...atoms.shadow_md,
+      shadowOpacity: 0.7,
+      shadowColor: tokens.color.trueBlack,
+    },
+    shadow_lg: {
+      ...atoms.shadow_lg,
+      shadowOpacity: 0.7,
+      shadowColor: tokens.color.trueBlack,
+    },
+  },
+}
diff --git a/src/alf/tokens.ts b/src/alf/tokens.ts
new file mode 100644
index 000000000..0e370cdc1
--- /dev/null
+++ b/src/alf/tokens.ts
@@ -0,0 +1,168 @@
+const BLUE_HUE = 211
+const RED_HUE = 346
+const GREEN_HUE = 152
+
+export const color = {
+  trueBlack: '#000000',
+
+  gray_0: `hsl(${BLUE_HUE}, 20%, 100%)`,
+  gray_25: `hsl(${BLUE_HUE}, 20%, 97%)`,
+  gray_50: `hsl(${BLUE_HUE}, 20%, 95%)`,
+  gray_100: `hsl(${BLUE_HUE}, 20%, 90%)`,
+  gray_200: `hsl(${BLUE_HUE}, 20%, 80%)`,
+  gray_300: `hsl(${BLUE_HUE}, 20%, 70%)`,
+  gray_400: `hsl(${BLUE_HUE}, 20%, 60%)`,
+  gray_500: `hsl(${BLUE_HUE}, 20%, 50%)`,
+  gray_600: `hsl(${BLUE_HUE}, 20%, 42%)`,
+  gray_700: `hsl(${BLUE_HUE}, 20%, 34%)`,
+  gray_800: `hsl(${BLUE_HUE}, 20%, 26%)`,
+  gray_900: `hsl(${BLUE_HUE}, 20%, 18%)`,
+  gray_950: `hsl(${BLUE_HUE}, 20%, 10%)`,
+  gray_975: `hsl(${BLUE_HUE}, 20%, 7%)`,
+  gray_1000: `hsl(${BLUE_HUE}, 20%, 4%)`,
+
+  blue_25: `hsl(${BLUE_HUE}, 99%, 97%)`,
+  blue_50: `hsl(${BLUE_HUE}, 99%, 95%)`,
+  blue_100: `hsl(${BLUE_HUE}, 99%, 90%)`,
+  blue_200: `hsl(${BLUE_HUE}, 99%, 80%)`,
+  blue_300: `hsl(${BLUE_HUE}, 99%, 70%)`,
+  blue_400: `hsl(${BLUE_HUE}, 99%, 60%)`,
+  blue_500: `hsl(${BLUE_HUE}, 99%, 53%)`,
+  blue_600: `hsl(${BLUE_HUE}, 99%, 42%)`,
+  blue_700: `hsl(${BLUE_HUE}, 99%, 34%)`,
+  blue_800: `hsl(${BLUE_HUE}, 99%, 26%)`,
+  blue_900: `hsl(${BLUE_HUE}, 99%, 18%)`,
+  blue_950: `hsl(${BLUE_HUE}, 99%, 10%)`,
+  blue_975: `hsl(${BLUE_HUE}, 99%, 7%)`,
+
+  green_25: `hsl(${GREEN_HUE}, 82%, 97%)`,
+  green_50: `hsl(${GREEN_HUE}, 82%, 95%)`,
+  green_100: `hsl(${GREEN_HUE}, 82%, 90%)`,
+  green_200: `hsl(${GREEN_HUE}, 82%, 80%)`,
+  green_300: `hsl(${GREEN_HUE}, 82%, 70%)`,
+  green_400: `hsl(${GREEN_HUE}, 82%, 60%)`,
+  green_500: `hsl(${GREEN_HUE}, 82%, 50%)`,
+  green_600: `hsl(${GREEN_HUE}, 82%, 42%)`,
+  green_700: `hsl(${GREEN_HUE}, 82%, 34%)`,
+  green_800: `hsl(${GREEN_HUE}, 82%, 26%)`,
+  green_900: `hsl(${GREEN_HUE}, 82%, 18%)`,
+  green_950: `hsl(${GREEN_HUE}, 82%, 10%)`,
+  green_975: `hsl(${GREEN_HUE}, 82%, 7%)`,
+
+  red_25: `hsl(${RED_HUE}, 91%, 97%)`,
+  red_50: `hsl(${RED_HUE}, 91%, 95%)`,
+  red_100: `hsl(${RED_HUE}, 91%, 90%)`,
+  red_200: `hsl(${RED_HUE}, 91%, 80%)`,
+  red_300: `hsl(${RED_HUE}, 91%, 70%)`,
+  red_400: `hsl(${RED_HUE}, 91%, 60%)`,
+  red_500: `hsl(${RED_HUE}, 91%, 50%)`,
+  red_600: `hsl(${RED_HUE}, 91%, 42%)`,
+  red_700: `hsl(${RED_HUE}, 91%, 34%)`,
+  red_800: `hsl(${RED_HUE}, 91%, 26%)`,
+  red_900: `hsl(${RED_HUE}, 91%, 18%)`,
+  red_950: `hsl(${RED_HUE}, 91%, 10%)`,
+  red_975: `hsl(${RED_HUE}, 91%, 7%)`,
+} as const
+
+export const space = {
+  _2xs: 2,
+  xs: 4,
+  sm: 8,
+  md: 12,
+  lg: 16,
+  xl: 20,
+  _2xl: 24,
+  _3xl: 28,
+  _4xl: 32,
+  _5xl: 40,
+} as const
+
+export const fontSize = {
+  _2xs: 10,
+  xs: 12,
+  sm: 14,
+  md: 16,
+  lg: 18,
+  xl: 20,
+  _2xl: 22,
+  _3xl: 26,
+  _4xl: 32,
+  _5xl: 40,
+} as const
+
+export const lineHeight = {
+  none: 1,
+  normal: 1.5,
+  relaxed: 1.625,
+} as const
+
+export const borderRadius = {
+  _2xs: 2,
+  xs: 4,
+  sm: 8,
+  md: 12,
+  full: 999,
+} as const
+
+export const fontWeight = {
+  normal: '400',
+  semibold: '600',
+  bold: '900',
+} as const
+
+export const gradients = {
+  sky: {
+    values: [
+      [0, '#0A7AFF'],
+      [1, '#59B9FF'],
+    ],
+    hover_value: '#0A7AFF',
+  },
+  midnight: {
+    values: [
+      [0, '#022C5E'],
+      [1, '#4079BC'],
+    ],
+    hover_value: '#022C5E',
+  },
+  sunrise: {
+    values: [
+      [0, '#4E90AE'],
+      [0.4, '#AEA3AB'],
+      [0.8, '#E6A98F'],
+      [1, '#F3A84C'],
+    ],
+    hover_value: '#AEA3AB',
+  },
+  sunset: {
+    values: [
+      [0, '#6772AF'],
+      [0.6, '#B88BB6'],
+      [1, '#FFA6AC'],
+    ],
+    hover_value: '#B88BB6',
+  },
+  nordic: {
+    values: [
+      [0, '#083367'],
+      [1, '#9EE8C1'],
+    ],
+    hover_value: '#3A7085',
+  },
+  bonfire: {
+    values: [
+      [0, '#203E4E'],
+      [0.4, '#755B62'],
+      [0.8, '#CD7765'],
+      [1, '#EF956E'],
+    ],
+    hover_value: '#755B62',
+  },
+} as const
+
+export type Color = keyof typeof color
+export type Space = keyof typeof space
+export type FontSize = keyof typeof fontSize
+export type LineHeight = keyof typeof lineHeight
+export type BorderRadius = keyof typeof borderRadius
+export type FontWeight = keyof typeof fontWeight
diff --git a/src/alf/types.ts b/src/alf/types.ts
new file mode 100644
index 000000000..76ac05d40
--- /dev/null
+++ b/src/alf/types.ts
@@ -0,0 +1,16 @@
+type LiteralToCommon<T extends PropertyKey> = T extends number
+  ? number
+  : T extends string
+  ? string
+  : T extends symbol
+  ? symbol
+  : never
+
+/**
+ * @see https://stackoverflow.com/questions/68249999/use-as-const-in-typescript-without-adding-readonly-modifiers
+ */
+export type Mutable<T> = {
+  -readonly [K in keyof T]: T[K] extends PropertyKey
+    ? LiteralToCommon<T[K]>
+    : Mutable<T[K]>
+}
diff --git a/src/alf/util/flatten.ts b/src/alf/util/flatten.ts
new file mode 100644
index 000000000..448716a08
--- /dev/null
+++ b/src/alf/util/flatten.ts
@@ -0,0 +1,3 @@
+import {StyleSheet} from 'react-native'
+
+export const flatten = StyleSheet.flatten
diff --git a/src/alf/util/platform.ts b/src/alf/util/platform.ts
new file mode 100644
index 000000000..544f5480b
--- /dev/null
+++ b/src/alf/util/platform.ts
@@ -0,0 +1,25 @@
+import {Platform} from 'react-native'
+
+export function web(value: any) {
+  return Platform.select({
+    web: value,
+  })
+}
+
+export function ios(value: any) {
+  return Platform.select({
+    ios: value,
+  })
+}
+
+export function android(value: any) {
+  return Platform.select({
+    android: value,
+  })
+}
+
+export function native(value: any) {
+  return Platform.select({
+    native: value,
+  })
+}
diff --git a/src/alf/util/useColorModeTheme.ts b/src/alf/util/useColorModeTheme.ts
new file mode 100644
index 000000000..79cebc139
--- /dev/null
+++ b/src/alf/util/useColorModeTheme.ts
@@ -0,0 +1,10 @@
+import {useColorScheme} from 'react-native'
+
+import * as persisted from '#/state/persisted'
+
+export function useColorModeTheme(
+  theme: persisted.Schema['colorMode'],
+): 'light' | 'dark' {
+  const colorScheme = useColorScheme()
+  return (theme === 'system' ? colorScheme : theme) || 'light'
+}