Typography in an instrument app is not decoration; it is part of the measurement surface. When Sonarish displays LAeq trending toward a legal noise threshold, the number itself must dominate the visual field and the chrome around it must recede. When Wheria shows a confidence badge during a garage walk, the label has to be readable at arm's length in dim light, without the user's brain spending cycles parsing inconsistent font weights. uranashel ships five apps across iOS and Android with a single typeface: Nunito, loaded as static font instances at weights 400 (Regular), 500 (Medium), 600 (SemiBold), and 700 (Bold). No system font fallback on app-owned surfaces. The only exceptions are OS-native dialogs (TimePicker, permission sheets, share sheets), which the operating system renders in San Francisco or Roboto and which no app can override without private API hacks we will not ship.
Why a custom font at all
Both iOS and Android default to excellent system typefaces, SF Pro and Roboto respectively, and our early prototypes ran on them. Quality was never the complaint. What we wanted was recognizability and control. A user who opens Wheria, then Sonarish, then Phyzix should sense one studio's hand on all three; the system-font prototypes felt like three unrelated apps that forgot to configure branding. Nunito is rounded enough to feel approachable in a consumer context, and its x-height and letter spacing stay readable at 13–15 pt on small screens, which is where instrument labels actually live. We auditioned geometric sans-serifs that look crisp at 24 pt and collapse at 13 pt. Out. We auditioned display fonts that would fight the monochrome design system described in our monochrome UI post. Also out. One typeface, five apps, two platforms, two themes each. That consistency builds a quiet trust that the numbers on screen were placed there deliberately.
The arithmetic of arm's length
How small can a label get before a tired driver misreads it? Legibility has actual arithmetic. A lowercase letter of x-height x viewed from distance d subtends a visual angle θ = 2·atan(x/2d). At 15 pt, one em is 15/72 inch, about 5.3 mm, and Nunito's x-height is roughly 0.53 em, so lowercase letters stand about 2.8 mm tall. Held at 55 cm, a typical arm's length, that gives θ ≈ 0.29°, about 17 arcminutes. The comfortable-reading floor quoted in vision literature sits near 0.2°. So 15 pt Nunito clears the floor with roughly 40% margin; 13 pt lands at 15 arcminutes, still fine for secondary labels; 11 pt is marginal. Bench note: 3 phones (an iPhone 13, a Pixel 7, a 2019-era Redmi), one evening on floor B2 near pillar E9, screens at 40% brightness, ambient light 38–45 lux, walking the aisle and reading trial labels set at 11, 13, and 15 pt. Two of the three of us misread the 11 pt labels at least once. Nobody misread 13 or 15. Those became the only secondary label sizes in Wheria.
The iOS font war
SwiftUI makes custom typography deceptively easy and structurally incomplete. Setting .font(.custom("Nunito-Regular", size: 15)) on individual Text views works until you miss one, and then a stray label renders in SF Pro and the whole screen reads as broken. Our fix lives in AppFont.swift and it is layered. The root view applies .font(.nunito(.body)) so any Text without an explicit override inherits Nunito at body size. Navigation bar titles are a separate battle. On iOS 16 and later, UINavigationBar.appearance().titleTextAttributes is ignored by SwiftUI navigation stacks; the title you see is rendered by SwiftUI's own toolbar system, which defaults to SF Pro semibold at 17 pt. We inject a custom .nunitoTitle() modifier as a ToolbarItem(placement: .principal) on every navigation screen. Toolbar buttons (Done, Save, Cancel) need their own explicit .font(.nunito(.subheadline)) or they quietly revert to the system button font. The worst offender is Form and List: SwiftUI Forms default to 17 pt system font for row labels while the body text around them is 15 pt Nunito. The Settings screens in Wheria, Estua, Sonarish, Phyzix, and Stashio all force .font(.nunito(.subheadline)) on form content. Miss it on one screen and ktuyen files a parity bug within the hour. She has done this four times. We deserved all four.
The Android font war
Jetpack Compose is more honest about typography. MaterialTheme.typography maps every Material text role to a Nunito instance in AppTypography.kt, and a single CompositionLocalProvider wrapping the app tree sets the default text style. The leak points are third-party composables and system dialogs. Some library components internally hardcode TextStyle.Default, which resolves to Roboto; we wrap those screens in an additional ProvideTextStyle as defense. TimePickerDialog, DatePickerDialog, and the permission rationale sheet are system surfaces. Roboto forever, unfixable, and acceptable, because users recognize them as OS chrome rather than app instrumentation. Cross-platform parity adds its own constraint. Android Settings rows have to match iOS Settings rows at 15 pt Medium weight, and Material's default BodyLarge would set them at 16 pt, so AppTypography.kt overrides it. ktuyen checks this side by side on an iPhone and a Pixel during every release pass, the discipline documented in our cross-platform post.
Static instances and the Vietnamese alphabet
Nunito also ships as a variable font, and for about a week during development we used it. The single file is tidy: one 590 KB TTF against four static instances at 168–176 KB each, roughly 690 KB total. We went back to statics for two reasons. Rendering first: Core Text on iOS and Minikin on Android interpolate variable weights slightly differently, and our width probe (next section) measured the interpolated 600 instance 2–3% wider on Android than on iOS at the same size. Static instances cut from the same master agree within 0.3%. Discipline second: with a weight axis available, a weight of 570 will eventually ship because someone nudged a slider. Four fixed weights are a constraint we can enforce in code review by grepping for font constructors.
The other reason Nunito survived our shortlist is Vietnamese. Half our interface strings carry diacritics, and stacked marks like the circumflex-plus-tone in ế or the horn-plus-tilde in ữ reach well above the Latin ascender line. A font with pretty Latin glyphs and afterthought Vietnamese marks clips at tight line heights. Before every release we run all localized strings through a render harness at every text style; the current values-vi set holds 1,842 strings, 61 of which open with a stacked mark on the first line, and every one of those 61 has clipped at least once in some layout during development. The fixes are unglamorous. includeFontPadding stays false on Android with an explicit 1.35 line-height multiplier, and fixed-height frames around single-line Text are banned on both platforms.
Catching leaks with a width probe
Manual screenshot review misses font leaks, because SF Pro, Roboto, and Nunito are all competent humanist-leaning sans-serifs, and at 13 pt on a 460 ppi screen the eye forgives a lot. Metrics forgive nothing. The three faces set the same string at measurably different widths: at 15 pt, our probe string renders 4.1% narrower in SF Pro and 2.7% narrower in Roboto than in Nunito. So every app carries a debug-only audit that walks the view tree and measures each text element twice, once with the paint the view actually has and once with the Nunito instance it should have.
PROBE = "Illegal1 O0 mịn ữ 12:45"
fun auditScreen(root):
for t in textNodes(root):
wActual = measure(PROBE, t.paint)
wExpect = measure(PROBE, nunito(t.weight, t.sizePt))
if abs(wActual - wExpect) / wExpect > 0.005:
report(t.id, t.paint.family, t.sizePt)
The probe string mixes ambiguous glyphs (Il1, O0), Vietnamese stacked marks, and digits, so it fingerprints the family and the feature settings at once. The 0.5% threshold sits above cross-platform rounding noise and far below the 2.7% Roboto gap. The first run across all five apps reported 11 leaks: 9 Form rows on iOS and 2 toolbar buttons. The audit costs about 40 ms per screen on a Pixel 7, which is why it runs only in debug builds and in ktuyen's release checklist. No leak has shipped since.
Radius, weight, and hierarchy without color
Font weight carries hierarchy in a monochrome app where color cannot. Primary instrument readings use SemiBold or Bold at 17–22 pt; secondary labels and timestamps sit at Regular, 13–15 pt. Disabled or stale data keeps Regular weight and just drops opacity; Nunito Light never ships in the bundle, so weight stays consistent even when luminance falls. Corner radius is 12 px on cards, buttons, and sheets across all five apps. We prototyped 8 px and 16 px and reverted both within a week: 8 looked sharp against Nunito's round terminals, 16 looked like a toy. Nunito at fixed weights, strict monochrome, and uniform radius together are what make a Wheria settings row and a Sonarish settings row feel like the same product, even though one app tracks magnetometer heading and the other tracks A-weighted decibels, a curve we derive in our A-weighting post. Typography is the glue. We tune it with the same rigor as a Kalman filter constant, and we regression-test it the same way.
Trong app đo đạc, typography thuộc về bề mặt đo chứ không phải lớp trang trí. Sonarish hiện LAeq đang tiến gần ngưỡng ồn pháp lý thì con số phải chiếm trọn tầm nhìn, phần khung quanh nó phải lùi lại. Wheria hiện badge độ tin cậy giữa hầm xe thì nhãn phải đọc được ở khoảng cách một sải tay, dưới ánh đèn lờ mờ, không bắt não user tốn công phân tích weight font lộn xộn. Năm app của uranashel trên cả iOS lẫn Android chỉ dùng một typeface: Nunito, load dạng instance tĩnh ở bốn weight 400 (Regular), 500 (Medium), 600 (SemiBold) và 700 (Bold). Surface nào app sở hữu thì không có fallback về font hệ thống. Ngoại lệ duy nhất là mấy dialog gốc của OS như TimePicker, sheet xin permission, share sheet. Chỗ đó OS vẽ bằng San Francisco hoặc Roboto, muốn override phải hack private API. Mình không ship kiểu đó.
Vì sao phải dùng font riêng
SF Pro và Roboto đều là typeface hệ thống rất tốt; prototype đời đầu của mình chạy trên chúng. Chất lượng không có gì để chê. Cái mình cần là nhận diện và quyền kiểm soát. Bạn mở Wheria, chuyển sang Sonarish rồi Phyzix — cảm giác phải là đồ nghề của cùng một xưởng. Bản prototype chạy font hệ thống trông như ba app xa lạ quên chỉnh branding. Nunito bo tròn vừa đủ để thân thiện, mà x-height với letter spacing vẫn rõ ở 13–15 pt, đúng dải cỡ chữ nơi nhãn của dụng cụ đo sinh sống. Mấy font geometric sắc nét ở 24 pt nhưng nát ở 13 pt: loại. Font display choảng nhau với hệ monochrome trong bài monochrome UI: cũng loại. Một typeface, năm app, hai nền tảng, mỗi nền hai theme. Nhất quán tới mức đó khiến user tin ngầm rằng từng con số trên màn hình được đặt có chủ đích.
Phép tính ở khoảng cách sải tay
Chữ nhỏ tới đâu thì tài xế mệt bắt đầu đọc nhầm? Chuyện này có công thức hẳn hoi. Chữ thường cao x nhìn từ khoảng cách d chắn một góc nhìn θ = 2·atan(x/2d). Ở 15 pt, một em bằng 15/72 inch, cỡ 5,3 mm; x-height của Nunito khoảng 0,53 em nên chữ thường cao chừng 2,8 mm. Cầm máy cách mắt 55 cm, tầm một sải tay, ra θ ≈ 0,29°, khoảng 17 phút cung. Ngưỡng đọc thoải mái trong tài liệu thị giác quanh mốc 0,2°. Vậy 15 pt dư chừng 40%; 13 pt còn 15 phút cung, vẫn ổn cho nhãn phụ; 11 pt thì chấp chới. Ghi chú bench: 3 máy (iPhone 13, Pixel 7 và một con Redmi đời 2019), một buổi tối ở tầng B2 gần cột E9, màn hình để 40% độ sáng, ánh sáng xung quanh 38–45 lux, cả nhóm đi dọc lối xe đọc nhãn thử ở 11, 13 và 15 pt. Hai trong ba người đọc nhầm nhãn 11 pt ít nhất một lần. Cỡ 13 và 15 không ai nhầm. Wheria giờ chỉ dùng đúng hai cỡ nhãn phụ đó.
Cuộc chiến font trên iOS
SwiftUI làm typography custom trông thì dễ mà hụt về cấu trúc. Set .font(.custom("Nunito-Regular", size: 15)) lên từng Text chạy tốt cho tới lúc sót một cái; label lạc hiện SF Pro và cả màn hình nhìn như bị vỡ. Fix của mình nằm trong AppFont.swift, xếp nhiều lớp. Root view apply .font(.nunito(.body)) để Text nào không override thì thừa hưởng Nunito cỡ body. Title trên navigation bar là mặt trận riêng: từ iOS 16, SwiftUI navigation stack lờ hẳn UINavigationBar.appearance().titleTextAttributes. Title bạn thấy do hệ toolbar của SwiftUI vẽ, mặc định SF Pro semibold 17 pt. Mình chèn modifier .nunitoTitle() dạng ToolbarItem(placement: .principal) vào mọi màn có nav. Nút toolbar kiểu Done, Save, Cancel phải set riêng .font(.nunito(.subheadline)), quên là tụt về font nút hệ thống. Tệ nhất là Form với List: Form của SwiftUI mặc định 17 pt system font cho label từng dòng, trong khi chữ xung quanh là Nunito 15 pt. Màn Settings của Wheria, Estua, Sonarish, Phyzix và Stashio đều ép .font(.nunito(.subheadline)) lên nội dung form. Sót một màn là trong vòng một tiếng ktuyen file bug parity. Đã bốn lần như vậy. Lần nào team cũng đáng bị.
Cuộc chiến font trên Android
Jetpack Compose thẳng thắn hơn khoản typography. MaterialTheme.typography map từng text role của Material sang instance Nunito trong AppTypography.kt; một CompositionLocalProvider bọc cây app là có default text style. Chỗ rò nằm ở composable bên thứ ba và dialog hệ thống. Vài component thư viện hardcode TextStyle.Default, resolve ra Roboto; màn nào dính thì bọc thêm một lớp ProvideTextStyle để phòng thủ. TimePickerDialog, DatePickerDialog và sheet giải thích permission là surface của hệ thống. Roboto vĩnh viễn, không sửa nổi, nhưng chấp nhận được: user nhìn là biết chrome của OS chứ không phải phần dụng cụ đo của app. Parity cross-platform thêm một ràng buộc nữa. Dòng Settings bên Android phải khớp iOS ở 15 pt weight Medium; BodyLarge mặc định của Material lại đặt 16 pt nên AppTypography.kt phải override. Mỗi pass release ktuyen đặt iPhone cạnh Pixel soi từng màn, quy trình kể kỹ trong bài cross-platform.
Instance tĩnh và bảng chữ tiếng Việt
Nunito có cả bản variable font; hồi mới làm mình xài thử chừng một tuần. File đơn gọn thật: một TTF 590 KB so với bốn instance tĩnh 168–176 KB mỗi file, tổng cỡ 690 KB. Rồi vẫn quay về bản tĩnh, vì hai lý do. Một là rendering: Core Text bên iOS và Minikin bên Android nội suy weight variable hơi lệch nhau; probe đo bề rộng (phần dưới) cho thấy instance 600 nội suy bên Android rộng hơn bên iOS 2–3% ở cùng cỡ chữ. Instance tĩnh cắt từ cùng master lệch dưới 0,3%. Hai là kỷ luật: có sẵn trục weight thì sớm muộn cũng lọt ra một weight 570 do ai đó kéo slider. Bốn weight cố định là ràng buộc enforce được ngay trong code review, grep constructor font là ra hết.
Lý do còn lại giúp Nunito trụ trong shortlist: tiếng Việt. Nửa số string giao diện của mình mang dấu. Dấu chồng như mũ cộng sắc trong ế hay móc cộng ngã trong ữ vươn cao hẳn trên đường ascender của chữ Latin; font nào Latin đẹp mà phần dấu Việt làm cho có thì cứ line height chật là cụt đầu chữ. Trước mỗi release, mình cho toàn bộ string đã localize chạy qua harness render ở đủ mọi text style. Bộ values-vi hiện có 1.842 string; 61 string mở đầu bằng dấu chồng ngay dòng một; cả 61 string đó đều từng bị cắt ngọn ít nhất một lần trong lúc dev. Cách sửa không có gì hoa mỹ: includeFontPadding giữ false bên Android kèm hệ số line height 1,35 khai báo tường minh; frame cao cố định quanh Text một dòng bị cấm trên cả hai nền.
Bắt leak font bằng probe bề rộng
Soi screenshot bằng mắt dễ sót leak font. SF Pro, Roboto và Nunito đều là sans-serif thiên hướng humanist; ở 13 pt trên màn 460 ppi, mắt người bỏ qua gần hết khác biệt. Số đo thì không tha. Ba font đặt cùng một chuỗi ra bề rộng khác nhau rõ rệt: ở 15 pt, chuỗi probe của mình hẹp hơn 4,1% nếu rơi vào SF Pro và hẹp hơn 2,7% nếu rơi vào Roboto, so với khi render bằng Nunito. Nên app nào cũng gắn một bước audit chỉ bật ở bản debug: duyệt cây view, đo mỗi phần tử chữ hai lần, một lần bằng paint thật của view và một lần bằng instance Nunito lẽ ra phải có.
PROBE = "Illegal1 O0 mịn ữ 12:45"
fun auditScreen(root):
for t in textNodes(root):
wActual = measure(PROBE, t.paint)
wExpect = measure(PROBE, nunito(t.weight, t.sizePt))
if abs(wActual - wExpect) / wExpect > 0.005:
report(t.id, t.paint.family, t.sizePt)
Chuỗi probe trộn glyph dễ lẫn (Il1, O0), dấu chồng tiếng Việt và chữ số, nên nhận diện được cả family lẫn thiết lập feature cùng lúc. Ngưỡng 0,5% nằm trên mức nhiễu làm tròn giữa hai nền và dưới xa khoảng cách 2,7% của Roboto. Lần chạy đầu trên cả năm app báo 11 leak: 9 dòng Form bên iOS và 2 nút toolbar. Mỗi màn tốn chừng 40 ms trên Pixel 7 nên audit chỉ chạy ở bản debug và trong checklist release của ktuyen. Từ đó chưa leak nào lọt ra bản ship.
Bo góc, weight và hierarchy không cần màu
App monochrome không nhờ được màu để phân cấp thì weight phải gánh. Số đo chính dùng SemiBold hoặc Bold 17–22 pt; nhãn phụ và timestamp dùng Regular 13–15 pt. Data disabled hoặc cũ vẫn giữ Regular, chỉ hạ opacity; Nunito Light không nằm trong bundle nên weight nhất quán kể cả khi độ chói tụt. Bo góc 12 px cho card, button và sheet trên cả năm app. Từng thử 8 px với 16 px, một tuần sau bỏ cả hai: 8 nhìn sắc lẹm cạnh nét tròn của Nunito, còn 16 nhìn như đồ chơi. Nunito weight cố định cộng monochrome nghiêm ngặt cộng radius đồng nhất là thứ khiến một dòng Settings của Wheria và một dòng Settings của Sonarish cho cảm giác cùng một sản phẩm, dù app này bám heading từ kế còn app kia bám decibel A-weighted (đường cong đó có bài riêng: giải thích A-weighting). Typography là keo dán. Mình tune nó kỹ ngang hằng số Kalman filter và regression-test cũng nghiêm y như vậy.