diff --git a/App.tsx b/App.tsx index 101a0a0..88d8b9c 100644 --- a/App.tsx +++ b/App.tsx @@ -6,8 +6,6 @@ import {ApolloProvider} from '@apollo/client/react'; import { - DarkTheme, - DefaultTheme, NavigationContainer, useNavigationContainerRef, } from '@react-navigation/native'; @@ -25,6 +23,7 @@ import { usePendingShare, useShareListener, } from './src/share/shareImport'; +import {useNavigationTheme} from './src/theme/navigationTheme'; import {useTheme} from './src/theme/useTheme'; function App() { @@ -44,6 +43,7 @@ function App() { function AppContent() { const theme = useTheme(); + const navigationTheme = useNavigationTheme(); const hydrated = useAuthHydrated(); const auth = useAuth(); const navigationRef = useNavigationContainerRef(); @@ -76,9 +76,9 @@ function AppContent() { style={[ styles.container, styles.splash, - {backgroundColor: theme.background}, + {backgroundColor: theme.canvas}, ]}> - + ); } @@ -87,14 +87,14 @@ function AppContent() { return ; } - // Match React Navigation's container background to the active scheme so the - // gap shown during the slide transition between screens isn't a white flash - // in dark mode. + // The navigator's own theme is built from our tokens, so the gap shown during + // the slide transition between screens is the canvas rather than React + // Navigation's default background. return ( + theme={navigationTheme}> ); diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index 55427cc..3511267 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png index ce2027c..9d307f8 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index 5bbb115..2f90eb8 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png index b80ba5f..c8f0696 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 64449ab..4212b5e 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png index 68f64e2..155df05 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index abf283c..eebfe5f 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png index 0890923..adfc394 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index f90e996..2e98208 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png index 0c8e530..9d8d221 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/values-night/colors.xml b/android/app/src/main/res/values-night/colors.xml new file mode 100644 index 0000000..77ae028 --- /dev/null +++ b/android/app/src/main/res/values-night/colors.xml @@ -0,0 +1,15 @@ + + + + #1a1817 + #ebe7e2 + #c47a52 + #4d3a3532 + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml index 5e335da..6dff3f1 100644 --- a/android/app/src/main/res/values/colors.xml +++ b/android/app/src/main/res/values/colors.xml @@ -1,4 +1,25 @@ + - #2A2A2E + #faf8f5 + #221f1d + #a8623d + + #4d8c8478 + + + #1a1817 diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml index 7ba83a2..af6b578 100644 --- a/android/app/src/main/res/values/styles.xml +++ b/android/app/src/main/res/values/styles.xml @@ -1,9 +1,30 @@ - + diff --git a/src/account/AccountMenu.tsx b/src/account/AccountMenu.tsx index 272930e..be62ece 100644 --- a/src/account/AccountMenu.tsx +++ b/src/account/AccountMenu.tsx @@ -63,13 +63,18 @@ export function AccountMenu() { const makeStyles = (theme: Theme) => StyleSheet.create({ + // Neutral: an avatar is an identity marker, not an action, and it sits over + // the map where an accent fill would pull against the pins. Matches the + // search button opposite it — both are map chrome. avatarButton: { position: 'absolute', right: 16, width: 40, height: 40, borderRadius: 20, - backgroundColor: theme.accent, + backgroundColor: theme.surface, + borderWidth: 1, + borderColor: theme.lineControl, alignItems: 'center', justifyContent: 'center', shadowColor: '#000', @@ -80,13 +85,13 @@ const makeStyles = (theme: Theme) => }, avatarPressed: {opacity: 0.8}, avatarText: { - color: '#ffffff', + color: theme.ink, fontSize: 16, fontWeight: '600', }, sheetEmail: { fontSize: 12, - color: theme.textSecondary, + color: theme.muted, paddingHorizontal: 12, paddingBottom: 8, }, @@ -96,10 +101,10 @@ const makeStyles = (theme: Theme) => borderRadius: 8, }, sheetItemPressed: { - backgroundColor: theme.surfaceMuted, + backgroundColor: theme.lineFill, }, sheetItemText: { fontSize: 16, - color: theme.textPrimary, + color: theme.ink, }, }); diff --git a/src/auth/LoginScreen.tsx b/src/auth/LoginScreen.tsx index 5286711..8dfbe3c 100644 --- a/src/auth/LoginScreen.tsx +++ b/src/auth/LoginScreen.tsx @@ -7,12 +7,12 @@ import { Pressable, StyleSheet, Text, - TextInput, View, } from 'react-native'; import {LoginDocument} from '../graphql/__generated__/types'; import type {Theme} from '../theme/colors'; import {useTheme} from '../theme/useTheme'; +import {TextField} from '../ui/TextField'; import {clearSecurityWarning, useSecurityWarning} from './authClient'; import {tokenStore} from './tokenStore'; @@ -85,10 +85,8 @@ export function LoginScreen() { Sign in to Culpeos - - {loading ? ( - + ) : ( Log in )} @@ -138,7 +134,7 @@ export function LoginScreen() { const makeStyles = (theme: Theme) => StyleSheet.create({ - flex: {flex: 1, backgroundColor: theme.background}, + flex: {flex: 1, backgroundColor: theme.canvas}, container: { flex: 1, padding: 24, @@ -150,30 +146,24 @@ const makeStyles = (theme: Theme) => fontWeight: '600', marginBottom: 16, textAlign: 'center', - color: theme.textPrimary, - }, - input: { - borderWidth: 1, - borderColor: theme.border, - borderRadius: 8, - paddingHorizontal: 12, - paddingVertical: 12, - fontSize: 16, - color: theme.textPrimary, + color: theme.ink, }, error: { - color: theme.error, + color: theme.danger, fontSize: 14, textAlign: 'center', }, warning: { - color: theme.error, + color: theme.danger, fontSize: 13, textAlign: 'center', fontStyle: 'italic', }, + // The screen's one CTA, so this is where accent is spent. The pressed + // state is the fill at 85%, which is what the opacity below amounts to + // over the canvas. button: { - backgroundColor: theme.action, + backgroundColor: theme.accent, borderRadius: 8, paddingVertical: 14, alignItems: 'center', @@ -182,7 +172,7 @@ const makeStyles = (theme: Theme) => buttonDisabled: {opacity: 0.5}, buttonPressed: {opacity: 0.85}, buttonText: { - color: theme.onAction, + color: theme.onAccent, fontSize: 16, fontWeight: '600', }, diff --git a/src/map/ElementDetailScreen.tsx b/src/map/ElementDetailScreen.tsx index a683223..99a28e4 100644 --- a/src/map/ElementDetailScreen.tsx +++ b/src/map/ElementDetailScreen.tsx @@ -122,8 +122,11 @@ function ModalContents({ {element.location.address} ) : null} {element.completed ? ( + // Neutral, with a tick doing the work the green used to: status + // is carried by icon and copy, not hue. `danger` is the app's + // only status color, and this isn't one. - Completed + ✓ Completed ) : null} @@ -232,7 +235,7 @@ function ModalContents({ ) : ( - {loading ? : null} + {loading ? : null} )} @@ -273,19 +276,22 @@ const makeStyles = (theme: Theme) => StyleSheet.create({ screen: { flex: 1, - backgroundColor: theme.background, + backgroundColor: theme.canvas, }, container: { flex: 1, - backgroundColor: theme.background, + backgroundColor: theme.canvas, }, + // Canvas plus a bottom rule, not a filled slab: the screen measures one + // background value end to end. A full point rather than a hairline, which + // at low brightness or through a screen protector barely renders. header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingBottom: 12, - borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: theme.border, + borderBottomWidth: 1, + borderBottomColor: theme.line, }, closeButton: { width: 32, @@ -293,19 +299,19 @@ const makeStyles = (theme: Theme) => borderRadius: 16, alignItems: 'center', justifyContent: 'center', - backgroundColor: theme.surfaceMuted, + backgroundColor: theme.lineFill, marginRight: 12, }, closeIcon: { fontSize: 20, lineHeight: 22, - color: theme.textPrimary, + color: theme.ink, }, headerTitle: { flex: 1, fontSize: 16, fontWeight: '600', - color: theme.textPrimary, + color: theme.ink, }, editButton: { marginLeft: 12, @@ -313,7 +319,7 @@ const makeStyles = (theme: Theme) => }, editButtonText: { fontSize: 16, - color: theme.action, + color: theme.accentText, fontWeight: '600', }, scrollContent: { @@ -336,17 +342,19 @@ const makeStyles = (theme: Theme) => }, subtitle: { fontSize: 13, - color: theme.textSecondary, + color: theme.muted, }, completedTag: { alignSelf: 'flex-start', - backgroundColor: theme.successMuted, + backgroundColor: theme.lineFill, paddingHorizontal: 8, paddingVertical: 3, borderRadius: 10, }, completedTagText: { - color: theme.success, + // `ink`, not `muted`: muted on a lineFill sits at 4.04:1 in light mode, + // under the 4.5:1 bar for text this size. + color: theme.ink, fontSize: 11, fontWeight: '600', }, @@ -358,7 +366,7 @@ const makeStyles = (theme: Theme) => width: 200, height: 140, borderRadius: 10, - backgroundColor: theme.surfaceMuted, + backgroundColor: theme.lineFill, }, photoPressed: { opacity: 0.7, @@ -369,7 +377,7 @@ const makeStyles = (theme: Theme) => sectionTitle: { fontSize: 12, fontWeight: '700', - color: theme.textTertiary, + color: theme.muted, textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 6, @@ -377,12 +385,15 @@ const makeStyles = (theme: Theme) => body: { fontSize: 15, lineHeight: 21, - color: theme.textPrimary, + color: theme.ink, }, + // Prose links carry a standing underline, so they don't rely on hue alone. link: { fontSize: 15, lineHeight: 21, - color: theme.accent, + color: theme.accentText, + textDecorationLine: 'underline', + textDecorationColor: theme.accentText, }, bookingRows: { gap: 10, @@ -398,7 +409,7 @@ const makeStyles = (theme: Theme) => width: 84, fontSize: 13, lineHeight: 21, - color: theme.textSecondary, + color: theme.muted, }, bookingValue: { flex: 1, @@ -411,17 +422,18 @@ const makeStyles = (theme: Theme) => flexWrap: 'wrap', gap: 6, }, + // A category label, so neutral — the wording is the label. labelChip: { - backgroundColor: theme.accentMuted, + backgroundColor: theme.lineFill, paddingHorizontal: 10, paddingVertical: 5, borderRadius: 12, }, labelChipPressed: { - backgroundColor: theme.border, + backgroundColor: theme.lineFillStrong, }, labelChipText: { - color: theme.accent, + color: theme.ink, fontSize: 12, fontWeight: '500', }, diff --git a/src/map/ElementEditScreen.tsx b/src/map/ElementEditScreen.tsx index bc2ea42..a8bfec5 100644 --- a/src/map/ElementEditScreen.tsx +++ b/src/map/ElementEditScreen.tsx @@ -12,7 +12,6 @@ import { StyleSheet, Switch, Text, - TextInput, View, } from 'react-native'; import {useSafeAreaInsets} from 'react-native-safe-area-context'; @@ -30,7 +29,9 @@ import {photoImageSource} from '../photos/photoImageSource'; import {usePhotoUploader} from '../photos/photoUpload'; import type {Theme} from '../theme/colors'; import {useTheme} from '../theme/useTheme'; +import {emojiPickerTheme} from '../ui/emojiPickerTheme'; import {Sheet} from '../ui/Sheet'; +import {TextField} from '../ui/TextField'; type Props = NativeStackScreenProps; @@ -67,7 +68,7 @@ export function ElementEditScreen({route, navigation}: Props) { /> ) : ( - {loading ? : null} + {loading ? : null} )} @@ -91,6 +92,7 @@ function EditForm({ }) { const theme = useTheme(); const styles = useMemo(() => makeStyles(theme), [theme]); + const pickerTheme = useMemo(() => emojiPickerTheme(theme), [theme]); const safeAreaInsets = useSafeAreaInsets(); const [name, setName] = useState(element.name); const [uri, setUri] = useState(element.uri); @@ -272,7 +274,7 @@ function EditForm({ disabled={!canSave} style={styles.headerButton}> {saving ? ( - + ) : ( - - {uriError ? {uriError} : null} Completed + {/* + Android tints a switch from the Material theme's colorPrimary, not + from ours, so both states are set explicitly. On is a solid mid-tone + against a translucent off — the same fill-weight cue the map pins + use, which survives desaturation. Deliberately not accent: several + switched-on controls would out-shout the pins. + */} @@ -366,7 +376,7 @@ function EditForm({ onPress={onAddPhoto} style={styles.photoAdd}> {uploading ? ( - + ) : ( )} @@ -375,8 +385,8 @@ function EditForm({ - ) : null} - {deleting ? ( - + ) : ( Delete element )} @@ -479,6 +489,7 @@ function EditForm({ open={pickerOpen} onClose={() => setPickerOpen(false)} onEmojiSelected={emoji => setIcon(emoji.emoji)} + theme={pickerTheme} /> flex: {flex: 1}, screen: { flex: 1, - backgroundColor: theme.background, + backgroundColor: theme.canvas, }, loadingPane: { flex: 1, @@ -550,8 +561,10 @@ const makeStyles = (theme: Theme) => alignItems: 'center', paddingHorizontal: 16, paddingBottom: 12, - borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: theme.border, + // Canvas plus a rule, not a filled bar — the screen is one background + // value end to end. A full point, since a hairline barely renders. + borderBottomWidth: 1, + borderBottomColor: theme.line, }, headerButton: { minWidth: 56, @@ -559,17 +572,17 @@ const makeStyles = (theme: Theme) => }, headerButtonText: { fontSize: 16, - color: theme.textPrimary, + color: theme.ink, }, headerTitle: { flex: 1, fontSize: 16, fontWeight: '600', - color: theme.textPrimary, + color: theme.ink, textAlign: 'center', }, saveText: { - color: theme.action, + color: theme.accentText, fontWeight: '600', textAlign: 'right', }, @@ -586,25 +599,16 @@ const makeStyles = (theme: Theme) => fieldLabel: { fontSize: 12, fontWeight: '700', - color: theme.textTertiary, + color: theme.muted, textTransform: 'uppercase', letterSpacing: 0.5, }, - input: { - borderWidth: 1, - borderColor: theme.border, - borderRadius: 8, - paddingHorizontal: 12, - paddingVertical: 12, - fontSize: 16, - color: theme.textPrimary, - }, iconButton: { width: 64, height: 64, borderRadius: 8, borderWidth: 1, - borderColor: theme.border, + borderColor: theme.lineControl, alignItems: 'center', justifyContent: 'center', }, @@ -613,7 +617,7 @@ const makeStyles = (theme: Theme) => }, iconButtonPlaceholder: { fontSize: 28, - color: theme.textTertiary, + color: theme.muted, }, multiline: { minHeight: 120, @@ -631,7 +635,7 @@ const makeStyles = (theme: Theme) => width: 80, height: 80, borderRadius: 8, - backgroundColor: theme.surfaceMuted, + backgroundColor: theme.lineFill, }, photoRemove: { position: 'absolute', @@ -642,12 +646,12 @@ const makeStyles = (theme: Theme) => borderRadius: 11, // Inverts against the screen so the badge reads on any photo in either // appearance: a dark chip in light mode, a light chip in dark mode. - backgroundColor: theme.textPrimary, + backgroundColor: theme.ink, alignItems: 'center', justifyContent: 'center', }, photoRemoveIcon: { - color: theme.background, + color: theme.canvas, fontSize: 16, lineHeight: 18, fontWeight: '600', @@ -657,14 +661,14 @@ const makeStyles = (theme: Theme) => height: 80, borderRadius: 8, borderWidth: 1, - borderColor: theme.border, + borderColor: theme.lineControl, borderStyle: 'dashed', alignItems: 'center', justifyContent: 'center', }, photoAddIcon: { fontSize: 28, - color: theme.textTertiary, + color: theme.muted, }, switchRow: { flexDirection: 'row', @@ -673,7 +677,7 @@ const makeStyles = (theme: Theme) => }, tripsSelector: { borderWidth: 1, - borderColor: theme.border, + borderColor: theme.lineControl, borderRadius: 8, paddingHorizontal: 12, paddingVertical: 12, @@ -682,7 +686,7 @@ const makeStyles = (theme: Theme) => }, tripsPlaceholder: { fontSize: 16, - color: theme.textTertiary, + color: theme.muted, }, tripPills: { flexDirection: 'row', @@ -693,7 +697,7 @@ const makeStyles = (theme: Theme) => flexDirection: 'row', alignItems: 'center', maxWidth: '100%', - backgroundColor: theme.accentMuted, + backgroundColor: theme.lineFill, borderRadius: 16, paddingHorizontal: 12, paddingVertical: 6, @@ -707,18 +711,18 @@ const makeStyles = (theme: Theme) => flexShrink: 1, fontSize: 14, fontWeight: '600', - color: theme.accent, + color: theme.ink, }, sheetTitle: { fontSize: 16, fontWeight: '600', - color: theme.textPrimary, + color: theme.ink, paddingHorizontal: 12, paddingBottom: 8, }, sheetEmpty: { fontSize: 14, - color: theme.textSecondary, + color: theme.muted, paddingHorizontal: 12, paddingVertical: 12, }, @@ -733,7 +737,7 @@ const makeStyles = (theme: Theme) => borderRadius: 8, }, tripRowPressed: { - backgroundColor: theme.surfaceMuted, + backgroundColor: theme.lineFill, }, tripRowIcon: { fontSize: 18, @@ -743,8 +747,11 @@ const makeStyles = (theme: Theme) => tripRowName: { flex: 1, fontSize: 16, - color: theme.textPrimary, + color: theme.ink, }, + // A selected state, which is a sanctioned use of accent. The tick carries + // the meaning on its own, so the hue is reinforcement rather than the + // only signal. tripRowCheck: { fontSize: 18, fontWeight: '700', @@ -760,14 +767,14 @@ const makeStyles = (theme: Theme) => flexDirection: 'row', alignItems: 'center', gap: 4, - backgroundColor: theme.accentMuted, + backgroundColor: theme.lineFill, paddingLeft: 10, paddingRight: 6, paddingVertical: 5, borderRadius: 12, }, labelChipText: { - color: theme.accent, + color: theme.ink, fontSize: 12, fontWeight: '500', }, @@ -778,7 +785,7 @@ const makeStyles = (theme: Theme) => justifyContent: 'center', }, labelChipRemoveIcon: { - color: theme.accent, + color: theme.ink, fontSize: 16, lineHeight: 18, fontWeight: '600', @@ -788,12 +795,14 @@ const makeStyles = (theme: Theme) => alignItems: 'center', gap: 8, }, + // A secondary action: a quiet fill with an ink label, rather than a solid + // one. A white label on `muted` is only 3.48:1, and flipping the label + // doesn't help — `muted` sits mid-scale in both appearances. labelAdd: { paddingHorizontal: 16, paddingVertical: 12, borderRadius: 8, - borderWidth: 1, - borderColor: theme.border, + backgroundColor: theme.lineFill, alignItems: 'center', justifyContent: 'center', }, @@ -801,12 +810,12 @@ const makeStyles = (theme: Theme) => opacity: 0.4, }, labelAddText: { - color: theme.action, + color: theme.ink, fontSize: 16, fontWeight: '600', }, error: { - color: theme.error, + color: theme.danger, fontSize: 14, }, deleteButton: { @@ -814,7 +823,7 @@ const makeStyles = (theme: Theme) => paddingVertical: 16, borderRadius: 8, borderWidth: 1, - borderColor: theme.error, + borderColor: theme.danger, alignItems: 'center', justifyContent: 'center', }, @@ -822,7 +831,7 @@ const makeStyles = (theme: Theme) => opacity: 0.4, }, deleteButtonText: { - color: theme.error, + color: theme.danger, fontSize: 16, fontWeight: '600', }, diff --git a/src/map/ElementPreviewCard.tsx b/src/map/ElementPreviewCard.tsx index 2e101b9..59deee2 100644 --- a/src/map/ElementPreviewCard.tsx +++ b/src/map/ElementPreviewCard.tsx @@ -67,7 +67,9 @@ const makeStyles = (theme: Theme) => position: 'absolute', left: 12, right: 12, - backgroundColor: theme.card, + backgroundColor: theme.surface, + borderWidth: 1, + borderColor: theme.line, borderRadius: 12, padding: 12, flexDirection: 'row', @@ -92,16 +94,16 @@ const makeStyles = (theme: Theme) => title: { fontSize: 16, fontWeight: '600', - color: theme.textPrimary, + color: theme.ink, }, subtitle: { fontSize: 12, - color: theme.textSecondary, + color: theme.muted, marginTop: 2, }, description: { fontSize: 13, - color: theme.textSecondary, + color: theme.muted, marginTop: 4, }, closeButton: { @@ -110,11 +112,11 @@ const makeStyles = (theme: Theme) => borderRadius: 16, alignItems: 'center', justifyContent: 'center', - backgroundColor: theme.surfaceMuted, + backgroundColor: theme.lineFill, }, closeIcon: { fontSize: 20, lineHeight: 22, - color: theme.textSecondary, + color: theme.ink, }, }); diff --git a/src/map/FilterChips.tsx b/src/map/FilterChips.tsx index c917cb4..03917a7 100644 --- a/src/map/FilterChips.tsx +++ b/src/map/FilterChips.tsx @@ -104,11 +104,17 @@ const makeStyles = (theme: Theme) => justifyContent: 'center', gap: 8, }, + // Neutral, deliberately. These sit over the map, and an accent-filled chrome + // element competes with the accent pins it's filtering — content should win. + // A pill is only on screen while its filter is active, so its presence is + // already the "on" state; it doesn't need a hue to say so as well. pill: { flexDirection: 'row', alignItems: 'center', maxWidth: '100%', - backgroundColor: theme.card, + backgroundColor: theme.surface, + borderWidth: 1, + borderColor: theme.line, borderRadius: 20, paddingLeft: 14, paddingRight: 6, @@ -128,7 +134,7 @@ const makeStyles = (theme: Theme) => flexShrink: 1, fontSize: 14, fontWeight: '600', - color: theme.accent, + color: theme.ink, }, clearButton: { width: 24, @@ -137,10 +143,10 @@ const makeStyles = (theme: Theme) => alignItems: 'center', justifyContent: 'center', marginLeft: 8, - backgroundColor: theme.accentMuted, + backgroundColor: theme.lineFill, }, clearIcon: { - color: theme.accent, + color: theme.ink, fontSize: 16, lineHeight: 18, }, diff --git a/src/map/MapScreen.tsx b/src/map/MapScreen.tsx index 9e4b352..db9c17c 100644 --- a/src/map/MapScreen.tsx +++ b/src/map/MapScreen.tsx @@ -46,6 +46,7 @@ import { type SearchPlace, type SearchTrip, } from './SearchOverlay'; +import {UserLocationPuck} from './UserLocationPuck'; import {usePinImages} from './usePinImages'; import {type Viewport, viewportStore} from './viewportStore'; @@ -524,10 +525,20 @@ export function MapScreen() { // real, as a disc over the top-right corner. We never rotate the map, // so turn it off explicitly. compass={false} + // The attribution button is the map SDK's own chrome and ships a light + // theme; left untinted it's one of the brightest things on a dark + // basemap. It stays visible — OpenFreeMap's data requires the credit — + // but as ink rather than the SDK's default. + tintColor={theme.ink} onPress={() => setSelectedElementId(null)} onRegionDidChange={onRegionDidChange}> - + + + StyleSheet.create({ container: { flex: 1, - backgroundColor: theme.background, + backgroundColor: theme.canvas, }, map: { flex: 1, }, + // Map chrome, not map content: neutral, so it doesn't compete with the + // pins it sits over. The ring is `lineControl` because it's the button's + // only edge — `surface` is 1.22:1 against the dark basemap and the drop + // shadow is black on near-black, so neither one bounds it. recenterButton: { position: 'absolute', right: 16, width: 48, height: 48, borderRadius: 24, - backgroundColor: theme.mapButtonBg, + backgroundColor: theme.surface, + borderWidth: 1, + borderColor: theme.lineControl, alignItems: 'center', justifyContent: 'center', shadowColor: '#000', @@ -606,6 +623,6 @@ const makeStyles = (theme: Theme) => recenterIcon: { fontSize: 24, lineHeight: 28, - color: theme.mapButtonIcon, + color: theme.ink, }, }); diff --git a/src/map/PinIcon.tsx b/src/map/PinIcon.tsx index 1619726..a104f56 100644 --- a/src/map/PinIcon.tsx +++ b/src/map/PinIcon.tsx @@ -7,8 +7,12 @@ import type {Theme} from '../theme/colors'; // Web draws it at 30px; we size up for touch. Exported for `pinHitTest`, which // measures taps against the shape these describe. export const PIN_SIZE = 36; -// Selected pins only grow (and draw on top); the fill stays red, as on web. +// Selected pins only grow (and draw on top); the fill stays accent, as on web. export const PIN_SELECTED_SCALE = 1.25; +// Border weight. Web went 1px → 2px because a hairline smears or vanishes on an +// e-ink refresh; the mobile equivalent is a hairline at low brightness or +// through a screen protector. Scaled with the pin so it holds at capture size. +const PIN_BORDER = 2; // Rotating the square makes it occupy a box of side × √2, with the square // corner — the teardrop's tip — landing at the bottom centre of that box. export const PIN_DIAGONAL = PIN_SIZE * Math.SQRT2; @@ -64,9 +68,14 @@ const makeStyles = (theme: Theme, scale: number) => borderTopRightRadius: (PIN_SIZE / 2) * scale, borderBottomRightRadius: (PIN_SIZE / 2) * scale, borderBottomLeftRadius: 0, - backgroundColor: theme.pin, - borderWidth: scale, - borderColor: theme.pinBorder, + // A saved location is a solid accent fill with an ink border — one of the + // four sanctioned uses of accent, and the heaviest mark on the map. The + // weight, not the hue, is what separates it from everything else: a + // distinction by hue alone disappears under a color vision deficiency or + // on a grayscale screen. + backgroundColor: theme.accent, + borderWidth: PIN_BORDER * scale, + borderColor: theme.ink, alignItems: 'center', justifyContent: 'center', boxShadow: [ diff --git a/src/map/SearchOverlay.tsx b/src/map/SearchOverlay.tsx index 87b4588..5194bb2 100644 --- a/src/map/SearchOverlay.tsx +++ b/src/map/SearchOverlay.tsx @@ -116,7 +116,14 @@ export function SearchOverlay({ onChangeText={setText} onSubmitEditing={submit} placeholder="Search elements, trips, places" - placeholderTextColor={theme.textTertiary} + // Not a `TextField`: this one has no border of its own — the pill + // around it is the field — so there's no rest/focus border to move, + // and it's autofocused the moment it appears. It still needs the + // caret and selection colors Android would otherwise theme itself. + placeholderTextColor={theme.muted} + cursorColor={theme.accent} + selectionColor={theme.accentRing} + selectionHandleColor={theme.accent} returnKeyType="search" style={styles.input} /> @@ -136,7 +143,7 @@ export function SearchOverlay({ {loading ? ( - + ) : hasResults ? ( [styles.row, pressed && styles.rowPressed]}> - + {glyph} @@ -278,13 +286,19 @@ function ResultRow({ const makeStyles = (theme: Theme) => StyleSheet.create({ + // Chrome floating over the map, so: a surface fill plus a ring. The ring is + // `lineControl`, not `line` — over the basemap it's the only thing bounding + // the button, since `surface` is 1.22:1 against the dark basemap and a black + // drop shadow is invisible on it. iconButton: { position: 'absolute', left: 16, width: 40, height: 40, borderRadius: 20, - backgroundColor: theme.card, + backgroundColor: theme.surface, + borderWidth: 1, + borderColor: theme.lineControl, alignItems: 'center', justifyContent: 'center', shadowColor: '#000', @@ -297,7 +311,7 @@ const makeStyles = (theme: Theme) => searchGlyph: { fontSize: 22, lineHeight: 26, - color: theme.accent, + color: theme.ink, }, expandedRoot: { position: 'absolute', @@ -308,7 +322,9 @@ const makeStyles = (theme: Theme) => fieldRow: { flexDirection: 'row', alignItems: 'center', - backgroundColor: theme.card, + backgroundColor: theme.surface, + borderWidth: 1, + borderColor: theme.lineControl, borderRadius: 22, paddingLeft: 14, paddingRight: 6, @@ -322,13 +338,13 @@ const makeStyles = (theme: Theme) => fieldGlyph: { fontSize: 20, lineHeight: 24, - color: theme.textTertiary, + color: theme.muted, marginRight: 8, }, input: { flex: 1, fontSize: 15, - color: theme.textPrimary, + color: theme.ink, padding: 0, }, fieldClose: { @@ -337,16 +353,18 @@ const makeStyles = (theme: Theme) => borderRadius: 16, alignItems: 'center', justifyContent: 'center', - backgroundColor: theme.surfaceMuted, + backgroundColor: theme.lineFill, }, fieldCloseIcon: { fontSize: 18, lineHeight: 20, - color: theme.textSecondary, + color: theme.ink, }, resultsCard: { marginTop: 8, - backgroundColor: theme.card, + backgroundColor: theme.surface, + borderWidth: 1, + borderColor: theme.line, borderRadius: 12, shadowColor: '#000', shadowOpacity: 0.15, @@ -365,7 +383,7 @@ const makeStyles = (theme: Theme) => }, emptyText: { fontSize: 14, - color: theme.textTertiary, + color: theme.muted, }, section: { paddingTop: 10, @@ -374,7 +392,7 @@ const makeStyles = (theme: Theme) => sectionTitle: { fontSize: 11, fontWeight: '700', - color: theme.textTertiary, + color: theme.muted, textTransform: 'uppercase', letterSpacing: 0.5, paddingHorizontal: 14, @@ -387,7 +405,7 @@ const makeStyles = (theme: Theme) => paddingVertical: 8, }, rowPressed: { - backgroundColor: theme.surfaceMuted, + backgroundColor: theme.lineFill, }, glyphWrap: { width: 36, @@ -396,15 +414,7 @@ const makeStyles = (theme: Theme) => alignItems: 'center', justifyContent: 'center', marginRight: 12, - }, - elementGlyphWrap: { - backgroundColor: theme.accent, - }, - tripGlyphWrap: { - backgroundColor: theme.trip, - }, - placeGlyphWrap: { - backgroundColor: theme.surfaceMuted, + backgroundColor: theme.lineFill, }, glyph: { fontSize: 18, @@ -418,21 +428,23 @@ const makeStyles = (theme: Theme) => rowTitle: { fontSize: 15, fontWeight: '600', - color: theme.textPrimary, + color: theme.ink, }, rowSubtitle: { fontSize: 12, - color: theme.textSecondary, + color: theme.muted, marginTop: 2, }, + // Neutral, like every other badge: it already says "Trip", so the hue was + // doing nothing a reader relied on. badge: { - backgroundColor: theme.tripMuted, + backgroundColor: theme.lineFill, paddingHorizontal: 8, paddingVertical: 3, borderRadius: 10, }, badgeText: { - color: theme.trip, + color: theme.ink, fontSize: 11, fontWeight: '600', }, diff --git a/src/map/UserLocationPuck.tsx b/src/map/UserLocationPuck.tsx new file mode 100644 index 0000000..0d2797d --- /dev/null +++ b/src/map/UserLocationPuck.tsx @@ -0,0 +1,81 @@ +import {Layer} from '@maplibre/maplibre-react-native'; +import type {Theme} from '../theme/colors'; + +// The id `` gives the annotation's source. Passed to each layer +// so these draw at the user's position, exactly as the SDK's own puck does. +const SOURCE_ID = 'mlrn-user-location'; + +/** + * The user's location, in our tokens. + * + * MapLibre's own puck hardcodes `#33B5E5` in the layer paint — there's no color + * prop to reassign, so the only way to restyle it is to replace it, which is + * what ``'s `children` are for. Left alone it puts a saturated + * blue dot on the map beside the rust pins, and nothing in our source would + * have turned it up. + * + * Neutral rather than accent: the pins are the content, and accent is spent on + * them. This is the same white-ring-around-a-solid-core construction as the + * SDK's, so it still reads as "you are here" — the core just inverts with the + * appearance instead of being blue in both. + */ +export function UserLocationPuck({ + theme, + accuracy, +}: { + theme: Theme; + /** Accuracy radius in meters, or undefined to skip the accuracy circle. */ + accuracy?: number; +}) { + return ( + <> + {typeof accuracy === 'number' ? ( + + ) : null} + + + + ); +} + +const RING_RADIUS = 9; +const CORE_RADIUS = 6; diff --git a/src/map/usePinImages.tsx b/src/map/usePinImages.tsx index b9aef41..0e00daa 100644 --- a/src/map/usePinImages.tsx +++ b/src/map/usePinImages.tsx @@ -48,10 +48,9 @@ export function usePinImages( const hosts = useRef(new Map()); // A rasterised pin bakes in the colours it was drawn with, so those are part - // of its name: should the palette ever stop being shared between light and - // dark, the new pins get new names and are captured afresh rather than - // quietly keeping the old bitmaps. - const variant = `${theme.pin}|${theme.pinBorder}`; + // of its name: switching appearance changes both, and the new pins get new + // names and are captured afresh rather than quietly keeping the old bitmaps. + const variant = `${theme.accent}|${theme.ink}`; const nameFor = useCallback( (icon: string | null | undefined) => `pin:${variant}:${icon ?? ''}`, [variant], diff --git a/src/photos/PhotoViewer.tsx b/src/photos/PhotoViewer.tsx index 414b432..f813c20 100644 --- a/src/photos/PhotoViewer.tsx +++ b/src/photos/PhotoViewer.tsx @@ -383,9 +383,14 @@ export function PhotoViewer({uri, description, onClose}: Props) { ); } -// Not themed: a photo viewer is black in both appearances, so the image sets -// the mood and its edges are unambiguous. The chrome is therefore fixed to -// translucent white rather than reading from the theme. +// Deliberately literal, and the only place in the app that is. Text and chrome +// over photography can't be checked against a token — the backdrop changes +// pixel to pixel — and shouldn't follow the color scheme either: a photo viewer +// is black in both appearances so the image sets the mood and its edges stay +// unambiguous. Hence fixed white on fixed black rather than ink on canvas. +// +// Leave these alone in any palette sweep. An accent here would do no work: over +// a photo it can't read as interactive, it just reads as dim. const styles = StyleSheet.create({ overlay: { ...StyleSheet.absoluteFill, diff --git a/src/share/ImportShareScreen.tsx b/src/share/ImportShareScreen.tsx index 081e716..27841c3 100644 --- a/src/share/ImportShareScreen.tsx +++ b/src/share/ImportShareScreen.tsx @@ -90,7 +90,7 @@ export function ImportShareScreen({route, navigation}: Props) { ) : ( <> - + Saving shared link… {content} @@ -106,7 +106,7 @@ const makeStyles = (theme: Theme) => StyleSheet.create({ screen: { flex: 1, - backgroundColor: theme.background, + backgroundColor: theme.canvas, }, closeButton: { position: 'absolute', @@ -116,13 +116,13 @@ const makeStyles = (theme: Theme) => borderRadius: 16, alignItems: 'center', justifyContent: 'center', - backgroundColor: theme.surfaceMuted, + backgroundColor: theme.lineFill, zIndex: 1, }, closeIcon: { fontSize: 20, lineHeight: 22, - color: theme.textPrimary, + color: theme.ink, }, center: { flex: 1, @@ -134,33 +134,34 @@ const makeStyles = (theme: Theme) => title: { fontSize: 18, fontWeight: '600', - color: theme.textPrimary, + color: theme.ink, textAlign: 'center', }, subtitle: { fontSize: 14, lineHeight: 20, - color: theme.textSecondary, + color: theme.muted, textAlign: 'center', }, + // The screen's one CTA — the other action is a plain text Cancel. retryButton: { marginTop: 8, paddingHorizontal: 24, paddingVertical: 12, borderRadius: 24, - backgroundColor: theme.action, + backgroundColor: theme.accent, }, retryButtonPressed: { opacity: 0.85, }, retryText: { - color: theme.onAction, + color: theme.onAccent, fontSize: 15, fontWeight: '600', }, cancelText: { marginTop: 4, - color: theme.textSecondary, + color: theme.muted, fontSize: 14, }, }); diff --git a/src/theme/colors.ts b/src/theme/colors.ts index 63d0621..d4d0ed0 100644 --- a/src/theme/colors.ts +++ b/src/theme/colors.ts @@ -1,9 +1,17 @@ /** - * Semantic color tokens for light and dark mode. + * The culpeo palette: ten semantic roles, declared once per appearance. + * + * The app is a warm neutral shell. The only saturated things on screen are + * photographs, map pins, and the primary action — everything else is ink, + * muted, line, canvas, surface. Matches the web app's token set so the two + * can't drift; see the style guide for the reasoning behind each value. * * Components consume these through {@link useTheme} rather than reading the - * device color scheme directly, so the same `Theme` shape drives both - * appearances and there is a single place to tune either palette. + * device color scheme directly, so there is exactly one declaration per role + * and no `isDark ? … : …` at any use site. + * + * Contrast ratios below are measured against that appearance's own canvas. AA + * is 4.5:1 for body text and 3:1 for borders and other non-text UI. */ export type ColorScheme = 'light' | 'dark'; @@ -11,131 +19,160 @@ export type ColorScheme = 'light' | 'dark'; export type Theme = { scheme: ColorScheme; - // Surfaces, from furthest-back to nearest. - background: string; // screen background behind everything - card: string; // cards, sheets, floating overlays - surfaceMuted: string; // subtle fills: inputs, close buttons, pills - - // Text, in descending emphasis. - textPrimary: string; - textSecondary: string; - textTertiary: string; // placeholders, disabled, faint captions - - // Lines. - border: string; - - // Brand accent (blue) and its tints. - accent: string; // primary actions, links, icons - accentStrong: string; // pressed/selected accent - accentMuted: string; // accent-tinted background (badge/glyph wrap) - - // Secondary "action" color (teal): solid buttons and inline actions - // (login button, Edit/Save text). - action: string; - onAction: string; // text/icon drawn on top of an `action` fill - - // Secondary "trip" accent (purple) and its tint. - trip: string; - tripMuted: string; - - // Status colors and their tints. - error: string; - success: string; - successMuted: string; - - // Scrim behind modal sheets. + /** Page background. Every full-screen region is canvas, never surface. */ + canvas: string; + /** Raised above the page only: sheets, popovers, menus, floating cards. */ + surface: string; + + /** Body text, headings, icons. */ + ink: string; + /** Secondary text, placeholders, disabled. */ + muted: string; + /** + * Dividers and rules — decoration, so deliberately quiet. Nothing relies on + * seeing it, which is why it's allowed to sit under the 3:1 non-text bar. + */ + line: string; + /** + * The boundary of a control: an input's border, a tappable button's edge. + * `line`'s stronger sibling, the way `accentText` is `accent`'s — same role, + * but this one is load-bearing, so it has to clear 3:1. + * + * Reach for this whenever the border is what tells you a control is there. A + * text input is `surface` on `canvas`, and those two are 1.06:1 apart in light + * and 1.10:1 in dark — the fill does no work in either scheme, so the border + * is the only thing saying "field". At plain `line` in dark that came to + * 1.32:1: a field you cannot find at night. + */ + lineControl: string; + + /** + * Culpeo rust. Sanctioned in exactly four places: the primary button, map + * pins, focus rings and focused input borders, and active/selected states. + * Everything else — badges, chips, category labels, counts, avatars — is + * neutral, because each already carries an icon or its own wording. + */ + accent: string; + /** Rust for links and other small text; darker so it still clears AA. */ + accentText: string; + /** The label sitting *on* an accent fill. Flips dark in dark mode. */ + onAccent: string; + /** Validation text and borders. The only status color. */ + danger: string; + + // Alpha variants of the tokens above. Not new roles — the same token at an + // opacity, kept here so use sites never compose one by hand. + /** `line` at 15%: quiet secondary-button fill, subtle panel fill. */ + lineFill: string; + /** `line` at 30%: pressed state of the above, and a switch's "off" track. */ + lineFillStrong: string; + /** `accent` at 20%: focus ring. */ + accentRing: string; + + /** Scrim behind modal sheets. */ scrim: string; - // Map. + /** Basemap style; see the note above MAP_STYLE_LIGHT. */ mapStyleUrl: string; - pin: string; // teardrop pin fill - pinBorder: string; // hairline outline around a pin, for contrast - mapButtonBg: string; // recenter button background - mapButtonIcon: string; }; -const MAP_STYLE_LIGHT = 'https://tiles.openfreemap.org/styles/liberty'; +// A dataviz basemap, not a streets one. The map is the largest colored surface +// in the app and a streets style fights the palette: `liberty` declares 44 +// distinct colors at 0.178 mean saturation, including green parks and blue +// water that argue directly with a rust accent. `positron` is 29 colors at +// 0.026 — built as a substrate for overlaid data, which is exactly the +// arrangement here: the pins are the content, the map is context. It keeps +// faint land/water/road separation, so it still reads as a map rather than +// going flat the way a toner style would. `dark` is the same family. +const MAP_STYLE_LIGHT = 'https://tiles.openfreemap.org/styles/positron'; const MAP_STYLE_DARK = 'https://tiles.openfreemap.org/styles/dark'; -// Pins are the same Google-Maps red in both appearances, matching the web app -// (`.maplibre-marker-pin` in culpeos assets/css/app.css) so the same place -// reads the same wherever it's opened. The outline is a translucent black -// hairline rather than a theme color, which works over either basemap. -const PIN_RED = '#ea4335'; -const PIN_OUTLINE = 'rgba(0,0,0,0.2)'; - -export const lightTheme: Theme = { - scheme: 'light', +/** + * A token at partial opacity, as an `rgba()` string. + * + * Kept as alpha rather than pre-composited against the canvas because these + * fills sit on both canvas and surface, and an alpha composites correctly + * against whichever is behind it. + */ +function alpha(hex: string, a: number): string { + const [r, g, b] = [1, 3, 5].map(i => + Number.parseInt(hex.slice(i, i + 2), 16), + ); + return `rgba(${r},${g},${b},${a})`; +} - background: '#ffffff', - card: '#ffffff', - surfaceMuted: '#f1f1f1', +const LIGHT_LINE = '#8c8478'; +const DARK_LINE = '#3a3532'; +const LIGHT_ACCENT = '#a8623d'; +const DARK_ACCENT = '#c47a52'; - textPrimary: '#111111', - textSecondary: '#666666', - textTertiary: '#999999', +// One value for both appearances, because one value clears the bar in both: +// 4.60:1 / 4.88:1 on the light canvas and surface, 3.63:1 / 3.29:1 on the dark +// pair. It sits below dark `muted` (5.70:1), so it still reads as a border +// rather than as text. A second declaration would only be two things to keep in +// step for no gain. +const LINE_CONTROL = '#7a6f69'; - border: '#dddddd', +export const lightTheme: Theme = { + scheme: 'light', - accent: '#1d6fe0', - accentStrong: '#0b4ea2', - accentMuted: '#eef3fb', + canvas: '#faf8f5', + surface: '#ffffff', - action: '#0a7ea4', - onAction: '#ffffff', + ink: '#221f1d', // 15.46:1 AAA + muted: '#776e63', // 4.72:1 AA + line: LIGHT_LINE, // 3.48:1 — decorative, no bar to clear + lineControl: LINE_CONTROL, // 4.60:1 on canvas, 4.88:1 on surface - trip: '#7a3ff2', - tripMuted: '#efe9fd', + accent: LIGHT_ACCENT, // 4.42:1 as a fill + accentText: '#9d5a37', // 5.01:1 AA + onAccent: '#ffffff', // 4.69:1 on accent + danger: '#a8322d', // 6.27:1 AA - error: '#cc0000', - success: '#1e8e3e', - successMuted: '#e6f4ea', + lineFill: alpha(LIGHT_LINE, 0.15), + lineFillStrong: alpha(LIGHT_LINE, 0.3), + accentRing: alpha(LIGHT_ACCENT, 0.2), scrim: 'rgba(0,0,0,0.35)', mapStyleUrl: MAP_STYLE_LIGHT, - pin: PIN_RED, - pinBorder: PIN_OUTLINE, - mapButtonBg: '#ffffff', - mapButtonIcon: '#1d6fe0', }; export const darkTheme: Theme = { scheme: 'dark', - background: '#121212', - card: '#1e1e1e', - surfaceMuted: '#2a2a2a', - - textPrimary: '#f2f2f2', - textSecondary: '#b0b0b0', - textTertiary: '#7e7e7e', - - border: '#3a3a3a', + canvas: '#1a1817', + surface: '#232120', - accent: '#4f93f0', - accentStrong: '#7badf5', - accentMuted: '#1b2a44', + ink: '#ebe7e2', // 14.37:1 AAA + muted: '#9a9186', // 5.70:1 AA + // 1.46:1 on the dark canvas. Fine for a rule between two things, which is all + // this is for — anything whose border has to be *seen* uses lineControl. + line: DARK_LINE, + lineControl: LINE_CONTROL, // 3.63:1 on canvas, 3.29:1 on surface - action: '#3aa9cf', - onAction: '#ffffff', + // Accent lightens in dark mode, so a white label on it drops to 3.36:1 — + // hence onAccent flipping dark rather than staying a literal white. + accent: DARK_ACCENT, // 5.27:1 as a fill + accentText: DARK_ACCENT, // 5.27:1 AA — same value as accent here + onAccent: '#1a1817', // 5.27:1 on accent + danger: '#dd8577', // 6.48:1 AA, tuned to read as *text* on a dark canvas - trip: '#a988f7', - tripMuted: '#2a2147', - - error: '#ff6b6b', - success: '#5bd07f', - successMuted: '#16331f', + lineFill: alpha(DARK_LINE, 0.15), + lineFillStrong: alpha(DARK_LINE, 0.3), + accentRing: alpha(DARK_ACCENT, 0.2), scrim: 'rgba(0,0,0,0.6)', mapStyleUrl: MAP_STYLE_DARK, - pin: PIN_RED, - pinBorder: PIN_OUTLINE, - mapButtonBg: '#1e1e1e', - mapButtonIcon: '#4f93f0', }; +// There is no `dangerFill` because nothing in the app fills with danger yet — +// the one destructive control is an outlined button using `danger` as border +// and text. Should a filled destructive button arrive, it needs its own token: +// dark `danger` is tuned to read as text on a dark canvas, which leaves it far +// too light under a white button label (2.73:1). + export function themeForScheme(scheme: string | null | undefined): Theme { return scheme === 'dark' ? darkTheme : lightTheme; } diff --git a/src/theme/navigationTheme.ts b/src/theme/navigationTheme.ts new file mode 100644 index 0000000..60d0f80 --- /dev/null +++ b/src/theme/navigationTheme.ts @@ -0,0 +1,41 @@ +import { + DarkTheme, + DefaultTheme, + type Theme as NavigationTheme, +} from '@react-navigation/native'; +import type {Theme} from './colors'; +import {useTheme} from './useTheme'; + +/** + * React Navigation's theme, built from our tokens. + * + * Handing it `DefaultTheme`/`DarkTheme` instead would leave a second palette + * running alongside ours — its blue `primary`, its cool `border`, and a + * `background` (`#f2f2f2` light, pure black dark) that differs from our canvas, + * which shows as a flash of the wrong color in the gap during a push + * transition. There is one declaration per role, and this is where the + * navigator reads it. + * + * Only `background` is really load-bearing today — headers are hidden and each + * screen draws its own chrome — but the rest are mapped so nothing new picks up + * a default. + */ +export function navigationThemeFor(theme: Theme): NavigationTheme { + const base = theme.scheme === 'dark' ? DarkTheme : DefaultTheme; + return { + ...base, + dark: theme.scheme === 'dark', + colors: { + primary: theme.accent, + background: theme.canvas, + card: theme.canvas, + text: theme.ink, + border: theme.line, + notification: theme.accent, + }, + }; +} + +export function useNavigationTheme(): NavigationTheme { + return navigationThemeFor(useTheme()); +} diff --git a/src/ui/Sheet.tsx b/src/ui/Sheet.tsx index 9005418..e50ac94 100644 --- a/src/ui/Sheet.tsx +++ b/src/ui/Sheet.tsx @@ -102,8 +102,12 @@ const makeStyles = (theme: Theme) => right: 0, bottom: 0, }, + // Genuinely raised, so genuinely a surface: rounded, over a scrim, with a + // `line` ring to hold its top edge in dark mode where the shadow can't. card: { - backgroundColor: theme.card, + backgroundColor: theme.surface, + borderTopWidth: 1, + borderTopColor: theme.line, borderTopLeftRadius: 16, borderTopRightRadius: 16, paddingTop: 20, diff --git a/src/ui/TextField.tsx b/src/ui/TextField.tsx new file mode 100644 index 0000000..be37d15 --- /dev/null +++ b/src/ui/TextField.tsx @@ -0,0 +1,99 @@ +import {useMemo, useState} from 'react'; +import { + type StyleProp, + StyleSheet, + TextInput, + type TextInputProps, + View, + type ViewStyle, +} from 'react-native'; +import type {Theme} from '../theme/colors'; +import {useTheme} from '../theme/useTheme'; + +type Props = TextInputProps & { + /** Draws the error border. The message itself is the caller's to render. */ + invalid?: boolean; + /** Layout for the wrapper, e.g. `flex: 1` inside a row. */ + containerStyle?: StyleProp; +}; + +/** + * The app's bordered text input. + * + * Exists so focus is visible. Rest and focus were previously the same neutral + * border, which meant focusing a field produced no visible change at all — + * fine-looking code, and a WCAG 2.4.7 failure. Focus now moves the border to + * `accent` and adds a ring, which is a sanctioned accent use. + * + * It also owns the handful of colors Android would otherwise supply from its + * own theme rather than ours: the caret, the selection highlight, the selection + * handles, and the placeholder. + */ +export function TextField({invalid, containerStyle, style, ...props}: Props) { + const theme = useTheme(); + const styles = useMemo(() => makeStyles(theme), [theme]); + const [focused, setFocused] = useState(false); + + return ( + + {/* Behind the input and outside its bounds, so the ring costs no layout + and doesn't shift the field when focus arrives. A shadow wouldn't do: + it's invisible over a dark canvas. */} + {focused ? : null} + { + setFocused(true); + props.onFocus?.(event); + }} + onBlur={event => { + setFocused(false); + props.onBlur?.(event); + }} + placeholderTextColor={theme.muted} + cursorColor={theme.accent} + selectionColor={theme.accentRing} + selectionHandleColor={theme.accent} + style={[ + styles.input, + focused && styles.inputFocused, + invalid && styles.inputInvalid, + style, + ]} + /> + + ); +} + +/** Ring thickness, and the input's corner radius it has to sit outside of. */ +const RING = 4; +const RADIUS = 8; + +const makeStyles = (theme: Theme) => + StyleSheet.create({ + ring: { + position: 'absolute', + top: -RING, + left: -RING, + right: -RING, + bottom: -RING, + borderRadius: RADIUS + RING, + backgroundColor: theme.accentRing, + }, + input: { + backgroundColor: theme.surface, + borderWidth: 1, + borderColor: theme.lineControl, + borderRadius: RADIUS, + paddingHorizontal: 12, + paddingVertical: 12, + fontSize: 16, + color: theme.ink, + }, + inputFocused: { + borderColor: theme.accent, + }, + inputInvalid: { + borderColor: theme.danger, + }, + }); diff --git a/src/ui/emojiPickerTheme.ts b/src/ui/emojiPickerTheme.ts new file mode 100644 index 0000000..dc0ef02 --- /dev/null +++ b/src/ui/emojiPickerTheme.ts @@ -0,0 +1,45 @@ +import type {Theme} from '../theme/colors'; + +/** + * The emoji picker's palette, in our tokens. + * + * `rn-emoji-keyboard` ships a hardcoded light theme — a white container and + * `#005b96` for the active category and pressed buttons. It has no notion of a + * color scheme, so in dark mode it opens as a white sheet with a blue highlight + * over the app's dark canvas, and nothing in our own source would have shown + * that. Its `theme` prop is the whole fix. + * + * The emoji themselves stay full color, of course: imagery carrying the color + * is the point of this palette, not an exception to it. + */ +export function emojiPickerTheme(theme: Theme) { + return { + backdrop: theme.scrim, + knob: theme.line, + container: theme.surface, + header: theme.ink, + skinTonesContainer: theme.lineFill, + category: { + icon: theme.muted, + // Selecting a category is an active state, which is a sanctioned use. + iconActive: theme.onAccent, + container: theme.lineFill, + containerActive: theme.accent, + }, + search: { + background: theme.lineFill, + text: theme.ink, + placeholder: theme.muted, + icon: theme.muted, + }, + customButton: { + icon: theme.ink, + iconPressed: theme.ink, + background: theme.lineFill, + backgroundPressed: theme.lineFillStrong, + }, + emoji: { + selected: theme.lineFillStrong, + }, + }; +}