From 0a176ab51c8f2b5e32a10f02c65a2caed1b267de Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Thu, 15 Oct 2026 14:49:37 +0200
Subject: [PATCH] feat: draw buttons, switches, selects, inputs and badges like
the webmail
---
src/components/Badge.tsx | 28 +--
src/components/Button.tsx | 70 +++++---
src/components/Input.tsx | 25 ++-
src/components/ToggleSwitch.tsx | 18 +-
src/components/settings/DownloadsSettings.tsx | 2 +-
src/components/settings/KeywordSettings.tsx | 1 +
src/components/settings/UpdatesSettings.tsx | 2 +-
src/components/settings/settings-section.tsx | 163 +++++++++++++++---
src/theme/tokens.ts | 2 +-
9 files changed, 222 insertions(+), 89 deletions(-)
diff --git a/src/components/Badge.tsx b/src/components/Badge.tsx
index c0432812..9083a1ae 100644
--- a/src/components/Badge.tsx
+++ b/src/components/Badge.tsx
@@ -1,20 +1,25 @@
import React from 'react';
import { View, Text, StyleSheet, ViewStyle } from 'react-native';
-import { radius, typography, type ThemePalette } from '../theme/tokens';
+import { radius, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
interface BadgeProps {
+ /** Sentence case ("Not on this server"); badges are never upper case. */
children: string;
variant?: 'default' | 'primary' | 'destructive' | 'outline' | 'muted';
style?: ViewStyle;
}
+/**
+ * A small status pill. The default is the webmail's outline badge: hairline
+ * border, muted 12px text. The tinted variants keep the same size.
+ */
export default function Badge({ children, variant = 'default', style }: BadgeProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
-
+
{children}
@@ -25,23 +30,24 @@ function makeStyles(c: ThemePalette) {
return StyleSheet.create({
base: {
borderRadius: radius.full,
+ borderWidth: 1,
+ borderColor: 'transparent',
paddingHorizontal: 8,
- paddingVertical: 2,
+ paddingVertical: 1,
alignSelf: 'flex-start',
},
- text: { ...typography.caption, fontWeight: '500' },
+ text: { fontSize: 12, lineHeight: 16 },
- bg_default: { backgroundColor: c.surface },
- bg_primary: { backgroundColor: c.primaryBg },
+ bg_default: { borderColor: c.border },
+ bg_outline: { borderColor: c.border },
+ bg_primary: { backgroundColor: c.accent },
bg_destructive: { backgroundColor: c.errorBg },
- bg_outline: { backgroundColor: 'transparent', borderWidth: 1, borderColor: c.border },
bg_muted: { backgroundColor: c.muted },
- text_default: { color: c.text },
- text_primary: { color: c.primary },
+ text_default: { color: c.mutedForeground },
+ text_outline: { color: c.mutedForeground },
+ text_primary: { color: c.accentForeground },
text_destructive: { color: c.error },
- text_outline: { color: c.text },
text_muted: { color: c.mutedForeground },
});
}
-
diff --git a/src/components/Button.tsx b/src/components/Button.tsx
index 89037e7b..78820fee 100644
--- a/src/components/Button.tsx
+++ b/src/components/Button.tsx
@@ -1,9 +1,19 @@
import React from 'react';
import { Pressable, Text, StyleSheet, ViewStyle, ActivityIndicator } from 'react-native';
-import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
+import { spacing, radius, componentSizes, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
-type ButtonVariant = 'default' | 'ghost' | 'outline' | 'destructive';
+/**
+ * The webmail's standard-theme buttons (components/ui/button.tsx):
+ * - default: bg-primary text-primary-foreground
+ * - secondary: bg-muted text-foreground
+ * - outline: border, transparent
+ * - ghost: no border or fill until pressed
+ * - destructive: red fill, white text
+ * - destructiveOutline: red border and text, for a reset in a settings row
+ * All 36 dp tall (lg 44 for a page's main action), radius 6, 14px medium.
+ */
+type ButtonVariant = 'default' | 'secondary' | 'ghost' | 'outline' | 'destructive' | 'destructiveOutline';
type ButtonSize = 'sm' | 'md' | 'lg' | 'icon';
interface ButtonProps {
@@ -19,8 +29,8 @@ interface ButtonProps {
accessibilityLabel?: string;
}
-// Extra touch area so even the small sizes reach a 48 dp target.
-const HIT_SLOP: Record = { sm: 6, md: 4, lg: 2, icon: 4 };
+// Extra touch area so every size reaches a 48 dp target.
+const HIT_SLOP: Record = { sm: 6, md: 6, lg: 2, icon: 4 };
export default function Button({
variant = 'default',
@@ -36,6 +46,9 @@ export default function Button({
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const isDisabled = disabled || loading;
+ const spinnerColor = variant === 'default'
+ ? c.primaryForeground
+ : variant === 'destructive' ? '#ffffff' : c.text;
return (
{loading ? (
-
+
) : (
<>
{icon}
{typeof children === 'string' ? (
-
+
{children}
) : (
@@ -79,30 +89,36 @@ function makeStyles(c: ThemePalette) {
return StyleSheet.create({
base: {
flexDirection: 'row', alignItems: 'center', justifyContent: 'center',
- borderRadius: radius.md, gap: spacing.sm,
+ borderRadius: radius.sm, gap: spacing.sm,
},
- text: { ...typography.bodyMedium },
+ text: { fontSize: 14, fontWeight: '500', lineHeight: 20 },
disabled: { opacity: 0.5 },
- textDisabled: { opacity: 0.5 },
- size_sm: { height: componentSizes.buttonSm, paddingHorizontal: spacing.md },
- size_md: { height: componentSizes.buttonMd, paddingHorizontal: spacing.lg, paddingVertical: spacing.sm },
- size_lg: { height: componentSizes.buttonLg, paddingHorizontal: spacing.xxxl },
+ // minHeight, so a larger system font grows the button instead of clipping.
+ size_sm: { minHeight: componentSizes.buttonSm, paddingHorizontal: spacing.md },
+ size_md: { minHeight: componentSizes.buttonSm, paddingHorizontal: 14 },
+ size_lg: { minHeight: componentSizes.buttonLg, paddingHorizontal: spacing.xxl },
size_icon: { height: componentSizes.buttonMd, width: componentSizes.buttonMd, paddingHorizontal: 0 },
- bg_default: { backgroundColor: c.primary, shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.05, shadowRadius: 1, elevation: 1 },
- bg_ghost: { backgroundColor: 'transparent' },
- bg_outline: { backgroundColor: c.background, borderWidth: 1, borderColor: c.border },
- bg_destructive: { backgroundColor: c.error, shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.05, shadowRadius: 1, elevation: 1 },
+ bg_default: { backgroundColor: c.primary },
+ bg_secondary: { backgroundColor: c.muted },
+ bg_ghost: { backgroundColor: 'transparent' },
+ bg_outline: { backgroundColor: 'transparent', borderWidth: 1, borderColor: c.border },
+ bg_destructive: { backgroundColor: c.error },
+ bg_destructiveOutline: { backgroundColor: 'transparent', borderWidth: 1, borderColor: c.error },
- pressed_default: { backgroundColor: c.primaryDark, shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1, shadowRadius: 2, elevation: 2 },
- pressed_ghost: { backgroundColor: c.accent },
- pressed_outline: { backgroundColor: c.accent },
- pressed_destructive: { opacity: 0.9 },
+ pressed_default: { opacity: 0.9 },
+ pressed_secondary: { opacity: 0.8 },
+ pressed_ghost: { backgroundColor: c.muted },
+ pressed_outline: { backgroundColor: c.muted },
+ pressed_destructive: { opacity: 0.9 },
+ pressed_destructiveOutline: { backgroundColor: c.errorBg },
- text_default: { color: c.primaryForeground },
- text_ghost: { color: c.text },
- text_outline: { color: c.text },
- text_destructive: { color: c.errorForeground },
+ text_default: { color: c.primaryForeground },
+ text_secondary: { color: c.text },
+ text_ghost: { color: c.text },
+ text_outline: { color: c.text },
+ text_destructive: { color: '#ffffff' },
+ text_destructiveOutline: { color: c.error },
});
}
diff --git a/src/components/Input.tsx b/src/components/Input.tsx
index ca64524e..ce3e8b93 100644
--- a/src/components/Input.tsx
+++ b/src/components/Input.tsx
@@ -14,13 +14,12 @@ interface InputProps extends TextInputProps {
}
/**
- * Matches webmail input.tsx:
- * - h-10 w-full rounded-md border border-input bg-background
- * - px-3 py-2 text-sm text-foreground
- * - placeholder:text-muted-foreground
- * - hover:border-muted-foreground
- * - focus:ring-2 ring-ring border-ring
- * - disabled:opacity-50
+ * Matches webmail input.tsx in the standard theme:
+ * - h-10 rounded-md border border-input, transparent background
+ * - px-3 text-sm text-foreground, placeholder in muted-foreground
+ * - focus: border-ring
+ * - disabled: opacity-50
+ * - label above in text-sm font-medium text-foreground
*/
export default function Input({
label,
@@ -50,7 +49,7 @@ export default function Input({
ref={ref}
{...props}
style={[styles.input, style]}
- placeholderTextColor={c.textMuted}
+ placeholderTextColor={c.mutedForeground}
onFocus={(e) => { setFocused(true); props.onFocus?.(e); }}
onBlur={(e) => { setFocused(false); props.onBlur?.(e); }}
/>
@@ -65,8 +64,8 @@ function makeStyles(c: ThemePalette) {
return StyleSheet.create({
label: {
...typography.bodyMedium,
- color: c.textSecondary,
- marginBottom: spacing.sm,
+ color: c.text,
+ marginBottom: 6,
},
inputWrapper: {
flexDirection: 'row',
@@ -75,12 +74,12 @@ function makeStyles(c: ThemePalette) {
borderWidth: 1,
borderColor: c.border, // border-input
borderRadius: radius.sm, // rounded-md = 6px
- backgroundColor: c.background, // bg-background
+ backgroundColor: 'transparent',
paddingHorizontal: spacing.md, // px-3 = 12px
},
+ // Colour only, so focusing doesn't shift the text by a pixel.
inputFocused: {
- borderColor: c.borderFocus, // border-ring (blue-500)
- borderWidth: 2,
+ borderColor: c.borderFocus,
},
inputError: {
borderColor: c.error,
diff --git a/src/components/ToggleSwitch.tsx b/src/components/ToggleSwitch.tsx
index c80a993f..2d041d8f 100644
--- a/src/components/ToggleSwitch.tsx
+++ b/src/components/ToggleSwitch.tsx
@@ -12,11 +12,9 @@ interface ToggleSwitchProps {
}
/**
- * Matches webmail ToggleSwitch:
- * - h-6 w-11 rounded-full
- * - checked: bg-primary, unchecked: bg-muted
- * - thumb: h-4 w-4 rounded-full bg-background
- * - translate-x-6 (checked) / translate-x-1 (unchecked)
+ * The webmail switch in the standard theme: a 44 x 24 track, bg-primary when
+ * on (knob in primary-foreground), bg-muted when off (knob in background).
+ * The knob is 20 dp with 2 dp of track around it.
*/
export default function ToggleSwitch({ value, onValueChange, disabled = false, accessibilityLabel }: ToggleSwitchProps) {
const c = useColors();
@@ -38,7 +36,8 @@ export default function ToggleSwitch({ value, onValueChange, disabled = false, a
@@ -60,9 +59,14 @@ function makeStyles(c: ThemePalette) {
backgroundColor: c.muted,
},
thumb: {
- width: componentSizes.toggleThumb, // h-4 w-4 = 16
+ width: componentSizes.toggleThumb,
height: componentSizes.toggleThumb,
borderRadius: radius.full,
+ },
+ thumbOn: {
+ backgroundColor: c.primaryForeground,
+ },
+ thumbOff: {
backgroundColor: c.background,
},
disabled: {
diff --git a/src/components/settings/DownloadsSettings.tsx b/src/components/settings/DownloadsSettings.tsx
index 604b1744..0f1688e9 100644
--- a/src/components/settings/DownloadsSettings.tsx
+++ b/src/components/settings/DownloadsSettings.tsx
@@ -179,7 +179,7 @@ function makeStyles(c: ThemePalette) {
backgroundColor: c.muted,
gap: 2,
},
- previewLabel: { fontSize: 11, fontWeight: '600', color: c.mutedForeground, textTransform: 'uppercase' },
+ previewLabel: { fontSize: 12, fontWeight: '500', color: c.mutedForeground },
previewValue: { ...typography.captionMedium, color: c.text },
tokenWrap: { flexDirection: 'row', flexWrap: 'wrap', gap: 4, marginTop: spacing.md },
tokenPill: {
diff --git a/src/components/settings/KeywordSettings.tsx b/src/components/settings/KeywordSettings.tsx
index 32bda22e..ce83d72e 100644
--- a/src/components/settings/KeywordSettings.tsx
+++ b/src/components/settings/KeywordSettings.tsx
@@ -317,6 +317,7 @@ function makeStyles(c: ThemePalette) {
},
bottomActions: {
flexDirection: 'row',
+ flexWrap: 'wrap',
gap: spacing.sm,
paddingTop: spacing.sm,
},
diff --git a/src/components/settings/UpdatesSettings.tsx b/src/components/settings/UpdatesSettings.tsx
index 6a36f421..0ec63570 100644
--- a/src/components/settings/UpdatesSettings.tsx
+++ b/src/components/settings/UpdatesSettings.tsx
@@ -267,7 +267,7 @@ function makeStyles(c: ThemePalette) {
backgroundColor: c.muted,
gap: spacing.sm,
},
- notesTitle: { ...typography.caption, color: c.mutedForeground, textTransform: 'uppercase' },
+ notesTitle: { ...typography.captionMedium, color: c.mutedForeground },
notesBody: { ...typography.body, color: c.text, lineHeight: 20 },
progressBox: {
paddingHorizontal: spacing.md,
diff --git a/src/components/settings/settings-section.tsx b/src/components/settings/settings-section.tsx
index e208f876..39a7dab4 100644
--- a/src/components/settings/settings-section.tsx
+++ b/src/components/settings/settings-section.tsx
@@ -8,7 +8,7 @@ import {
ScrollView,
ViewStyle,
} from 'react-native';
-import { FlaskConical, Lock, ChevronDown, Check } from '../icons';
+import { FlaskConical, Lock, ChevronDown, ChevronRight, Check, ExternalLink, type AppIcon } from '../icons';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import ToggleSwitchComponent from '../ToggleSwitch';
@@ -17,12 +17,14 @@ import { useSearchHighlight } from './search-highlight';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
/**
- * Mirrors webmail settings-section.tsx:
- * - SettingsSection: space-y-4, with experimental banner, h3 title + description
- * - SettingItem: flex items-start justify-between py-3 border-b border-border
- * - ToggleSwitch: h-6 w-11
- * - Select: rounded-md bg-muted border text-sm px-3 py-1.5
+ * Mirrors webmail settings-section.tsx in the standard theme:
+ * - SettingsSection: title 18px medium, description 14px muted, then rows
+ * - SettingItem: label 14px medium, description 12px muted, 12 dp vertical
+ * padding, a 1 dp border under each row, the control on the right
+ * - ToggleSwitch: 44 x 24
+ * - Select: a 36 dp outline button with a chevron, radius 6
* - RadioGroup: flex gap-1.5, pill buttons
+ * Plus SettingsRow, the 56 dp row of the Settings list (icon, title, value).
*/
interface SettingsSectionProps {
@@ -76,6 +78,9 @@ interface SettingItemProps {
children?: React.ReactNode;
locked?: boolean;
noBorder?: boolean;
+ /** Makes the whole row a link: to a page (chevron) or out of the app. */
+ onPress?: () => void;
+ link?: 'page' | 'external';
}
export function SettingItem({
@@ -84,16 +89,15 @@ export function SettingItem({
children,
locked,
noBorder,
+ onPress,
+ link = 'page',
}: SettingItemProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const highlight = useSearchHighlight(label);
- return (
-
+ const rowStyle = [styles.settingItem, !noBorder && styles.settingItemBorder, locked && styles.locked, highlight.highlighted && styles.searchHighlight];
+ const body = (
+ <>
{label}
@@ -106,10 +110,81 @@ export function SettingItem({
{children}
)}
+ {onPress && (link === 'external'
+ ?
+ : )}
+ >
+ );
+ if (onPress) {
+ return (
+ [...rowStyle, pressed && styles.settingItemPressed]}
+ >
+ {body}
+
+ );
+ }
+ return (
+
+ {body}
);
}
+interface SettingsRowProps {
+ icon: AppIcon;
+ title: string;
+ /** The current value, e.g. "Dark ยท Standard theme". */
+ value?: string | null;
+ onPress: () => void;
+ disabled?: boolean;
+ /** The page open beside the list on a wide window. */
+ selected?: boolean;
+ /** Sits after the title, e.g. a Badge. */
+ badge?: React.ReactNode;
+}
+
+/** A row of the Settings list: muted icon, 15px title, 13px current value. */
+export function SettingsRow({ icon: Icon, title, value, onPress, disabled, selected, badge }: SettingsRowProps) {
+ const c = useColors();
+ const styles = React.useMemo(() => makeStyles(c), [c]);
+ return (
+ [
+ styles.row,
+ disabled && styles.rowDisabled,
+ !disabled && pressed && styles.rowPressed,
+ selected && styles.rowSelected,
+ ]}
+ >
+
+
+
+ {title}
+ {badge}
+
+ {value ? {value} : null}
+
+
+ );
+}
+
+/** A group label in the Settings list: title case, 13px medium, muted. */
+export function SettingsGroupLabel({ children }: { children: string }) {
+ const c = useColors();
+ const styles = React.useMemo(() => makeStyles(c), [c]);
+ return {children};
+}
+
interface ToggleSwitchProps {
checked: boolean;
onChange: (v: boolean) => void;
@@ -151,6 +226,7 @@ export function RadioGroup({ value, onChange, options, style }: RadioGroupProps)
accessibilityRole="radio"
accessibilityState={{ selected, checked: selected }}
accessibilityLabel={opt.label}
+ hitSlop={{ top: 10, bottom: 10 }}
style={[
styles.radioOption,
selected ? styles.radioSelected : styles.radioUnselected,
@@ -185,14 +261,15 @@ export function Select({ value, onChange, options, style }: SelectProps) {
return (
<>
[styles.select, pressed && styles.selectPressed, style]}
onPress={() => setOpen(true)}
+ hitSlop={6}
accessibilityRole="combobox"
accessibilityLabel={current?.label ?? ''}
accessibilityState={{ expanded: open }}
>
{current?.label ?? ''}
-
+