diff --git a/mobile/src/common/constants/constants.ts b/mobile/src/common/constants/constants.ts index 64665831..31dae655 100644 --- a/mobile/src/common/constants/constants.ts +++ b/mobile/src/common/constants/constants.ts @@ -16,7 +16,7 @@ export const ALLOW_FONT_SCALLING = false; // Text follows the system text size, up to the „Îndrăzneț” caps (fontScaleCap in design/tokens): // titles stop at 1.15×, text at 1.35×. -export const MAX_FONT_SCALE = { title: 1.15, text: 1.35 } as const; +export const MAX_FONT_SCALE = { title: 1.15, text: 1.35, tabLabel: 1.1 } as const; export const MIME_TYPES: any = { doc: 'application/msword', diff --git a/mobile/src/components/navigation/GlassTabBar.tsx b/mobile/src/components/navigation/GlassTabBar.tsx index 96825aea..f260cdaa 100644 --- a/mobile/src/components/navigation/GlassTabBar.tsx +++ b/mobile/src/components/navigation/GlassTabBar.tsx @@ -19,7 +19,7 @@ import Animated, { import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { BottomTabBarProps } from '@react-navigation/bottom-tabs'; import { elevation, fontFamily, fontScaleCap, glass, palette, tabBar } from '../../design/tokens'; -import { duration, spring } from '../../design/motion'; +import { duration, easing, spring, springTo } from '../../design/motion'; import { haptics } from '../../design/haptics'; import { CalendarIcon, @@ -66,6 +66,10 @@ const PILL = tabBar.pill + PILL_RING * 2; const ACCESSORY_HEIGHT = tabBar.pill; const ACCESSORY_GAP = 10; const EXPAND_HINT = 'Arată toate tab-urile'; +const LABEL_LINE = 13; +// how far a label may reach into the neighbouring slots („Evenimente” is 63pt, a slot at 320pt +// width is 56pt; its neighbours „Acasă” and „Ore” leave more than that free) +const LABEL_OVERHANG = 5; /** * Real glass on iOS 26 (Liquid Glass); older iOS a blur with 58% white over it; Android a dense @@ -164,9 +168,10 @@ export const GlassTabBar = ({ state, navigation, items }: GlassTabBarProps) => { ? withTiming(activeIndex * slot, { duration: duration.reduced }) : withSpring(activeIndex * slot, spring.snappy); if (!reducedMotion) { + // prototype @keyframes squish: 300 ms, widest (1.12 × 0.92) at 40% squish.value = withSequence( - withTiming(1.12, { duration: 120 }), - withSpring(1, spring.snappy), + withTiming(1.12, { duration: 120, easing: easing.standard }), + withTiming(1, { duration: 180, easing: easing.standard }), ); } }, [activeIndex, slot, reducedMotion, lensX, squish]); @@ -262,6 +267,8 @@ export const GlassTabBar = ({ state, navigation, items }: GlassTabBarProps) => { {accessory.label} @@ -369,6 +376,25 @@ const TabButton = ({ }: TabButtonProps) => { const Icon = ICONS[item.icon]; const color = focused ? palette.navy : palette.ink2; + const { fontScale } = useWindowDimensions(); + // the label row is as tall as one line at the capped scale, so it never clips + const labelHeight = Math.ceil( + LABEL_LINE * Math.min(Math.max(fontScale, 1), fontScaleCap.tabLabel), + ); + + // prototype `.tab.on svg{transform:translateY(-1px) scale(1.06)}` and `.tab:active svg{scale(.86)}`: + // only the icon moves, the button and its slot never grow + const on = useSharedValue(focused ? 1 : 0); + const pressed = useSharedValue(0); + useEffect(() => { + on.value = springTo(focused ? 1 : 0, 'snappy', reducedMotion); + }, [focused, reducedMotion, on]); + const iconStyle = useAnimatedStyle(() => ({ + transform: [ + { translateY: -on.value }, + { scale: (1 + 0.06 * on.value) * (1 - 0.14 * pressed.value) }, + ], + })); // Collapsing, every slot slides toward the pill: the active one lands inside the lens, the // others slide out of the clipped pill and fade. The slot never gets bigger than it was. @@ -385,10 +411,13 @@ const TabButton = ({ }; }); - // labels go out first (first 35% of the collapse) and come back last + // labels go out first (first 35% of the collapse, sliding 4pt down) and come back last const labelStyle = useAnimatedStyle(() => ({ opacity: interpolate(progress.value, [0, 0.35], [1, 0], Extrapolation.CLAMP), - height: interpolate(progress.value, [0, 0.35], [13, 0], Extrapolation.CLAMP), + height: interpolate(progress.value, [0, 0.35], [labelHeight, 0], Extrapolation.CLAMP), + transform: [ + { translateY: interpolate(progress.value, [0, 0.35], [0, 4], Extrapolation.CLAMP) }, + ], })); return ( @@ -404,16 +433,32 @@ const TabButton = ({ accessibilityHint={collapsed && focused ? EXPAND_HINT : undefined} accessibilityElementsHidden={hiddenWhenCollapsed} importantForAccessibility={hiddenWhenCollapsed ? 'no-hide-descendants' : 'auto'} + // big Dynamic Type: a long press shows the label in the iOS Large Content Viewer + accessibilityShowsLargeContentViewer + accessibilityLargeContentTitle={item.label} onPress={onPress} + onPressIn={() => { + if (!reducedMotion) { + pressed.value = springTo(1, 'snappy', false); + } + }} + onPressOut={() => { + pressed.value = springTo(0, 'snappy', reducedMotion); + }} onLongPress={onLongPress} hitSlop={4} pressRetentionOffset={24} style={styles.tab} > - + + + + {/* never ellipsized: the label may use 5pt of each neighbour's slot and shrinks to fit */} +// … +// +// +// - FlatList header: same, with the LargeTitleHeader in ListHeaderComponent and the +// CompactTitleBar after the list. +// - Static header (the title does not scroll, e.g. above a list): renders both (bar="inline", the default). +// - Compact bar only: . +// `compact={false}` / `compactOnly` are the older names of bar="none" / bar="only". import React, { ReactNode } from 'react'; -import { StyleSheet, View } from 'react-native'; -import { BlurView } from 'expo-blur'; -import Animated, { - interpolate, - SharedValue, - useAnimatedStyle, - Extrapolation, -} from 'react-native-reanimated'; +import { View } from 'react-native'; +import Animated, { SharedValue } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { VText } from './VText'; -import { colors, spacing } from '../../common/theme/tokens'; -import { glass, lightColors } from '../../design/tokens'; +import { StyleSheet } from 'react-native-unistyles'; +import { AppText } from '../../design/components/Text'; +import { CompactTitleBar, useLargeTitleCollapse } from './ScreenChrome'; + +export { CompactTitleBar, useLargeTitleCollapse, COMPACT_BAR_AFTER } from './ScreenChrome'; +/** The compact glass bar alone (same as CompactTitleBar). */ +export const LargeTitleCompactBar = CompactTitleBar; export interface LargeTitleHeaderProps { title: string; - /** Scroll offset of the screen; when set, a compact title fades in once the large one scrolls away. */ + /** Scroll offset of the screen: drives the title collapse and the compact bar. */ scrollY?: SharedValue; right?: ReactNode; left?: ReactNode; /** - * Render the compact glass bar here (default true). Inside a FlatList header the bar would scroll - * away with the content: pass false and render `LargeTitleCompactBar` beside the list instead. + * Where the compact glass bar goes: `inline` renders it here (only right when this header does + * not scroll), `none` leaves it to a `CompactTitleBar` you render after the list, `only` renders + * just the bar. Default `inline`. */ + bar?: 'inline' | 'none' | 'only'; + /** @deprecated use bar="none" */ compact?: boolean; - /** - * Only the compact bar, for screens whose large title scrolls inside a list (the list header - * draws its own large title). - */ + /** @deprecated use bar="only" */ compactOnly?: boolean; + /** add the safe-area top inset above the title (default true; false inside a padded list) */ + safeTop?: boolean; + testID?: string; } -// the compact glass bar appears after 48pt of scroll (handbook: Antet) -const COLLAPSE_AT = 48; - -/** The compact glass bar alone, pinned to the top of the screen (fades in after 48pt of scroll). */ -export const LargeTitleCompactBar = ({ - title, - scrollY, -}: { - title: string; - scrollY: SharedValue; -}) => { - const insets = useSafeAreaInsets(); - const compactStyle = useAnimatedStyle(() => { - const y = scrollY.value; - return { - opacity: interpolate(y, [COLLAPSE_AT - 16, COLLAPSE_AT], [0, 1], Extrapolation.CLAMP), - }; - }); - const compactTitleStyle = useAnimatedStyle(() => { - const y = scrollY.value; - return { - transform: [ - { - translateY: interpolate( - y, - [COLLAPSE_AT - 16, COLLAPSE_AT + 8], - [6, 0], - Extrapolation.CLAMP, - ), - }, - ], - }; - }); - return ( - - - - - - {title} - - - - ); -}; - -/** - * „Îndrăzneț” large title (Bricolage 44) in the content. With `scrollY` it also renders a compact - * glass bar (paper at 72% over a blur, Bricolage 17 title, hairline) that fades in after 48pt of - * scroll while the large title shrinks and fades away; the compact title rises 6pt into place. - */ export const LargeTitleHeader = ({ title, scrollY, right, left, + bar, compact = true, compactOnly, + safeTop = true, + testID, }: LargeTitleHeaderProps) => { const insets = useSafeAreaInsets(); - - const largeStyle = useAnimatedStyle(() => { - const y = scrollY ? scrollY.value : 0; - return { - opacity: interpolate(y, [0, COLLAPSE_AT], [1, 0], Extrapolation.CLAMP), - transform: [ - { - scale: interpolate(y, [-80, 0, COLLAPSE_AT], [1.08, 1, 0.92], Extrapolation.CLAMP), - }, - ], - }; - }); + const mode = bar ?? (compactOnly ? 'only' : compact ? 'inline' : 'none'); + const collapse = useLargeTitleCollapse(scrollY); return ( <> - {scrollY && (compact || compactOnly) && ( - - )} - {!compactOnly && ( - + {scrollY && mode !== 'none' && } + {mode !== 'only' && ( + {left} - - + + {title} - + {right} @@ -128,27 +85,14 @@ export const LargeTitleHeader = ({ export default LargeTitleHeader; -const styles = StyleSheet.create({ - compact: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - zIndex: 10, - overflow: 'hidden', - borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: colors.line, - alignItems: 'center', - justifyContent: 'center', - }, - compactTint: { backgroundColor: lightColors.topBarTint }, - compactTitle: { fontSize: 17, lineHeight: 22, letterSpacing: -0.2 }, +const styles = StyleSheet.create((theme) => ({ large: { flexDirection: 'row', alignItems: 'center', - paddingHorizontal: spacing.gutter, - paddingBottom: spacing.m, - gap: spacing.m, + paddingHorizontal: theme.space.gutter, + paddingTop: 8, + paddingBottom: 16, + gap: theme.space.m, }, - titleWrap: { flex: 1, transformOrigin: 'left bottom' }, -}); + titleWrap: { flex: 1, minWidth: 0, transformOrigin: 'left bottom' }, +})); diff --git a/mobile/src/components/ui/PressableScale.tsx b/mobile/src/components/ui/PressableScale.tsx index 74e5a07e..d817ea4c 100644 --- a/mobile/src/components/ui/PressableScale.tsx +++ b/mobile/src/components/ui/PressableScale.tsx @@ -4,22 +4,27 @@ import Animated, { useAnimatedStyle, useReducedMotion, useSharedValue, - withTiming, } from 'react-native-reanimated'; -import { motion } from '../../common/theme/tokens'; +import { springTo } from '../../design/motion'; + +/** Prototype `.press:active{transform:scale(.965)}` with the snappy spring. */ +export const PRESSED_SCALE = 0.965; const AnimatedPressable = Animated.createAnimatedComponent(Pressable); export interface PressableScaleProps extends Omit { style?: StyleProp; - /** Scale while pressed. Defaults to the canvas value (.98). */ + /** Scale while pressed. Defaults to the prototype value (.965). */ pressedScale?: number; } -/** Pressable that shrinks slightly while pressed (canvas: `.btn:active{transform:scale(.98)}`). */ +/** + * Pressable that shrinks slightly while pressed and springs back (snappy spring). Reduce Motion: no + * scale. + */ export const PressableScale = ({ style, - pressedScale = motion.press.scale, + pressedScale = PRESSED_SCALE, onPressIn, onPressOut, disabled, @@ -37,12 +42,12 @@ export const PressableScale = ({ {...props} onPressIn={(event) => { if (!reducedMotion) { - scale.value = withTiming(pressedScale, { duration: motion.press.duration }); + scale.value = springTo(pressedScale, 'snappy', false); } onPressIn?.(event); }} onPressOut={(event) => { - scale.value = withTiming(1, { duration: motion.press.duration }); + scale.value = springTo(1, 'snappy', reducedMotion); onPressOut?.(event); }} style={[style, animatedStyle]} diff --git a/mobile/src/components/ui/ScreenChrome.tsx b/mobile/src/components/ui/ScreenChrome.tsx index 1816dff7..495e8683 100644 --- a/mobile/src/components/ui/ScreenChrome.tsx +++ b/mobile/src/components/ui/ScreenChrome.tsx @@ -1,18 +1,23 @@ // Screen chrome „Îndrăzneț”: the 44pt round back button of pushed screens, their header row, and // the compact glass bar of tab screens whose large title scrolls with the content. import React from 'react'; -import { StyleSheet as RNStyleSheet, StyleProp, View, ViewStyle } from 'react-native'; +import { Platform, StyleSheet as RNStyleSheet, StyleProp, View, ViewStyle } from 'react-native'; import { BlurView } from 'expo-blur'; import Animated, { Extrapolation, interpolate, SharedValue, useAnimatedStyle, + useDerivedValue, + useReducedMotion, + withSpring, + withTiming, } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useTranslation } from 'react-i18next'; import { StyleSheet } from 'react-native-unistyles'; -import { glass } from '../../design/tokens'; +import { fontScaleCap, glass } from '../../design/tokens'; +import { duration, easing, spring } from '../../design/motion'; import { ChevronLeftIcon, ICON_STROKE } from '../../design/icons'; import { hit, palette } from '../../design/tokens'; import { AppText } from '../../design/components/Text'; @@ -77,7 +82,7 @@ export const PushedHeader = ({ {onBack && } {!!title && ( - + {title} )} @@ -85,8 +90,10 @@ export const PushedHeader = ({ ); -// the compact bar appears after 48pt of scroll (handbook: Antet), like LargeTitleHeader -const COLLAPSE_AT = 48; +/** Scroll offset after which the compact glass bar shows (handbook: Antet). */ +export const COMPACT_BAR_AFTER = 48; +/** Height of the compact bar under the status bar (prototype .topbar: 100 - 54). */ +export const COMPACT_BAR_HEIGHT = 46; export interface CompactTitleBarProps { title: string; @@ -95,44 +102,52 @@ export interface CompactTitleBarProps { } /** - * The compact glass bar of a tab screen whose large title is part of the scroll content: paper at - * 72% over a blur, a Bricolage 17 title and a hairline. It fades in after 48pt of scroll while the - * title rises 6pt into place. Render it after the scroll view. + * The compact glass bar of a tab screen whose large title scrolls with the content (prototype + * `.topbar`): paper at 72% over a blur, a Bricolage 17 title and a 1pt hairline. Once the content + * passes 48pt it fades in (200 ms, standard easing) and the title rises 6pt into place with the + * snappy spring; scrolling back above 48pt reverses it. Reduce Motion: a 150 ms fade, no rise. + * + * Render it AFTER the scroll view / list (it is absolutely positioned at the top, zIndex 10), never + * inside a list header: it must not scroll. It is hidden from VoiceOver (the large title is the + * header). Pair it with `useLargeTitleCollapse(scrollY)` on the large title in the content. */ export const CompactTitleBar = ({ title, scrollY, testID }: CompactTitleBarProps) => { const insets = useSafeAreaInsets(); - const barStyle = useAnimatedStyle(() => ({ - opacity: interpolate( - scrollY.value, - [COLLAPSE_AT - 16, COLLAPSE_AT], - [0, 1], - Extrapolation.CLAMP, - ), - })); - const titleStyle = useAnimatedStyle(() => ({ - transform: [ - { - translateY: interpolate( - scrollY.value, - [COLLAPSE_AT - 16, COLLAPSE_AT + 8], - [6, 0], - Extrapolation.CLAMP, - ), - }, - ], - })); + const reduced = useReducedMotion(); + const shown = useDerivedValue(() => (scrollY.value > COMPACT_BAR_AFTER ? 1 : 0)); + const opacity = useDerivedValue(() => + withTiming(shown.value, { + duration: reduced ? duration.reduced : 200, + easing: easing.standard, + }), + ); + const rise = useDerivedValue(() => + reduced ? 0 : withSpring(shown.value ? 0 : 6, spring.snappy), + ); + const barStyle = useAnimatedStyle(() => ({ opacity: opacity.value })); + const titleStyle = useAnimatedStyle(() => ({ transform: [{ translateY: rise.value }] })); return ( - + {Platform.OS === 'ios' && ( + + )} - + {title} @@ -140,6 +155,23 @@ export const CompactTitleBar = ({ title, scrollY, testID }: CompactTitleBarProps ); }; +/** + * The large title's own collapse (prototype `.lt`): between 10 and 80pt of scroll it shrinks to + * 88% (anchored bottom-left) and fades to 10%, while the compact bar takes over at 48pt. Apply the + * returned style to an Animated.View around the large title. Reduce Motion: fade only. + */ +export const useLargeTitleCollapse = (scrollY?: SharedValue) => { + const reduced = useReducedMotion(); + return useAnimatedStyle(() => { + const y = scrollY ? scrollY.value : 0; + const p = interpolate(y, [10, 80], [0, 1], Extrapolation.CLAMP); + return { + opacity: 1 - p * 0.9, + transform: [{ scale: reduced ? 1 : 1 - p * 0.12 }], + }; + }); +}; + export default BackButton; const styles = StyleSheet.create((theme) => ({ @@ -168,17 +200,20 @@ const styles = StyleSheet.create((theme) => ({ right: 0, zIndex: 10, overflow: 'hidden', - borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomWidth: 1, borderBottomColor: theme.colors.line, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 60, }, - compactTint: { backgroundColor: theme.colors.topBarTint }, + // Android has no live blur outside a BlurTargetView: a denser paper keeps the title readable + compactTint: { + backgroundColor: Platform.OS === 'ios' ? theme.colors.topBarTint : 'rgba(244,241,233,0.96)', + }, compactTitle: { fontFamily: theme.fontFamily.display, fontSize: 17, lineHeight: 22, - letterSpacing: -0.2, + letterSpacing: -0.17, }, })); diff --git a/mobile/src/components/ui/VText.tsx b/mobile/src/components/ui/VText.tsx index 4aef6930..c1ad5c7b 100644 --- a/mobile/src/components/ui/VText.tsx +++ b/mobile/src/components/ui/VText.tsx @@ -1,30 +1,47 @@ import React from 'react'; -import { Text, TextProps } from 'react-native'; +import { Text, TextProps, TextStyle } from 'react-native'; import { typography } from '../../common/theme/tokens'; import { MAX_FONT_SCALE } from '../../common/constants/constants'; +import { trimStyleFor, useEffectiveFontScale } from '../../design/components/Text'; export type TextVariant = keyof typeof typography; export interface VTextProps extends TextProps { variant?: TextVariant; color?: string; + /** keep tight Bricolage titles unclipped at their designed line height (see AppText) */ + trim?: boolean; } const TITLE_VARIANTS: TextVariant[] = ['largeTitle', 'h2']; /** - * Text with the VIC 2.0 type scale (Titillium Web titles, Roboto body). It follows the system text - * size (accessibility), capped so the layouts hold. + * Text with the older VIC 2.0 variant names, drawn with the „Îndrăzneț” type scale (largeTitle, + * h2 = title, …). Prefer AppText in new code. It follows the system text size, capped so the + * layouts hold, and trims tight Bricolage titles like AppText. */ -export const VText = ({ variant = 'body', color, style, ...props }: VTextProps) => ( - -); +export const VText = ({ + variant = 'body', + color, + style, + trim = true, + maxFontSizeMultiplier, + allowFontScaling = true, + ...props +}: VTextProps) => { + const cap = + maxFontSizeMultiplier ?? + (TITLE_VARIANTS.includes(variant) ? MAX_FONT_SCALE.title : MAX_FONT_SCALE.text); + const scale = useEffectiveFontScale(cap, allowFontScaling); + const trimStyle = trim ? trimStyleFor([typography[variant] as TextStyle, style], scale) : null; + return ( + + ); +}; export default VText; diff --git a/mobile/src/design/components/Group.tsx b/mobile/src/design/components/Group.tsx index 1d2faf82..f57977c8 100644 --- a/mobile/src/design/components/Group.tsx +++ b/mobile/src/design/components/Group.tsx @@ -44,5 +44,6 @@ const styles = StyleSheet.create((theme) => ({ overflow: 'hidden', }, padded: { paddingHorizontal: theme.space.l }, - line: { height: StyleSheet.hairlineWidth, backgroundColor: theme.colors.line }, + // 1pt line, like the prototype (`border-top:1px solid var(--line)`) + line: { height: 1, backgroundColor: theme.colors.line }, })); diff --git a/mobile/src/design/components/Text.tsx b/mobile/src/design/components/Text.tsx index 163470ea..fec653e6 100644 --- a/mobile/src/design/components/Text.tsx +++ b/mobile/src/design/components/Text.tsx @@ -1,29 +1,158 @@ // Text „Îndrăzneț”: one component for the whole type scale (Bricolage for numbers and titles, // Manrope for the rest). Dynamic Type: text grows to 1.35×, display and largeTitle stop at 1.15×. +// +// Tight Bricolage lines (display 0.86, largeTitle 0.96, title 1.1) are smaller than the font's own +// 1.2 em box. Left alone, iOS bottom-aligns each line and cuts the top of the glyphs (42h, Î, Ă) +// and Android cuts both ends. AppText therefore „trims” such text: the Text view grows so every +// glyph is inside it (padding), and negative margins pull its layout box back to exactly the +// designed line height, with the baseline where CSS puts it. Result: the layout matches the +// prototype, line pitch stays tight on wrapped lines, and nothing is clipped. +// +// Screen code: +// - trims automatically, also with your own +// fontSize / lineHeight / margins in `style` (your margins and paddings are kept, the trim is +// added to them). Pass `trim={false}` to opt out. +// - Plain `Text` with a tight Bricolage line: spread `tight(size, ratio, scale)` (one shared +// generator; HomeParts `tightLine` and layouts/displayTitle.ts can migrate to it). +// - For a Bricolage `Text` / `Animated.Text` you style yourself, spread `useTextTrim(style, cap)` +// after your style, or the static `trimFor(fontSize, lineHeight)` (exact at 1×, a few points off +// at the 1.15× cap). +// - The glyphs overflow the layout box by half the trim (≈ 13pt for display, 5pt for largeTitle), +// like in CSS: don't hug a tight Bricolage title with a parent that has `overflow: 'hidden'`. import React from 'react'; -import { Text, TextProps, TextStyle } from 'react-native'; +import { + Platform, + StyleProp, + StyleSheet as RNStyleSheet, + Text, + TextProps, + TextStyle, + useWindowDimensions, +} from 'react-native'; import { StyleSheet } from 'react-native-unistyles'; -import { fontScaleCap, type, TypeVariant } from '../tokens'; +import { fontFamily, fontMetrics, fontScaleCap, type, TypeVariant } from '../tokens'; export interface AppTextProps extends TextProps { variant?: TypeVariant; /** text color; defaults to the theme ink */ color?: string; + /** + * Keep tight Bricolage lines unclipped while their layout box stays at the designed line height + * (default true). Only acts when the line height is below the font's 1.2 em. + */ + trim?: boolean; } const DISPLAY_VARIANTS: TypeVariant[] = ['display', 'largeTitle']; +const DISPLAY_FAMILIES: string[] = [fontFamily.display, fontFamily.displayBold]; +const NATURAL_LINE = fontMetrics.display.ascent + fontMetrics.display.descent; export const maxScaleFor = (variant: TypeVariant) => DISPLAY_VARIANTS.includes(variant) ? fontScaleCap.display : fontScaleCap.text; -export const AppText = ({ variant = 'body', color, style, ...props }: AppTextProps) => ( - -); +/** + * Padding and margins that keep a Bricolage line of `lineHeight` (< 1.2 em) unclipped while its + * layout box stays `lineHeight` tall. `scale` is the font scale the text is drawn at. + * iOS bottom-aligns a squeezed line (the extra goes on top); Android centres it (CSS-like). + */ +export const trimFor = (fontSize: number, lineHeight: number, scale = 1) => { + const d = (fontSize * NATURAL_LINE - lineHeight) * scale; + if (!(d > 0.5)) { + return null; + } + return Platform.OS === 'android' + ? { paddingTop: d / 2, paddingBottom: d / 2, marginTop: -d / 2, marginBottom: -d / 2 } + : { paddingTop: d, paddingBottom: 0, marginTop: -d / 2, marginBottom: -d / 2 }; +}; + +/** + * Style generator for a tight Bricolage line drawn with a plain `Text`: font size, designed line + * height (`ratio` × size, e.g. 0.86 display, 0.96 largeTitle, 1.1 title) and the trim. Replaces + * the per-screen helpers (HomeParts `tightLine`, layouts/displayTitle.ts): spread it last and add + * your own vertical margins to the returned ones. `scale` = the drawn font scale + * (`useEffectiveFontScale(cap)`); AppText does all of this by itself. + */ +export const tight = (fontSize: number, ratio: number, scale = 1): TextStyle => { + const lineHeight = Math.round(ratio * fontSize * 10) / 10; + return { fontSize, lineHeight, ...(trimFor(fontSize, lineHeight, scale) ?? {}) }; +}; + +/** First defined value; null when it is not a number (percent, 'auto'). */ +const num = (...values: unknown[]): number | null => { + for (const value of values) { + if (value !== undefined && value !== null) { + return typeof value === 'number' ? value : null; + } + } + return 0; +}; + +/** + * The trim for an arbitrary text style: null unless it is Bricolage with a line height under + * 1.2 em. Your own vertical margins and paddings are added in, never replaced. + */ +export const trimStyleFor = (style: StyleProp, scale = 1): TextStyle | null => { + const flat = RNStyleSheet.flatten(style) ?? {}; + const { fontFamily: family, fontSize, lineHeight } = flat; + if (!family || !DISPLAY_FAMILIES.includes(family) || !fontSize || !lineHeight) { + return null; + } + const trim = trimFor(fontSize, lineHeight, scale); + if (!trim) { + return null; + } + const mt = num(flat.marginTop, flat.marginVertical, flat.margin); + const mb = num(flat.marginBottom, flat.marginVertical, flat.margin); + const pt = num(flat.paddingTop, flat.paddingVertical, flat.padding); + const pb = num(flat.paddingBottom, flat.paddingVertical, flat.padding); + if (mt === null || mb === null || pt === null || pb === null) { + // percentages / 'auto': leave the text as it is rather than guess + return null; + } + return { + marginTop: mt + trim.marginTop, + marginBottom: mb + trim.marginBottom, + paddingTop: pt + trim.paddingTop, + paddingBottom: pb + trim.paddingBottom, + }; +}; + +/** The font scale a Text is drawn at: the system scale, capped by maxFontSizeMultiplier. */ +export const useEffectiveFontScale = (maxFontSizeMultiplier?: number | null, allow = true) => { + const { fontScale } = useWindowDimensions(); + if (!allow) { + return 1; + } + return maxFontSizeMultiplier && maxFontSizeMultiplier >= 1 + ? Math.min(fontScale, maxFontSizeMultiplier) + : fontScale; +}; + +/** Hook form of `trimStyleFor` for your own `Text` / `Animated.Text`: spread it after your style. */ +export const useTextTrim = (style: StyleProp, maxFontSizeMultiplier?: number) => + trimStyleFor(style, useEffectiveFontScale(maxFontSizeMultiplier)); + +export const AppText = ({ + variant = 'body', + color, + style, + trim = true, + maxFontSizeMultiplier, + allowFontScaling = true, + ...props +}: AppTextProps) => { + const cap = maxFontSizeMultiplier ?? maxScaleFor(variant); + const scale = useEffectiveFontScale(cap, allowFontScaling); + const trimStyle = trim ? trimStyleFor([type[variant] as TextStyle, style], scale) : null; + return ( + + ); +}; export default AppText; diff --git a/mobile/src/design/motion.ts b/mobile/src/design/motion.ts index 4dfe7e69..c9187be1 100644 --- a/mobile/src/design/motion.ts +++ b/mobile/src/design/motion.ts @@ -32,6 +32,9 @@ export const duration = { toast: 2200, } as const; +/** Entrance lift in points (prototype `@keyframes stIn`). */ +export const ENTER_LIFT = 16; + /** At most 8 stagger steps; later items enter at once. */ export const MAX_STAGGER = 8; @@ -54,6 +57,8 @@ export const enter = (i: number, reduced = false) => : FadeInDown.springify() .stiffness(spring.gentle.stiffness) .damping(spring.gentle.damping) + // the handbook lift is 16pt (FadeInDown's own default is 25) + .withInitialValues({ opacity: 0, transform: [{ translateY: ENTER_LIFT }] }) .delay(Math.min(i, MAX_STAGGER) * duration.stagger); export const exit = (reduced = false) => diff --git a/mobile/src/design/tokens.ts b/mobile/src/design/tokens.ts index 652e6695..1537d727 100644 --- a/mobile/src/design/tokens.ts +++ b/mobile/src/design/tokens.ts @@ -97,24 +97,42 @@ export const fontFamily = { // Letter spacing in RN is in points: em × fontSize. const em = (value: number, size: number) => Math.round(value * size * 100) / 100; +/** + * Vertical metrics of the fonts (hhea, per 1 em). Bricolage Grotesque has a tall ascent (0.93) and + * descent (0.27): its natural line is 1.2 em, so the designed lines (display 0.86, largeTitle 0.96, + * title 1.1) are tighter than the font box. The tallest glyphs (Î at 0.909, Ș/ț down to -0.232) + * still fit inside that 1.2 em box. AppText keeps the glyphs inside the Text view and pulls the + * layout box back to the designed line height (see `trimFor` in design/components/Text.tsx). + */ +export const fontMetrics = { + display: { ascent: 0.93, descent: 0.27 }, + text: { ascent: 1.066, descent: 0.3 }, +} as const; + +/** Designed line heights (CSS ratios from the handbook). */ +const lh = (ratio: number, size: number) => Math.round(ratio * size * 10) / 10; + export const type = { + // 76 / 0.86: the layout box is 65.4 pt, the glyphs overflow it like they do in CSS display: { fontFamily: fontFamily.display, fontSize: 76, - lineHeight: 66, + lineHeight: lh(0.86, 76), letterSpacing: em(-0.055, 76), fontVariant: ['tabular-nums'], }, + // 44 / 0.96 largeTitle: { fontFamily: fontFamily.display, fontSize: 44, - lineHeight: 44, + lineHeight: lh(0.96, 44), letterSpacing: em(-0.04, 44), }, + // 24 / 1.1 title: { fontFamily: fontFamily.display, fontSize: 24, - lineHeight: 27, + lineHeight: lh(1.1, 24), letterSpacing: em(-0.025, 24), }, headline: { fontFamily: fontFamily.textHeavy, fontSize: 16, lineHeight: 20 }, @@ -134,8 +152,12 @@ export const type = { export type TypeVariant = keyof typeof type; -/** Dynamic Type: text grows to 1.35×, display and large titles stop at 1.15×. */ -export const fontScaleCap = { text: 1.35, display: 1.15 } as const; +/** + * Dynamic Type: text grows to 1.35×, display and large titles stop at 1.15×. Tab bar labels stop at + * 1.1× (they also shrink to fit their slot and never ellipsize; the iOS Large Content Viewer shows + * them big on long press, like the system tab bar). + */ +export const fontScaleCap = { text: 1.35, display: 1.15, tabLabel: 1.1 } as const; export const glass = { tint: lightColors.glassTint, diff --git a/mobile/src/screens/hours.test.tsx b/mobile/src/screens/hours.test.tsx index 88150e49..bfe749bf 100644 --- a/mobile/src/screens/hours.test.tsx +++ b/mobile/src/screens/hours.test.tsx @@ -243,7 +243,10 @@ describe('Ore', () => { } expect(summary.getByTestId('impact-hexagon', { includeHiddenElements: true })).toBeTruthy(); // the large title and the compact one that comes in after scrolling - expect(screen.getAllByText('Ore de voluntariat')).toHaveLength(2); + // (the compact one is hidden from VoiceOver: the large title is the header) + expect(screen.getAllByText('Ore de voluntariat', { includeHiddenElements: true })).toHaveLength( + 2, + ); }); it('refetches the current list and counters on initial focus and when returning', async () => {