diff --git a/mobile/src/components/SystemState.tsx b/mobile/src/components/SystemState.tsx
index 28da7dc8..68a07345 100644
--- a/mobile/src/components/SystemState.tsx
+++ b/mobile/src/components/SystemState.tsx
@@ -11,6 +11,7 @@ import { AppText } from '../design/components/Text';
import { Button } from '../design/components/Button';
import { enter } from '../design/motion';
import { palette } from '../design/tokens';
+import { displayTitle } from '../layouts/displayTitle';
export interface SystemStateProps {
/** small line above the title, e.g. "VIC" */
@@ -88,7 +89,7 @@ const styles = StyleSheet.create((theme) => ({
},
body: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 24 },
copy: { alignItems: 'center', gap: 10 },
- title: { fontSize: 38, lineHeight: 40, textAlign: 'center' },
+ title: { ...displayTitle(38), textAlign: 'center' },
description: { maxWidth: 320 },
center: { textAlign: 'center' },
}));
diff --git a/mobile/src/components/auth/CodeInput.tsx b/mobile/src/components/auth/CodeInput.tsx
index a71d99f3..8284ef16 100644
--- a/mobile/src/components/auth/CodeInput.tsx
+++ b/mobile/src/components/auth/CodeInput.tsx
@@ -38,9 +38,7 @@ const CodeBox = ({
return (
-
+
{digit ?? ''}
@@ -114,7 +112,10 @@ CodeInput.displayName = 'CodeInput';
export default CodeInput;
-const BOX_HEIGHT = 60;
+// the field height of the handbook; the focus halo is the field's 5pt one
+const BOX_HEIGHT = 56;
+const HALO = 5;
+const BOX_RADIUS = 14;
const styles = StyleSheet.create((theme) => ({
row: { flexDirection: 'row', justifyContent: 'center', gap: 8 },
@@ -122,28 +123,29 @@ const styles = StyleSheet.create((theme) => ({
cell: { flex: 1, maxWidth: 56 },
halo: {
position: 'absolute',
- top: -4,
- left: -4,
- right: -4,
- bottom: -4,
- borderRadius: 18,
+ top: -HALO,
+ left: -HALO,
+ right: -HALO,
+ bottom: -HALO,
+ borderRadius: BOX_RADIUS + HALO,
backgroundColor: theme.colors.yellow,
},
box: {
height: BOX_HEIGHT,
- borderRadius: 14,
+ borderRadius: BOX_RADIUS,
borderWidth: 2,
borderColor: 'transparent',
backgroundColor: theme.colors.surface,
alignItems: 'center',
justifyContent: 'center',
},
- filled: { backgroundColor: theme.colors.card },
error: { backgroundColor: theme.colors.errorTint },
digit: {
fontFamily: theme.fontFamily.display,
fontSize: 28,
lineHeight: 34,
+ letterSpacing: -0.7,
+ includeFontPadding: false,
color: theme.colors.ink,
fontVariant: ['tabular-nums'],
},
diff --git a/mobile/src/components/auth/SocialButton.tsx b/mobile/src/components/auth/SocialButton.tsx
index f5cfc837..445d2506 100644
--- a/mobile/src/components/auth/SocialButton.tsx
+++ b/mobile/src/components/auth/SocialButton.tsx
@@ -117,7 +117,8 @@ const styles = StyleSheet.create((theme) => ({
paddingHorizontal: 18,
},
light: { backgroundColor: theme.colors.card },
- outline: { borderWidth: 2, borderColor: 'rgba(255,255,255,0.7)' },
+ // the handbook's outline button (2pt, full color), in white on navy
+ outline: { borderWidth: 2, borderColor: theme.colors.white },
disabled: { opacity: 0.45 },
icon: { width: 24, alignItems: 'center' },
label: { flex: 1, textAlign: 'center' },
diff --git a/mobile/src/components/onboarding/StoryScenes.tsx b/mobile/src/components/onboarding/StoryScenes.tsx
index 3a937aa0..f8379412 100644
--- a/mobile/src/components/onboarding/StoryScenes.tsx
+++ b/mobile/src/components/onboarding/StoryScenes.tsx
@@ -2,6 +2,7 @@ import React, { useEffect } from 'react';
import { StyleSheet, View } from 'react-native';
import Svg, { Path } from 'react-native-svg';
import Animated, {
+ FadeIn,
useAnimatedStyle,
useReducedMotion,
useSharedValue,
@@ -11,9 +12,16 @@ import Animated, {
withTiming,
} from 'react-native-reanimated';
import { useTranslation } from 'react-i18next';
-import { VText } from '../ui/VText';
+import { VText as BaseText, VTextProps } from '../ui/VText';
import { Badge } from '../ui/Badge';
import { colors, fonts, motion } from '../../common/theme/tokens';
+import { radius as dsRadius } from '../../design/tokens';
+import { duration } from '../../design/motion';
+
+// The scenes are pictures: the stage is one button with its own label, so their sample text is not
+// read and does not grow with Dynamic Type (it would overflow the fixed cards); the story title
+// under the stage grows instead.
+const VText = (props: VTextProps) => ;
const EASE = motion.enter.easing;
@@ -44,7 +52,10 @@ const Fly = ({
}) => {
const reducedMotion = useReducedMotion();
return (
-
+
{children}
);
@@ -268,8 +279,16 @@ const HoursScene = () => {
}
- to={}
+ from={
+
+
+
+ }
+ to={
+
+
+
+ }
/>
@@ -321,7 +340,7 @@ export const StoryScenes = [OrganizationsScene, EventScene, HoursScene, Document
const styles = StyleSheet.create({
mini: {
backgroundColor: colors.white,
- borderRadius: 16,
+ borderRadius: dsRadius.row,
shadowColor: '#000',
shadowOpacity: 0.35,
shadowRadius: 20,
@@ -355,8 +374,8 @@ const styles = StyleSheet.create({
cover: {
height: 150,
backgroundColor: colors.teal,
- borderTopLeftRadius: 16,
- borderTopRightRadius: 16,
+ borderTopLeftRadius: dsRadius.row,
+ borderTopRightRadius: dsRadius.row,
},
coverBadge: {
position: 'absolute',
@@ -375,11 +394,20 @@ const styles = StyleSheet.create({
sceneHours: { width: 290, gap: 12 },
hoursCard: { padding: 16, gap: 10 },
hoursTotal: { flexDirection: 'row', alignItems: 'baseline', gap: 6 },
- bigNumber: { fontFamily: fonts.title, fontSize: 34, lineHeight: 38, color: colors.ink },
+ bigNumber: {
+ fontFamily: fonts.title,
+ fontSize: 34,
+ lineHeight: 38,
+ letterSpacing: -1.36,
+ fontVariant: ['tabular-nums'],
+ color: colors.ink,
+ },
bars: { flexDirection: 'row', alignItems: 'flex-end', gap: 8, height: 80 },
barSlot: { flex: 1, height: '100%', justifyContent: 'flex-end' },
bar: { width: '100%', borderTopLeftRadius: 6, borderTopRightRadius: 6, borderRadius: 3 },
- swapBadge: { width: 104, height: 24, alignItems: 'flex-end' },
+ // the status pills are 28pt; both sit at the right edge of the row
+ swapBadge: { width: 124, height: 28 },
+ badgeEnd: { flex: 1, alignItems: 'flex-end', justifyContent: 'center' },
sceneDocs: { width: 250, height: 300 },
paper: { position: 'absolute', padding: 18, gap: 8 },
paperBack: {
diff --git a/mobile/src/design/components/Button.tsx b/mobile/src/design/components/Button.tsx
index b585788e..3db3a4b3 100644
--- a/mobile/src/design/components/Button.tsx
+++ b/mobile/src/design/components/Button.tsx
@@ -120,6 +120,9 @@ export const Button = ({
: { transform: [{ scale }] };
});
const pressFill = useAnimatedStyle(() => ({ opacity: pressed.value }));
+ // the yellow button's darker bottom edge (inset 0 -3 navy 12%) tightens to 1pt when pressed
+ const edgeRest = useAnimatedStyle(() => ({ opacity: 1 - pressed.value }));
+ const edgePressed = useAnimatedStyle(() => ({ opacity: pressed.value }));
// a 34pt button still has a 44pt target
const slop = hitSlop ?? Math.max(0, Math.ceil((hit.min - dims.height) / 2));
@@ -164,6 +167,18 @@ export const Button = ({
pressFill,
]}
/>
+ {variant === 'primary' && (
+ <>
+
+
+ >
+ )}
{/* the label stays laid out (invisible) while loading, so the width does not jump */}
({
content: { flexShrink: 1, flexDirection: 'row', alignItems: 'center', gap: theme.space.xs },
hidden: { opacity: 0 },
bordered: { borderWidth: 2 },
+ edge: { ...StyleSheet.absoluteFillObject, borderColor: 'rgba(1,52,79,0.12)' },
+ edgeRest: { borderBottomWidth: 3 },
+ edgePressed: { borderBottomWidth: 1 },
label: { flexShrink: 1, textAlign: 'center' },
spinner: {
...StyleSheet.absoluteFillObject,
diff --git a/mobile/src/design/components/DateField.tsx b/mobile/src/design/components/DateField.tsx
index 99fd22b7..9e707a18 100644
--- a/mobile/src/design/components/DateField.tsx
+++ b/mobile/src/design/components/DateField.tsx
@@ -164,7 +164,7 @@ export const DateField = ({
>
({
]}
>
diff --git a/mobile/src/design/components/TextField.tsx b/mobile/src/design/components/TextField.tsx
index 1a625fe0..753a6533 100644
--- a/mobile/src/design/components/TextField.tsx
+++ b/mobile/src/design/components/TextField.tsx
@@ -317,7 +317,15 @@ export const fieldStyles = StyleSheet.create((theme) => ({
},
inputMultiline: { minHeight: 60, textAlignVertical: 'top' },
inputDisabled: { color: theme.colors.textSecondary },
- value: { flex: 1, fontFamily: theme.fontFamily.textBold, fontSize: 15.5, lineHeight: 21 },
+ // select / date value: wraps to a second line for long names, with room above and below
+ value: {
+ flex: 1,
+ minWidth: 0,
+ paddingVertical: 6,
+ fontFamily: theme.fontFamily.textBold,
+ fontSize: 15.5,
+ lineHeight: 21,
+ },
placeholder: { fontFamily: theme.fontFamily.text },
errorFold: { overflow: 'hidden' },
errorRow: {
diff --git a/mobile/src/layouts/LayoutHeader.tsx b/mobile/src/layouts/LayoutHeader.tsx
index 14e8ab05..a2e1556f 100644
--- a/mobile/src/layouts/LayoutHeader.tsx
+++ b/mobile/src/layouts/LayoutHeader.tsx
@@ -12,6 +12,7 @@ import { AppText } from '../design/components/Text';
import { springTo } from '../design/motion';
import { ChevronLeftIcon, CloseIcon, EditIcon } from '../design/icons';
import { hit, palette } from '../design/tokens';
+import { displayTitle } from './displayTitle';
export interface RoundIconButtonProps {
icon: 'back' | 'close';
@@ -80,6 +81,7 @@ export const LayoutHeader = ({
}: LayoutHeaderProps) => (
{leading}
+ {/* long titles („Completează profil voluntar”) wrap to a second line instead of being cut */}
({
justifyContent: 'center',
},
roundDark: { backgroundColor: 'rgba(255,255,255,0.14)' },
- title: { flex: 1, fontSize: 22, lineHeight: 26, letterSpacing: -0.5 },
- titleAlone: { fontSize: 24, lineHeight: 28 },
+ title: { flex: 1, minWidth: 0, ...displayTitle(22, 1.1, -0.025) },
+ titleAlone: displayTitle(24, 1.1, -0.025),
edit: {
+ flexShrink: 0,
minHeight: hit.min,
flexDirection: 'row',
alignItems: 'center',
diff --git a/mobile/src/layouts/ModalLayout.tsx b/mobile/src/layouts/ModalLayout.tsx
index 9f2f385c..fba06713 100644
--- a/mobile/src/layouts/ModalLayout.tsx
+++ b/mobile/src/layouts/ModalLayout.tsx
@@ -106,7 +106,7 @@ export default ModalLayout;
const styles = StyleSheet.create((theme) => ({
screen: { flex: 1, backgroundColor: theme.colors.background },
header: { paddingTop: theme.space.m },
- childrenContainer: { flex: 1, paddingHorizontal: 16 },
+ childrenContainer: { flex: 1, paddingHorizontal: theme.space.gutter },
bottomActionContainer: {
width: '100%',
backgroundColor: theme.colors.background,
diff --git a/mobile/src/layouts/PageLayout.tsx b/mobile/src/layouts/PageLayout.tsx
index e0dc97dc..4ab42bf5 100644
--- a/mobile/src/layouts/PageLayout.tsx
+++ b/mobile/src/layouts/PageLayout.tsx
@@ -133,7 +133,11 @@ export default PageLayout;
const styles = StyleSheet.create((theme) => ({
screen: { flex: 1, backgroundColor: theme.colors.background },
- childrenContainer: { flex: 1, paddingHorizontal: 16, paddingVertical: theme.space.xs },
+ childrenContainer: {
+ flex: 1,
+ paddingHorizontal: theme.space.gutter,
+ paddingVertical: theme.space.xs,
+ },
actions: {
gap: theme.space.s,
paddingTop: theme.space.m,
diff --git a/mobile/src/layouts/displayTitle.ts b/mobile/src/layouts/displayTitle.ts
new file mode 100644
index 00000000..672b9733
--- /dev/null
+++ b/mobile/src/layouts/displayTitle.ts
@@ -0,0 +1,26 @@
+/**
+ * Bricolage Grotesque title metrics for the auth, onboarding and layout titles.
+ *
+ * The font's ascent (.93em) and descent (.27em) are tall, so with the tight line height of the
+ * handbook (≈ .96em for large titles) the accents of Î / Ș / Ă and the commas of ș / ț reach
+ * outside the text box: iOS lays the glyphs from the bottom of a short line (no baseline offset
+ * when the line is shorter than the font), Android clips at the view's edge. The box gets that
+ * headroom as padding, cancelled by negative margins, so the layout keeps the tight line height
+ * and nothing is clipped. Letter spacing is in points, computed for the size used.
+ */
+export const displayTitle = (size: number, ratio = 0.96, tracking = -0.04) => {
+ const top = Math.ceil(size * 0.18);
+ const bottom = Math.ceil(size * 0.1);
+ return {
+ fontSize: size,
+ lineHeight: Math.round(size * ratio),
+ letterSpacing: Math.round(size * tracking * 100) / 100,
+ includeFontPadding: false,
+ paddingTop: top,
+ marginTop: -top,
+ paddingBottom: bottom,
+ marginBottom: -bottom,
+ };
+};
+
+export default displayTitle;
diff --git a/mobile/src/screens/CreateUser.tsx b/mobile/src/screens/CreateUser.tsx
index 65962fd3..66fb9718 100644
--- a/mobile/src/screens/CreateUser.tsx
+++ b/mobile/src/screens/CreateUser.tsx
@@ -34,6 +34,7 @@ import { InternalErrors } from '../common/errors/internal-errors.class';
import { CONSTANTS, REGEX } from '../common/constants/constants';
import { renderPhoneNumberPrefix } from '../components/InputPrefixes';
import { usePaddingTop } from '../hooks/usePaddingTop';
+import { displayTitle } from '../layouts/displayTitle';
export type UserFormTypes = {
firstName: string;
@@ -151,7 +152,6 @@ const CreateUser = ({ navigation }: any) => {
headerStyle={{ paddingTop }}
>
- {/* 20pt side margins: the layout gives 16 */}
@@ -275,9 +275,9 @@ const CreateUser = ({ navigation }: any) => {
export default CreateUser;
const styles = StyleSheet.create((theme) => ({
- content: { marginHorizontal: theme.space.gutter - 16, gap: 18 },
+ content: { gap: 18 },
intro: { gap: theme.space.s, paddingTop: theme.space.xxs, paddingBottom: theme.space.xxs },
- heading: { fontSize: 36, lineHeight: 38 },
+ heading: displayTitle(36),
// the consent sits on a white card so it reads as one block
terms: {
backgroundColor: theme.colors.card,
diff --git a/mobile/src/screens/Landing.tsx b/mobile/src/screens/Landing.tsx
index b43fb10a..6e2aa113 100644
--- a/mobile/src/screens/Landing.tsx
+++ b/mobile/src/screens/Landing.tsx
@@ -21,6 +21,7 @@ import { MailIcon } from '../design/icons';
import { palette } from '../design/tokens';
import { hasSeenOnboarding } from '../common/utils/onboarding';
import { isMigrationNoticeActive } from '../common/utils/migration-notice';
+import { displayTitle } from '../layouts/displayTitle';
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@@ -251,8 +252,9 @@ const styles = StyleSheet.create((theme) => ({
content: { flexGrow: 1, paddingHorizontal: 24 },
copy: { gap: 12 },
copyCompact: { gap: 8 },
- title: { fontSize: 46, lineHeight: 46, maxWidth: 340 },
- titleCompact: { fontSize: 38, lineHeight: 39 },
+ // the masthead headline: tight like the handbook's h1, with headroom for Î / ț (see displayTitle)
+ title: { ...displayTitle(46, 0.92), maxWidth: 340 },
+ titleCompact: displayTitle(38, 0.94),
subtitle: {
color: 'rgba(255,255,255,0.78)',
fontFamily: theme.fontFamily.textBold,
diff --git a/mobile/src/screens/Login.tsx b/mobile/src/screens/Login.tsx
index 88ebf362..2f4e06da 100644
--- a/mobile/src/screens/Login.tsx
+++ b/mobile/src/screens/Login.tsx
@@ -21,6 +21,7 @@ import { enter } from '../design/motion';
import { MailIcon } from '../design/icons';
import { palette } from '../design/tokens';
import { isMigrationNoticeActive } from '../common/utils/migration-notice';
+import { displayTitle } from '../layouts/displayTitle';
// A new code can be asked for once a minute (the backend answers AUTH_010 sooner).
export const RESEND_AFTER_SECONDS = 60;
@@ -229,7 +230,7 @@ const Login = ({ navigation, route }: any) => {
)}
-
+
{t('code.or_type')}
@@ -251,7 +252,11 @@ const Login = ({ navigation, route }: any) => {
{secondsLeft > 0 ? (
-
+
{t('code.resend_in', { seconds: secondsLeft })}
) : (
@@ -360,7 +365,7 @@ const styles = StyleSheet.create((theme) => ({
// the round button keeps its own 44pt, not the column's width
back: { alignSelf: 'flex-start' },
intro: { gap: 10 },
- title: { fontSize: 40, lineHeight: 42 },
+ title: displayTitle(40),
mailHex: { width: 64, height: 70, alignItems: 'center', justifyContent: 'center' },
notice: {
backgroundColor: theme.colors.card,
@@ -369,12 +374,18 @@ const styles = StyleSheet.create((theme) => ({
},
divider: { flexDirection: 'row', alignItems: 'center', gap: 12 },
rule: { flex: 1, height: StyleSheet.hairlineWidth, backgroundColor: theme.colors.line },
+ // wraps at the largest text sizes on a 320pt phone instead of pushing "Alt email" off screen
resendRow: {
minHeight: 44,
flexDirection: 'row',
+ flexWrap: 'wrap',
justifyContent: 'space-between',
alignItems: 'center',
+ columnGap: 16,
+ rowGap: 8,
},
+ shrink: { flexShrink: 1 },
+ dividerText: { flexShrink: 1, textAlign: 'center' },
tabular: { fontVariant: ['tabular-nums'] },
underline: { textDecorationLine: 'underline' },
footer: {
diff --git a/mobile/src/screens/Onboarding.tsx b/mobile/src/screens/Onboarding.tsx
index 9cb40134..676ba68f 100644
--- a/mobile/src/screens/Onboarding.tsx
+++ b/mobile/src/screens/Onboarding.tsx
@@ -33,6 +33,7 @@ import { VicLogo } from '../components/brand/VicLogo';
import { HexMotifShape } from '../components/auth/LoginHero';
import { StoryScenes } from '../components/onboarding/StoryScenes';
import { markOnboardingSeen } from '../common/utils/onboarding';
+import { displayTitle } from '../layouts/displayTitle';
export const STORY_DURATION = 3800;
@@ -137,11 +138,16 @@ const Onboarding = ({ navigation }: any) => {
const Scene = StoryScenes[index];
const story = STORIES[index];
- // The scene shrinks to the room left between the header and the text: on a small phone
- // (iPhone SE, Android 360×640) the text never covers it.
+ // The scene shrinks to the room left between the header and the text, and to the screen width:
+ // on a small phone (iPhone SE, Android 360×640, 320pt) the text never covers it and it never
+ // runs off the sides.
const [stageHeight, setStageHeight] = useState(0);
- const [sceneHeight, setSceneHeight] = useState(0);
- const sceneScale = stageHeight && sceneHeight ? Math.min(1, (stageHeight - 16) / sceneHeight) : 1;
+ const [scene, setScene] = useState({ width: 0, height: 0 });
+ const sceneScale = Math.min(
+ 1,
+ stageHeight && scene.height ? (stageHeight - 16) / scene.height : 1,
+ scene.width ? (width - 32) / scene.width : 1,
+ );
const motifSize = Math.min(340, width * 0.9);
return (
@@ -190,7 +196,12 @@ const Onboarding = ({ navigation }: any) => {
setSceneHeight(event.nativeEvent.layout.height)}
+ onLayout={(event) =>
+ setScene({
+ width: event.nativeEvent.layout.width,
+ height: event.nativeEvent.layout.height,
+ })
+ }
>
@@ -279,7 +290,7 @@ const styles = StyleSheet.create((theme) => ({
},
motif: { position: 'absolute' },
copy: { paddingHorizontal: 24, paddingTop: 8, paddingBottom: 16, gap: 10, minHeight: 112 },
- title: { fontSize: 34, lineHeight: 36 },
+ title: displayTitle(34),
foot: { paddingHorizontal: 24, gap: 14 },
dots: { flexDirection: 'row', alignItems: 'center', gap: 6, marginBottom: 4 },
dot: {