Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions scripts/mobile-ui-design-system.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? {}),
Expand All @@ -129,13 +130,27 @@ 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] })),
}, null, 2)}\n`;
}

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');
Expand All @@ -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) {
Expand All @@ -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)
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -299,11 +303,29 @@ 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,
content = content,
)
}
}

/**
* 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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
15 changes: 15 additions & 0 deletions src/apps/mobile/design-system/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/apps/mobile/design-system/tokens/mobile-tokens.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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');
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading