From 57732f62817c50da3580cccf6e072bd435e746c3 Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Sun, 20 Sep 2026 12:55:22 +0800 Subject: [PATCH] feat(mobile): normalize text size by physical pixel density Side by side on real hardware, the same token reads noticeably larger on HarmonyOS and Android than on iOS. The logical sizes match; the pixels do not. A Mate X7 packs 415 dpi into density 3.125, so one vp covers 0.193mm, while an iPhone 13 Pro spreads 460 dpi over density 3 and one pt covers 0.166mm. Every label lands 16% bigger in the hand. Add a text_scale contract to the design tokens that divides a device's real logical dpi by the iPhone reference of 153.3 and clamps the result to [0.85, 1.0], so the factor only ever shrinks and iOS stays the reference at 1.0. Bogus display metrics fall back to 1.0 rather than shrinking text to nothing. HarmonyOS has no app level font scale hook, so the factor folds into the generated typography getters and EntryAbility applies it from the display metrics at startup. Android provides a Density with a scaled fontScale so every sp under the theme follows, including the literals the tokens do not cover yet. On a Mate X7 the factor is 0.868 and body text measures 2.47mm against the iPhone's 2.37mm, down from 2.83mm. Inline markdown on iOS hardcoded 14pt for strong and 13pt for code, so emphasis shrank below the 16pt paragraph around it. Use the inline presentation intents instead and let the run inherit the enclosing font. Fold the two literal font sizes left in ChatMessageContent into labelSmall so the HarmonyOS architecture check passes again. --- scripts/mobile-ui-design-system.mjs | 20 ++++++- .../openbitfun/mobile/app/ui/theme/Theme.kt | 24 +++++++- .../ui/theme/generated/MobileDesignTokens.kt | 21 +++++++ .../app/ui/theme/MobileTextScaleTest.kt | 33 +++++++++++ src/apps/mobile/design-system/README.md | 15 +++++ .../preview/generated/mobile-design-data.js | 5 ++ .../design-system/tokens/mobile-tokens.json | 5 ++ .../main/ets/entryability/EntryAbility.ets | 20 ++++++- .../main/ets/generated/MobileDesignTokens.ets | 58 +++++++++++++++++-- .../pages/components/ChatMessageContent.ets | 5 +- .../entry/src/test/LocalUnit.test.ets | 2 + .../entry/src/test/TextScaleUnit.test.ets | 44 ++++++++++++++ .../Features/Chat/ChatTimelineView.swift | 10 +++- 13 files changed, 248 insertions(+), 14 deletions(-) create mode 100644 src/apps/mobile/android/app/src/test/kotlin/com/openbitfun/mobile/app/ui/theme/MobileTextScaleTest.kt create mode 100644 src/apps/mobile/harmonyos/entry/src/test/TextScaleUnit.test.ets diff --git a/scripts/mobile-ui-design-system.mjs b/scripts/mobile-ui-design-system.mjs index 36afa9a26b..142e9ce50b 100644 --- a/scripts/mobile-ui-design-system.mjs +++ b/scripts/mobile-ui-design-system.mjs @@ -106,6 +106,7 @@ function validateContract(tokenContract, componentContract, scenarioContract) { for (const [name, value] of Object.entries(tokenContract.geometry ?? {})) { if (!Number.isFinite(value) || value <= 0) throw new Error(`Invalid geometry token ${name}.`); } + validateTextScale(tokenContract.text_scale); const availableTokenNames = new Set([ ...Object.keys(tokenContract.colors ?? {}), ...Object.keys(tokenContract.typography ?? {}), @@ -129,6 +130,19 @@ function validateContract(tokenContract, componentContract, scenarioContract) { } } +// The text scale normalises how big one logical unit of type is on the glass. +// The ramp was tuned on the iPhone Pro class (460 ppi at 3x = 153.3 logical +// units per inch); a screen whose logical unit is physically larger shrinks +// its text by the ratio, clamped, so a 16 reads as the same millimetres. +function validateTextScale(textScale) { + if (!textScale) throw new Error('Missing text_scale in mobile tokens.'); + const { reference_logical_dpi: reference, min_factor: min, max_factor: max } = textScale; + if (!Number.isFinite(reference) || reference <= 0) throw new Error('Invalid text_scale.reference_logical_dpi.'); + if (!Number.isFinite(min) || !Number.isFinite(max) || min <= 0 || min > max || max > 1) { + throw new Error('text_scale factors must satisfy 0 < min_factor <= max_factor <= 1.'); + } +} + function renderHarmonyColors(colors, appearance) { return `${JSON.stringify({ color: Object.entries(colors).map(([name, pair]) => ({ name, value: pair[appearance] })), @@ -136,6 +150,7 @@ function renderHarmonyColors(colors, appearance) { } function renderHarmonyTokens(contract) { + const textScale = contract.text_scale; const colors = Object.entries(contract.colors) .map(([name, pair]) => ` static readonly ${camel(name)}: MobileColorPair = new MobileColorPair(${quoted(pair.light)}, ${quoted(pair.dark)});`) .join('\n'); @@ -145,7 +160,7 @@ function renderHarmonyTokens(contract) { const geometry = renderNumberProperties(contract.geometry, ' static readonly'); const breakpoints = renderNumberProperties(contract.breakpoints, ' static readonly'); const motion = renderNumberProperties(contract.motion, ' static readonly'); - return `// Generated by scripts/mobile-ui-design-system.mjs. Do not edit.\n\nexport class MobileColorPair {\n readonly light: string;\n readonly dark: string;\n\n constructor(light: string, dark: string) {\n this.light = light;\n this.dark = dark;\n }\n}\n\nexport class MobileDesignColors {\n${colors}\n}\n\nexport class MobileTypographyToken {\n readonly size: number;\n readonly lineHeight: number;\n readonly weight: number;\n\n constructor(size: number, lineHeight: number, weight: number) {\n this.size = size;\n this.lineHeight = lineHeight;\n this.weight = weight;\n }\n}\n\nexport class MobileDesignTypography {\n${typography}\n}\n\nexport class MobileDesignGeometry {\n${geometry}\n}\n\nexport class MobileDesignBreakpoints {\n${breakpoints}\n}\n\nexport class MobileDesignMotion {\n${motion}\n}\n`; + return `// Generated by scripts/mobile-ui-design-system.mjs. Do not edit.\n\nexport class MobileColorPair {\n readonly light: string;\n readonly dark: string;\n\n constructor(light: string, dark: string) {\n this.light = light;\n this.dark = dark;\n }\n}\n\nexport class MobileDesignColors {\n${colors}\n}\n\nexport class MobileTextScale {\n static readonly referenceLogicalDpi: number = ${textScale.reference_logical_dpi};\n static readonly minFactor: number = ${textScale.min_factor};\n static readonly maxFactor: number = ${textScale.max_factor};\n private static current: number = 1;\n\n static get factor(): number {\n return MobileTextScale.current;\n }\n\n // xdpi is physical pixels per inch along the horizontal axis; densityPixels is\n // physical pixels per logical unit. Their ratio is logical units per inch,\n // compared against the reference the type ramp was tuned on. Displays that\n // report an implausible xdpi against their nominal 160-per-unit density fall\n // back to the unscaled ramp rather than trusting bad metrics.\n static resolve(xdpi: number, densityPixels: number): number {\n if (!(xdpi > 0) || !(densityPixels > 0)) {\n return 1;\n }\n const nominalDpi = densityPixels * 160;\n if (xdpi < nominalDpi * 0.5 || xdpi > nominalDpi * 2) {\n return 1;\n }\n const factor = xdpi / densityPixels / MobileTextScale.referenceLogicalDpi;\n const clamped = Math.min(MobileTextScale.maxFactor, Math.max(MobileTextScale.minFactor, factor));\n return Math.round(clamped * 1000) / 1000;\n }\n\n static apply(xdpi: number, densityPixels: number): number {\n MobileTextScale.current = MobileTextScale.resolve(xdpi, densityPixels);\n return MobileTextScale.current;\n }\n\n static reset(): void {\n MobileTextScale.current = 1;\n }\n\n static scaled(value: number): number {\n return Math.round(value * MobileTextScale.current * 2) / 2;\n }\n}\n\nexport class MobileTypographyToken {\n readonly baseSize: number;\n readonly baseLineHeight: number;\n readonly weight: number;\n\n constructor(size: number, lineHeight: number, weight: number) {\n this.baseSize = size;\n this.baseLineHeight = lineHeight;\n this.weight = weight;\n }\n\n get size(): number {\n return MobileTextScale.scaled(this.baseSize);\n }\n\n get lineHeight(): number {\n return MobileTextScale.scaled(this.baseLineHeight);\n }\n}\n\nexport class MobileDesignTypography {\n${typography}\n}\n\nexport class MobileDesignGeometry {\n${geometry}\n}\n\nexport class MobileDesignBreakpoints {\n${breakpoints}\n}\n\nexport class MobileDesignMotion {\n${motion}\n}\n`; } function renderHarmonyScenarios(contract) { @@ -154,6 +169,7 @@ function renderHarmonyScenarios(contract) { } function renderAndroidTokens(contract) { + const textScale = contract.text_scale; const lightColors = renderKotlinColors(contract.colors, 'light'); const darkColors = renderKotlinColors(contract.colors, 'dark'); const typography = Object.entries(contract.typography) @@ -164,7 +180,7 @@ function renderAndroidTokens(contract) { .join('\n'); const breakpoints = renderKotlinInts(contract.breakpoints); const motion = renderKotlinInts(contract.motion); - return `// Generated by scripts/mobile-ui-design-system.mjs. Do not edit.\npackage com.openbitfun.mobile.app.ui.theme.generated\n\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.text.TextStyle\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.ui.unit.sp\n\ninternal object MobileDesignColors {\n object Light {\n${lightColors}\n }\n\n object Dark {\n${darkColors}\n }\n}\n\ninternal object MobileDesignTypography {\n${typography}\n}\n\ninternal object MobileDesignGeometry {\n${geometry}\n}\n\ninternal object MobileDesignBreakpoints {\n${breakpoints}\n}\n\ninternal object MobileDesignMotion {\n${motion}\n}\n`; + return `// Generated by scripts/mobile-ui-design-system.mjs. Do not edit.\npackage com.openbitfun.mobile.app.ui.theme.generated\n\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.text.TextStyle\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.ui.unit.sp\n\ninternal object MobileDesignColors {\n object Light {\n${lightColors}\n }\n\n object Dark {\n${darkColors}\n }\n}\n\ninternal object MobileDesignTypography {\n${typography}\n}\n\n/**\n * Normalises how big one logical unit of type is on the glass. The ramp was\n * tuned on 153.3 logical units per inch; a screen whose dp is physically larger\n * shrinks its text by the ratio, clamped, so a 16.sp reads as the same\n * millimetres. Returns 1 for displays whose xdpi is implausible against their\n * nominal 160-per-dp density rather than trusting bad metrics.\n */\ninternal object MobileTextScale {\n const val ReferenceLogicalDpi: Float = ${textScale.reference_logical_dpi}f\n const val MinFactor: Float = ${textScale.min_factor}f\n const val MaxFactor: Float = ${textScale.max_factor}f\n\n fun resolve(xdpi: Float, density: Float): Float {\n if (!(xdpi > 0f) || !(density > 0f)) return 1f\n val nominalDpi = density * 160f\n if (xdpi < nominalDpi * 0.5f || xdpi > nominalDpi * 2f) return 1f\n val factor = (xdpi / density / ReferenceLogicalDpi).coerceIn(MinFactor, MaxFactor)\n return Math.round(factor * 1000f) / 1000f\n }\n}\n\ninternal object MobileDesignGeometry {\n${geometry}\n}\n\ninternal object MobileDesignBreakpoints {\n${breakpoints}\n}\n\ninternal object MobileDesignMotion {\n${motion}\n}\n`; } function renderAndroidScenarios(contract) { diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/theme/Theme.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/theme/Theme.kt index 2332a81199..d00fe602cb 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/theme/Theme.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/theme/Theme.kt @@ -8,8 +8,12 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Density import com.openbitfun.mobile.app.ui.theme.generated.MobileDesignColors import com.openbitfun.mobile.app.ui.theme.generated.MobileDesignTypography +import com.openbitfun.mobile.app.ui.theme.generated.MobileTextScale /** * The palette, ported from the HarmonyOS client's `Theme.ets` plus its @@ -299,7 +303,10 @@ internal val openBitFunColors: OpenBitFunColors @Composable internal fun OpenBitFunTheme(dark: Boolean, content: @Composable () -> Unit) { - CompositionLocalProvider(LocalOpenBitFunColors provides if (dark) DarkExtras else LightExtras) { + CompositionLocalProvider( + LocalOpenBitFunColors provides if (dark) DarkExtras else LightExtras, + LocalDensity provides textScaledDensity(), + ) { MaterialTheme( colorScheme = if (dark) DarkScheme else LightScheme, typography = OpenBitFunTypography, @@ -307,3 +314,18 @@ internal fun OpenBitFunTheme(dark: Boolean, content: @Composable () -> Unit) { ) } } + +/** + * The same ramp reads physically larger on a screen whose dp is bigger than the + * reference the sizes were tuned on, so every `sp` under the theme is folded by + * the display's own pitch. It rides on top of the user's font-size preference + * rather than replacing it, and dp geometry is untouched. + */ +@Composable +@ReadOnlyComposable +private fun textScaledDensity(): Density { + val base = LocalDensity.current + val metrics = LocalContext.current.resources.displayMetrics + val factor = MobileTextScale.resolve(xdpi = metrics.xdpi, density = metrics.density) + return if (factor == 1f) base else Density(base.density, base.fontScale * factor) +} diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/theme/generated/MobileDesignTokens.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/theme/generated/MobileDesignTokens.kt index 11823306b9..636927ddda 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/theme/generated/MobileDesignTokens.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/theme/generated/MobileDesignTokens.kt @@ -155,6 +155,27 @@ internal object MobileDesignTypography { val BrandWordmark = TextStyle(fontSize = 42.sp, lineHeight = 56.sp, fontWeight = FontWeight.Medium) } +/** + * Normalises how big one logical unit of type is on the glass. The ramp was + * tuned on 153.3 logical units per inch; a screen whose dp is physically larger + * shrinks its text by the ratio, clamped, so a 16.sp reads as the same + * millimetres. Returns 1 for displays whose xdpi is implausible against their + * nominal 160-per-dp density rather than trusting bad metrics. + */ +internal object MobileTextScale { + const val ReferenceLogicalDpi: Float = 153.3f + const val MinFactor: Float = 0.85f + const val MaxFactor: Float = 1f + + fun resolve(xdpi: Float, density: Float): Float { + if (!(xdpi > 0f) || !(density > 0f)) return 1f + val nominalDpi = density * 160f + if (xdpi < nominalDpi * 0.5f || xdpi > nominalDpi * 2f) return 1f + val factor = (xdpi / density / ReferenceLogicalDpi).coerceIn(MinFactor, MaxFactor) + return Math.round(factor * 1000f) / 1000f + } +} + internal object MobileDesignGeometry { val ConversationHeaderHeight = 76.dp val ConversationHeaderCompactHeight = 64.dp diff --git a/src/apps/mobile/android/app/src/test/kotlin/com/openbitfun/mobile/app/ui/theme/MobileTextScaleTest.kt b/src/apps/mobile/android/app/src/test/kotlin/com/openbitfun/mobile/app/ui/theme/MobileTextScaleTest.kt new file mode 100644 index 0000000000..a7ab833fd6 --- /dev/null +++ b/src/apps/mobile/android/app/src/test/kotlin/com/openbitfun/mobile/app/ui/theme/MobileTextScaleTest.kt @@ -0,0 +1,33 @@ +package com.openbitfun.mobile.app.ui.theme + +import com.openbitfun.mobile.app.ui.theme.generated.MobileTextScale +import org.junit.Assert.assertEquals +import org.junit.Test + +class MobileTextScaleTest { + @Test + fun referenceClassStaysUnscaled() { + // 460 ppi at 3x is the ramp's reference; anything denser per dp caps at 1. + assertEquals(1f, MobileTextScale.resolve(xdpi = 460f, density = 3f), 0.0001f) + assertEquals(1f, MobileTextScale.resolve(xdpi = 489f, density = 3f), 0.0001f) + } + + @Test + fun largerDpShrinksText() { + // HUAWEI Mate X7 outer screen: 415.6 xdpi at density 3.125. + assertEquals(0.868f, MobileTextScale.resolve(xdpi = 415.636f, density = 3.125f), 0.0001f) + } + + @Test + fun neverDropsBelowFloor() { + assertEquals(0.85f, MobileTextScale.resolve(xdpi = 300f, density = 3f), 0.0001f) + } + + @Test + fun implausibleMetricsFallBackToUnscaled() { + assertEquals(1f, MobileTextScale.resolve(xdpi = 0f, density = 3f), 0.0001f) + assertEquals(1f, MobileTextScale.resolve(xdpi = 96f, density = 3f), 0.0001f) + assertEquals(1f, MobileTextScale.resolve(xdpi = 2000f, density = 3f), 0.0001f) + assertEquals(1f, MobileTextScale.resolve(xdpi = 415f, density = 0f), 0.0001f) + } +} diff --git a/src/apps/mobile/design-system/README.md b/src/apps/mobile/design-system/README.md index 69288d318a..d4e1cc3898 100644 --- a/src/apps/mobile/design-system/README.md +++ b/src/apps/mobile/design-system/README.md @@ -40,6 +40,21 @@ platform presentation primitives. - Native hosts follow the system font-size preference with a documented maximum scale. Validate the standard size and at least one enlarged accessibility size without changing display zoom, and prefer wrapping or ellipsis over clipping. +- The ramp's sizes were tuned on the iPhone Pro class, where one logical unit is + 1/153.3 inch (460 ppi at 3x). A screen whose logical unit is physically larger + renders the same `16` visibly bigger, so `text_scale` normalises it: the host + computes `xdpi / density / reference_logical_dpi`, clamps it to + `[min_factor, max_factor]`, and applies it to text only, on top of the user's + font-size preference. HarmonyOS folds it into the generated typography roles + (`MobileTextScale.apply` at ability start, so every role getter returns the + scaled value); Android folds it into the theme's `Density.fontScale` so every + `sp` under `OpenBitFunTheme` follows. iOS is the reference and stays at 1. + Symbol glyph and `dp`/`vp` geometry never scale. A HUAWEI Mate X7 + (415.6 dpi at density 3.125) resolves to 0.868, so its `16` reads as 14 vp — + the same millimetres as 16 pt on the iPhone. +- Inline Markdown runs (`**strong**`, `*emphasis*`, `` `code` ``) change only + weight, slant, or family; they inherit the paragraph role's size on all three + hosts. The native role names map to the product's formal content purposes as follows: diff --git a/src/apps/mobile/design-system/preview/generated/mobile-design-data.js b/src/apps/mobile/design-system/preview/generated/mobile-design-data.js index 24534fa11f..cf290a686d 100644 --- a/src/apps/mobile/design-system/preview/generated/mobile-design-data.js +++ b/src/apps/mobile/design-system/preview/generated/mobile-design-data.js @@ -334,6 +334,11 @@ export const mobileTokens = { "weight": 500 } }, + "text_scale": { + "reference_logical_dpi": 153.3, + "min_factor": 0.85, + "max_factor": 1 + }, "geometry": { "conversation_header_height": 76, "conversation_header_compact_height": 64, diff --git a/src/apps/mobile/design-system/tokens/mobile-tokens.json b/src/apps/mobile/design-system/tokens/mobile-tokens.json index f34770c638..0a7263a725 100644 --- a/src/apps/mobile/design-system/tokens/mobile-tokens.json +++ b/src/apps/mobile/design-system/tokens/mobile-tokens.json @@ -333,6 +333,11 @@ "weight": 500 } }, + "text_scale": { + "reference_logical_dpi": 153.3, + "min_factor": 0.85, + "max_factor": 1 + }, "geometry": { "conversation_header_height": 76, "conversation_header_compact_height": 64, diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets index 968ef088b9..87e748dd6d 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets @@ -1,7 +1,7 @@ import { AbilityConstant, Configuration, ConfigurationConstant, UIAbility, Want } from '@kit.AbilityKit'; import { hilog } from '@kit.PerformanceAnalysisKit'; -import { window } from '@kit.ArkUI'; -import { MobileDesignColors } from '../generated/MobileDesignTokens'; +import { display, window } from '@kit.ArkUI'; +import { MobileDesignColors, MobileTextScale } from '../generated/MobileDesignTokens'; const DOMAIN = 0x0000; const TAG = 'OpenBitFunRemote'; @@ -33,9 +33,25 @@ export default class EntryAbility extends UIAbility { } catch (err) { hilog.error(DOMAIN, TAG, 'Failed to set colorMode. Cause: %{public}s', JSON.stringify(err)); } + this.applyTextScale(); hilog.info(DOMAIN, TAG, '%{public}s', 'Ability onCreate'); } + // The type ramp is read through getters, so this has to land before the first + // page module loads; onCreate runs ahead of loadContent. A fold changing + // screens keeps the same pixel pitch on the devices we ship to, so one read at + // startup is enough. + private applyTextScale(): void { + try { + const metrics = display.getDefaultDisplaySync(); + const factor = MobileTextScale.apply(metrics.xDPI, metrics.densityPixels); + hilog.info(DOMAIN, TAG, 'Text scale %{public}s (xDPI %{public}s, density %{public}s)', + String(factor), String(metrics.xDPI), String(metrics.densityPixels)); + } catch (err) { + hilog.error(DOMAIN, TAG, 'Failed to read display metrics for text scale. Cause: %{public}s', JSON.stringify(err)); + } + } + onDestroy(): void { hilog.info(DOMAIN, TAG, '%{public}s', 'Ability onDestroy'); } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/generated/MobileDesignTokens.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/generated/MobileDesignTokens.ets index 96075ed014..fa5335a702 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/generated/MobileDesignTokens.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/generated/MobileDesignTokens.ets @@ -73,16 +73,66 @@ export class MobileDesignColors { static readonly statusWarning: MobileColorPair = new MobileColorPair("#FF8C00", "#FF8C00"); } +export class MobileTextScale { + static readonly referenceLogicalDpi: number = 153.3; + static readonly minFactor: number = 0.85; + static readonly maxFactor: number = 1; + private static current: number = 1; + + static get factor(): number { + return MobileTextScale.current; + } + + // xdpi is physical pixels per inch along the horizontal axis; densityPixels is + // physical pixels per logical unit. Their ratio is logical units per inch, + // compared against the reference the type ramp was tuned on. Displays that + // report an implausible xdpi against their nominal 160-per-unit density fall + // back to the unscaled ramp rather than trusting bad metrics. + static resolve(xdpi: number, densityPixels: number): number { + if (!(xdpi > 0) || !(densityPixels > 0)) { + return 1; + } + const nominalDpi = densityPixels * 160; + if (xdpi < nominalDpi * 0.5 || xdpi > nominalDpi * 2) { + return 1; + } + const factor = xdpi / densityPixels / MobileTextScale.referenceLogicalDpi; + const clamped = Math.min(MobileTextScale.maxFactor, Math.max(MobileTextScale.minFactor, factor)); + return Math.round(clamped * 1000) / 1000; + } + + static apply(xdpi: number, densityPixels: number): number { + MobileTextScale.current = MobileTextScale.resolve(xdpi, densityPixels); + return MobileTextScale.current; + } + + static reset(): void { + MobileTextScale.current = 1; + } + + static scaled(value: number): number { + return Math.round(value * MobileTextScale.current * 2) / 2; + } +} + export class MobileTypographyToken { - readonly size: number; - readonly lineHeight: number; + readonly baseSize: number; + readonly baseLineHeight: number; readonly weight: number; constructor(size: number, lineHeight: number, weight: number) { - this.size = size; - this.lineHeight = lineHeight; + this.baseSize = size; + this.baseLineHeight = lineHeight; this.weight = weight; } + + get size(): number { + return MobileTextScale.scaled(this.baseSize); + } + + get lineHeight(): number { + return MobileTextScale.scaled(this.baseLineHeight); + } } export class MobileDesignTypography { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageContent.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageContent.ets index e92db7926b..a20b886d2c 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageContent.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageContent.ets @@ -10,6 +10,7 @@ import { FileReferenceCard } from './FileReferenceCard'; import { MarkdownContent } from './MarkdownContent'; import { StreamingMarkdownContent } from './StreamingMarkdownContent'; import { LINE, MEDIA_BACKGROUND, MUTED } from './Theme'; +import { MobileDesignTypography } from '../../generated/MobileDesignTokens'; @ComponentV2 export struct MessageImageGallery { @@ -30,13 +31,13 @@ export struct MessageImageGallery { // the attachment instead and say why it is not here. Column() { Text(image.name || RemoteI18n.t('chat.image')) - .fontSize(11) + .fontSize(MobileDesignTypography.labelSmall.size) .fontColor(MUTED) .maxLines(2) .textOverflow({ overflow: TextOverflow.Ellipsis }) .textAlign(TextAlign.Center) Text(RemoteI18n.t('chat.imageUnavailable')) - .fontSize(10) + .fontSize(MobileDesignTypography.labelSmall.size) .fontColor(MUTED) .maxLines(2) .textOverflow({ overflow: TextOverflow.Ellipsis }) diff --git a/src/apps/mobile/harmonyos/entry/src/test/LocalUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/LocalUnit.test.ets index 88aad63924..210f7d3544 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/LocalUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/LocalUnit.test.ets @@ -10,11 +10,13 @@ import deviceDirectoryUnitTest from './DeviceDirectoryUnit.test'; import architectureUnitTest from './ArchitectureUnit.test'; import i18nUnitTest from './I18nUnit.test'; import watchProvisionUnitTest from './WatchProvisionUnit.test'; +import textScaleUnitTest from './TextScaleUnit.test'; export default function localUnitTest() { miniAppUnitTest(); i18nUnitTest(); watchProvisionUnitTest(); + textScaleUnitTest(); architectureUnitTest(); transportAndGeneralChatUnitTest(); conversationStateUnitTest(); diff --git a/src/apps/mobile/harmonyos/entry/src/test/TextScaleUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/TextScaleUnit.test.ets new file mode 100644 index 0000000000..431a2b1b5c --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/test/TextScaleUnit.test.ets @@ -0,0 +1,44 @@ +import { describe, it, expect, afterEach } from '@ohos/hypium'; +import { MobileDesignTypography, MobileTextScale } from '../main/ets/generated/MobileDesignTokens'; + +export default function textScaleUnitTest() { + describe('Mobile text scale', () => { + afterEach(() => { + MobileTextScale.reset(); + }); + + it('leaves the iPhone Pro class reference unscaled', 0, () => { + // 460 ppi at 3x is the ramp's reference; anything denser per unit caps at 1. + expect(MobileTextScale.resolve(460, 3)).assertEqual(1); + expect(MobileTextScale.resolve(489, 3)).assertEqual(1); + }); + + it('shrinks text on a screen whose logical unit is physically larger', 0, () => { + // HUAWEI Mate X7 outer screen: 415.6 xDPI at density 3.125. + expect(MobileTextScale.resolve(415.636, 3.125)).assertEqual(0.868); + }); + + it('never drops below the floor', 0, () => { + expect(MobileTextScale.resolve(300, 3)).assertEqual(0.85); + }); + + it('ignores implausible or missing metrics', 0, () => { + expect(MobileTextScale.resolve(0, 3)).assertEqual(1); + expect(MobileTextScale.resolve(96, 3)).assertEqual(1); + expect(MobileTextScale.resolve(2000, 3)).assertEqual(1); + expect(MobileTextScale.resolve(415, 0)).assertEqual(1); + }); + + it('scales the typography ramp to half units once applied', 0, () => { + expect(MobileDesignTypography.bodyLarge.size).assertEqual(16); + expect(MobileDesignTypography.bodyLarge.lineHeight).assertEqual(24); + MobileTextScale.apply(415.636, 3.125); + expect(MobileTextScale.factor).assertEqual(0.868); + expect(MobileDesignTypography.bodyLarge.size).assertEqual(14); + expect(MobileDesignTypography.bodyLarge.lineHeight).assertEqual(21); + expect(MobileDesignTypography.titleMedium.size).assertEqual(15); + expect(MobileDesignTypography.labelSmall.size).assertEqual(10.5); + expect(MobileDesignTypography.bodyLarge.baseSize).assertEqual(16); + }); + }); +} diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Chat/ChatTimelineView.swift b/src/apps/mobile/ios/OpenBitFun/Features/Chat/ChatTimelineView.swift index 91cb500be4..e78ec252a7 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Chat/ChatTimelineView.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Chat/ChatTimelineView.swift @@ -984,11 +984,15 @@ private func markdownInlineString( var result = AttributedString() for inline in inlines { var part = AttributedString(inline.text) + // Presentation intents inherit the enclosing Text's font, so an inline + // run keeps the role size (and Dynamic Type) of the paragraph it sits + // in — the same as the ArkUI and Compose renderers, which only change + // weight, slant, or family. switch inline.type { - case "strong": part.font = .system(size: 14, weight: .semibold) - case "emphasis": part.font = .system(size: 14).italic() + case "strong": part.inlinePresentationIntent = .stronglyEmphasized + case "emphasis": part.inlinePresentationIntent = .emphasized case "code": - part.font = .system(size: 13, design: .monospaced) + part.inlinePresentationIntent = .code part.backgroundColor = OpenBitFunTheme.soft case "link": part.foregroundColor = MobileDesignColors.fileLink