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