Mobile Design System — Library at Dusk
Design Language
"Library at Dusk" is Learnimo's visual brand: warm parchment backgrounds (#F5F0E8), ember-orange CTAs (#D4854A), and deep navy for dark mode (#0F1B2D). The palette evokes a study room at dusk — paper, ink, candlelight. It replaces the previous indigo/gray Tailwind defaults across all surfaces.
The web source of truth is web/src/app/globals.css and web/tailwind.config.ts. For exact hex values and the full mapping table, read references/tokens-reference.md.
Token Architecture
All mobile styling flows through mobile/src/theme/tokens.ts → ThemeContext → useTheme().
const { theme } = useTheme();
// theme.colors.*, theme.spacing.*, theme.radii.*, theme.typography.*
Rules:
- Preserve the
buildTheme(scheme: 'light' | 'dark')function andAppThemetype — 2,800 lines of code depend on them - Extend
colorsinbuildTheme()by adding new keys; do not rename or remove existing ones - Add
typography.fontFamilywith DM Sans and Sora variants - Replace
paletteentirely with Library at Dusk values (seereferences/tokens-reference.md) - All component styling stays as inline objects — do not switch to
StyleSheet.create
New color keys to add to buildTheme():
inputBg, inputBorder, inputPlaceholder, disabledBg, disabledText, tagPillBg, tagPillText, contentBody
Component Recipes
Button
4 variants + disabled state. Press feedback: opacity: 0.8 on Pressable pressed.
| Variant | Background | Text color | Border |
|---|---|---|---|
primary |
colors.primary (#D4854A) |
colors.textInverse |
none |
secondary |
colors.surfaceAlt |
colors.textPrimary |
1px colors.border |
ghost |
transparent | colors.primary |
none |
danger |
colors.error |
colors.textInverse |
none |
| disabled (any) | colors.disabledBg |
colors.disabledText |
none |
Padding: vertical 10px, horizontal 16px (spacing.md). Border radius: radii.md (8px). No shadow.
Card
1px border, no shadow. Light: surface (#FFFFFF) bg, #E8E4DF border. Dark: #1A365D bg, #2B4A78 border.
Border radius: radii.lg (12px). Padding: spacing.md (16px).
PressableCard adds opacity: 0.85 on press.
Text
7 variants. Headings use Sora (typography.fontFamily.heading); body text uses DM Sans.
| Variant | Size | Weight | Font | Color |
|---|---|---|---|---|
title |
30px (xxxl) | bold | Sora | textPrimary |
heading |
20px (xl) | semibold | Sora | textPrimary |
subheading |
17px (lg) | medium | Sora | textPrimary |
label |
15px (md) | medium | DM Sans | textPrimary |
body |
15px (md) | regular | DM Sans | textPrimary |
bodySm |
13px (sm) | regular | DM Sans | textSecondary |
caption |
11px (xs) | regular | DM Sans | textSecondary |
The color prop overrides the variant default when provided.
TextInput
1px border, radii.md, padding h=spacing.md, v=10px. Color: colors.textPrimary.
- Normal:
colors.inputBgbg,colors.inputBorderborder - Focus:
colors.primary(#D4854A) border - Error:
colors.errorborder - Placeholder:
colors.inputPlaceholder - Error text below:
captionvariant incolors.error
Note: Do NOT use colors.surface as input bg in dark mode — it would be #1A365D (card blue), not the correct #0F1B2D. Use the new colors.inputBg token.
ErrorMessage
Background: colors.errorBackground. Border radius: radii.md. Padding: spacing.sm.
Text: bodySm variant in colors.error. Renders nothing when message is null/empty.
MarkdownContent
Built with react-native-markdown-display. Styles derived from useTheme():
- Headings: match Text variant sizes (h1=xxxl, h2=xxl, h3=xl)
- Inline code:
colors.surfaceAltbg,radii.sm - Code blocks:
colors.surfaceAltbg,radii.md,spacing.smpadding - Blockquotes: 3px left border (
colors.border),colors.surfacebg - Links:
colors.primary - Body text:
colors.contentBody(nottextPrimary— see gotchas)
Avatar
Circular (borderRadius: size / 2). Shows avatarUrl image or initials placeholder.
Initials bg: deterministic from handle hash — update hardcoded INITIALS_COLORS array to Library at Dusk brand accents:
['#D4854A', '#1A365D', '#2B4A78', '#8B5E3C', '#6B4226', '#C07340', '#0F1B2D', '#A05C32']
Screen Layout Patterns
Screen root (all screens)
<SafeAreaView style={{ flex: 1, backgroundColor: theme.colors.background }}>
{/* content */}
</SafeAreaView>
Always import SafeAreaView from react-native-safe-area-context.
List screens (FlatList)
<FlatList
contentContainerStyle={{ padding: theme.spacing.md, gap: theme.spacing.sm, flexGrow: 1 }}
refreshing={refreshing}
/>
Loading: centered <ActivityIndicator size="large" color={theme.colors.primary} />.
Empty: centered View with bodySm + caption text + secondary Button.
Form screens
<ScrollView
contentContainerStyle={{ padding: theme.spacing.md, gap: theme.spacing.md }}
keyboardShouldPersistTaps="handled"
/>
Auth screens (login, register) also wrap in KeyboardAvoidingView:
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'} flex={1} />
Bottom tab bar
tabBarStyle: { backgroundColor: theme.colors.surface, borderTopColor: theme.colors.border }
tabBarActiveTintColor: theme.colors.primary
tabBarInactiveTintColor: theme.colors.textSecondary
Spacing conventions
- Container padding:
spacing.md(16px) orspacing.lg(24px) for auth screens - Sibling gap: use the
gapprop, not margins - Action button rows:
flexDirection: 'row', gap: spacing.sm, each buttonflex: 1
Tag / chip pattern
{ backgroundColor: colors.tagPillBg, borderRadius: radii.full,
paddingHorizontal: spacing.sm, paddingVertical: 2 }
// Text: caption variant in colors.tagPillText
Modal / bottom sheet
Semi-transparent backdrop (rgba(0,0,0,0.4)), borderTopLeftRadius: radii.lg, max height 400.
Use Modal transparent animationType="slide".
Font Loading
Load via expo-font in App.tsx before rendering. Keep SplashScreen visible until fonts are ready.
// Fonts to load:
'DMSans_400Regular' // DM Sans Regular — body text
'DMSans_500Medium' // DM Sans Medium — labels, buttons
'Sora_600SemiBold' // Sora SemiBold — all headings
Do NOT load Bricolage Grotesque — it's the wordmark font (web only). Use a static image for the wordmark on mobile.
Add to typography in tokens.ts:
fontFamily: {
body: 'DMSans_400Regular',
bodyMedium: 'DMSans_500Medium',
heading: 'Sora_600SemiBold',
}
Shadows
Current mobile convention: 1px borders instead of shadows (no shadow* props anywhere). This is intentional — cross-platform shadows are noisy. Maintain this convention unless a specific design calls for elevation.
If elevation is ever needed, use these RN-equivalent recipes:
| Web shadow | iOS | Android |
|---|---|---|
shadow-sm |
shadowOffset:{w:0,h:1} shadowOpacity:0.05 shadowRadius:2 |
elevation:1 |
shadow-md |
shadowOffset:{w:0,h:4} shadowOpacity:0.10 shadowRadius:6 |
elevation:3 |
shadow-lg |
shadowOffset:{w:0,h:10} shadowOpacity:0.15 shadowRadius:15 |
elevation:5 |
Always set shadowColor: '#000'.
Animations
Web uses CSS keyframes; RN equivalents via Animated API:
fadeIn (150ms, easeOut):
Animated.timing(opacity, { toValue: 1, duration: 150, useNativeDriver: true })
slideUp (200ms, easeOut):
Animated.parallel([
Animated.timing(opacity, { toValue: 1, duration: 200, useNativeDriver: true }),
Animated.timing(translateY, { toValue: 0, duration: 200, useNativeDriver: true }),
])
// Start translateY from 8 (matches Tailwind's 0.5rem = 8px)
Known Gotchas
errorBackgroundnot theme-switched in current code — it maps topalette.errorLightfor both modes. Dark mode target is#2D1A1A. Fix: make it conditional inbuildTheme().textInversehex values change — current: white (light) / gray-900 (dark). Target: parchment #F5F0E8 (light) / ink #1A1A2E (dark). These are close but not identical — update explicitly.Avatar hardcodes initials colors —
INITIALS_COLORSarray inAvatar.tsxuses random web colors. Update to Library at Dusk brand accents (see Avatar recipe above).TextInput background — current code uses
colors.surfaceas input bg. In dark mode,surface= #1A365D (card blue), but target input bg = #0F1B2D (background). Always use the newcolors.inputBgtoken in TextInput, notsurface.contentBodyvstextPrimary— MarkdownContent body text should usecolors.contentBody(#333333 / #C8D4E0), which is softer thantextPrimary(#1A1A2E / #F5F0E8). The distinction exists on web and should be preserved on mobile.
Reference
For full hex tables, the complete palette replacement, and the ember-CTA scale, read:
references/tokens-reference.md
Source: lucasxf/engineering-daybook — distributed by TomeVault.