diff --git a/src/App.web.tsx b/src/App.web.tsx
index 6c67dc28ba..1bdb3c208c 100644
--- a/src/App.web.tsx
+++ b/src/App.web.tsx
@@ -7,6 +7,7 @@ import {RootSiblingParent} from 'react-native-root-siblings'
import 'view/icons'
+import {ThemeProvider as Alf} from '#/alf'
import {init as initPersistedState} from '#/state/persisted'
import {useColorMode} from 'state/shell'
import {Shell} from 'view/shell/index'
@@ -28,11 +29,13 @@ import {
} from 'state/session'
import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
import * as persisted from '#/state/persisted'
+import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
function InnerApp() {
const {isInitialLoad, currentAccount} = useSession()
const {resumeSession} = useSessionApi()
const colorMode = useColorMode()
+ const theme = useColorModeTheme(colorMode)
// init
useEffect(() => {
@@ -44,23 +47,25 @@ function InnerApp() {
if (isInitialLoad) return null
return (
-
-
-
-
- {/* All components should be within this provider */}
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+ {/* All components should be within this provider */}
+
+
+
+
+
+
+
+
+
+
+
)
}
diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index 7bb1aa0ade..76a893c68c 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -61,7 +61,7 @@ import {ProfileListScreen} from './view/screens/ProfileList'
import {PostThreadScreen} from './view/screens/PostThread'
import {PostLikedByScreen} from './view/screens/PostLikedBy'
import {PostRepostedByScreen} from './view/screens/PostRepostedBy'
-import {DebugScreen} from './view/screens/Debug'
+import {DebugScreen} from './view/screens/DebugNew'
import {LogScreen} from './view/screens/Log'
import {SupportScreen} from './view/screens/Support'
import {PrivacyPolicyScreen} from './view/screens/PrivacyPolicy'
diff --git a/src/alf/README.md b/src/alf/README.md
new file mode 100644
index 0000000000..aa31bcf988
--- /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'
+
+
+```
+
+### Theme
+
+Any values that rely on the theme, namely colors.
+
+```tsx
+const t = useTheme()
+
+
+```
+
+### 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 0000000000..c142f5f716
--- /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 0000000000..1daa0bfedc
--- /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 (
+ ({
+ themeName: themeName,
+ theme: theme,
+ breakpoints,
+ }),
+ [theme, themeName, breakpoints],
+ )}>
+ {children}
+
+ )
+}
+
+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 0000000000..aae5c58931
--- /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
+
+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 0000000000..4034e0deb4
--- /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 0000000000..76ac05d40e
--- /dev/null
+++ b/src/alf/types.ts
@@ -0,0 +1,16 @@
+type LiteralToCommon = 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 = {
+ -readonly [K in keyof T]: T[K] extends PropertyKey
+ ? LiteralToCommon
+ : Mutable
+}
diff --git a/src/alf/util/platform.ts b/src/alf/util/platform.ts
new file mode 100644
index 0000000000..544f5480b6
--- /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 0000000000..79cebc1394
--- /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'
+}
diff --git a/src/view/com/Button.tsx b/src/view/com/Button.tsx
new file mode 100644
index 0000000000..d1f70d4aeb
--- /dev/null
+++ b/src/view/com/Button.tsx
@@ -0,0 +1,204 @@
+import React from 'react'
+import {Pressable, Text, PressableProps, TextProps} from 'react-native'
+import * as tokens from '#/alf/tokens'
+import {atoms} from '#/alf'
+
+export type ButtonType =
+ | 'primary'
+ | 'secondary'
+ | 'tertiary'
+ | 'positive'
+ | 'negative'
+export type ButtonSize = 'small' | 'large'
+
+export type VariantProps = {
+ type?: ButtonType
+ size?: ButtonSize
+}
+type ButtonState = {
+ pressed: boolean
+ hovered: boolean
+ focused: boolean
+}
+export type ButtonProps = Omit &
+ VariantProps & {
+ children:
+ | ((props: {
+ state: ButtonState
+ type?: ButtonType
+ size?: ButtonSize
+ }) => React.ReactNode)
+ | React.ReactNode
+ | string
+ }
+export type ButtonTextProps = TextProps & VariantProps
+
+export function Button({children, style, type, size, ...rest}: ButtonProps) {
+ const {baseStyles, hoverStyles} = React.useMemo(() => {
+ const baseStyles = []
+ const hoverStyles = []
+
+ switch (type) {
+ case 'primary':
+ baseStyles.push({
+ backgroundColor: tokens.color.blue_500,
+ })
+ break
+ case 'secondary':
+ baseStyles.push({
+ backgroundColor: tokens.color.gray_200,
+ })
+ hoverStyles.push({
+ backgroundColor: tokens.color.gray_100,
+ })
+ break
+ default:
+ }
+
+ switch (size) {
+ case 'large':
+ baseStyles.push(
+ atoms.py_md,
+ atoms.px_xl,
+ atoms.rounded_md,
+ atoms.gap_sm,
+ )
+ break
+ case 'small':
+ baseStyles.push(
+ atoms.py_sm,
+ atoms.px_md,
+ atoms.rounded_sm,
+ atoms.gap_xs,
+ )
+ break
+ default:
+ }
+
+ return {
+ baseStyles,
+ hoverStyles,
+ }
+ }, [type, size])
+
+ const [state, setState] = React.useState({
+ pressed: false,
+ hovered: false,
+ focused: false,
+ })
+
+ const onPressIn = React.useCallback(() => {
+ setState(s => ({
+ ...s,
+ pressed: true,
+ }))
+ }, [setState])
+ const onPressOut = React.useCallback(() => {
+ setState(s => ({
+ ...s,
+ pressed: false,
+ }))
+ }, [setState])
+ const onHoverIn = React.useCallback(() => {
+ setState(s => ({
+ ...s,
+ hovered: true,
+ }))
+ }, [setState])
+ const onHoverOut = React.useCallback(() => {
+ setState(s => ({
+ ...s,
+ hovered: false,
+ }))
+ }, [setState])
+ const onFocus = React.useCallback(() => {
+ setState(s => ({
+ ...s,
+ focused: true,
+ }))
+ }, [setState])
+ const onBlur = React.useCallback(() => {
+ setState(s => ({
+ ...s,
+ focused: false,
+ }))
+ }, [setState])
+
+ return (
+ [
+ atoms.flex_row,
+ atoms.align_center,
+ ...baseStyles,
+ ...(state.hovered ? hoverStyles : []),
+ typeof style === 'function' ? style(state) : style,
+ ]}
+ onPressIn={onPressIn}
+ onPressOut={onPressOut}
+ onHoverIn={onHoverIn}
+ onHoverOut={onHoverOut}
+ onFocus={onFocus}
+ onBlur={onBlur}>
+ {typeof children === 'string' ? (
+
+ {children}
+
+ ) : typeof children === 'function' ? (
+ children({state, type, size})
+ ) : (
+ children
+ )}
+
+ )
+}
+
+export function ButtonText({
+ children,
+ style,
+ type,
+ size,
+ ...rest
+}: ButtonTextProps) {
+ const textStyles = React.useMemo(() => {
+ const base = []
+
+ switch (type) {
+ case 'primary':
+ base.push({color: tokens.color.white})
+ break
+ case 'secondary':
+ base.push({
+ color: tokens.color.gray_700,
+ })
+ break
+ default:
+ }
+
+ switch (size) {
+ case 'small':
+ base.push(atoms.text_sm, {paddingBottom: 1})
+ break
+ case 'large':
+ base.push(atoms.text_md, {paddingBottom: 1})
+ break
+ default:
+ }
+
+ return base
+ }, [type, size])
+
+ return (
+
+ {children}
+
+ )
+}
diff --git a/src/view/com/Typography.tsx b/src/view/com/Typography.tsx
new file mode 100644
index 0000000000..6579c2e511
--- /dev/null
+++ b/src/view/com/Typography.tsx
@@ -0,0 +1,104 @@
+import React from 'react'
+import {Text as RNText, TextProps} from 'react-native'
+import {useTheme, atoms, web} from '#/alf'
+
+export function Text({style, ...rest}: TextProps) {
+ const t = useTheme()
+ return
+}
+
+export function H1({style, ...rest}: TextProps) {
+ const t = useTheme()
+ const attr =
+ web({
+ role: 'heading',
+ 'aria-level': 1,
+ }) || {}
+ return (
+
+ )
+}
+
+export function H2({style, ...rest}: TextProps) {
+ const t = useTheme()
+ const attr =
+ web({
+ role: 'heading',
+ 'aria-level': 2,
+ }) || {}
+ return (
+
+ )
+}
+
+export function H3({style, ...rest}: TextProps) {
+ const t = useTheme()
+ const attr =
+ web({
+ role: 'heading',
+ 'aria-level': 3,
+ }) || {}
+ return (
+
+ )
+}
+
+export function H4({style, ...rest}: TextProps) {
+ const t = useTheme()
+ const attr =
+ web({
+ role: 'heading',
+ 'aria-level': 4,
+ }) || {}
+ return (
+
+ )
+}
+
+export function H5({style, ...rest}: TextProps) {
+ const t = useTheme()
+ const attr =
+ web({
+ role: 'heading',
+ 'aria-level': 5,
+ }) || {}
+ return (
+
+ )
+}
+
+export function H6({style, ...rest}: TextProps) {
+ const t = useTheme()
+ const attr =
+ web({
+ role: 'heading',
+ 'aria-level': 6,
+ }) || {}
+ return (
+
+ )
+}
diff --git a/src/view/screens/DebugNew.tsx b/src/view/screens/DebugNew.tsx
new file mode 100644
index 0000000000..0b7c5f03bc
--- /dev/null
+++ b/src/view/screens/DebugNew.tsx
@@ -0,0 +1,541 @@
+import React from 'react'
+import {View} from 'react-native'
+import {CenteredView, ScrollView} from '#/view/com/util/Views'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+
+import {useSetColorMode} from '#/state/shell'
+import * as tokens from '#/alf/tokens'
+import {atoms as a, useTheme, useBreakpoints, ThemeProvider as Alf} from '#/alf'
+import {Button, ButtonText} from '#/view/com/Button'
+import {Text, H1, H2, H3, H4, H5, H6} from '#/view/com/Typography'
+
+function ThemeSelector() {
+ const setColorMode = useSetColorMode()
+
+ return (
+
+
+
+
+
+ )
+}
+
+function BreakpointDebugger() {
+ const t = useTheme()
+ const breakpoints = useBreakpoints()
+
+ return (
+
+ Breakpoint Debugger
+
+ Current breakpoint: {!breakpoints.gtMobile && mobile}
+ {breakpoints.gtMobile && !breakpoints.gtTablet && tablet}
+ {breakpoints.gtTablet && desktop}
+
+
+ {JSON.stringify(breakpoints, null, 2)}
+
+
+ )
+}
+
+function ThemedSection() {
+ const t = useTheme()
+
+ return (
+
+ theme.atoms.text
+
+
+ theme.atoms.text_contrast_700
+
+
+
+ theme.atoms.text_contrast_500
+
+
+
+
+
+ theme.bg
+
+
+ theme.bg_contrast_100
+
+
+
+
+ theme.bg_contrast_200
+
+
+ theme.bg_contrast_300
+
+
+
+
+ theme.bg_positive
+
+
+ theme.bg_negative
+
+
+
+ )
+}
+
+export function DebugScreen() {
+ const t = useTheme()
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ Heading 1
+ Heading 2
+ Heading 3
+ Heading 4
+ Heading 5
+ Heading 6
+
+ atoms.text_xxl
+ atoms.text_xl
+ atoms.text_lg
+ atoms.text_md
+ atoms.text_sm
+ atoms.text_xs
+ atoms.text_xxs
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Spacing
+
+
+
+ xxs (2px)
+
+
+
+
+ xs (4px)
+
+
+
+
+ sm (8px)
+
+
+
+
+ md (12px)
+
+
+
+
+ lg (18px)
+
+
+
+
+ xl (24px)
+
+
+
+
+ xxl (32px)
+
+
+
+
+
+
+
+
+
+ )
+}