The presentation rulebook for the Sleek Coach mobile app: how screens and components look, how they use the theme, and what keeps the app feeling like one product. Treat this file as the source of truth for design review: the design-review skill walks it, and the pre-commit design agent checks UI-touching changes against it. Every rule below was earned by a real cleanup pass (the token sweep that removed hardcoded rgba() borders, the double-divider fix above the chat input, the semantic palette that sat unused while components hardcoded #F59E0B).
Every visual value flows from src/theme/. Components consume tokens; they never define them. A color, radius, spacing, or font size written inline in a component is a fork of the design system, and forks are how the app stops feeling like one product.
Boundaries: docs/AI_CONVENTIONS.md owns the coach AI behavior and the chat streaming contract; this doc owns how things look and feel. Cross-reference, never duplicate.
Use theme.colors via useAppTheme() (screens) or useTheme<AppTheme>() (components, when the custom semantic colors are needed). No hex or rgba() literals outside src/theme/.
Why: the theme guarantees light/dark parity and Material 3 contrast ratios; a hardcoded rgba(0,0,0,0.1) border reads fine in light mode and disappears in dark mode.
Specific assignments, so nobody has to guess:
theme.colors.outlineVariant, width 1.theme.colors.outline.theme.colors.backdrop.theme.colors.shadow.success / warning / info (and their on* / *Container variants) from the app’s semantic palette in src/theme/colors.ts. These exist for fitness feedback (goal hit, trend warning, informational callouts); use them instead of re-inventing greens and ambers.Padding, margin, and gap values come from the spacing scale (xs 4, sm 8, md 16, lg 24, xl 32, xxl 48). Corner radii come from the borderRadius scale (xs 4, sm 8, md 12, lg 16, xl 24, full), both in src/theme/spacing.ts. Fixed-size pills and circles use borderRadius.full, not a hand-computed half-height. This applies to relocated code too: a refactor that moves an old off-scale value into a new file is the moment to fix it, not to carry it over.
Why: one rhythm knob. When every gap is a multiple from one scale, screens align without anyone measuring, and a future density change is a one-file edit.
Use Paper’s <Text variant="..."> first. When a raw style is unavoidable (a TextInput, a third-party component), compose from the typography presets in src/theme/typography.ts. No literal fontSize in components. Markdown styling comes from createMarkdownStyles in src/theme/markdown.ts, never a per-component style object.
Why: a single type ramp. Literal font sizes drift one screen at a time until headings disagree across tabs.
Reach for react-native-paper components and the app’s own primitives in src/components/ui/ (Button, Card, Chip, Input, EmptyState, LoadingSpinner, ErrorBoundary, TargetProgressRow) before writing a new component. If a pattern appears a third time, extract it into src/components/ui/.
Why: reuse carries accessibility, theming, and dark mode for free; one-offs carry none of them.
StyleSheet.create holds static layout (flex, dimensions, static spacing). Theme-dependent values are applied inline via style arrays: style={[styles.header, { borderBottomColor: theme.colors.outlineVariant }]}. Do not put theme colors inside StyleSheet.create, and do not rebuild whole style objects per render.
Why: StyleSheet gives render performance for the static part; the array keeps the dynamic part reactive to theme changes. This is the pattern ChatInput established.
Tuned values that shape behavior (chat message length limit, scroll thresholds, stream throttles, history caps) live in src/constants/ (chat.ts, config.ts), imported where used. Never inline them in a component.
Why: the chat scroll threshold and throttle were tuned deliberately in PRs #47 through #49; a value with history behind it must be findable and must not be re-tuned by accident in a copy.
Every new surface is verified in dark mode before it ships (theme.dark is the switch; the ThemeContext supports light, dark, and system). If a color pair looks wrong in dark mode, the fix is choosing the right token, not adding a theme.dark ? ternary with literals.
Why: roughly half of real sessions run dark. Rule 1 makes this nearly automatic; this rule makes it checked.
Touch targets are at least 44pt. Icon-only controls carry an accessibilityLabel. Text is not embedded in images. Interactive states (disabled, loading) are visually distinct using theme tokens.
Why: a coaching app is used mid-workout, tired, and one-handed; generous targets and labeled controls are function, not polish.
Walk this in order against the diff. Run the mechanical greps as written (scope them to changed files when the diff is small); the judgment items require reading the full post-change files, not just the hunks.
Mechanical:
grep -rEn "'#[0-9A-Fa-f]{3,8}'" apps/mobile/src --include='*.tsx' --include='*.ts' | grep -v __tests__ | grep -v src/themegrep -rn 'rgba(\|rgb(' apps/mobile/src --include='*.tsx' | grep -v __tests__ | grep -v src/themegrep -rEn 'fontSize: [0-9]' apps/mobile/src --include='*.tsx' | grep -v __tests__ | grep -v src/themegit diff origin/main... -- apps/mobile/src | grep -E '^\+.*(borderRadius|border[A-Za-z]*Radius|padding[A-Za-z]*|margin[A-Za-z]*|gap|rowGap|columnGap): [0-9]'Judgment:
outlineVariant, overlays to backdrop, semantics to success/warning/info).src/components/ui/ and Paper for an existing primitive; third occurrences extracted.StyleSheet.create.src/constants/, not inlined.theme.dark ternaries.