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.ts514
-rw-r--r--src/alf/index.tsx92
-rw-r--r--src/alf/themes.ts108
-rw-r--r--src/alf/tokens.ts100
-rw-r--r--src/alf/types.ts16
-rw-r--r--src/alf/util/platform.ts25
-rw-r--r--src/alf/util/useColorModeTheme.ts10
8 files changed, 921 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..c142f5f71
--- /dev/null
+++ b/src/alf/atoms.ts
@@ -0,0 +1,514 @@
+import * as tokens from '#/alf/tokens'
+
+export const atoms = {
+  /*
+   * Positioning
+   */
+  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,
+  },
+
+  /*
+   * Width
+   */
+  w_full: {
+    width: '100%',
+  },
+  h_full: {
+    height: '100%',
+  },
+
+  /*
+   * Border radius
+   */
+  rounded_sm: {
+    borderRadius: tokens.borderRadius.sm,
+  },
+  rounded_md: {
+    borderRadius: tokens.borderRadius.md,
+  },
+  rounded_full: {
+    borderRadius: tokens.borderRadius.full,
+  },
+
+  /*
+   * Flex
+   */
+  gap_xxs: {
+    gap: tokens.space.xxs,
+  },
+  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_xxl: {
+    gap: tokens.space.xxl,
+  },
+  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_xxs: {
+    fontSize: tokens.fontSize.xxs,
+    lineHeight: tokens.fontSize.xxs,
+  },
+  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_xxl: {
+    fontSize: tokens.fontSize.xxl,
+    lineHeight: tokens.fontSize.xxl,
+  },
+  leading_tight: {
+    lineHeight: 1.25,
+  },
+  leading_normal: {
+    lineHeight: 1.5,
+  },
+  font_normal: {
+    fontWeight: tokens.fontWeight.normal,
+  },
+  font_semibold: {
+    fontWeight: tokens.fontWeight.semibold,
+  },
+  font_bold: {
+    fontWeight: tokens.fontWeight.bold,
+  },
+
+  /*
+   * Border
+   */
+  border: {
+    borderWidth: 1,
+  },
+  border_t: {
+    borderTopWidth: 1,
+  },
+  border_b: {
+    borderBottomWidth: 1,
+  },
+
+  /*
+   * Padding
+   */
+  p_xxs: {
+    padding: tokens.space.xxs,
+  },
+  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_xxl: {
+    padding: tokens.space.xxl,
+  },
+  px_xxs: {
+    paddingLeft: tokens.space.xxs,
+    paddingRight: tokens.space.xxs,
+  },
+  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_xxl: {
+    paddingLeft: tokens.space.xxl,
+    paddingRight: tokens.space.xxl,
+  },
+  py_xxs: {
+    paddingTop: tokens.space.xxs,
+    paddingBottom: tokens.space.xxs,
+  },
+  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_xxl: {
+    paddingTop: tokens.space.xxl,
+    paddingBottom: tokens.space.xxl,
+  },
+  pt_xxs: {
+    paddingTop: tokens.space.xxs,
+  },
+  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_xxl: {
+    paddingTop: tokens.space.xxl,
+  },
+  pb_xxs: {
+    paddingBottom: tokens.space.xxs,
+  },
+  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_xxl: {
+    paddingBottom: tokens.space.xxl,
+  },
+  pl_xxs: {
+    paddingLeft: tokens.space.xxs,
+  },
+  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_xxl: {
+    paddingLeft: tokens.space.xxl,
+  },
+  pr_xxs: {
+    paddingRight: tokens.space.xxs,
+  },
+  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_xxl: {
+    paddingRight: tokens.space.xxl,
+  },
+
+  /*
+   * Margin
+   */
+  m_xxs: {
+    margin: tokens.space.xxs,
+  },
+  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_xxl: {
+    margin: tokens.space.xxl,
+  },
+  mx_xxs: {
+    marginLeft: tokens.space.xxs,
+    marginRight: tokens.space.xxs,
+  },
+  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_xxl: {
+    marginLeft: tokens.space.xxl,
+    marginRight: tokens.space.xxl,
+  },
+  my_xxs: {
+    marginTop: tokens.space.xxs,
+    marginBottom: tokens.space.xxs,
+  },
+  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_xxl: {
+    marginTop: tokens.space.xxl,
+    marginBottom: tokens.space.xxl,
+  },
+  mt_xxs: {
+    marginTop: tokens.space.xxs,
+  },
+  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_xxl: {
+    marginTop: tokens.space.xxl,
+  },
+  mb_xxs: {
+    marginBottom: tokens.space.xxs,
+  },
+  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_xxl: {
+    marginBottom: tokens.space.xxl,
+  },
+  ml_xxs: {
+    marginLeft: tokens.space.xxs,
+  },
+  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_xxl: {
+    marginLeft: tokens.space.xxl,
+  },
+  mr_xxs: {
+    marginRight: tokens.space.xxs,
+  },
+  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_xxl: {
+    marginRight: tokens.space.xxl,
+  },
+} as const
diff --git a/src/alf/index.tsx b/src/alf/index.tsx
new file mode 100644
index 000000000..1daa0bfed
--- /dev/null
+++ b/src/alf/index.tsx
@@ -0,0 +1,92 @@
+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'
+
+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..aae5c5893
--- /dev/null
+++ b/src/alf/themes.ts
@@ -0,0 +1,108 @@
+import * as tokens from '#/alf/tokens'
+import type {Mutable} from '#/alf/types'
+
+export type ThemeName = 'light' | 'dark'
+export type ReadonlyTheme = typeof light
+export type Theme = Mutable<ReadonlyTheme>
+
+export type Palette = {
+  primary: string
+  positive: string
+  negative: string
+}
+
+export const lightPalette: Palette = {
+  primary: tokens.color.blue_500,
+  positive: tokens.color.green_500,
+  negative: tokens.color.red_500,
+} as const
+
+export const darkPalette: Palette = {
+  primary: tokens.color.blue_500,
+  positive: tokens.color.green_400,
+  negative: tokens.color.red_400,
+} as const
+
+export const light = {
+  palette: lightPalette,
+  atoms: {
+    text: {
+      color: tokens.color.gray_1000,
+    },
+    text_contrast_700: {
+      color: tokens.color.gray_700,
+    },
+    text_contrast_500: {
+      color: tokens.color.gray_500,
+    },
+    text_inverted: {
+      color: tokens.color.white,
+    },
+    bg: {
+      backgroundColor: tokens.color.white,
+    },
+    bg_contrast_100: {
+      backgroundColor: tokens.color.gray_100,
+    },
+    bg_contrast_200: {
+      backgroundColor: tokens.color.gray_200,
+    },
+    bg_contrast_300: {
+      backgroundColor: tokens.color.gray_300,
+    },
+    bg_positive: {
+      backgroundColor: tokens.color.green_500,
+    },
+    bg_negative: {
+      backgroundColor: tokens.color.red_400,
+    },
+    border: {
+      borderColor: tokens.color.gray_200,
+    },
+    border_contrast_500: {
+      borderColor: tokens.color.gray_500,
+    },
+  },
+}
+
+export const dark: Theme = {
+  palette: darkPalette,
+  atoms: {
+    text: {
+      color: tokens.color.white,
+    },
+    text_contrast_700: {
+      color: tokens.color.gray_300,
+    },
+    text_contrast_500: {
+      color: tokens.color.gray_500,
+    },
+    text_inverted: {
+      color: tokens.color.gray_1000,
+    },
+    bg: {
+      backgroundColor: tokens.color.gray_1000,
+    },
+    bg_contrast_100: {
+      backgroundColor: tokens.color.gray_900,
+    },
+    bg_contrast_200: {
+      backgroundColor: tokens.color.gray_800,
+    },
+    bg_contrast_300: {
+      backgroundColor: tokens.color.gray_700,
+    },
+    bg_positive: {
+      backgroundColor: tokens.color.green_400,
+    },
+    bg_negative: {
+      backgroundColor: tokens.color.red_400,
+    },
+    border: {
+      borderColor: tokens.color.gray_800,
+    },
+    border_contrast_500: {
+      borderColor: tokens.color.gray_500,
+    },
+  },
+}
diff --git a/src/alf/tokens.ts b/src/alf/tokens.ts
new file mode 100644
index 000000000..4034e0deb
--- /dev/null
+++ b/src/alf/tokens.ts
@@ -0,0 +1,100 @@
+const BLUE_HUE = 211
+const GRAYSCALE_SATURATION = 22
+
+export const color = {
+  white: '#FFFFFF',
+
+  gray_0: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 100%)`,
+  gray_100: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 95%)`,
+  gray_200: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 85%)`,
+  gray_300: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 75%)`,
+  gray_400: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 65%)`,
+  gray_500: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 55%)`,
+  gray_600: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 45%)`,
+  gray_700: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 35%)`,
+  gray_800: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 25%)`,
+  gray_900: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 15%)`,
+  gray_1000: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 5%)`,
+
+  blue_0: `hsl(${BLUE_HUE}, 99%, 100%)`,
+  blue_100: `hsl(${BLUE_HUE}, 99%, 93%)`,
+  blue_200: `hsl(${BLUE_HUE}, 99%, 83%)`,
+  blue_300: `hsl(${BLUE_HUE}, 99%, 73%)`,
+  blue_400: `hsl(${BLUE_HUE}, 99%, 63%)`,
+  blue_500: `hsl(${BLUE_HUE}, 99%, 53%)`,
+  blue_600: `hsl(${BLUE_HUE}, 99%, 43%)`,
+  blue_700: `hsl(${BLUE_HUE}, 99%, 33%)`,
+  blue_800: `hsl(${BLUE_HUE}, 99%, 23%)`,
+  blue_900: `hsl(${BLUE_HUE}, 99%, 13%)`,
+  blue_1000: `hsl(${BLUE_HUE}, 99%, 8%)`,
+
+  green_0: `hsl(130, 60%, 100%)`,
+  green_100: `hsl(130, 60%, 95%)`,
+  green_200: `hsl(130, 60%, 85%)`,
+  green_300: `hsl(130, 60%, 75%)`,
+  green_400: `hsl(130, 60%, 65%)`,
+  green_500: `hsl(130, 60%, 55%)`,
+  green_600: `hsl(130, 60%, 45%)`,
+  green_700: `hsl(130, 60%, 35%)`,
+  green_800: `hsl(130, 60%, 25%)`,
+  green_900: `hsl(130, 60%, 15%)`,
+  green_1000: `hsl(130, 60%, 5%)`,
+
+  red_0: `hsl(349, 96%, 100%)`,
+  red_100: `hsl(349, 96%, 95%)`,
+  red_200: `hsl(349, 96%, 85%)`,
+  red_300: `hsl(349, 96%, 75%)`,
+  red_400: `hsl(349, 96%, 65%)`,
+  red_500: `hsl(349, 96%, 55%)`,
+  red_600: `hsl(349, 96%, 45%)`,
+  red_700: `hsl(349, 96%, 35%)`,
+  red_800: `hsl(349, 96%, 25%)`,
+  red_900: `hsl(349, 96%, 15%)`,
+  red_1000: `hsl(349, 96%, 5%)`,
+} as const
+
+export const space = {
+  xxs: 2,
+  xs: 4,
+  sm: 8,
+  md: 12,
+  lg: 18,
+  xl: 24,
+  xxl: 32,
+} as const
+
+export const fontSize = {
+  xxs: 10,
+  xs: 12,
+  sm: 14,
+  md: 16,
+  lg: 18,
+  xl: 22,
+  xxl: 26,
+} as const
+
+// TODO test
+export const lineHeight = {
+  none: 1,
+  normal: 1.5,
+  relaxed: 1.625,
+} as const
+
+export const borderRadius = {
+  sm: 8,
+  md: 12,
+  full: 999,
+} as const
+
+export const fontWeight = {
+  normal: '400',
+  semibold: '600',
+  bold: '900',
+} 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/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'
+}