From 1f05888de7411b9461303852fa29be6d1e298243 Mon Sep 17 00:00:00 2001 From: Andrew Radulescu Date: Fri, 13 Nov 2026 07:56:41 +0200 Subject: [PATCH 1/3] =?UTF-8?q?fix(mobile):=20hexagonul=20de=20impact=20ar?= =?UTF-8?q?at=C4=83=20lichid=20=C8=99i=20aproape=20plin=20(spa=C8=9Biu=20s?= =?UTF-8?q?ub=20col=C8=9B,=20dungi=20ca=20=C3=AEn=20prototip)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../design/components/ImpactHexagon.test.tsx | 68 ++++++++++++++ .../src/design/components/ImpactHexagon.tsx | 89 ++++++++++++++----- 2 files changed, 133 insertions(+), 24 deletions(-) create mode 100644 mobile/src/design/components/ImpactHexagon.test.tsx diff --git a/mobile/src/design/components/ImpactHexagon.test.tsx b/mobile/src/design/components/ImpactHexagon.test.tsx new file mode 100644 index 00000000..5430b8f8 --- /dev/null +++ b/mobile/src/design/components/ImpactHexagon.test.tsx @@ -0,0 +1,68 @@ +import { act, renderHook } from '@testing-library/react-native'; +import { impactHexagonLabel, liquidY, useCountUp } from './ImpactHexagon'; + +// size 108 on Acasă: H = 119, R = 52, corners at y 7.5 and 111.5, headroom 21 +const TOP = 7.5; +const BOTTOM = 111.5; +const HEADROOM = 21; + +describe('ImpactHexagon liquid level', () => { + it('hides the wave under the bottom corner when empty', () => { + // the crest (middle line − amp) is still at the bottom corner + expect(liquidY(0, TOP, BOTTOM, 7, HEADROOM) - 7).toBe(BOTTOM); + }); + + it('keeps the crest under the top corner when full, so the liquid still reads as liquid', () => { + const middle = liquidY(1, TOP, BOTTOM, 7, HEADROOM); + expect(middle - 7).toBeGreaterThan(TOP + 10); + }); + + it('42 aprobate + 1 respinsă (98%) still shows the crest below the top corner', () => { + const middle = liquidY(42 / 43, TOP, BOTTOM, 7, HEADROOM); + expect(middle - 7).toBeGreaterThan(TOP + 10); + expect(middle).toBeGreaterThan(liquidY(1, TOP, BOTTOM, 7, HEADROOM)); + }); + + it('rises with the level and clamps outside 0–1', () => { + expect(liquidY(0.5, TOP, BOTTOM, 7, HEADROOM)).toBeLessThan( + liquidY(0.25, TOP, BOTTOM, 7, HEADROOM), + ); + expect(liquidY(2, TOP, BOTTOM, 7, HEADROOM)).toBe(liquidY(1, TOP, BOTTOM, 7, HEADROOM)); + expect(liquidY(-1, TOP, BOTTOM, 7, HEADROOM)).toBe(liquidY(0, TOP, BOTTOM, 7, HEADROOM)); + }); + + it('labels the hours in Romanian', () => { + expect(impactHexagonLabel(42, 0, 1)).toBe('42 de ore aprobate, 1 oră respinsă'); + expect(impactHexagonLabel(42, 5, 0)).toBe('42 de ore aprobate, 5 ore în așteptare'); + }); +}); + +describe('useCountUp', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it('counts again from 0 when the replay key changes (each focus of Acasă)', async () => { + const hook = await renderHook( + ({ replay }: { replay: number }) => useCountUp(42, false, replay), + { initialProps: { replay: 1 } }, + ); + await act(async () => { + jest.advanceTimersByTime(1200); + }); + expect(hook.result.current).toBe(42); + await hook.rerender({ replay: 2 }); + await act(async () => { + jest.advanceTimersByTime(100); + }); + expect(hook.result.current).toBeLessThan(42); + await act(async () => { + jest.advanceTimersByTime(1200); + }); + expect(hook.result.current).toBe(42); + }); + + it('shows the number at once with Reduce Motion', async () => { + const hook = await renderHook(() => useCountUp(42, true, 1)); + expect(hook.result.current).toBe(42); + }); +}); diff --git a/mobile/src/design/components/ImpactHexagon.tsx b/mobile/src/design/components/ImpactHexagon.tsx index 70f04888..c516dfbf 100644 --- a/mobile/src/design/components/ImpactHexagon.tsx +++ b/mobile/src/design/components/ImpactHexagon.tsx @@ -33,9 +33,15 @@ export interface ImpactHexagonProps { const FAST_MS = 2600; const SLOW_MS = 3600; +// the prototype's wavelength: one crest every 120pt, whatever the size +const PERIOD = 120; +const FAST_AMP = 7; +const SLOW_AMP = 4; // both waves are back where they started after this long, so the clock can wrap without a jump const CLOCK_WRAP = 46800; const COUNT_MS = 900; +// the dark half of the „în așteptare” stripes on the navy card (prototype pattern #012A40) +const STRIPE_BG_ON_NAVY = '#012A40'; /** Romanian count: „1 oră”, „5 ore”, „42 de ore” (20+ and round hundreds take „de”). */ const roHours = (n: number) => { @@ -93,15 +99,43 @@ const waveLine = (width: number, period: number, amp: number) => { const waveFill = (width: number, height: number, period: number, amp: number) => `${waveLine(width, period, amp)}L${width} ${height}L0 ${height}Z`; -/** 45° stripes, 4pt yellow every 8pt, covering width × height. */ +/** + * The prototype's stripe pattern (8pt tile with a 4pt bar, rotated 45°): bars 4pt wide every 8pt + * measured across the stripes, so 4√2 wide every 8√2 along a horizontal line. + */ const stripes = (width: number, height: number) => { + const pitch = 8 * Math.SQRT2; + const bar = 4 * Math.SQRT2; let d = ''; - for (let x = -height; x < width; x += 8) { - d += `M${x} 0L${x + 4} 0L${x + 4 + height} ${height}L${x + height} ${height}Z`; + for (let x = -height; x < width; x += pitch) { + d += `M${x.toFixed(2)} 0L${(x + bar).toFixed(2)} 0L${(x + bar + height).toFixed(2)} ${height}L${( + x + height + ).toFixed(2)} ${height}Z`; } return d; }; +/** + * Where the middle of a wave sits (y, from the top of the canvas) for a fill level 0–1. + * 0 hides the wave under the bottom corner. 1 does NOT reach the top corner: the prototype maps + * the level straight onto the height, so real numbers like 42 aprobate + 1 respinsă (98%) cover + * the whole hexagon and it reads as a flat yellow shape. Here a full level stops `headroom` + * below the top corner, so the crest and its motion always stay visible under the outline. + */ +export const liquidY = ( + level: number, + top: number, + bottom: number, + amp: number, + headroom: number, +) => { + 'worklet'; + const empty = bottom + amp; + const full = top + headroom; + const clamped = Math.min(1, Math.max(0, level)); + return empty - clamped * (empty - full); +}; + const svgPath = (d: string) => Skia.Path.MakeFromSVGString(d) ?? Skia.Path.Make(); /** Focus of the screen around the hexagon; always focused outside a navigator. */ @@ -134,22 +168,27 @@ export const ImpactHexagon = ({ }: ImpactHexagonProps) => { const reduced = useReducedMotion(); const focused = useScreenFocused(); + const onNavy = tone === 'onNavy'; const W = size; const H = Math.round(size * 1.1); - const period = Math.round(size * 1.1); + const R = W / 2 - 2; + const period = PERIOD; const bandWidth = W + period + 8; + // the hexagon's top and bottom corners, and the gap a full level keeps under the top one + const top = H / 2 - R; + const bottom = H / 2 + R; + const headroom = Math.round(R * 0.4); const paths = useMemo(() => { - const hex = roundedHexPath(W / 2, H / 2, W / 2 - 2, W * 0.12); + const hex = roundedHexPath(W / 2, H / 2, R, W * 0.12); return { hex: svgPath(hex), - approved: svgPath(waveFill(bandWidth, H * 2, period, 7)), - pending: svgPath(waveFill(bandWidth, H * 2, period, 4)), - crest: svgPath(waveLine(bandWidth, period, 4)), + approved: svgPath(waveFill(bandWidth, H * 2, period, FAST_AMP)), + pending: svgPath(waveFill(bandWidth, H * 2, period, SLOW_AMP)), stripes: svgPath(stripes(bandWidth, H * 2)), }; - }, [W, H, period, bandWidth]); + }, [W, H, R, period, bandWidth]); // defensive: counters can arrive as numeric strings const a = toHours(approved); @@ -188,15 +227,15 @@ export const ImpactHexagon = ({ const approvedTransform = useDerivedValue(() => [ { translateX: -((clock.value / FAST_MS) % 1) * period }, - { translateY: H * (1 - approvedLevel.value) - 5 }, + // the wave path has its middle line at y = amp + { translateY: liquidY(approvedLevel.value, top, bottom, FAST_AMP, headroom) - FAST_AMP }, ]); const pendingTransform = useDerivedValue(() => [ // counter-phase: the slow wave runs the other way { translateX: -(1 - ((clock.value / SLOW_MS) % 1)) * period }, - { translateY: H * (1 - pendingLevel.value) - 4 }, + { translateY: liquidY(pendingLevel.value, top, bottom, SLOW_AMP, headroom) - SLOW_AMP }, ]); - const onNavy = tone === 'onNavy'; return ( - - - - + {/* no band without pending hours: its crest would show in the troughs of the liquid */} + {pendingShare > 0 && ( + + + + + - - + )} From 2373a2cf9a920f2bcdff18e545b3cf9543034b87 Mon Sep 17 00:00:00 2001 From: Andrew Radulescu Date: Fri, 13 Nov 2026 07:56:41 +0200 Subject: [PATCH 2/3] =?UTF-8?q?fix(mobile):=20Acas=C4=83=20ca=20=C3=AEn=20?= =?UTF-8?q?prototip=20=E2=80=94=20cifra=2042h=20str=C3=A2ns=C4=83,=20cardu?= =?UTF-8?q?ri=20egale=20=C3=AEn=20carusel,=20intrarea=20=C3=AEn=20stagger?= =?UTF-8?q?=20la=20schimbarea=20tabului?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mobile/src/screens/Home.tsx | 16 ++++- mobile/src/screens/home/HomeParts.tsx | 92 +++++++++++++++++++-------- 2 files changed, 79 insertions(+), 29 deletions(-) diff --git a/mobile/src/screens/Home.tsx b/mobile/src/screens/Home.tsx index af80620b..f3691e61 100644 --- a/mobile/src/screens/Home.tsx +++ b/mobile/src/screens/Home.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { RefreshControl, View, useWindowDimensions } from 'react-native'; import Animated, { useReducedMotion, useSharedValue } from 'react-native-reanimated'; import { useFocusEffect } from '@react-navigation/native'; @@ -70,6 +70,18 @@ const Home = ({ navigation }: any) => { const orgSheet = useRef(null); // each focus refills the hexagon and counts the hours again const [focusCount, setFocusCount] = useState(0); + // switching to Acasă from another tab plays the staggered entrance again (prototype: each tab + // enters with its blocks 40 ms apart); coming back from a pushed screen does not + const [enterKey, setEnterKey] = useState(0); + useEffect( + () => + navigation.addListener('tabPress', () => { + if (!navigation.isFocused()) { + setEnterKey((key) => key + 1); + } + }), + [navigation], + ); const { userProfile } = useUserProfile(); const { setUserProfile } = useStore(); @@ -222,7 +234,7 @@ const Home = ({ navigation }: any) => { showsVerticalScrollIndicator={false} testID="home" > - + Math.min(useWindowDimensions().fontScale || 1, fontScaleCap.display); + +/** + * Line height and bottom margin that put the glyphs where CSS puts them for `line-height: lh`: + * digits start `(ascent - capHeight) - (1.2 - lh) / 2` below the top of the line and the line + * ends `descent - (1.2 - lh) / 2` below the baseline. + */ +const tightLine = (size: number, lh: number, scale: number) => { + const half = (1.2 - lh) / 2; + const below = (DESCENT - half) * size; // CSS: from the baseline to the bottom of the line + const height = lh * size; + const lineHeight = height - below + DESCENT * size; + return { lineHeight, marginBottom: -(lineHeight - height) * scale }; +}; + // --------------------------------------------------------------------------------------------- // Press: scale .965 with the snappy spring (handbook `.press:active`) @@ -135,6 +159,7 @@ export const CompactBar = ({ title, scrollY }: { title: string; scrollY: SharedV }; export const LargeTitle = ({ title, scrollY }: { title: string; scrollY: SharedValue }) => { + const line = tightLine(44, 0.96, useDisplayScale()); const style = useAnimatedStyle(() => { const p = interpolate(scrollY.value, [10, 80], [0, 1], Extrapolation.CLAMP); return { @@ -146,7 +171,12 @@ export const LargeTitle = ({ title, scrollY }: { title: string; scrollY: SharedV }); return ( - + {title} @@ -238,6 +268,7 @@ export const ImpactCard = ({ const { t } = useTranslation('home'); const reduced = useReducedMotion(); const shown = useCountUp(approved, reduced, replay); + const numLine = tightLine(76, 0.86, useDisplayScale()); const approvedA11y = approved === 1 ? t('impact.a11y_approved_singular') @@ -276,21 +307,20 @@ export const ImpactCard = ({ {t('impact.overline')} - - - {`${shown}`} - - h - - - + + {`${shown}`} + {/* the small „h” (36pt) inherits the line of the number */} + + h + + {approved === 1 ? t('impact.label_singular') : t('impact.label')} @@ -437,7 +467,9 @@ const EventSlide = ({ return ( onPress(event.id)} - style={[styles.ecard, { width }]} + // every card as tall as the tallest one (the prototype's flex row stretches them) + outerStyle={{ width }} + style={styles.ecard} accessibilityLabel={[event.name, when, event.location, event.organizationName, status?.label] .filter(Boolean) .join(', ')} @@ -455,9 +487,7 @@ const EventSlide = ({ - - {event.name} - + {event.name} {!!event.location && } {!!event.organizationName && } @@ -493,7 +523,7 @@ const MetaLine = ({ }) => ( {Icon && } - + {text} @@ -622,6 +652,7 @@ const styles = StyleSheet.create((theme) => ({ letterSpacing: -0.2, }, largeTitle: { marginTop: 14, marginBottom: 16, transformOrigin: 'left bottom' }, + largeTitleText: { includeFontPadding: false }, orgRow: { marginTop: 6, flexDirection: 'row', @@ -652,8 +683,8 @@ const styles = StyleSheet.create((theme) => ({ overflow: 'hidden', }, impactBody: { flex: 1, minWidth: 0 }, - numRow: { flexDirection: 'row', alignItems: 'flex-end', marginTop: 4 }, - num: { flexShrink: 1 }, + // prototype `.impact .num`: 76pt, line-height .86, right under the overline + num: { includeFontPadding: false }, numUnit: { fontSize: 36, letterSpacing: -1.08 }, impactLabel: { fontFamily: theme.fontFamily.textBold, @@ -732,6 +763,7 @@ const styles = StyleSheet.create((theme) => ({ gap: CARD_GAP, }, ecard: { + flex: 1, backgroundColor: theme.colors.surface, borderRadius: theme.radius.card, padding: 10, @@ -754,10 +786,13 @@ const styles = StyleSheet.create((theme) => ({ letterSpacing: 0.74, color: theme.colors.navy, }, + // prototype `.dateb b`: 30pt, line-height 1 (see tightLine) datebDay: { fontFamily: theme.fontFamily.display, fontSize: 30, - lineHeight: 32, + lineHeight: 33, + marginBottom: -3, + includeFontPadding: false, letterSpacing: -1.2, color: theme.colors.navy, fontVariant: ['tabular-nums'], @@ -782,10 +817,13 @@ const styles = StyleSheet.create((theme) => ({ borderWidth: 2, borderColor: 'rgba(1,52,79,0.18)', }, + // prototype `.tile .n`: 40pt, line-height .9 (see tightLine) tileN: { fontFamily: theme.fontFamily.display, fontSize: 40, - lineHeight: 40, + lineHeight: 42, + marginBottom: -6, + includeFontPadding: false, letterSpacing: -1.6, fontVariant: ['tabular-nums'], }, @@ -793,6 +831,6 @@ const styles = StyleSheet.create((theme) => ({ fontFamily: theme.fontFamily.textBold, fontSize: 12.5, lineHeight: 15.6, - marginTop: 6, + marginTop: 2, }, })); From 2dea8249728e3fc58735029e0488f0a2e1849656 Mon Sep 17 00:00:00 2001 From: Andrew Radulescu Date: Fri, 13 Nov 2026 08:01:35 +0200 Subject: [PATCH 3/3] =?UTF-8?q?refactor(mobile):=20Acas=C4=83=20folose?= =?UTF-8?q?=C8=99te=20t=C4=83ierea=20Bricolage=20din=20AppText=20=C8=99i?= =?UTF-8?q?=20PressableScale=20comun?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mobile/src/screens/home/HomeParts.tsx | 126 +++++--------------------- 1 file changed, 24 insertions(+), 102 deletions(-) diff --git a/mobile/src/screens/home/HomeParts.tsx b/mobile/src/screens/home/HomeParts.tsx index dff17b31..a9bf8b03 100644 --- a/mobile/src/screens/home/HomeParts.tsx +++ b/mobile/src/screens/home/HomeParts.tsx @@ -1,24 +1,14 @@ // Pieces of Acasă „Îndrăzneț” (handbook prototype, screen „Acasă”): the compact glass bar, the // organization row, the navy impact card with the liquid hexagon, the yellow to-do cards, the // „Urmează” carousel and the „Luna aceasta” bento. -import React, { ReactNode } from 'react'; -import { - AccessibilityProps, - Pressable, - ScrollView, - StyleProp, - Text, - View, - ViewStyle, - useWindowDimensions, -} from 'react-native'; +import React from 'react'; +import { Pressable, ScrollView, Text, View, useWindowDimensions } from 'react-native'; import Animated, { Extrapolation, interpolate, SharedValue, useAnimatedStyle, useReducedMotion, - useSharedValue, } from 'react-native-reanimated'; import { BlurView } from 'expo-blur'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -31,7 +21,6 @@ import { EventRegistrationStatus } from '../../common/enums/event-registration-s import { IEventListItem } from '../../common/interfaces/event-list-item.interface'; import { formatEventWhen, shortMonth } from '../../common/utils/event-format'; import { fontScaleCap, glass, lightColors, palette, space, StatusKind } from '../../design/tokens'; -import { springTo } from '../../design/motion'; import { ArrowRightIcon, BellIcon, @@ -45,72 +34,11 @@ import { PinIcon, } from '../../design/icons'; import { AppText } from '../../design/components/Text'; +import { PressableScale } from '../../components/ui/PressableScale'; import { StatusChip } from '../../design/components/Chip'; import { HexAvatar } from '../../design/components/ListRow'; import { ImpactHexagon, useCountUp } from '../../design/components/ImpactHexagon'; -// --------------------------------------------------------------------------------------------- -// Bricolage Grotesque metrics (ascent 0.93, descent 0.27 of the size). When a line is shorter than -// the font (1.2 × size), iOS and Android both cut the difference from the top: the baseline sits -// `descent` above the bottom of the line. CSS centres the glyphs instead, so the prototype's tight -// `line-height: .86` lines are rebuilt here with a taller line and a negative bottom margin. -const DESCENT = 0.27; - -/** Display text scale, as AppText caps it (display and large titles stop at 1.15×). */ -const useDisplayScale = () => Math.min(useWindowDimensions().fontScale || 1, fontScaleCap.display); - -/** - * Line height and bottom margin that put the glyphs where CSS puts them for `line-height: lh`: - * digits start `(ascent - capHeight) - (1.2 - lh) / 2` below the top of the line and the line - * ends `descent - (1.2 - lh) / 2` below the baseline. - */ -const tightLine = (size: number, lh: number, scale: number) => { - const half = (1.2 - lh) / 2; - const below = (DESCENT - half) * size; // CSS: from the baseline to the bottom of the line - const height = lh * size; - const lineHeight = height - below + DESCENT * size; - return { lineHeight, marginBottom: -(lineHeight - height) * scale }; -}; - -// --------------------------------------------------------------------------------------------- -// Press: scale .965 with the snappy spring (handbook `.press:active`) - -interface PressProps extends AccessibilityProps { - onPress?: () => void; - style?: StyleProp; - /** style of the touch area around the scaled content (e.g. flex: 1 in a row) */ - outerStyle?: StyleProp; - children: ReactNode; - testID?: string; - disabled?: boolean; - hitSlop?: number; -} - -export const Press = ({ onPress, style, outerStyle, children, disabled, ...props }: PressProps) => { - const reduced = useReducedMotion(); - const pressed = useSharedValue(0); - const scale = useAnimatedStyle(() => ({ - transform: [{ scale: reduced ? 1 : interpolate(pressed.value, [0, 1], [1, 0.965]) }], - })); - return ( - { - pressed.value = springTo(1, 'snappy', reduced); - }} - onPressOut={() => { - pressed.value = springTo(0, 'snappy', reduced); - }} - > - {children} - - ); -}; - // --------------------------------------------------------------------------------------------- // Large title + compact glass bar. The large title scrolls with the content (as in the // prototype), shrinks and fades; the compact bar „Acasă” fades in after 48pt. @@ -159,7 +87,6 @@ export const CompactBar = ({ title, scrollY }: { title: string; scrollY: SharedV }; export const LargeTitle = ({ title, scrollY }: { title: string; scrollY: SharedValue }) => { - const line = tightLine(44, 0.96, useDisplayScale()); const style = useAnimatedStyle(() => { const p = interpolate(scrollY.value, [10, 80], [0, 1], Extrapolation.CLAMP); return { @@ -175,7 +102,7 @@ export const LargeTitle = ({ title, scrollY }: { title: string; scrollY: SharedV variant="largeTitle" accessibilityRole="header" numberOfLines={2} - style={[styles.largeTitleText, line]} + style={styles.largeTitleText} > {title} @@ -200,11 +127,10 @@ export const OrgRow = ({ return ( {name ? ( - - + ) : ( )} - - + ); }; @@ -268,7 +194,6 @@ export const ImpactCard = ({ const { t } = useTranslation('home'); const reduced = useReducedMotion(); const shown = useCountUp(approved, reduced, replay); - const numLine = tightLine(76, 0.86, useDisplayScale()); const approvedA11y = approved === 1 ? t('impact.a11y_approved_singular') @@ -287,7 +212,7 @@ export const ImpactCard = ({ .filter(Boolean) .join(', '); return ( - {`${shown}`} - {/* the small „h” (36pt) inherits the line of the number */} + {/* the small „h” (36pt) inherits the line of the number; AppText trims the line */} h @@ -346,7 +271,7 @@ export const ImpactCard = ({ )} - + ); }; @@ -371,7 +296,7 @@ export const TodoCard = ({ onPress: () => void; testID?: string; }) => ( - - + ); // --------------------------------------------------------------------------------------------- @@ -465,11 +390,10 @@ const EventSlide = ({ : { kind: 'ok', label: `${t('card.going')}` }; const audience = event.isPublic ? `${t('card.public')}` : `${t('card.members')}`; return ( - onPress(event.id)} // every card as tall as the tallest one (the prototype's flex row stretches them) - outerStyle={{ width }} - style={styles.ecard} + style={[styles.ecard, { width }]} accessibilityLabel={[event.name, when, event.location, event.organizationName, status?.label] .filter(Boolean) .join(', ')} @@ -506,7 +430,7 @@ const EventSlide = ({ {status && } - + ); }; @@ -616,7 +540,7 @@ export const MonthBento = ({ tiles }: { tiles: BentoTile[] }) => { {content} ) : ( - { testID={`home-stat-${tile.key}`} > {content} - + ); })} @@ -786,12 +710,11 @@ const styles = StyleSheet.create((theme) => ({ letterSpacing: 0.74, color: theme.colors.navy, }, - // prototype `.dateb b`: 30pt, line-height 1 (see tightLine) + // prototype `.dateb b`: 30pt, line-height 1 (AppText trims the Bricolage line) datebDay: { fontFamily: theme.fontFamily.display, fontSize: 30, - lineHeight: 33, - marginBottom: -3, + lineHeight: 30, includeFontPadding: false, letterSpacing: -1.2, color: theme.colors.navy, @@ -817,12 +740,11 @@ const styles = StyleSheet.create((theme) => ({ borderWidth: 2, borderColor: 'rgba(1,52,79,0.18)', }, - // prototype `.tile .n`: 40pt, line-height .9 (see tightLine) + // prototype `.tile .n`: 40pt, line-height .9 (AppText trims the Bricolage line) tileN: { fontFamily: theme.fontFamily.display, fontSize: 40, - lineHeight: 42, - marginBottom: -6, + lineHeight: 36, includeFontPadding: false, letterSpacing: -1.6, fontVariant: ['tabular-nums'],