feat(client): add component library and simplify routing (#66)

* feat(client): add component library and simplify routing

Remove tab layout, explore page, modal, and unused template
components. Replace with single-page layout and a dev component
showcase with per-component detail pages.

- Add Button with label prop, leading/trailing icon support
- Add FeedCard, SerifText, SansSerifText, MonospaceText
- Add colocated *.showcase.tsx files for each component
- Use Stack navigator with themed headers

Co-authored-by: Ona <no-reply@ona.com>

* fix(client): render showcase as JSX component

Co-authored-by: Ona <no-reply@ona.com>

* chore(client): remove dead code chain

Remove ThemedText, useThemeColor, useColorScheme hook,
Colors, and Fonts — none referenced by current screens.

Co-authored-by: Ona <no-reply@ona.com>

---------

Co-authored-by: Ona <no-reply@ona.com>
This commit is contained in:
2026-03-13 00:23:06 +00:00
committed by GitHub
parent e07157eba0
commit c78ad25f0d
32 changed files with 396 additions and 721 deletions

View File

@@ -1,25 +0,0 @@
import { Href, Link } from "expo-router"
import { openBrowserAsync, WebBrowserPresentationStyle } from "expo-web-browser"
import { type ComponentProps } from "react"
type Props = Omit<ComponentProps<typeof Link>, "href"> & { href: Href & string }
export function ExternalLink({ href, ...rest }: Props) {
return (
<Link
target="_blank"
{...rest}
href={href}
onPress={async (event) => {
if (process.env.EXPO_OS !== "web") {
// Prevent the default behavior of linking to the default browser on native.
event.preventDefault()
// Open the link in an in-app browser.
await openBrowserAsync(href, {
presentationStyle: WebBrowserPresentationStyle.AUTOMATIC,
})
}
}}
/>
)
}

View File

@@ -1,18 +0,0 @@
import { BottomTabBarButtonProps } from "@react-navigation/bottom-tabs"
import { PlatformPressable } from "@react-navigation/elements"
import * as Haptics from "expo-haptics"
export function HapticTab(props: BottomTabBarButtonProps) {
return (
<PlatformPressable
{...props}
onPressIn={(ev) => {
if (process.env.EXPO_OS === "ios") {
// Add a soft haptic feedback when pressing down on the tabs.
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)
}
props.onPressIn?.(ev)
}}
/>
)
}

View File

@@ -1,20 +0,0 @@
import Animated from "react-native-reanimated"
export function HelloWave() {
return (
<Animated.Text
style={{
fontSize: 28,
lineHeight: 32,
marginTop: -6,
animationName: {
"50%": { transform: [{ rotate: "25deg" }] },
},
animationIterationCount: 4,
animationDuration: "300ms",
}}
>
👋
</Animated.Text>
)
}

View File

@@ -1,82 +0,0 @@
import type { PropsWithChildren, ReactElement } from "react"
import { StyleSheet } from "react-native"
import Animated, {
interpolate,
useAnimatedRef,
useAnimatedStyle,
useScrollOffset,
} from "react-native-reanimated"
import { ThemedView } from "@/components/themed-view"
import { useColorScheme } from "@/hooks/use-color-scheme"
import { useThemeColor } from "@/hooks/use-theme-color"
const HEADER_HEIGHT = 250
type Props = PropsWithChildren<{
headerImage: ReactElement
headerBackgroundColor: { dark: string; light: string }
}>
export default function ParallaxScrollView({
children,
headerImage,
headerBackgroundColor,
}: Props) {
const backgroundColor = useThemeColor({}, "background")
const colorScheme = useColorScheme() ?? "light"
const scrollRef = useAnimatedRef<Animated.ScrollView>()
const scrollOffset = useScrollOffset(scrollRef)
const headerAnimatedStyle = useAnimatedStyle(() => {
return {
transform: [
{
translateY: interpolate(
scrollOffset.value,
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75],
),
},
{
scale: interpolate(scrollOffset.value, [-HEADER_HEIGHT, 0, HEADER_HEIGHT], [2, 1, 1]),
},
],
}
})
return (
<Animated.ScrollView
ref={scrollRef}
style={{ backgroundColor, flex: 1 }}
scrollEventThrottle={16}
>
<Animated.View
style={[
styles.header,
{ backgroundColor: headerBackgroundColor[colorScheme] },
headerAnimatedStyle,
]}
>
{headerImage}
</Animated.View>
<ThemedView style={styles.content}>{children}</ThemedView>
</Animated.ScrollView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
height: HEADER_HEIGHT,
overflow: "hidden",
},
content: {
flex: 1,
padding: 32,
gap: 16,
overflow: "hidden",
},
})

View File

@@ -0,0 +1,18 @@
import { View } from "react-native"
import tw from "twrnc"
import { SansSerifText } from "./ui/sans-serif-text"
export type Showcase = {
title: string
component: React.ComponentType
}
export function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<View style={tw`gap-3`}>
<SansSerifText style={tw`text-sm text-stone-500 dark:text-stone-400`}>{title}</SansSerifText>
{children}
</View>
)
}

View File

@@ -1,60 +0,0 @@
import { StyleSheet, Text, type TextProps } from "react-native"
import { useThemeColor } from "@/hooks/use-theme-color"
export type ThemedTextProps = TextProps & {
lightColor?: string
darkColor?: string
type?: "default" | "title" | "defaultSemiBold" | "subtitle" | "link"
}
export function ThemedText({
style,
lightColor,
darkColor,
type = "default",
...rest
}: ThemedTextProps) {
const color = useThemeColor({ light: lightColor, dark: darkColor }, "text")
return (
<Text
style={[
{ color },
type === "default" ? styles.default : undefined,
type === "title" ? styles.title : undefined,
type === "defaultSemiBold" ? styles.defaultSemiBold : undefined,
type === "subtitle" ? styles.subtitle : undefined,
type === "link" ? styles.link : undefined,
style,
]}
{...rest}
/>
)
}
const styles = StyleSheet.create({
default: {
fontSize: 16,
lineHeight: 24,
},
defaultSemiBold: {
fontSize: 16,
lineHeight: 24,
fontWeight: "600",
},
title: {
fontSize: 32,
fontWeight: "bold",
lineHeight: 32,
},
subtitle: {
fontSize: 20,
fontWeight: "bold",
},
link: {
lineHeight: 30,
fontSize: 16,
color: "#0a7ea4",
},
})

View File

@@ -1,14 +0,0 @@
import { View, type ViewProps } from "react-native"
import { useThemeColor } from "@/hooks/use-theme-color"
export type ThemedViewProps = ViewProps & {
lightColor?: string
darkColor?: string
}
export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) {
const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, "background")
return <View style={[{ backgroundColor }, style]} {...otherProps} />
}

View File

@@ -0,0 +1,43 @@
import Feather from "@expo/vector-icons/Feather"
import { View } from "react-native"
import tw from "twrnc"
import { Button } from "./button"
import { type Showcase, Section } from "../showcase"
function ButtonShowcase() {
return (
<View style={tw`gap-6`}>
<Section title="Default">
<Button style={tw`self-start`} label="Press me" />
</Section>
<Section title="Leading icon">
<Button
style={tw`self-start`}
label="Add item"
leadingIcon={<Feather name="plus" size={18} color="#e7e5e4" />}
/>
</Section>
<Section title="Trailing icon">
<Button
style={tw`self-start`}
label="Next"
trailingIcon={<Feather name="arrow-right" size={18} color="#e7e5e4" />}
/>
</Section>
<Section title="Both icons">
<Button
style={tw`self-start`}
label="Download"
leadingIcon={<Feather name="download" size={18} color="#e7e5e4" />}
trailingIcon={<Feather name="chevron-down" size={18} color="#e7e5e4" />}
/>
</Section>
</View>
)
}
export const buttonShowcase: Showcase = {
title: "Button",
component: ButtonShowcase,
}

View File

@@ -0,0 +1,30 @@
import { type PressableProps, Pressable, View } from "react-native"
import tw from "twrnc"
import { SansSerifText } from "./sans-serif-text"
type ButtonProps = Omit<PressableProps, "children"> & {
label: string
leadingIcon?: React.ReactNode
trailingIcon?: React.ReactNode
}
export function Button({ style, label, leadingIcon, trailingIcon, ...props }: ButtonProps) {
const hasIcons = leadingIcon != null || trailingIcon != null
const textElement = <SansSerifText style={tw`text-stone-200 font-medium`}>{label}</SansSerifText>
return (
<Pressable style={[tw`rounded-full bg-teal-600 px-4 py-3 w-fit`, style]} {...props}>
{hasIcons ? (
<View style={tw`flex-row items-center gap-1.5`}>
{leadingIcon}
{textElement}
{trailingIcon}
</View>
) : (
textElement
)}
</Pressable>
)
}

View File

@@ -1,46 +0,0 @@
import { PropsWithChildren, useState } from "react"
import { StyleSheet, TouchableOpacity } from "react-native"
import { ThemedText } from "@/components/themed-text"
import { ThemedView } from "@/components/themed-view"
import { IconSymbol } from "@/components/ui/icon-symbol"
import { Colors } from "@/constants/theme"
import { useColorScheme } from "@/hooks/use-color-scheme"
export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
const [isOpen, setIsOpen] = useState(false)
const theme = useColorScheme() ?? "light"
return (
<ThemedView>
<TouchableOpacity
style={styles.heading}
onPress={() => setIsOpen((value) => !value)}
activeOpacity={0.8}
>
<IconSymbol
name="chevron.right"
size={18}
weight="medium"
color={theme === "light" ? Colors.light.icon : Colors.dark.icon}
style={{ transform: [{ rotate: isOpen ? "90deg" : "0deg" }] }}
/>
<ThemedText type="defaultSemiBold">{title}</ThemedText>
</TouchableOpacity>
{isOpen && <ThemedView style={styles.content}>{children}</ThemedView>}
</ThemedView>
)
}
const styles = StyleSheet.create({
heading: {
flexDirection: "row",
alignItems: "center",
gap: 6,
},
content: {
marginTop: 6,
marginLeft: 24,
},
})

View File

@@ -0,0 +1,32 @@
import { View } from "react-native"
import tw from "twrnc"
import { Button } from "./button"
import { FeedCard } from "./feed-card"
import { SansSerifText } from "./sans-serif-text"
import { SerifText } from "./serif-text"
import { type Showcase, Section } from "../showcase"
function FeedCardShowcase() {
return (
<View style={tw`gap-6`}>
<Section title="Default">
<FeedCard style={tw`p-4`}>
<SansSerifText>Card content goes here</SansSerifText>
</FeedCard>
</Section>
<Section title="With mixed content">
<FeedCard style={tw`p-4 gap-2`}>
<SerifText style={tw`text-xl`}>Title</SerifText>
<SansSerifText>Body text inside a feed card.</SansSerifText>
<Button style={tw`self-start mt-2`} label="Action" />
</FeedCard>
</Section>
</View>
)
}
export const feedCardShowcase: Showcase = {
title: "FeedCard",
component: FeedCardShowcase,
}

View File

@@ -0,0 +1,6 @@
import { View, type ViewProps } from "react-native"
import tw from "twrnc"
export function FeedCard({ style, ...props }: ViewProps) {
return <View style={[tw`border border-stone-200 dark:border-stone-800 rounded-lg`, style]} {...props} />
}

View File

@@ -1,32 +0,0 @@
import { SymbolView, SymbolViewProps, SymbolWeight } from "expo-symbols"
import { StyleProp, ViewStyle } from "react-native"
export function IconSymbol({
name,
size = 24,
color,
style,
weight = "regular",
}: {
name: SymbolViewProps["name"]
size?: number
color: string
style?: StyleProp<ViewStyle>
weight?: SymbolWeight
}) {
return (
<SymbolView
weight={weight}
tintColor={color}
resizeMode="scaleAspectFit"
name={name}
style={[
{
width: size,
height: size,
},
style,
]}
/>
)
}

View File

@@ -1,41 +0,0 @@
// Fallback for using MaterialIcons on Android and web.
import MaterialIcons from "@expo/vector-icons/MaterialIcons"
import { SymbolWeight, SymbolViewProps } from "expo-symbols"
import { ComponentProps } from "react"
import { OpaqueColorValue, type StyleProp, type TextStyle } from "react-native"
type IconMapping = Record<SymbolViewProps["name"], ComponentProps<typeof MaterialIcons>["name"]>
type IconSymbolName = keyof typeof MAPPING
/**
* Add your SF Symbols to Material Icons mappings here.
* - see Material Icons in the [Icons Directory](https://icons.expo.fyi).
* - see SF Symbols in the [SF Symbols](https://developer.apple.com/sf-symbols/) app.
*/
const MAPPING = {
"house.fill": "home",
"paperplane.fill": "send",
"chevron.left.forwardslash.chevron.right": "code",
"chevron.right": "chevron-right",
} as IconMapping
/**
* An icon component that uses native SF Symbols on iOS, and Material Icons on Android and web.
* This ensures a consistent look across platforms, and optimal resource usage.
* Icon `name`s are based on SF Symbols and require manual mapping to Material Icons.
*/
export function IconSymbol({
name,
size = 24,
color,
style,
}: {
name: IconSymbolName
size?: number
color: string | OpaqueColorValue
style?: StyleProp<TextStyle>
weight?: SymbolWeight
}) {
return <MaterialIcons color={color} size={size} name={MAPPING[name]} style={style} />
}

View File

@@ -0,0 +1,31 @@
import { View } from "react-native"
import tw from "twrnc"
import { MonospaceText } from "./monospace-text"
import { type Showcase, Section } from "../showcase"
function MonospaceTextShowcase() {
return (
<View style={tw`gap-6`}>
<Section title="Sizes">
<View style={tw`gap-2`}>
<MonospaceText style={tw`text-sm`}>Small monospace text</MonospaceText>
<MonospaceText style={tw`text-base`}>Base monospace text</MonospaceText>
<MonospaceText style={tw`text-xl`}>Extra large monospace text</MonospaceText>
<MonospaceText style={tw`text-3xl`}>3XL monospace text</MonospaceText>
</View>
</Section>
<Section title="Code-like usage">
<View style={tw`bg-stone-200 dark:bg-stone-800 rounded-lg p-3`}>
<MonospaceText style={tw`text-sm`}>{"const x = 42;"}</MonospaceText>
<MonospaceText style={tw`text-sm`}>{"console.log(x);"}</MonospaceText>
</View>
</Section>
</View>
)
}
export const monospaceTextShowcase: Showcase = {
title: "MonospaceText",
component: MonospaceTextShowcase,
}

View File

@@ -0,0 +1,10 @@
import { Text, type TextProps } from "react-native"
import tw from "twrnc"
export function MonospaceText({ children, style, ...props }: TextProps) {
return (
<Text style={[tw`text-stone-800 dark:text-stone-200`, { fontFamily: "Menlo" }, style]} {...props}>
{children}
</Text>
)
}

View File

@@ -0,0 +1,34 @@
import { View } from "react-native"
import tw from "twrnc"
import { SansSerifText } from "./sans-serif-text"
import { type Showcase, Section } from "../showcase"
function SansSerifTextShowcase() {
return (
<View style={tw`gap-6`}>
<Section title="Sizes">
<View style={tw`gap-2`}>
<SansSerifText style={tw`text-sm`}>Small sans-serif text</SansSerifText>
<SansSerifText style={tw`text-base`}>Base sans-serif text</SansSerifText>
<SansSerifText style={tw`text-xl`}>Extra large sans-serif text</SansSerifText>
<SansSerifText style={tw`text-3xl`}>3XL sans-serif text</SansSerifText>
</View>
</Section>
<Section title="Weights">
<View style={tw`gap-2`}>
<SansSerifText style={tw`font-light`}>Light weight</SansSerifText>
<SansSerifText style={tw`font-normal`}>Normal weight</SansSerifText>
<SansSerifText style={tw`font-medium`}>Medium weight</SansSerifText>
<SansSerifText style={tw`font-semibold`}>Semibold weight</SansSerifText>
<SansSerifText style={tw`font-bold`}>Bold weight</SansSerifText>
</View>
</Section>
</View>
)
}
export const sansSerifTextShowcase: Showcase = {
title: "SansSerifText",
component: SansSerifTextShowcase,
}

View File

@@ -0,0 +1,10 @@
import { Text, type TextProps } from "react-native"
import tw from "twrnc"
export function SansSerifText({ children, style, ...props }: TextProps) {
return (
<Text style={[tw`text-stone-800 dark:text-stone-200`, { fontFamily: "Inter" }, style]} {...props}>
{children}
</Text>
)
}

View File

@@ -0,0 +1,25 @@
import { View } from "react-native"
import tw from "twrnc"
import { SerifText } from "./serif-text"
import { type Showcase, Section } from "../showcase"
function SerifTextShowcase() {
return (
<View style={tw`gap-6`}>
<Section title="Sizes">
<View style={tw`gap-2`}>
<SerifText style={tw`text-sm`}>Small serif text</SerifText>
<SerifText style={tw`text-base`}>Base serif text</SerifText>
<SerifText style={tw`text-xl`}>Extra large serif text</SerifText>
<SerifText style={tw`text-3xl`}>3XL serif text</SerifText>
</View>
</Section>
</View>
)
}
export const serifTextShowcase: Showcase = {
title: "SerifText",
component: SerifTextShowcase,
}

View File

@@ -0,0 +1,10 @@
import { Text, type TextProps } from "react-native"
import tw from "twrnc"
export function SerifText({ children, style, ...props }: TextProps) {
return (
<Text style={[tw`text-stone-800 dark:text-stone-200`, { fontFamily: "Source Serif 4" }, style]} {...props}>
{children}
</Text>
)
}