JB logo
CoffeeyOUTUBE
Blog
Next

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):

  1. 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).
  2. Build every screen from the screen templates (section 10). Committed's real screens are the worked examples (section 11).
  3. 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

  1. Design principles
  2. The look in one paragraph
  3. Adapting to a new brand
  4. Tokens
  5. Typography
  6. Colour usage rules
  7. Primitives (code)
  8. Component specifications
  9. Layout and navigation
  10. Screen templates
  11. Committed screen catalogue (worked examples)
  12. Interaction: press feedback, haptics, motion
  13. Copy and voice
  14. Accessibility
  15. Review checklist
  16. Do and don't
  17. Platform pitfalls we hit (and the fixes)

1. Design principles

  1. 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.
  2. 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.
  3. 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).
  4. 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.
  5. Native-sized type. 15pt body, 16–20pt headings, one big light number for the hero stat. The UI never shouts.
  6. 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.
  7. 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.
  8. 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:

SlotCommittedHow 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#E7F3EEThe theme colour at roughly 10% on white. Used for selected chips, the active tab pill, "mine" chat bubbles, icon circles and unread rows.
accentFlash#D5EDE3Slightly stronger than THEME_SOFT, for a 150ms tap flash.
FontsInter (300/400/500/600/700) + JetBrains Mono 500Any clean grotesk with those weights, loaded with expo-font / @expo-google-fonts. Keep one mono for codes.
Brand markCommit glyph on a rounded green tileA single simple glyph, white on a theme-colour tile with radius 26% of its size.
Wordmark"Committed", bold 24, theme colourThe app name in bold theme colour on the home tab only.
App icon / splashTheme square, white glyph; splash: theme tile on whiteSame 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

TokenHexContrast on whiteUse
bgPrimary / surface#FFFFFFEvery screen, rows, sheets, tab bar, headers
surfaceMuted#F2F4F5Inputs, chips, secondary buttons, tiles, "their" bubbles, skeletons, day pills
hairline#E6E9EBRow separators, tab bar top edge, card borders
ink (theme)#0F7B575.3:1Primary buttons, active icons, links, focus, wordmark, badges
inkSoft#E7F3EESelected chip, tab pill, "my" bubble, icon circles, unread row tint
textPrimary#111B2117:1Names, titles, body, numbers
textSecondary#54656F6.4:1Sublines, labels, inactive tabs
textMuted#6677814.6:1Timestamps, hints, placeholders in the app
textTertiary#8696A03.3:1Placeholders and fine print on sign-in only (large or non-essential text)
onInk#FFFFFF5.3:1 on themeText and icons on theme fills
danger / dangerSoft#D93F3F / #FDECEC4.5:1Destructive buttons, errors
warning / warningText / warningSoft#C77700 / #9A5B00 / #FFF4E0text 6.1:1Streak flames, at-risk and sync warnings
info / infoSoft#2F6FDB / #EAF1FD4.6:1Admin badge, chat notification icon, neutral notices
scrimrgba(17,27,33,0.4)Behind sheets and modals
gold / silver / bronze#F5B800 / #A7B0B7 / #C27A3Aicons onlyTop 3 medals, only when the score is above 0

5. Typography

  • Font: Inter, loaded as separate families per weight. Always set fontFamily, never fontWeight (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 tabular to any number that changes or sits in a column.
  • Load fonts before first render. Return null from the root layout until they load or fail; on failure, continue on system fonts.
TokenSize / lineWeightWhere
display60 / 68Light 300The one hero number per screen (points, balance)
3xl28 / 34SemiboldBig stat inside a card (streak count)
wordmark24 / 30BoldApp name on the home tab header, theme colour
2xl22 / 27SemiboldStat tile values, club name on invite
title20 / 25SemiboldScreen titles (app bar)
xl18 / 23SemiboldSheet titles, profile name
lg16 / 21SemiboldEmpty-state title, card headings
base15 / 20RegularBody copy, message text. Row title = base + semibold
label14 / 19MediumChips (selected: semibold), section headers, notice titles, hero stat line
sm13 / 18RegularSublines, previews, helper and error text
caption12 / 16MediumTab labels, chat day pills, sender names
xs11 / 14MediumBadges, "N members" meta
micro11 / 14RegularTimestamps 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

  1. 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.
  2. 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.
  3. Soft tints mean state, not decoration: inkSoft = selected/mine/unread; warningSoft = at risk; dangerSoft = error/destructive; infoSoft = neutral notice.
  4. Greys: textPrimary for anything the user reads first; textSecondary for supporting text; textMuted for metadata (times, counts, hints). Don't use textMuted for sentences people need to read.
  5. Borders: hairline (StyleSheet.hairlineWidth) in hairline colour. No 1px or 2px borders except: focused input (1px theme), code input boxes (1.5px), error input (1px danger).
  6. 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 style on Pressable (style={({ pressed }) => …}). With NativeWind installed it's silently dropped on Android, and the layout breaks: rows stacked vertically in Committed. Use static styles plus PressableScale (or android_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

VariantFillLabelUse
PrimaryinkonInk semibold 15The one main action per view ("Create club", "Share invite link")
SecondarysurfaceMutedtextPrimaryAlternatives ("Share as text", "Sign out", "Try again", "Unblock")
GhostnoneinkInline or low-emphasis ("Cancel" in sheets, "Mark all read", "See all")
DestructivedangerSoftdanger"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 compact prop, 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 typeLeadingMin heightSeparator insetTrailing
Group / chat listMonogram or avatar 487676Time (micro) in the title line; optional badge column
People (members, blocked)Avatar 4464–7272Badge, score, or compact button
Ranked people (leaderboard)Rank cell 40 + avatar 4264full widthScore (base medium, tabular) over time (micro)
Notification / activityIcon circle 4064–7268Unread dot 10 (theme)
SettingsIcon circle 36 in grey56full width inside the grouped cardDetail text (sm) and chevron
Action row ("Chat", "Invite people")Icon 22–30 in theme64full widthnone

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 PressableScale 0.99. Long press (300ms) opens that row's action sheet.
  • Unread rows get an inkSoft background 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 textMuted rather than leaving a gap.
  • Separators come from the list's ItemSeparatorComponent with rowInset(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 is 2xl tabular; the unit is sm muted.

8.5 Chips (filters, pickers)

  • Grey pill, height ≥ 34, padding 14 × 6, label 14 medium textSecondary.
  • Selected: inkSoft fill 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
  • 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 inkSoft circle with a 30 theme icon
    • title lg
    • one sentence (base secondary) saying what goes here and how to start
    • at most one primary action, stretched
  • 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 scrim Pressable (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 danger text 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 destructive style 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-outline 20 white; "You're offline. Showing what was last loaded." in sm white
    • "Retry" pill: onInkSubtle fill, semibold 14 white
    • fades up in 200ms and out in 150ms; retries every 8s; refetches all queries when back online
  • 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 neutralSoft with a theme fill. Sits just above the tab bar.
  • Inline notices (in content): surfaceMuted (neutral), warningSoft (warning) or dangerSoft (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 at onEndReached 0.4.
  • Empty chat: render EmptyState outside 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; theirs surfaceMuted; 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 with react-native-view-shot.
  • Header band: solid theme colour, padding 20: a small uppercase wordmark (bold 12, letterSpacing 2, white), the title (2xl white), and the period (sm onInkMuted).
  • Rows: min height 60, radius 12, the viewer's row tinted inkSoft. Rank circle 28 (medal fill for the top 3, neutralSoft otherwise), avatar 36, name base semibold, score xl tabular.
  • Footer strip: surfaceMuted with 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 App

9.2 Screen spacing

  • Horizontal gutter: 16 everywhere. Full-bleed rows handle their own 16 inside the row.
  • Between sections: 24 (SectionHeader provides 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: Screen title 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.replace to 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 removes

Settings 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.replace to 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 warningSoft card 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.

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.

ScreenTemplateKey details
LoginT1GitHub primary, Apple on iOS, email behind "Sign in with email", terminal line $ git commit -m "climb the board" with blinking caret, 2FA inline
RegisterT1BrandMark 60, "Create account", first + last name side by side, password + confirm with eye toggles
Auth callbackT2Three-node "commit loader", "Verifying with GitHub…" in mono
Ranks (home)T4Green "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
ClubsT3"Clubs · N clubs", "+" opens create/join sheet · All/Owner/Admin/Member chips · rows: Monogram 48, name, description, role badge + member count
Chat listT3Rows: Monogram 48, club name + time, "Sender: message" preview; no chevrons
Chat threadT10Grey day pills, green "mine" bubbles, Cheer 🥳 / Nudge 👉 / Taunt 😝 chips above the pill input, long press to react/report/block
ProfileT6Gear to Settings; club picker chips; streak card with freezes; Commit Score with 2×2 tiles "× weight = pts"; recent activity rows
Member profileT6Same as Profile, with a 😊 header action to react
Club settingsT5Hero card, "Invite people", Scoring tiles with "Edit", Members (8 inline + "See all"), "Leave club"
MembersT12-likeVirtualised people rows; long press opens role actions
ScoringT8Four mono number fields with hints, audit-log note
New club / JoinT8 / T9Replace to Ranks on success
InviteT13QR with green commit badge, https link, "Reset" with confirmation
ShareT14Green header band card, top 5 + your row
NotificationsT11Tone icons by kind, tinted unread rows
SettingsT7Security · Privacy and safety · About; Sign out; Delete account (two-step)
Blocked peopleT12Unblock with confirmation
Two-factor / Change passwordT15
Quick action sheet8.12"Get in the game": Create a club / Join with a code
Reaction sheet8.12Three tiles + Report message / Block {name} section; Report → reason list
Crash screenT16
Web pagesT17/privacy, /terms, /support, /delete-account, /join/CODE

12. Interaction: press feedback, haptics, motion

12.1 Press feedback (one system)

ElementFeedback
Buttons, chipsPressableScale 0.98
List rows, settings rowsPressableScale 0.99
Big option tilesPressableScale 0.95
Icon buttons, header actionsPressableScale 0.9 + Android borderless ripple
Text links / inline actionsPressableScale 0.95, hitSlop 10
TabsSelection haptic + active state (no scale)

Nothing tappable is ever without feedback. Disabled is always opacity 0.45.

12.2 Haptics (expo-haptics; always .catch(() => {}))

MomentHaptic
Switching tab, segment tab, chip or pickerselectionAsync
Sending a messageselectionAsync
Pressing a sign-in buttonimpactAsync(Light)
Opening a long-press menuimpactAsync(Light)
Something created, joined, shared, reported or sent (reaction)notificationAsync(Success)
An action failed (sign-in, submit)notificationAsync(Error)
Before a destructive confirmationnotificationAsync(Warning)
Rank upnotificationAsync(Success); streak milestone: impactAsync(Heavy)
OTP code fillednotificationAsync(Success)

12.3 Motion (react-native-reanimated)

MotionSpec
Press springin: damping 18, stiffness 320, mass 0.6 · out: damping 16, stiffness 260
Sheet enterSlideInDown 250ms over a fading scrim
Toast / pillFadeInUp 200–250ms, FadeOutUp 150–200ms
Screen content entrance (sign-in only)FadeInDown 500ms, staggered 120ms
Reveal inline sectionFadeInDown 300ms
Step swap (2FA)FadeIn 250 / FadeOut 150
Chip tap flashscale 0.9 in 80ms → spring back (damping 14, stiffness 260); accentFlash 60ms in, 150ms out
Skeletonopacity 0.5↔1, 750ms, repeat
Caret blink500–530ms each way
Celebrationshows 3500ms
ModalsfullScreenModal + 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 hitSlop for small text links).
  • Contrast: body text ≥ 4.5:1 (textPrimary, textSecondary, textMuted, theme colour all pass on white). textTertiary only for placeholders and fine print.
  • Roles and labels:
    • every pressable: accessibilityRole="button" (or "link", "tab") and an accessibilityLabel that 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")
  • State: accessibilityState={{ selected }} on chips and tabs, { disabled, busy } on buttons.
  • Headers: titles use accessibilityRole="header".
  • Live regions: banners and toasts use accessibilityRole="alert" and accessibilityLiveRegion="polite" (assertive for failures).
  • Decorative elements: hide monograms (the name is read elsewhere) and emoji badges from screen readers.
  • Numbers that update use tabular so 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 / authType entries; fontFamily, not fontWeight
  • 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 PressableScale feedback; 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

DoDon't
White rows separated by inset hairlinesA bordered card around every row, with gaps between them
One green primary pill per viewSeveral filled buttons competing, or theme-coloured text everywhere
Grey pill chips, pale green when selectedOutlined chips, or solid theme-coloured chips
Bare black header iconsCircled 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 contentCentred spinners for page loads
A bottom sheet for per-item actionsAlert.alert with five buttons as an action menu
Faint shadow on sheets onlyDrop shadows on cards, buttons or the tab bar
Pill inputs in chat; rounded 12 fields in formsSquare inputs, or underline-only inputs
White icons on theme fillsTheme-coloured icons on theme fills (invisible)
?? [] for API listsIndexing straight into API data
Tokens for every value"Just this once" hex codes and magic numbers

17. Platform pitfalls we hit (and the fixes)

  1. NativeWind drops Pressable style functions on Android → rows collapsed into vertical stacks. Fix: static styles plus PressableScale or android_ripple.
  2. 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.
  3. Inverted FlatList mirrors its ListEmptyComponent on some Android versions (text read backwards) → render the empty state outside the list when data.length === 0.
  4. Launch crash from null lists. A Go API serialised an empty slice as null; 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.
  5. 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).
  6. 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).
  7. Android keyboard: use KeyboardAvoidingView with behavior="padding" on iOS only (Android resizes by default).
  8. Status bar: always StatusBar style="dark" on white screens (set it in Screen and the auth background).
  9. Safe areas: SafeAreaView edges={["top"]} in Screen; bottom insets are handled by the tab bar, composer and sheets, never twice.
  10. 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).
  11. Metro misses file changes on Windows now and then → if a change doesn't appear after a reload, restart Metro before debugging the code.