Skip to content

Commit f06fff3

Browse files
committed
feat(app): apply Chronicle Espresso design system to the mobile app
The web UIs moved to the Espresso palette in 0896992; the mobile app was never migrated and still shipped the stock iOS palette (#007AFF blue, #34C759 green) from a flat 14-key src/theme.ts. Replace that with a real token layer plus a primitive library, so a reskin is a one-file edit rather than a sweep through every component: - src/theme/palette.ts raw Espresso ramps — the single knob for colour - src/theme/themes.ts semantic aliases (surface.page, status.danger.fg, accent.on ...) mirroring the design system's tokens/colors.css, for both light and dark - src/theme/tokens.ts 4px spacing grid, type scale, radii, elevation - src/theme/ThemeProvider.tsx system-or-pinned appearance, persisted Light mode uses the design system's designed warm creams rather than an inversion of the dark ramp, and status foregrounds are the darkened variants that hit AA on cream. src/components/ui/ is the React Native counterpart of the web dashboard's primitives: Button, Card/CardWell/Divider, TextField, Badge/StatusDot, InlineAlert, Screen, and typography. All 21 app files are migrated onto them; the old ThemeColors shape is removed rather than shimmed. Fixes surfaced along the way: DeviceDetails' listener button drew white text on an amber fill, and StatusIndicator hardcoded two iOS colours. Adds app/app/theme-preview.tsx, a living style guide rendering every token and primitive in both themes from the real theme module, and `npm run check:theme`, which fails if a colour literal appears outside src/theme/ — the invariant that keeps future reskins to one file. react-native-web and @expo/metro-runtime are added so the already-declared `npm run web` script works; that is how the style guide and the migrated screens were visually verified.
1 parent 801c92f commit f06fff3

38 files changed

Lines changed: 3255 additions & 1866 deletions

app/app/_layout.tsx

Lines changed: 42 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,51 @@
1-
import { useEffect } from "react";
2-
import { Stack } from "expo-router";
3-
import { useTheme } from "@/theme";
4-
import { ConnectionLogProvider } from "@/contexts/ConnectionLogContext";
5-
import { AppSettingsProvider } from "@/contexts/AppSettingsContext";
6-
import ErrorBoundary from "@/components/ErrorBoundary";
7-
import { initLogger, logInfo } from "@/utils/logger";
1+
import { useEffect } from 'react';
2+
import { Stack } from 'expo-router';
83

9-
export default function RootLayout() {
10-
const { colors, isDark } = useTheme();
4+
import ErrorBoundary from '@/components/ErrorBoundary';
5+
import { AppSettingsProvider } from '@/contexts/AppSettingsContext';
6+
import { ConnectionLogProvider } from '@/contexts/ConnectionLogContext';
7+
import { ThemeProvider, useTheme } from '@/theme';
8+
import { initLogger, logInfo } from '@/utils/logger';
9+
10+
/**
11+
* The navigator, split out because `useTheme()` requires a `ThemeProvider`
12+
* above it — the component that renders the provider cannot also consume it.
13+
*/
14+
function ThemedStack() {
15+
const t = useTheme();
1116

17+
return (
18+
<Stack
19+
screenOptions={{
20+
headerStyle: { backgroundColor: t.color.surface.raised },
21+
headerTintColor: t.color.text.primary,
22+
headerTitleStyle: { fontWeight: t.weight.semibold },
23+
contentStyle: { backgroundColor: t.color.surface.page },
24+
}}
25+
>
26+
<Stack.Screen name="index" options={{ title: 'Chronicle', headerShown: false }} />
27+
<Stack.Screen name="diagnostics" options={{ title: 'Diagnostics', presentation: 'card' }} />
28+
<Stack.Screen name="settings" options={{ title: 'Settings', presentation: 'card' }} />
29+
<Stack.Screen name="theme-preview" options={{ title: 'Design System', presentation: 'card' }} />
30+
</Stack>
31+
);
32+
}
33+
34+
export default function RootLayout() {
1235
useEffect(() => {
1336
initLogger().then(() => logInfo('RootLayout', 'app mounted'));
1437
}, []);
1538

1639
return (
17-
<ErrorBoundary>
18-
<ConnectionLogProvider>
19-
<AppSettingsProvider>
20-
<Stack
21-
screenOptions={{
22-
headerStyle: { backgroundColor: colors.card },
23-
headerTintColor: colors.text,
24-
headerTitleStyle: { fontWeight: '600' },
25-
contentStyle: { backgroundColor: colors.background },
26-
}}
27-
>
28-
<Stack.Screen name="index" options={{ title: 'Chronicle', headerShown: false }} />
29-
<Stack.Screen name="diagnostics" options={{ title: 'Diagnostics', presentation: 'card' }} />
30-
<Stack.Screen name="settings" options={{ title: 'Settings', presentation: 'card' }} />
31-
</Stack>
32-
</AppSettingsProvider>
33-
</ConnectionLogProvider>
34-
</ErrorBoundary>
40+
// The provider sits outside the boundary so the crash screen is themed too.
41+
<ThemeProvider>
42+
<ErrorBoundary>
43+
<ConnectionLogProvider>
44+
<AppSettingsProvider>
45+
<ThemedStack />
46+
</AppSettingsProvider>
47+
</ConnectionLogProvider>
48+
</ErrorBoundary>
49+
</ThemeProvider>
3550
);
3651
}

app/app/diagnostics.tsx

Lines changed: 101 additions & 114 deletions
Original file line numberDiff line numberDiff line change
@@ -1,98 +1,100 @@
11
import React from 'react';
2-
import { View, Text, FlatList, TouchableOpacity, StyleSheet, SafeAreaView, Share, Platform, Alert } from 'react-native';
3-
import { useTheme, ThemeColors } from '@/theme';
2+
import { View, Text, FlatList, StyleSheet, SafeAreaView, Share, Platform, Alert } from 'react-native';
3+
import { useTheme, type Theme } from '@/theme';
4+
import { Badge, Button, Heading, Mono, type Tone } from '@/components/ui';
45
import { useConnectionLog, ConnectionEvent, ConnectionEventType } from '@/contexts/ConnectionLogContext';
56
import { getLogPath, readLog, clearLog } from '@/utils/logger';
67

7-
const EVENT_BADGE_COLORS: Record<ConnectionEventType, string> = {
8-
scan_start: '#007AFF',
9-
scan_stop: '#8E8E93',
10-
scan_result: '#5856D6',
11-
connect_start: '#FF9500',
12-
connect_success: '#34C759',
13-
connect_fail: '#FF3B30',
14-
disconnect: '#FF3B30',
15-
battery_read: '#34C759',
16-
audio_start: '#007AFF',
17-
audio_stop: '#8E8E93',
18-
error: '#FF3B30',
19-
health_ping: '#34C759',
20-
reconnect_attempt: '#FF9500',
21-
reconnect_backoff: '#FF9500',
22-
bt_state_change: '#5856D6',
23-
ws_connecting: '#FF9500',
24-
ws_open: '#34C759',
25-
ws_close: '#FF3B30',
26-
ws_error: '#FF3B30',
27-
ws_reconnect: '#FF9500',
28-
ws_reauth: '#AF52DE',
29-
net_change: '#5856D6',
8+
const EVENT_BADGE_TONES: Record<ConnectionEventType, Tone> = {
9+
scan_start: 'info',
10+
scan_stop: 'neutral',
11+
scan_result: 'suggest',
12+
connect_start: 'warning',
13+
connect_success: 'success',
14+
connect_fail: 'danger',
15+
disconnect: 'danger',
16+
battery_read: 'success',
17+
audio_start: 'info',
18+
audio_stop: 'neutral',
19+
error: 'danger',
20+
health_ping: 'success',
21+
reconnect_attempt: 'warning',
22+
reconnect_backoff: 'warning',
23+
bt_state_change: 'suggest',
24+
ws_connecting: 'warning',
25+
ws_open: 'success',
26+
ws_close: 'danger',
27+
ws_error: 'danger',
28+
ws_reconnect: 'warning',
29+
ws_reauth: 'suggest',
30+
net_change: 'suggest',
3031
};
3132

3233
function formatTime(date: Date): string {
3334
return date.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
3435
}
3536

36-
function EventItem({ event, colors }: { event: ConnectionEvent; colors: ThemeColors }) {
37-
const badgeColor = EVENT_BADGE_COLORS[event.type] || colors.textTertiary;
37+
function EventItem({ event, t }: { event: ConnectionEvent; t: Theme }) {
38+
const s = createItemStyles(t);
39+
const badgeTone = EVENT_BADGE_TONES[event.type] ?? 'neutral';
3840

3941
return (
40-
<View style={[itemStyles.row, { borderBottomColor: colors.separator }]}>
41-
<Text style={[itemStyles.time, { color: colors.textTertiary }]}>{formatTime(event.timestamp)}</Text>
42-
<View style={[itemStyles.badge, { backgroundColor: badgeColor }]}>
43-
<Text style={itemStyles.badgeText}>{event.type.replace(/_/g, ' ')}</Text>
44-
</View>
45-
<View style={itemStyles.details}>
46-
{event.deviceName && <Text style={[itemStyles.device, { color: colors.text }]}>{event.deviceName}</Text>}
47-
{event.details && <Text style={[itemStyles.detail, { color: colors.textSecondary }]} numberOfLines={2}>{event.details}</Text>}
48-
{event.rssi != null && <Text style={[itemStyles.detail, { color: colors.textTertiary }]}>RSSI: {event.rssi} dBm</Text>}
42+
<View style={s.row}>
43+
<Mono style={s.time}>{formatTime(event.timestamp)}</Mono>
44+
<Badge tone={badgeTone} mono style={s.badge}>{event.type.replace(/_/g, ' ')}</Badge>
45+
<View style={s.details}>
46+
{event.deviceName && <Text style={s.device}>{event.deviceName}</Text>}
47+
{event.details && <Text style={s.detail} numberOfLines={2}>{event.details}</Text>}
48+
{event.rssi != null && <Text style={s.detailMuted}>RSSI: {event.rssi} dBm</Text>}
4949
</View>
5050
</View>
5151
);
5252
}
5353

54-
const itemStyles = StyleSheet.create({
54+
const createItemStyles = (t: Theme) => StyleSheet.create({
5555
row: {
5656
flexDirection: 'row',
5757
alignItems: 'flex-start',
58-
paddingVertical: 8,
59-
paddingHorizontal: 12,
60-
borderBottomWidth: 1,
58+
paddingVertical: t.space[2],
59+
paddingHorizontal: t.space[3],
60+
borderBottomWidth: t.borderWidth,
61+
borderBottomColor: t.color.border.subtle,
6162
},
6263
time: {
63-
fontSize: 11,
64-
fontFamily: 'monospace',
64+
fontFamily: t.font.mono,
6565
width: 65,
6666
marginTop: 3,
6767
},
6868
badge: {
69-
paddingHorizontal: 6,
70-
paddingVertical: 2,
71-
borderRadius: 4,
72-
marginRight: 8,
73-
marginTop: 2,
74-
},
75-
badgeText: {
76-
color: 'white',
77-
fontSize: 10,
78-
fontWeight: '600',
79-
textTransform: 'uppercase',
69+
marginRight: t.space[2],
70+
marginTop: t.space[0.5],
8071
},
8172
details: {
8273
flex: 1,
8374
},
8475
device: {
85-
fontSize: 13,
86-
fontWeight: '500',
76+
fontFamily: t.font.sans,
77+
...t.type.sm,
78+
fontWeight: t.weight.medium,
79+
color: t.color.text.primary,
8780
},
8881
detail: {
89-
fontSize: 12,
82+
fontFamily: t.font.sans,
83+
...t.type.xs,
84+
color: t.color.text.secondary,
85+
marginTop: 1,
86+
},
87+
detailMuted: {
88+
fontFamily: t.font.sans,
89+
...t.type.xs,
90+
color: t.color.text.muted,
9091
marginTop: 1,
9192
},
9293
});
9394

9495
export default function DiagnosticsScreen() {
95-
const { colors } = useTheme();
96+
const t = useTheme();
97+
const s = createScreenStyles(t);
9698
const { events, clearEvents } = useConnectionLog();
9799

98100
const shareLogFile = async () => {
@@ -120,102 +122,87 @@ export default function DiagnosticsScreen() {
120122
};
121123

122124
return (
123-
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }}>
124-
<View style={[screenStyles.logBar, { borderBottomColor: colors.separator, backgroundColor: colors.card }]}>
125-
<Text style={[screenStyles.logBarTitle, { color: colors.text }]}>Crash Log</Text>
126-
<Text style={[screenStyles.logBarPath, { color: colors.textTertiary }]} numberOfLines={1}>{getLogPath()}</Text>
127-
<View style={screenStyles.logBarRow}>
128-
<TouchableOpacity onPress={shareLogFile} style={[screenStyles.logBtn, { backgroundColor: colors.inputBackground }]}>
129-
<Text style={[screenStyles.logBtnText, { color: colors.text }]}>Share Log File</Text>
130-
</TouchableOpacity>
131-
<TouchableOpacity onPress={wipeLogFile} style={[screenStyles.logBtn, { backgroundColor: colors.inputBackground }]}>
132-
<Text style={[screenStyles.logBtnText, { color: colors.danger }]}>Clear File</Text>
133-
</TouchableOpacity>
125+
<SafeAreaView style={s.safeArea}>
126+
<View style={s.logBar}>
127+
<Text style={s.logBarTitle}>Crash Log</Text>
128+
<Mono style={s.logBarPath} numberOfLines={1}>{getLogPath()}</Mono>
129+
<View style={s.logBarRow}>
130+
<Button variant="secondary" size="sm" style={s.logBtn} onPress={shareLogFile}>Share Log File</Button>
131+
<Button variant="danger" size="sm" style={s.logBtn} onPress={wipeLogFile}>Clear File</Button>
134132
</View>
135133
</View>
136-
<View style={[screenStyles.header, { borderBottomColor: colors.separator }]}>
137-
<Text style={[screenStyles.title, { color: colors.text }]}>Connection Log ({events.length})</Text>
138-
<TouchableOpacity onPress={clearEvents} style={[screenStyles.clearButton, { backgroundColor: colors.inputBackground }]}>
139-
<Text style={[screenStyles.clearText, { color: colors.danger }]}>Clear</Text>
140-
</TouchableOpacity>
134+
<View style={s.header}>
135+
<Heading>Connection Log ({events.length})</Heading>
136+
<Button variant="danger" size="sm" onPress={clearEvents}>Clear</Button>
141137
</View>
142138

143139
{events.length === 0 ? (
144-
<View style={screenStyles.empty}>
145-
<Text style={[screenStyles.emptyText, { color: colors.textTertiary }]}>No events recorded yet. Scan or connect a device to see events here.</Text>
140+
<View style={s.empty}>
141+
<Text style={s.emptyText}>No events recorded yet. Scan or connect a device to see events here.</Text>
146142
</View>
147143
) : (
148144
<FlatList
149145
data={events}
150-
renderItem={({ item }) => <EventItem event={item} colors={colors} />}
146+
renderItem={({ item }) => <EventItem event={item} t={t} />}
151147
keyExtractor={(item) => item.id}
152-
style={{ backgroundColor: colors.card }}
148+
style={s.list}
153149
/>
154150
)}
155151
</SafeAreaView>
156152
);
157153
}
158154

159-
const screenStyles = StyleSheet.create({
155+
const createScreenStyles = (t: Theme) => StyleSheet.create({
156+
safeArea: {
157+
flex: 1,
158+
backgroundColor: t.color.surface.page,
159+
},
160+
list: {
161+
backgroundColor: t.color.surface.raised,
162+
},
160163
header: {
161164
flexDirection: 'row',
162165
justifyContent: 'space-between',
163166
alignItems: 'center',
164-
paddingHorizontal: 16,
165-
paddingVertical: 12,
166-
borderBottomWidth: 1,
167-
},
168-
title: {
169-
fontSize: 17,
170-
fontWeight: '600',
171-
},
172-
clearButton: {
173-
paddingHorizontal: 12,
174-
paddingVertical: 6,
175-
borderRadius: 6,
176-
},
177-
clearText: {
178-
fontSize: 14,
179-
fontWeight: '500',
167+
paddingHorizontal: t.space[4],
168+
paddingVertical: t.space[3],
169+
borderBottomWidth: t.borderWidth,
170+
borderBottomColor: t.color.border.subtle,
180171
},
181172
empty: {
182173
flex: 1,
183174
justifyContent: 'center',
184175
alignItems: 'center',
185-
padding: 40,
176+
padding: t.space[10],
186177
},
187178
emptyText: {
188-
fontSize: 15,
179+
fontFamily: t.font.sans,
180+
...t.type.base,
181+
color: t.color.text.muted,
189182
textAlign: 'center',
190183
},
191184
logBar: {
192-
paddingHorizontal: 16,
193-
paddingVertical: 10,
194-
borderBottomWidth: 1,
185+
paddingHorizontal: t.space[4],
186+
paddingVertical: t.space[3],
187+
borderBottomWidth: t.borderWidth,
188+
borderBottomColor: t.color.border.subtle,
189+
backgroundColor: t.color.surface.raised,
195190
},
196191
logBarTitle: {
197-
fontSize: 15,
198-
fontWeight: '600',
192+
fontFamily: t.font.sans,
193+
...t.type.base,
194+
fontWeight: t.weight.semibold,
195+
color: t.color.text.primary,
199196
},
200197
logBarPath: {
201-
fontSize: 10,
202-
fontFamily: 'monospace',
203-
marginTop: 2,
204-
marginBottom: 8,
198+
marginTop: t.space[0.5],
199+
marginBottom: t.space[2],
205200
},
206201
logBarRow: {
207202
flexDirection: 'row',
208-
gap: 8,
203+
gap: t.space[2],
209204
},
210205
logBtn: {
211206
flex: 1,
212-
paddingHorizontal: 12,
213-
paddingVertical: 8,
214-
borderRadius: 6,
215-
alignItems: 'center',
216-
},
217-
logBtnText: {
218-
fontSize: 13,
219-
fontWeight: '500',
220207
},
221208
});

0 commit comments

Comments
 (0)