Flat Light: A Mobile App Design System for Expo & React Native
A complete, copy-paste design system for Expo / React Native apps — light mode only, one theme colour, flat white surfaces separated by hairlines, in the spirit of WhatsApp and Instagram light mode. Design tokens, typography and colour rules, a full set of UI primitives, exact component specs, screen templates, interaction and haptics and motion, copy voice, accessibility, and a per-screen review checklist — the system behind the Committed app.
Flat Light: Mobile App Style Guide
The design system behind Committed (Expo / React Native). Use it for any new app with the same look: light mode only, one theme colour, flat white surfaces that blend together, in the spirit of WhatsApp and Instagram light mode.
How to use this file (builders, human or AI):
- Copy the tokens (section 4) and primitives (section 7) into the new app as they are. Change only the theme colour, fonts and brand mark (section 3).
- Build every screen from the screen templates (section 10). Committed's real screens are the worked examples (section 11).
- Before calling a screen done, run the review checklist (section 15). Anything that fails it is a bug, not a style choice.
Rule zero: never hard-code a colour, font size, radius, spacing or shadow in a screen. If a value isn't a token, add the token first, then use it. Most drift starts with one "just this once" hex code.
Contents
- Design principles
- The look in one paragraph
- Adapting to a new brand
- Tokens
- Typography
- Colour usage rules
- Primitives (code)
- Component specifications
- Layout and navigation
- Screen templates
- Committed screen catalogue (worked examples)
- Interaction: press feedback, haptics, motion
- Copy and voice
- Accessibility
- Review checklist
- Do and don't
- Platform pitfalls we hit (and the fixes)
1. Design principles
- Flat, not floating. Surfaces are white. Separation comes from hairlines and soft grey fills, never from shadows or borders around everything. Only things that truly float over content (bottom sheets, toasts, the offline pill) get a faint shadow.
- One theme colour. A single brand colour carries every action: primary buttons, the active tab, selected chips, links, the focus ring. Everything else is greyscale. Status colours (danger, warning, info) appear only when they mean something.
- Blend, don't box. Lists are flat full-width rows with inset hairlines, like a messaging app's chat list. Cards are the exception, used only for self-contained objects (a QR code, a share image, grouped settings, a stat block).
- Soft corners, no sharp edges. Buttons, chips, inputs in chat and toasts are pills. Cards and tiles use 12–20 radius. Nothing has square corners, and nothing is bubbly.
- Native-sized type. 15pt body, 16–20pt headings, one big light number for the hero stat. The UI never shouts.
- Content first, chrome quiet. Headers are white with no bar colour. Icons are bare glyphs. The data (names, numbers, messages) is the loudest thing on screen.
- Every state designed. Loading shows skeletons in the shape of the content; empty states have an icon, a sentence and one action; errors say what happened and what to do. Never a blank screen, and never a blocking spinner for page loads.
- Honest and safe by default. Destructive actions confirm. Report, block, delete account and legal links are part of the design, not afterthoughts. (The app stores require them; see Publishing a Mobile App to the App Store and Google Play.)
2. The look in one paragraph
A white screen. At the top, a plain title in near-black semibold (or the green wordmark on the home tab), with bare black icons on the right. Below it, content sits directly on white: rows of 44–48pt round avatars with a semibold name, a grey one-line subline and a small grey timestamp on the right, separated by hairlines that start where the text starts. Filters are grey pills; the selected one turns pale green with green text. The main button is a full-width green pill with white text; secondary buttons are grey pills. At the bottom, a white tab bar with a hairline on top: the active tab's icon sits in a pale green pill. Nothing has a drop shadow except a bottom sheet sliding up over a dimmed screen.
3. Adapting to a new brand
Change only these, and keep everything else:
| Slot | Committed | How to choose for a new app |
|---|---|---|
THEME | #0F7B57 (green) | A mid-dark brand colour with ≥ 4.5:1 contrast on white so it works for text and for white text on it. Test it with a contrast checker. Avoid neon and pastel. |
THEME_SOFT | #E7F3EE | The theme colour at roughly 10% on white. Used for selected chips, the active tab pill, "mine" chat bubbles, icon circles and unread rows. |
accentFlash | #D5EDE3 | Slightly stronger than THEME_SOFT, for a 150ms tap flash. |
| Fonts | Inter (300/400/500/600/700) + JetBrains Mono 500 | Any clean grotesk with those weights, loaded with expo-font / @expo-google-fonts. Keep one mono for codes. |
| Brand mark | Commit glyph on a rounded green tile | A single simple glyph, white on a theme-colour tile with radius 26% of its size. |
| Wordmark | "Committed", bold 24, theme colour | The app name in bold theme colour on the home tab only. |
| App icon / splash | Theme square, white glyph; splash: theme tile on white | Same recipe. |
Keep the neutral greys (they're WhatsApp's light palette and work with any theme colour). Keep the status colours unless they clash with the theme; if the theme is red, move danger to a deeper red so the two stay distinguishable.
4. Tokens
Put this in lib/tokens.ts. Every style value in the app comes from here.
import { Platform, type TextStyle, type ViewStyle } from "react-native";
// Flat Light design tokens: light mode only, one theme colour, flat surfaces
// that blend (hairlines and soft grey fills, not shadows).
// Never hard-code a colour, size, radius or shadow in a screen: add it here first.
/** The theme colour. Must be ≥ 4.5:1 on white, so it works for text and fills. */
const THEME = "#0F7B57";
const THEME_SOFT = "#E7F3EE";
export const colors = {
bgPrimary: "#FFFFFF",
surface: "#FFFFFF",
/** Soft grey fill: inputs, chips, tiles, secondary buttons, "their" chat bubbles, skeletons. */
surfaceMuted: "#F2F4F5",
hairline: "#E6E9EB",
/** The theme colour: primary buttons, active tab, links, selected states, focus. */
ink: THEME,
/** Theme tint: selected chips, active tab pill, "my" chat bubbles, icon circles, unread rows. */
inkSoft: THEME_SOFT,
textPrimary: "#111B21",
textSecondary: "#54656F",
textMuted: "#667781",
/** Placeholder and fine print (sign-in). */
textTertiary: "#8696A0",
onInk: "#FFFFFF",
onInkMuted: "rgba(255,255,255,0.8)",
/** Translucent white control on a theme-coloured surface (the offline pill's Retry). */
onInkSubtle: "rgba(255,255,255,0.16)",
accentFlash: "#D5EDE3",
accent: THEME,
accentText: THEME,
accentSoft: THEME_SOFT,
success: THEME,
danger: "#D93F3F",
dangerSoft: "#FDECEC",
warning: "#C77700",
warningText: "#9A5B00",
warningSoft: "#FFF4E0",
info: "#2F6FDB",
infoSoft: "#EAF1FD",
neutralSoft: "#F2F4F5",
scrim: "rgba(17,27,33,0.4)",
gold: "#F5B800",
silver: "#A7B0B7",
bronze: "#C27A3A",
} as const;
/** 4px spacing scale. */
export const space = {
1: 4,
2: 8,
3: 12,
4: 16,
5: 20,
6: 24,
8: 32,
10: 40,
12: 48,
16: 64,
} as const;
export const radius = { sm: 6, md: 12, lg: 14, xl: 20, full: 9999 } as const;
export const iconSize = { sm: 16, md: 20, lg: 24 } as const;
/** Fixed component sizes, so no screen invents its own. */
export const size = {
touch: 44, // minimum touch target, icon buttons
button: 48, // Button / TextField height
buttonCompact: 36,
authControl: 50, // sign-in buttons and fields
appBar: 56, // header row
tabBarItem: 56,
tabIndicatorW: 60,
tabIndicatorH: 32,
avatarSm: 28, // chat bubble avatar
avatar: 44, // people rows
avatarLg: 48, // group / chat list rows
avatarXl: 72, // profile header
iconCircle: 40, // notification / activity icons
emptyIcon: 72,
rowMin: 64,
rowMinLarge: 76,
} as const;
// React Native picks a weight by font family, so tokens name the family.
export const fonts = {
light: "Inter_300Light",
regular: "Inter_400Regular",
medium: "Inter_500Medium",
semibold: "Inter_600SemiBold",
bold: "Inter_700Bold",
mono: "JetBrainsMono_500Medium",
} as const;
// Sized like native list apps: 15pt body, 16–20pt headings, one big light number.
export const typeScale = {
micro: { fontFamily: fonts.regular, fontSize: 11, lineHeight: 14 }, // timestamps in rows
xs: { fontFamily: fonts.medium, fontSize: 11, lineHeight: 14 }, // badges, captions
caption: { fontFamily: fonts.medium, fontSize: 12, lineHeight: 16 }, // tab labels, day pills, sender names
sm: { fontFamily: fonts.regular, fontSize: 13, lineHeight: 18 }, // sublines, helper text
label: { fontFamily: fonts.medium, fontSize: 14, lineHeight: 19 }, // chips, section headers, notices
base: { fontFamily: fonts.regular, fontSize: 15, lineHeight: 20 }, // body, row titles (+semibold)
lg: {
fontFamily: fonts.semibold,
fontSize: 16,
lineHeight: 21,
letterSpacing: -0.2,
},
xl: {
fontFamily: fonts.semibold,
fontSize: 18,
lineHeight: 23,
letterSpacing: -0.2,
},
title: {
fontFamily: fonts.semibold,
fontSize: 20,
lineHeight: 25,
letterSpacing: -0.3,
},
"2xl": {
fontFamily: fonts.semibold,
fontSize: 22,
lineHeight: 27,
letterSpacing: -0.4,
},
wordmark: {
fontFamily: fonts.bold,
fontSize: 24,
lineHeight: 30,
letterSpacing: -0.6,
},
"3xl": {
fontFamily: fonts.semibold,
fontSize: 28,
lineHeight: 34,
letterSpacing: -0.6,
},
display: {
fontFamily: fonts.light,
fontSize: 60,
lineHeight: 68,
letterSpacing: -1.6,
},
} satisfies Record<string, TextStyle>;
/** Sign-in screens use Apple's larger text styles (set in the same font). */
export const authType = {
largeTitle: {
fontFamily: fonts.bold,
fontSize: 28,
lineHeight: 34,
letterSpacing: -0.5,
},
title3: {
fontFamily: fonts.semibold,
fontSize: 20,
lineHeight: 25,
letterSpacing: -0.2,
},
body: {
fontFamily: fonts.regular,
fontSize: 17,
lineHeight: 22,
letterSpacing: -0.2,
},
callout: {
fontFamily: fonts.regular,
fontSize: 16,
lineHeight: 21,
letterSpacing: -0.15,
},
subheadline: {
fontFamily: fonts.regular,
fontSize: 15,
lineHeight: 20,
letterSpacing: -0.1,
},
footnote: { fontFamily: fonts.regular, fontSize: 13, lineHeight: 18 },
caption: { fontFamily: fonts.medium, fontSize: 12, lineHeight: 16 },
mono: { fontFamily: fonts.mono, fontSize: 13, lineHeight: 18 },
} satisfies Record<string, TextStyle>;
/** Digits line up in columns (scores, counts, prices, times). */
export const tabular: TextStyle = { fontVariant: ["tabular-nums"] };
// Flat by default. Only things that float over content get a faint shadow.
export const shadow = {
floating: Platform.select<ViewStyle>({
ios: {
shadowColor: "#000000",
shadowOpacity: 0.08,
shadowRadius: 12,
shadowOffset: { width: 0, height: 4 },
},
default: { elevation: 3 },
}),
};
export const motion = {
tap: 80,
flash: 150,
fast: 150,
base: 250,
celebrate: 600,
toast: 3500,
} as const;
/** Opacity for disabled controls. One value everywhere. */
export const disabledOpacity = 0.45;
/** Space under scrolling content on tab screens (the tab bar is in the layout). */
export const tabBarClearance = 24;4.1 Colour reference
| Token | Hex | Contrast on white | Use |
|---|---|---|---|
bgPrimary / surface | #FFFFFF | — | Every screen, rows, sheets, tab bar, headers |
surfaceMuted | #F2F4F5 | — | Inputs, chips, secondary buttons, tiles, "their" bubbles, skeletons, day pills |
hairline | #E6E9EB | — | Row separators, tab bar top edge, card borders |
ink (theme) | #0F7B57 | 5.3:1 | Primary buttons, active icons, links, focus, wordmark, badges |
inkSoft | #E7F3EE | — | Selected chip, tab pill, "my" bubble, icon circles, unread row tint |
textPrimary | #111B21 | 17:1 | Names, titles, body, numbers |
textSecondary | #54656F | 6.4:1 | Sublines, labels, inactive tabs |
textMuted | #667781 | 4.6:1 | Timestamps, hints, placeholders in the app |
textTertiary | #8696A0 | 3.3:1 | Placeholders and fine print on sign-in only (large or non-essential text) |
onInk | #FFFFFF | 5.3:1 on theme | Text and icons on theme fills |
danger / dangerSoft | #D93F3F / #FDECEC | 4.5:1 | Destructive buttons, errors |
warning / warningText / warningSoft | #C77700 / #9A5B00 / #FFF4E0 | text 6.1:1 | Streak flames, at-risk and sync warnings |
info / infoSoft | #2F6FDB / #EAF1FD | 4.6:1 | Admin badge, chat notification icon, neutral notices |
scrim | rgba(17,27,33,0.4) | — | Behind sheets and modals |
gold / silver / bronze | #F5B800 / #A7B0B7 / #C27A3A | icons only | Top 3 medals, only when the score is above 0 |
5. Typography
- Font: Inter, loaded as separate families per weight. Always set
fontFamily, neverfontWeight(React Native picks the weight by family). - Mono: JetBrains Mono 500, for codes (invite codes, 2FA, setup keys), handles (
@username), repo names, and the sign-in terminal line. - Numbers: add
tabularto any number that changes or sits in a column. - Load fonts before first render. Return
nullfrom the root layout until they load or fail; on failure, continue on system fonts.
| Token | Size / line | Weight | Where |
|---|---|---|---|
display | 60 / 68 | Light 300 | The one hero number per screen (points, balance) |
3xl | 28 / 34 | Semibold | Big stat inside a card (streak count) |
wordmark | 24 / 30 | Bold | App name on the home tab header, theme colour |
2xl | 22 / 27 | Semibold | Stat tile values, club name on invite |
title | 20 / 25 | Semibold | Screen titles (app bar) |
xl | 18 / 23 | Semibold | Sheet titles, profile name |
lg | 16 / 21 | Semibold | Empty-state title, card headings |
base | 15 / 20 | Regular | Body copy, message text. Row title = base + semibold |
label | 14 / 19 | Medium | Chips (selected: semibold), section headers, notice titles, hero stat line |
sm | 13 / 18 | Regular | Sublines, previews, helper and error text |
caption | 12 / 16 | Medium | Tab labels, chat day pills, sender names |
xs | 11 / 14 | Medium | Badges, "N members" meta |
micro | 11 / 14 | Regular | Timestamps on rows and bubbles |
Sign-in screens (login, register, OAuth return) use authType: large title 28 bold, body 17, 50pt controls. They're a deliberately roomier "front door"; the rest of the app uses typeScale.
Truncation: row titles and sublines are numberOfLines={1}. Notification titles and bodies allow 2 lines. Screen titles are 1 line.
Dynamic type: let text scale with the OS. Cap only compact chrome: maxFontSizeMultiplier={1.3} on tab labels and badges. Rows use minHeight, never a fixed height, so they grow.
6. Colour usage rules
- Theme colour appears on at most three kinds of thing per screen: the primary action, the selected or active state, and links or wordmark. If a screen looks green, it's too green.
- White text on the theme colour only. Never put theme-coloured icons on a theme-coloured fill (they vanish). On theme fills, icons are
onInk. - Soft tints mean state, not decoration:
inkSoft= selected/mine/unread;warningSoft= at risk;dangerSoft= error/destructive;infoSoft= neutral notice. - Greys:
textPrimaryfor anything the user reads first;textSecondaryfor supporting text;textMutedfor metadata (times, counts, hints). Don't usetextMutedfor sentences people need to read. - Borders: hairline (
StyleSheet.hairlineWidth) inhairlinecolour. No 1px or 2px borders except: focused input (1px theme), code input boxes (1.5px), error input (1px danger). - No gradients, no glass, no glows, no dark mode. If an old component still has "glass" or "gradient" names, rename it; don't bring the look back.
7. Primitives (code)
Put these in components/ui.tsx (plus PressableScale). Every screen is assembled from them. They're the Committed versions, cleaned up to follow this guide.
7.1 PressableScale (all press feedback)
import { forwardRef } from "react";
import { Pressable, type PressableProps, type ViewStyle } from "react-native";
import Animated, {
useAnimatedStyle,
useSharedValue,
withSpring,
} from "react-native-reanimated";
const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
/** Spring scale on press: the only press feedback style in the app. */
export const PressableScale = forwardRef<
typeof AnimatedPressable,
PressableProps & { pressScale?: number }
>(function PressableScale(
{ pressScale = 0.98, style, onPressIn, onPressOut, children, ...rest },
ref
) {
const scale = useSharedValue(1);
const animated = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
return (
<AnimatedPressable
ref={ref as never}
onPressIn={(e) => {
scale.value = withSpring(pressScale, {
damping: 18,
stiffness: 320,
mass: 0.6,
});
onPressIn?.(e);
}}
onPressOut={(e) => {
scale.value = withSpring(1, { damping: 16, stiffness: 260, mass: 0.6 });
onPressOut?.(e);
}}
style={[animated, style as ViewStyle]}
{...rest}
>
{children as never}
</AnimatedPressable>
);
});Press scales: buttons and chips 0.98 · list rows 0.99 · big option tiles 0.95 · icon buttons 0.9.
Never pass a function to
styleonPressable(style={({ pressed }) => …}). With NativeWind installed it's silently dropped on Android, and the layout breaks: rows stacked vertically in Committed. Use static styles plusPressableScale(orandroid_ripple).
7.2 The primitive set
import {
useEffect,
useState,
type ComponentProps,
type ReactNode,
} from "react";
import {
ActivityIndicator,
StyleSheet,
Text,
TextInput,
View,
type StyleProp,
type TextInputProps,
type ViewStyle,
} from "react-native";
import Animated, {
useAnimatedStyle,
useSharedValue,
withRepeat,
withTiming,
} from "react-native-reanimated";
import { Image } from "expo-image";
import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import { StatusBar } from "expo-status-bar";
import { SafeAreaView } from "react-native-safe-area-context";
import { PressableScale } from "./pressable-scale";
import {
colors,
disabledOpacity,
fonts,
iconSize,
radius,
size,
space,
typeScale,
} from "@/lib/tokens";
export type IconName = ComponentProps<typeof Ionicons>["name"];
/* ── Button: pill. primary (theme) · secondary (grey) · ghost (theme text) · destructive (red tint) ── */
type ButtonVariant = "primary" | "secondary" | "ghost" | "destructive";
const buttonTone: Record<ButtonVariant, { bg: string; fg: string }> = {
primary: { bg: colors.ink, fg: colors.onInk },
secondary: { bg: colors.surfaceMuted, fg: colors.textPrimary },
ghost: { bg: "transparent", fg: colors.ink },
destructive: { bg: colors.dangerSoft, fg: colors.danger },
};
export function Button(props: {
label: string;
onPress: () => void;
variant?: ButtonVariant;
icon?: IconName;
loading?: boolean;
disabled?: boolean;
compact?: boolean;
style?: StyleProp<ViewStyle>;
}) {
const {
label,
onPress,
variant = "primary",
icon,
loading = false,
disabled = false,
compact = false,
style,
} = props;
const inactive = disabled || loading;
const tone = buttonTone[variant];
return (
<PressableScale
onPress={onPress}
disabled={inactive}
pressScale={0.98}
accessibilityRole="button"
accessibilityLabel={label}
accessibilityState={{ disabled: inactive, busy: loading }}
style={[
s.button,
compact && s.buttonCompact,
{ backgroundColor: tone.bg },
inactive && s.inactive,
style,
]}
>
{loading ? (
<ActivityIndicator color={tone.fg} />
) : (
<>
{icon ? (
<Ionicons
name={icon}
size={iconSize.md}
color={tone.fg}
style={{ marginRight: space[2] }}
/>
) : null}
<Text
style={[
typeScale.base,
{ fontFamily: fonts.semibold, color: tone.fg },
]}
>
{label}
</Text>
</>
)}
</PressableScale>
);
}
/* ── IconButton: 44pt bare glyph (24) or a theme circle with a white 20 glyph (send) ── */
export function IconButton({
icon,
label,
onPress,
tone = "surface",
disabled = false,
}: {
icon: IconName;
label: string;
onPress: () => void;
tone?: "surface" | "ink";
disabled?: boolean;
}) {
const ink = tone === "ink";
return (
<PressableScale
onPress={onPress}
disabled={disabled}
hitSlop={4}
pressScale={0.9}
android_ripple={{
color: colors.surfaceMuted,
borderless: true,
radius: 22,
}}
accessibilityRole="button"
accessibilityLabel={label}
accessibilityState={{ disabled }}
style={[
s.iconButton,
ink && { backgroundColor: colors.ink },
disabled && s.inactive,
]}
>
<Ionicons
name={icon}
size={ink ? iconSize.md : iconSize.lg}
color={ink ? colors.onInk : colors.textPrimary}
/>
</PressableScale>
);
}
/* ── ListRow: THE list pattern. Flat white row; separators come from <Divider inset>. ── */
export function ListRow({
leading,
title,
subtitle,
meta,
trailing,
onPress,
onLongPress,
unread,
accessibilityLabel,
}: {
leading?: ReactNode;
title: string;
subtitle?: string;
meta?: string;
trailing?: ReactNode;
onPress?: () => void;
onLongPress?: () => void;
unread?: boolean;
accessibilityLabel?: string;
}) {
const body = (
<View style={[s.row, unread && { backgroundColor: colors.inkSoft }]}>
{leading}
<View style={{ flex: 1 }}>
<View style={s.rowTitleLine}>
<Text
style={[
typeScale.base,
{
fontFamily: fonts.semibold,
color: colors.textPrimary,
flex: 1,
},
]}
numberOfLines={1}
>
{title}
</Text>
{meta ? (
<Text style={[typeScale.micro, { color: colors.textMuted }]}>
{meta}
</Text>
) : null}
</View>
{subtitle ? (
<Text
style={[typeScale.sm, { color: colors.textSecondary }]}
numberOfLines={1}
>
{subtitle}
</Text>
) : null}
</View>
{trailing}
</View>
);
if (!onPress && !onLongPress) return body;
return (
<PressableScale
onPress={onPress}
onLongPress={onLongPress}
delayLongPress={300}
pressScale={0.99}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel ?? title}
>
{body}
</PressableScale>
);
}
/* ── Divider: hairline; inset = 16 + leading size + 12, so it starts under the text ── */
export function Divider({ inset = 0 }: { inset?: number }) {
return (
<View
style={{
height: StyleSheet.hairlineWidth,
backgroundColor: colors.hairline,
marginLeft: inset,
}}
/>
);
}
export const rowInset = (leadingSize: number) =>
space[4] + leadingSize + space[3];
/* ── Card: only for self-contained objects (QR, share image, grouped settings, stat block) ── */
export function Card({
children,
style,
padded = true,
}: {
children: ReactNode;
style?: StyleProp<ViewStyle>;
padded?: boolean;
}) {
return (
<View style={[s.card, padded && { padding: space[4] }, style]}>
{children}
</View>
);
}
/* ── Chip: grey pill; selected = theme tint + theme semibold text ── */
export function Chip({
label,
icon,
selected = false,
onPress,
}: {
label: string;
icon?: IconName;
selected?: boolean;
onPress: () => void;
}) {
return (
<PressableScale
onPress={onPress}
pressScale={0.98}
accessibilityRole="button"
accessibilityLabel={label}
accessibilityState={{ selected }}
style={[s.chip, selected && { backgroundColor: colors.inkSoft }]}
>
{icon ? (
<Ionicons
name={icon}
size={iconSize.sm}
color={selected ? colors.ink : colors.textSecondary}
style={{ marginRight: space[1] + 2 }}
/>
) : null}
<Text
numberOfLines={1}
style={[
typeScale.label,
{ color: selected ? colors.ink : colors.textSecondary },
selected && { fontFamily: fonts.semibold },
]}
>
{label}
</Text>
</PressableScale>
);
}
/* ── Badge: small tinted pill (roles, statuses) ── */
export function Badge({
label,
tone = "neutral",
}: {
label: string;
tone?: "theme" | "info" | "warning" | "danger" | "neutral";
}) {
const t = {
theme: [colors.inkSoft, colors.ink],
info: [colors.infoSoft, colors.info],
warning: [colors.warningSoft, colors.warningText],
danger: [colors.dangerSoft, colors.danger],
neutral: [colors.neutralSoft, colors.textSecondary],
}[tone];
return (
<View
style={{
backgroundColor: t[0],
borderRadius: radius.sm,
paddingHorizontal: space[2],
paddingVertical: 2,
}}
>
<Text style={[typeScale.xs, { color: t[1] }]}>{label}</Text>
</View>
);
}
/* ── Monogram & Avatar: initial in a tinted circle; image layered on top ── */
const monogramTones = [
[colors.accentSoft, colors.accentText],
[colors.infoSoft, colors.info],
[colors.warningSoft, colors.warningText],
[colors.neutralSoft, colors.textSecondary],
];
export function Monogram({
name,
size: d = size.avatarLg,
}: {
name: string;
size?: number;
}) {
const tone =
monogramTones[
[...name].reduce((n, ch) => n + ch.charCodeAt(0), 0) %
monogramTones.length
];
return (
<View
style={{
width: d,
height: d,
borderRadius: radius.full,
backgroundColor: tone[0],
alignItems: "center",
justifyContent: "center",
}}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
>
<Text
style={{
fontFamily: fonts.semibold,
fontSize: Math.round(d * 0.4),
color: tone[1],
}}
>
{name.trim().charAt(0).toUpperCase() || "?"}
</Text>
</View>
);
}
export function Avatar({
uri,
name,
size: d = size.avatar,
}: {
uri?: string;
name: string;
size?: number;
}) {
const [failed, setFailed] = useState(false);
if (!uri || failed) return <Monogram name={name} size={d} />;
return (
<View style={{ width: d, height: d }} accessible accessibilityLabel={name}>
<Monogram name={name} size={d} />
<Image
source={{ uri }}
style={[StyleSheet.absoluteFill, { borderRadius: radius.full }]}
onError={() => setFailed(true)}
/>
</View>
);
}
/* ── EmptyState: icon circle, title, one sentence, at most one action ── */
export function EmptyState({
icon,
title,
message,
actionLabel,
onAction,
}: {
icon: IconName;
title: string;
message?: string;
actionLabel?: string;
onAction?: () => void;
}) {
return (
<View style={s.empty}>
<View style={s.emptyIcon}>
<Ionicons name={icon} size={30} color={colors.ink} />
</View>
<Text
style={[
typeScale.lg,
{ color: colors.textPrimary, textAlign: "center" },
]}
>
{title}
</Text>
{message ? (
<Text
style={[
typeScale.base,
{
color: colors.textSecondary,
textAlign: "center",
marginTop: space[1],
},
]}
>
{message}
</Text>
) : null}
{actionLabel && onAction ? (
<Button
label={actionLabel}
onPress={onAction}
style={{ marginTop: space[6], alignSelf: "stretch" }}
/>
) : null}
</View>
);
}
/* ── TextField: label above a grey field; theme border on focus; error or hint below ── */
export function TextField({
label,
error,
hint,
mono = false,
style,
onFocus,
onBlur,
...rest
}: TextInputProps & {
label: string;
error?: string;
hint?: string;
mono?: boolean;
}) {
const [focused, setFocused] = useState(false);
return (
<View style={{ marginBottom: space[4] }}>
<Text
style={[
typeScale.sm,
{
fontFamily: fonts.medium,
color: colors.textSecondary,
marginBottom: space[1] + 2,
},
]}
>
{label}
</Text>
<TextInput
placeholderTextColor={colors.textMuted}
selectionColor={colors.ink}
cursorColor={colors.ink}
accessibilityLabel={label}
accessibilityHint={hint}
{...rest}
onFocus={(e) => {
setFocused(true);
onFocus?.(e);
}}
onBlur={(e) => {
setFocused(false);
onBlur?.(e);
}}
style={[
typeScale.base,
s.input,
mono && { fontFamily: fonts.mono, letterSpacing: 2 },
focused && s.inputFocused,
error ? { borderColor: colors.danger } : null,
style,
]}
/>
{error ? (
<Text
style={[typeScale.sm, { color: colors.danger, marginTop: space[1] }]}
>
{error}
</Text>
) : hint ? (
<Text
style={[
typeScale.sm,
{ color: colors.textMuted, marginTop: space[1] },
]}
>
{hint}
</Text>
) : null}
</View>
);
}
/* ── ErrorBanner / Notice ── */
export function ErrorBanner({ message }: { message: string }) {
return (
<View
style={[s.notice, { backgroundColor: colors.dangerSoft }]}
accessibilityRole="alert"
>
<Ionicons name="alert-circle" size={iconSize.md} color={colors.danger} />
<Text style={[typeScale.base, { color: colors.textPrimary, flex: 1 }]}>
{message}
</Text>
</View>
);
}
/* ── SectionHeader: small grey label with an optional theme action ── */
export function SectionHeader({
title,
actionLabel,
onAction,
}: {
title: string;
actionLabel?: string;
onAction?: () => void;
}) {
return (
<View style={s.sectionHeader}>
<Text
style={[
typeScale.label,
{ fontFamily: fonts.semibold, color: colors.textSecondary },
]}
accessibilityRole="header"
>
{title}
</Text>
{actionLabel && onAction ? (
<PressableScale
onPress={onAction}
hitSlop={10}
pressScale={0.95}
accessibilityRole="button"
accessibilityLabel={actionLabel}
>
<Text
style={[
typeScale.label,
{ fontFamily: fonts.semibold, color: colors.ink },
]}
>
{actionLabel}
</Text>
</PressableScale>
) : null}
</View>
);
}
/* ── Screen: white SafeAreaView + plain app bar (back arrow, title, subtitle, right actions) ── */
export function Screen({
title,
subtitle,
back = false,
right,
children,
}: {
title?: string;
subtitle?: string;
back?: boolean;
right?: ReactNode;
children: ReactNode;
}) {
const router = useRouter();
const goBack = () =>
router.canGoBack() ? router.back() : router.replace("/(tabs)");
return (
<SafeAreaView
style={{ flex: 1, backgroundColor: colors.bgPrimary }}
edges={["top"]}
>
<StatusBar style="dark" />
{title || back || right ? (
<View style={s.header}>
{back ? (
<IconButton icon="arrow-back" label="Go back" onPress={goBack} />
) : null}
<View style={{ flex: 1, marginLeft: back ? space[1] : space[3] }}>
{title ? (
<Text
style={[typeScale.title, { color: colors.textPrimary }]}
numberOfLines={1}
accessibilityRole="header"
>
{title}
</Text>
) : null}
{subtitle ? (
<Text
style={[typeScale.sm, { color: colors.textSecondary }]}
numberOfLines={1}
>
{subtitle}
</Text>
) : null}
</View>
{right}
</View>
) : null}
{children}
</SafeAreaView>
);
}
/* ── SkeletonRow: pulsing grey block in the shape of the content ── */
export function SkeletonRow({ height = size.rowMin }: { height?: number }) {
const opacity = useSharedValue(0.5);
useEffect(() => {
opacity.value = withRepeat(withTiming(1, { duration: 750 }), -1, true);
}, [opacity]);
const animated = useAnimatedStyle(() => ({ opacity: opacity.value }));
return (
<Animated.View
style={[
{
height,
borderRadius: radius.md,
backgroundColor: colors.surfaceMuted,
marginBottom: space[3],
},
animated,
]}
accessibilityLabel="Loading"
/>
);
}
const s = StyleSheet.create({
header: {
flexDirection: "row",
alignItems: "center",
minHeight: size.appBar,
paddingHorizontal: space[2],
gap: space[1],
},
button: {
minHeight: size.button,
borderRadius: radius.full,
paddingHorizontal: space[5],
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
},
buttonCompact: { minHeight: size.buttonCompact, paddingHorizontal: space[4] },
inactive: { opacity: disabledOpacity },
iconButton: {
width: size.touch,
height: size.touch,
borderRadius: radius.full,
alignItems: "center",
justifyContent: "center",
},
row: {
flexDirection: "row",
alignItems: "center",
gap: space[3],
minHeight: size.rowMin,
paddingHorizontal: space[4],
paddingVertical: space[3],
backgroundColor: colors.surface,
},
rowTitleLine: { flexDirection: "row", alignItems: "center", gap: space[2] },
card: {
backgroundColor: colors.surface,
borderRadius: radius.lg,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.hairline,
},
chip: {
minHeight: 34,
borderRadius: radius.full,
paddingHorizontal: space[3] + 2,
paddingVertical: space[1] + 2,
flexDirection: "row",
alignItems: "center",
backgroundColor: colors.surfaceMuted,
},
empty: {
flex: 1,
alignItems: "center",
justifyContent: "center",
padding: space[8],
},
emptyIcon: {
width: size.emptyIcon,
height: size.emptyIcon,
borderRadius: radius.full,
backgroundColor: colors.inkSoft,
alignItems: "center",
justifyContent: "center",
marginBottom: space[4],
},
input: {
minHeight: size.button,
color: colors.textPrimary,
backgroundColor: colors.surfaceMuted,
borderWidth: 1,
borderColor: colors.surfaceMuted,
borderRadius: radius.md,
paddingHorizontal: space[4],
paddingVertical: space[3],
},
inputFocused: { borderColor: colors.ink, backgroundColor: colors.surface },
notice: {
flexDirection: "row",
alignItems: "center",
gap: space[2],
borderRadius: radius.md,
padding: space[3],
marginBottom: space[4],
},
sectionHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
marginTop: space[6],
marginBottom: space[2],
minHeight: 32,
},
});8. Component specifications
Exact measurements for everything, including composite components built on the primitives.
8.1 Buttons
| Variant | Fill | Label | Use |
|---|---|---|---|
| Primary | ink | onInk semibold 15 | The one main action per view ("Create club", "Share invite link") |
| Secondary | surfaceMuted | textPrimary | Alternatives ("Share as text", "Sign out", "Try again", "Unblock") |
| Ghost | none | ink | Inline or low-emphasis ("Cancel" in sheets, "Mark all read", "See all") |
| Destructive | dangerSoft | danger | "Delete account", "Leave club", "Turn off two-factor". Always confirmed first |
- Size: height 48, pill, horizontal padding 20. Compact: height 36, padding 16 (use the
compactprop, not a style override). - Icon: 20, 8 to the left of the label.
- Loading: a spinner replaces the label and the button is disabled. This is the only place a spinner is allowed.
- Disabled: opacity 0.45.
- Layout: full-width buttons stretch in forms and at the bottom of sheets. Stack them 12 apart. Primary goes on top.
- Sign-in buttons (
AuthButton) are the same pill at height 50 with label 16, a light impact haptic, and variants primary / grey / plain-theme-text.
8.2 Icon buttons and header actions
- 44×44 touch area; bare 24 glyph in
textPrimary. - The "ink" tone is a 44 theme circle with a white 20 glyph, used only for send.
- Header badge (unread count): absolute top 6 / right 6; min 17×17 theme pill with a 1.5px white border; bold 10 white; shows "9+" above 9.
8.3 List rows: the core pattern
┌──────────────────────────────────────────────────────────┐
│ 16 │(avatar 44–48)│ 12 │ Title (base semibold) meta(11) │ 16
│ │ │ │ Subline (sm, secondary, 1 line) │
└──────────────────────────────────────────────────────────┘
└── hairline starts here: 16 + avatar + 12 ──────| Row type | Leading | Min height | Separator inset | Trailing |
|---|---|---|---|---|
| Group / chat list | Monogram or avatar 48 | 76 | 76 | Time (micro) in the title line; optional badge column |
| People (members, blocked) | Avatar 44 | 64–72 | 72 | Badge, score, or compact button |
| Ranked people (leaderboard) | Rank cell 40 + avatar 42 | 64 | full width | Score (base medium, tabular) over time (micro) |
| Notification / activity | Icon circle 40 | 64–72 | 68 | Unread dot 10 (theme) |
| Settings | Icon circle 36 in grey | 56 | full width inside the grouped card | Detail text (sm) and chevron |
| Action row ("Chat", "Invite people") | Icon 22–30 in theme | 64 | full width | none |
Rules
- Rows are white on white. No card borders, no gaps between rows, no chevrons in content lists (chats, groups, people, notifications). Chevrons appear only in settings-style navigation lists.
- Press feedback is
PressableScale0.99. Long press (300ms) opens that row's action sheet. - Unread rows get an
inkSoftbackground and a semibold title; read rows are medium. - A subline under a name: role · streak or sender: last message or description. Show "No description yet" in
textMutedrather than leaving a gap. - Separators come from the list's
ItemSeparatorComponentwithrowInset(leadingSize). Never hand-type a number like 76.
8.4 Cards (use sparingly)
- White, radius 14, hairline border, padding 16 (or 20 for hero cards). No shadow.
- Use only for:
- a grouped settings section
- a hero or stat block (score + tiles)
- a QR code
- the share image
- a form on a sparse screen
- Stat tile (inside a card):
surfaceMuted, radius 12, padding 16, 2×2 grid (47% basis, gap 12). Label is sm secondary; value is2xltabular; the unit is sm muted.
8.5 Chips (filters, pickers)
- Grey pill, height ≥ 34, padding 14 × 6, label 14 medium
textSecondary. - Selected:
inkSoftfill and theme semibold label. - Horizontal scroll row: gap 8, horizontal padding 16, 12–16 below. A selection haptic fires on change.
- Optional 16 icon, 6 before the label.
8.6 Tabs inside a screen (period / segment tabs)
- Equal-width text tabs: height ≥ 44, label 15 medium
textSecondary; active is semibold theme colour. - Underline: 3px theme bar with 3 radius on the top corners, inset 16, under the active tab. A hairline sits below the whole row.
- A selection haptic fires on change.
8.7 Bottom tab bar
- An in-flow white bar (not floating), with a hairline on top. Padding top 6, bottom
max(safeAreaBottom, 8). - At most 4 tabs, each a single short word.
- Item: min height 56; icon 22 (outline when inactive, filled when active); label caption 12.
- Active:
- the icon sits in a 60×32 pill, radius 16 (half the height), filled
inkSoft, icon in theme colour - label semibold
textPrimary
- the icon sits in a 60×32 pill, radius 16 (half the height), filled
- Inactive: icon and label
textSecondary. - Always give the indicator a background (white when inactive). Android drops rounded corners when a background appears only on focus.
- Selection haptic when switching tabs. Tab labels have
maxFontSizeMultiplier 1.3. - No centre "+" button: create actions live in the header.
8.8 Inputs
- App field: label above (sm medium secondary, 6 gap), grey field at height 48, radius 12, padding 16×12. Focus: white fill, 1px theme border. Error: danger border and sm danger text below. Hint: sm muted below.
- Sign-in field: height 50, input 16, placeholder
textTertiary, and a password eye toggle (eye-outline/eye-off-outline, 44 touch). - Code input (2FA / OTP):
- N boxes, gap 8, max width 50, aspect 0.88, radius 12, 1.5 border
- active box: theme border and white fill, with a blinking 2×22 theme caret
- one hidden
TextInput(number-pad,oneTimeCode) - auto-submits with a success haptic when full
- Chat composer: grey pill (radius 22), min 44 / max 120 high, multiline, padding 16×10, next to a 44 theme send button that's disabled when empty.
- Mono fields (codes, keys): mono font, letterSpacing 2–3,
autoCapitalize="characters".
8.9 Badges
- Radius 6, padding 8×2, xs 11 medium.
- Tones:
- theme (Owner, positive)
- info (Admin)
- neutral (Member)
- warning / danger (statuses)
8.10 Avatars and monograms
- Sizes: 28 (chat bubble), 36 (share card), 42–44 (people rows), 48 (group rows), 56 (group header), 72 (profile).
- The monogram's initial is 40% of the size, semibold, with a tone chosen by hashing the name (theme, info, warning or neutral tint). The image sits on top of the monogram, so a broken image still shows the initial.
8.11 Empty, loading and error states
- Empty:
- 72
inkSoftcircle with a 30 theme icon - title
lg - one sentence (
basesecondary) saying what goes here and how to start - at most one primary action, stretched
- 72
- Loading:
SkeletonRows in the rough shape of the content (2–3 rows at the row height; a 112–168 block for hero cards), inside 16 horizontal padding. The opacity pulse is 0.5→1 over 750ms. - Error:
ErrorBanner(danger tint, alert icon, the message) followed by a secondary "Try again" button. Keep whatever content was already loaded on screen. - Offline: a global pill (8.14), not per-screen banners.
- Every list screen handles all four: loading, error, empty, content. So does every detail screen.
8.12 Bottom sheets
- RN
Modal(transparent, fade) with a full-screen scrimPressable(colors.scrim, label "Close"). - Sheet:
- white, 20 radius on the top corners, padding 20 horizontal and 12 top
- bottom padding = safe area + 20
- slides up from the bottom over 250ms
shadow.floating
- Handle: 40×5 hairline pill, 20 below.
- Title
xl; subtitle sm secondary, 4 below, 20 above the content. - Option tiles (big choices like Cheer / Nudge / Taunt): equal columns,
surfaceMuted, radius 12, padding 16, emoji or icon 30, label base semibold, press scale 0.95. - Action rows inside a sheet: flat, min height 52, icon 22, label base. Destructive rows ("Report", "Block") use
dangertext and icon, sit in their own section below a hairline, and never sit next to a tile that looks like a positive action. - Multi-step sheets (Report → reason) swap content in place and reset when closed.
- Footer: ghost "Cancel".
- When to use: use a sheet for choices about one item (react / report / block, create / join). Use a native alert only for yes/no confirmations.
8.13 Confirmations (native alerts)
- Title is a question with the object: "Delete your account?", "Block Flavia?", "Leave Friends?", "Replace the invite?".
- Message: one or two sentences of consequence ("You won't see their messages in any club, and they won't be notified.").
- Buttons: "Cancel" (cancel style) and a specific verb ("Block", "Leave", "Replace", "Delete forever"), with
destructivestyle when it destroys or hides something. - Irreversible account-level actions get two steps ("Delete your account?" → "Are you sure?", with "Keep my account" / "Delete forever").
- A warning notification haptic fires before a destructive confirmation.
8.14 Toasts, pills and banners
- Offline pill (global): absolute, top = safe area + 8, horizontal 16, max width 520.
- theme fill, pill shape, min height 48,
shadow.floating cloud-offline-outline20 white; "You're offline. Showing what was last loaded." in sm white- "Retry" pill:
onInkSubtlefill, semibold 14 white - fades up in 200ms and out in 150ms; retries every 8s; refetches all queries when back online
- theme fill, pill shape, min height 48,
- Celebration toast: theme pill, 16×12 padding,
shadow.floating; white icon (20) and sm semibold white text. Fades in 250 and out 200, auto-hides after 3.5s, with a success haptic. Place it below the header. - Progress banner (background jobs): white card with either a hairline border or a shadow, not both. Icon, title (sm semibold), percent (xs tabular), 6px track in
neutralSoftwith a theme fill. Sits just above the tab bar. - Inline notices (in content):
surfaceMuted(neutral),warningSoft(warning) ordangerSoft(error), radius 12, padding 12, icon 20 and label text; optionally a compact ghost action.
8.15 Chat
- List: an inverted
FlatList(newest at the bottom), padding 16×12. Load older pages atonEndReached0.4. - Empty chat: render
EmptyStateoutside the inverted list. Inside it, Android mirrors the text. - Day divider: a centred grey pill (
surfaceMuted, caption 12 medium secondary, padding 12×4), margin 12 above and below. - Sender name: caption 12 medium muted, 36 from the left, shown when the sender changes or a new day starts.
- Bubble:
- max width 78%, padding 12 horizontal, 8 top, 4 bottom
- radius 18, with the tail corner at 6 (bottom-right for mine, bottom-left for theirs)
- mine
inkSoft; theirssurfaceMuted; dark text on both, no border - time micro 11 muted, right-aligned
- avatar 28, only on the first bubble of a group, in a 28 + 8 slot
- Reactions (special message types) tint the bubble softly (theme / warning / info soft) and add a 24 white emoji badge at the corner (hairline border, top −10, outer side −8).
- Composer: white bar with a top hairline and padding 16 (bottom = max(safe area, 12)); quick-reaction chips above the pill input and send button.
- Long press on someone else's message opens the sheet (react / report / block). Your own messages have no long-press menu.
8.16 QR / invite card
- A centred card, radius 20, hairline border, QR 220 inside 16 padding. Centre badge: 34 theme tile with radius 9, 3px white border and a white 18 brand glyph.
- Above: "Invite people to join" (base secondary) over the group name (
2xl). - Below: the caption "Ask teammates to scan this code with their camera" (label secondary), then a primary "Share invite link" button, then a row with "Invite code" (sm muted), the code (mono 15, letterSpacing 2, selectable) and a "Reset" theme text action (confirmed).
- Share links are https URLs, which chat apps make tappable. Custom schemes aren't tappable in most chat apps.
- Loading is a skeleton block in the card's place, not a spinner.
8.17 Share image card
- A white card (radius 20, hairline border,
overflow: hidden) captured withreact-native-view-shot. - Header band: solid theme colour, padding 20: a small uppercase wordmark (bold 12, letterSpacing 2, white), the title (
2xlwhite), and the period (smonInkMuted). - Rows: min height 60, radius 12, the viewer's row tinted
inkSoft. Rank circle 28 (medal fill for the top 3,neutralSoftotherwise), avatar 36, name base semibold, scorexltabular. - Footer strip:
surfaceMutedwith a top hairline; what's counted (xs muted) and"Ranked on {App}"(xs semibold theme). - Actions below the card: primary "Share image", secondary "Share as text".
9. Layout and navigation
9.1 Structure (Expo Router)
app/
_layout.tsx fonts → providers → auth redirect → Stack (headerShown: false)
(auth)/_layout.tsx Stack, animation "fade", white
(auth)/login.tsx, register.tsx, auth/callback.tsx
(tabs)/_layout.tsx Tabs with the custom tab bar, max 4 tabs
(tabs)/index.tsx home (the product's main screen)
(tabs)/… other tab roots
settings.tsx, notifications.tsx, blocked.tsx … stack screens
thing/[id].tsx detail
thing/new.tsx create form
thing/invite/[id].tsx presentation: "fullScreenModal", animation: "slide_from_bottom"
index.tsx custom entry: crash guard around expo-router's App9.2 Screen spacing
- Horizontal gutter: 16 everywhere. Full-bleed rows handle their own 16 inside the row.
- Between sections: 24 (
SectionHeaderprovides its top margin). - Inside cards: 16, or 20 for hero and form cards.
- Bottom of scroll content:
tabBarClearance(24) on tab screens; 40 on stack screens. - Forms: fields stack 16 apart; the primary button follows the last field.
9.3 Headers
- Tab root:
Screentitle with no back button (for example "Clubs", "Chat", "Profile") and a count or subtitle beneath. Right: bare icon actions ("+" to create, gear for Settings). - Home tab: a custom header: theme-coloured wordmark on the left, bare icon actions on the right (share, bell with badge, add). Segment tabs go underneath, and a hairline runs below the whole header.
- Stack screen: back arrow, title, and optional subtitle (context such as the group name), with optional right actions (ghost compact button or icon). No bottom hairline on the plain app bar.
- Full-screen modal: a close "×" instead of a back arrow; same title style.
9.4 Navigation rules
- Tapping a group anywhere selects it and opens it on the home tab.
- After creating or joining something,
router.replaceto its home (the form isn't left in the back stack), with a success haptic. - Settings and notifications are stack screens reached from header icons, not tabs.
- Deep links (
app://join/CODE) and https invite pages hand off to the same join flow. - The auth redirect lives in the root layout: signed out → login; signed in on an auth screen → home.
10. Screen templates
Build new screens from these. Each lists structure, then required states.
T1. Sign-in (login / register)
AuthBackground (white SafeArea, top+bottom)
KeyboardAvoidingView → ScrollView (centred vertically, padding 20×32)
Brand block (fade-in-down 500ms): BrandMark 72 · App name (largeTitle) · tagline (body secondary) · optional TerminalLine/tagline visual
Form (fade-in-down, delay 120ms):
AuthNotice (error / session expired)
Primary social button ("Continue with GitHub/Google") · Sign in with Apple (iOS, same size)
"Sign in with email" plain link → reveals: divider "OR WITH EMAIL" · fields · grey "Sign in" pill
Footer: "New to {App}? Create an account"
LegalConsent: "By continuing, you agree to the Terms of Use and Privacy Policy…" (links in theme)- States: field errors below fields; a banner for server errors; an inline 2FA step (code boxes) swapped in with a fade.
- Haptics: light impact on button press, error notification on failure, success on sign-in.
- Validation copy has no final period: "Enter a valid email", "At least 8 characters", "The passwords don't match".
T2. OAuth return / processing screen
A centred brand mark with a status block: loader + "Signing you in" + a mono subline → check circle + "You're in" → or a danger circle + "Couldn't sign you in" + the reason + a primary "Back to sign in". Use live regions (polite, then assertive for failure).
T3. Tab root list (groups, chats)
Screen(title, subtitle=count, right=IconButton "+")
[optional] Chip filter row
FlatList of ListRow (flat) + inset Divider separators, pull-to-refresh- States: 3 skeleton rows in padding 16 · ErrorBanner + "Try again" · EmptyState with a create action · a filtered-empty EmptyState ("No clubs where you're admin" / "Try another filter.").
T4. Home / dashboard (the main tab)
Custom header: wordmark · share · bell(badge) · add + segment tabs (hairline below)
FlatList
ListHeader: Hero (label · display number + chevron · stat line) · context chips · divider · notices · primary action row
Rows: ranked/primary items (flat)
ListFooter: summary row · management action rows
Overlays: celebration toast · action sheet · create sheet- States: skeleton hero and rows; an ErrorBanner if the main query fails (don't render an empty screen); an EmptyState when the user has no groups at all.
T5. Detail screen (group settings, item detail)
Screen(back, title, subtitle=name)
ScrollView (padding 16, pull-to-refresh)
Hero Card: Monogram 56 · name (xl) · badge · meta · description
Primary/secondary action button(s)
SectionHeader + content (stat tiles card / people rows)
"See all N" ghost button when the list is truncated (max 8 inline)
Destructive action at the bottom ("Leave club"), confirmed- States: skeleton blocks in the section shapes; a full-screen EmptyState on load failure with "Back to …".
T6. Profile / stats
Identity card (avatar 72 · name xl · @handle mono · badge · context)
[picker chips if multiple contexts]
SectionHeader "Streak" → streak card (flame circle 64, 3xl count, longest/freezes, at-risk/broken notice)
SectionHeader "Score" → period chips → card (display number, rank, 2×2 tiles)
SectionHeader "Recent activity" → flat activity rows (icon circle 40)T7. Settings
Screen(back, "Settings")
ScrollView padding 16
For each section: UPPERCASE label (xs semibold, letterSpacing 0.8, secondary, 24 above, 8 below)
Card(padded=false) of settings rows (icon circle 36 · title · detail · chevron)
Sections: Account/Security · Privacy and safety (Blocked people, Privacy Policy, Terms) · About (Help and support, Version, Signed in as)
Secondary "Sign out" · Destructive "Delete account" · small note on what deletion removesSettings is the one list that uses grouped cards, like iOS Settings.
T8. Create / edit form
Screen(back, "New club")
ScrollView padding 16
Help sentence (base secondary, 20 below)
Card padding 20: ErrorBanner · TextFields (autofocus first, max lengths, hints for optional fields) · Primary submit (icon)- Validate with zod. Messages have no final period ("At least 2 characters"). Disable submit until the minimum is met where that's obvious.
- On success: success haptic, select the new item,
router.replaceto where it lives.
T9. Join / redeem code
A card with a 48 inkSoft icon circle, a lg question title ("Got an invite?"), one sentence, a mono code field (uppercase, autofocus) and a primary "Join". A deep-link variant auto-submits and shows a centred processing card; on error it offers "Enter a code instead".
T10. Chat thread
See 8.15. Header: back, group name, "N members". Long press on others' messages opens react / report / block.
T11. Notifications / activity feed
Screen(back, "Notifications", subtitle "N unread", right: ghost compact "Mark all read")- Flat rows: 40 tinted icon circle (tone by kind: streak = warning, rank drop = danger, chat = info, other = theme), title (semibold when unread), body (2 lines), time (micro), unread dot. Unread rows are tinted
inkSoft. - Tapping marks it read and follows the link.
- Empty: "You're all caught up".
T12. Blocked / manage people
- Intro sentence (sm secondary, padding 16×12).
- Flat people rows: avatar 44, name semibold, and a compact secondary action ("Unblock", confirmed).
- Empty: explain how to add someone ("To block someone, press and hold one of their chat messages.").
T13. Invite (full-screen modal)
See 8.16. Close ×, title "Invite". Share sheet message: Join my club "{name}" on {App}, {one-line pitch}: {https url}.
T14. Share card screen
Period chips → share image card (8.17) → primary "Share image" → secondary "Share as text". Fall back to text when image sharing isn't available.
T15. Security flows (2FA, change password)
- Forms in cards (padding 20).
- 2FA setup: QR 180 in a white hairline box, a setup key in mono on a grey chip, then a code field.
- Backup codes: a
warningSoftcard with a codes grid (mono, white chips), "Save or share", and a confirm button that's enabled after sharing. - Turning off security asks for confirmation, then the password.
T16. Crash screen (app-wide)
- Wraps the whole app via a custom entry. System fonts and literal colours (the token system might be what broke).
- Content: "Something went wrong" (22 bold), one paragraph, a selectable details box (grey, radius 12, monospace 12), primary "Try again" (remounts the app), and text button "Share details".
- Also sends a crash report to the API and hides the splash screen.
T17. Legal / web pages (served by the API)
Same look in HTML: white page, max width 680, 16 gutter, theme links, the brand tile and wordmark in the header, h1 28, h2 19, body 16/1.55 #3B4A54, pill buttons, grey inputs with a theme focus border, notices in soft tints, and a footer nav (Support · Privacy · Terms · Delete account). No inline JavaScript (the Content-Security-Policy blocks it); choose platform-specific links on the server.
11. Committed screen catalogue (worked examples)
How each template is applied in Committed. Copy the pattern, not the domain.
| Screen | Template | Key details |
|---|---|---|
| Login | T1 | GitHub primary, Apple on iOS, email behind "Sign in with email", terminal line $ git commit -m "climb the board" with blinking caret, 2FA inline |
| Register | T1 | BrandMark 60, "Create account", first + last name side by side, password + confirm with eye toggles |
| Auth callback | T2 | Three-node "commit loader", "Verifying with GitHub…" in mono |
| Ranks (home) | T4 | Green "Committed" wordmark · Today/Yesterday/Week/Month underline tabs · "points today" + 60 light number · club chips · GitHub-link notice · "Chat" action row with last message · ranked rows (medals only when score > 0; "Owner · 🔥 14d" subline; score over "3h ago") · footer "Club total points", "Invite people to club", "Club settings" · long press a row to cheer/nudge/taunt |
| Clubs | T3 | "Clubs · N clubs", "+" opens create/join sheet · All/Owner/Admin/Member chips · rows: Monogram 48, name, description, role badge + member count |
| Chat list | T3 | Rows: Monogram 48, club name + time, "Sender: message" preview; no chevrons |
| Chat thread | T10 | Grey day pills, green "mine" bubbles, Cheer 🥳 / Nudge 👉 / Taunt 😝 chips above the pill input, long press to react/report/block |
| Profile | T6 | Gear to Settings; club picker chips; streak card with freezes; Commit Score with 2×2 tiles "× weight = pts"; recent activity rows |
| Member profile | T6 | Same as Profile, with a 😊 header action to react |
| Club settings | T5 | Hero card, "Invite people", Scoring tiles with "Edit", Members (8 inline + "See all"), "Leave club" |
| Members | T12-like | Virtualised people rows; long press opens role actions |
| Scoring | T8 | Four mono number fields with hints, audit-log note |
| New club / Join | T8 / T9 | Replace to Ranks on success |
| Invite | T13 | QR with green commit badge, https link, "Reset" with confirmation |
| Share | T14 | Green header band card, top 5 + your row |
| Notifications | T11 | Tone icons by kind, tinted unread rows |
| Settings | T7 | Security · Privacy and safety · About; Sign out; Delete account (two-step) |
| Blocked people | T12 | Unblock with confirmation |
| Two-factor / Change password | T15 | — |
| Quick action sheet | 8.12 | "Get in the game": Create a club / Join with a code |
| Reaction sheet | 8.12 | Three tiles + Report message / Block {name} section; Report → reason list |
| Crash screen | T16 | — |
| Web pages | T17 | /privacy, /terms, /support, /delete-account, /join/CODE |
12. Interaction: press feedback, haptics, motion
12.1 Press feedback (one system)
| Element | Feedback |
|---|---|
| Buttons, chips | PressableScale 0.98 |
| List rows, settings rows | PressableScale 0.99 |
| Big option tiles | PressableScale 0.95 |
| Icon buttons, header actions | PressableScale 0.9 + Android borderless ripple |
| Text links / inline actions | PressableScale 0.95, hitSlop 10 |
| Tabs | Selection haptic + active state (no scale) |
Nothing tappable is ever without feedback. Disabled is always opacity 0.45.
12.2 Haptics (expo-haptics; always .catch(() => {}))
| Moment | Haptic |
|---|---|
| Switching tab, segment tab, chip or picker | selectionAsync |
| Sending a message | selectionAsync |
| Pressing a sign-in button | impactAsync(Light) |
| Opening a long-press menu | impactAsync(Light) |
| Something created, joined, shared, reported or sent (reaction) | notificationAsync(Success) |
| An action failed (sign-in, submit) | notificationAsync(Error) |
| Before a destructive confirmation | notificationAsync(Warning) |
| Rank up | notificationAsync(Success); streak milestone: impactAsync(Heavy) |
| OTP code filled | notificationAsync(Success) |
12.3 Motion (react-native-reanimated)
| Motion | Spec |
|---|---|
| Press spring | in: damping 18, stiffness 320, mass 0.6 · out: damping 16, stiffness 260 |
| Sheet enter | SlideInDown 250ms over a fading scrim |
| Toast / pill | FadeInUp 200–250ms, FadeOutUp 150–200ms |
| Screen content entrance (sign-in only) | FadeInDown 500ms, staggered 120ms |
| Reveal inline section | FadeInDown 300ms |
| Step swap (2FA) | FadeIn 250 / FadeOut 150 |
| Chip tap flash | scale 0.9 in 80ms → spring back (damping 14, stiffness 260); accentFlash 60ms in, 150ms out |
| Skeleton | opacity 0.5↔1, 750ms, repeat |
| Caret blink | 500–530ms each way |
| Celebration | shows 3500ms |
| Modals | fullScreenModal + slide_from_bottom; auth stack fade |
Reduced motion: check useReducedMotion(). Stop decorative loops (carets, loaders, glows) and keep state changes.
13. Copy and voice
- Voice: short, friendly, second person, contractions. Confident about the product's point ("Ship something to keep your streak alive.").
- Titles: sentence case, no final period: "Club settings", "Blocked people", "Delete your account?".
- Buttons: verb first, specific: "Create club", "Share invite link", "Join club", "Save scoring", "Delete forever", "Keep my account". Never "OK / Submit / Yes".
- Empty states: title = the state ("No messages yet"); message = what to do ("Start the banter, or send a cheer.").
- Errors:
"Couldn't {do the thing}"plus the reason if known ("Couldn't block this person"). Validation messages: short, no final period. Sentences in banners and alerts: with final periods. - Confirmations: question titles naming the object; a message stating the consequence; a verb button.
- Counts: "1 member" / "N members"; "9+" in badges; times as "3h ago", "Yesterday".
- Don't mention a specific sign-in method in general copy (say "sign back in any time", not "with GitHub").
- Don't use jargon (IDs, HTTP, null) in anything users see; put technical detail only on the crash screen.
- No exclamation marks except in celebratory chat content. No emoji in UI chrome (only in reactions and user content).
- Legal copy on sign-in: "By continuing, you agree to the Terms of Use and Privacy Policy, including zero tolerance for abusive content."
14. Accessibility
- Touch targets ≥ 44×44 (use
hitSlopfor small text links). - Contrast: body text ≥ 4.5:1 (
textPrimary,textSecondary,textMuted, theme colour all pass on white).textTertiaryonly for placeholders and fine print. - Roles and labels:
- every pressable:
accessibilityRole="button"(or"link","tab") and anaccessibilityLabelthat includes context ("Friends chat. Melex: 6 days in a row") - icon-only buttons always have a label
- rows with long-press actions have an
accessibilityHint("Long press to react to, report or block Flavia")
- every pressable:
- State:
accessibilityState={{ selected }}on chips and tabs,{ disabled, busy }on buttons. - Headers: titles use
accessibilityRole="header". - Live regions: banners and toasts use
accessibilityRole="alert"andaccessibilityLiveRegion="polite"(assertive for failures). - Decorative elements: hide monograms (the name is read elsewhere) and emoji badges from screen readers.
- Numbers that update use
tabularso they don't jitter. - Dynamic type: no fixed heights on text containers; only compact chrome gets a font scale cap.
15. Review checklist
Run this on every screen before it ships.
Tokens and style
- No hex, rgb, font size, radius or spacing literal outside
tokens.ts(the crash screen is the only exception) - Text uses
typeScale/authTypeentries;fontFamily, notfontWeight - Theme colour limited to the primary action, selected state and links
- No shadow except on sheets, toasts and floating pills; no border and shadow together
- No gradient, glass, dark-mode or "vibe" leftovers
Layout
- 16 horizontal gutter; 24 between sections
- Content lists are flat rows with inset hairlines computed from
rowInset(); no cards per row; no chevrons outside settings - Loading and error blocks have the same 16 padding as content
- Header matches the template (tab root, home, stack or modal)
States
- Loading skeletons in content shape (no page spinner)
- Error banner and retry when the main query fails (never a silently empty screen)
- Empty state with icon, sentence and one action
- API lists may come back
null: normalise with?? []in the query function, and use optional indexing (data?.[0])
Interaction
- Every tappable has
PressableScalefeedback; disabled = 0.45 - Haptics per table 12.2
- Destructive actions confirmed with a specific verb; account-level ones in two steps
- Long press has a hint and opens a sheet (not an alert used as a menu)
- No
style={({ pressed }) => …}on Pressable
Copy and accessibility
- Sentence case; verb buttons; validation messages without final periods
- Labels, roles, states and live regions set; touch targets ≥ 44
Platform
- Checked on Android and iOS, on a small phone and a large one, with large text on
- Inverted lists don't contain their empty state
- Safe areas: top via
Screen; bottom via tab bar, composer or sheet padding
Store
- Settings has Privacy Policy, Terms, Support, Blocked people, Sign out and Delete account
- Sign-in has the legal consent line, and Sign in with Apple on iOS if any social login exists
- User content has report and block
16. Do and don't
| Do | Don't |
|---|---|
| White rows separated by inset hairlines | A bordered card around every row, with gaps between them |
| One green primary pill per view | Several filled buttons competing, or theme-coloured text everywhere |
| Grey pill chips, pale green when selected | Outlined chips, or solid theme-coloured chips |
| Bare black header icons | Circled or coloured header icons, or a coloured app bar |
inkSoft for "mine", "selected" and "unread" | Random pastel backgrounds for decoration |
| Skeletons in the shape of the content | Centred spinners for page loads |
| A bottom sheet for per-item actions | Alert.alert with five buttons as an action menu |
| Faint shadow on sheets only | Drop shadows on cards, buttons or the tab bar |
| Pill inputs in chat; rounded 12 fields in forms | Square inputs, or underline-only inputs |
| White icons on theme fills | Theme-coloured icons on theme fills (invisible) |
?? [] for API lists | Indexing straight into API data |
| Tokens for every value | "Just this once" hex codes and magic numbers |
17. Platform pitfalls we hit (and the fixes)
- NativeWind drops
Pressablestyle functions on Android → rows collapsed into vertical stacks. Fix: static styles plusPressableScaleorandroid_ripple. - Tab indicator lost its rounded corners after a tab switch (Android) → give the indicator a background in every state (white when inactive) and an explicit half-height radius (16 on a 32 view), not
radius.full. - Inverted
FlatListmirrors itsListEmptyComponenton some Android versions (text read backwards) → render the empty state outside the list whendata.length === 0. - Launch crash from
nulllists. A Go API serialised an empty slice asnull;data[0]threw "Cannot convert null value to object" in Hermes. Fix: the server always returns[]; the client normalises in the query function (page?.data ?? []) and indexes optionally. - Release builds close silently on a JS error → wrap the app in a crash guard (error boundary plus
ErrorUtils.setGlobalHandler) that shows a details screen and reports to the API. The report pointed straight at the bug in (4). - Emoji and system text render differently per OEM; keep emoji in content only and give emoji tiles a fixed size (30 in sheets, 16 in chips).
- Android keyboard: use
KeyboardAvoidingViewwithbehavior="padding"on iOS only (Android resizes by default). - Status bar: always
StatusBar style="dark"on white screens (set it inScreenand the auth background). - Safe areas:
SafeAreaView edges={["top"]}inScreen; bottom insets are handled by the tab bar, composer and sheets, never twice. - Custom URL schemes aren't tappable in chat apps → share https links that open a web page, which hands off to the app (intent link on Android, chosen server-side).
- Metro misses file changes on Windows now and then → if a change doesn't appear after a reload, restart Metro before debugging the code.


