From a5b474895a27bb36381cca6a580dc19e4c4b10c2 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Mon, 8 Jan 2024 19:43:56 -0600 Subject: [PATCH] Application Layout Framework (#1732) * Initial library setup * Add docblocks * Some cleanup * New storybook * Playing around * Remove silly test, use for...in * Memo * Memo * Add hooks example * Tweak colors, bit of cleanup * Improve macro handling * Add some more examples * Rename for better diff * Cleanup * Add nested context example * Add todo * Less break more perf * Buttons, you get the idea * Fix test * Remove temp colors * Add a few more common macros * Docs * Perf improvements * Alf go brrrr * Update breakpoint handling * I think it'll work * Better naming, better code * Fix typo * Some renaming * More complete pass at Tailwind naming * Build out storybook * Playing around with curves * Revert "Playing around with curves" This reverts commit 6b0e0e5c9d842a2d9af31b53affe2f6291c3fa0d. * Smooth brain * Remove outdated docs * Some docs, fix line-height values, export tokens --- src/App.web.tsx | 39 ++- src/Navigation.tsx | 2 +- src/alf/README.md | 56 ++++ src/alf/atoms.ts | 514 ++++++++++++++++++++++++++++ src/alf/index.tsx | 92 +++++ src/alf/themes.ts | 108 ++++++ src/alf/tokens.ts | 100 ++++++ src/alf/types.ts | 16 + src/alf/util/platform.ts | 25 ++ src/alf/util/useColorModeTheme.ts | 10 + src/view/com/Button.tsx | 204 +++++++++++ src/view/com/Typography.tsx | 104 ++++++ src/view/screens/DebugNew.tsx | 541 ++++++++++++++++++++++++++++++ 13 files changed, 1793 insertions(+), 18 deletions(-) create mode 100644 src/alf/README.md create mode 100644 src/alf/atoms.ts create mode 100644 src/alf/index.tsx create mode 100644 src/alf/themes.ts create mode 100644 src/alf/tokens.ts create mode 100644 src/alf/types.ts create mode 100644 src/alf/util/platform.ts create mode 100644 src/alf/util/useColorModeTheme.ts create mode 100644 src/view/com/Button.tsx create mode 100644 src/view/com/Typography.tsx create mode 100644 src/view/screens/DebugNew.tsx diff --git a/src/App.web.tsx b/src/App.web.tsx index 6c67dc28..1bdb3c20 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 7bb1aa0a..76a893c6 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 00000000..aa31bcf9 --- /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 00000000..c142f5f7 --- /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 00000000..1daa0bfe --- /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 00000000..aae5c589 --- /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 00000000..4034e0de --- /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 00000000..76ac05d4 --- /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 00000000..544f5480 --- /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 00000000..79cebc13 --- /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 00000000..d1f70d4a --- /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 00000000..6579c2e5 --- /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 00000000..0b7c5f03 --- /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) + + + + + + + +
+
+ ) +}