diff --git a/scripts/mobile-ui-design-system.mjs b/scripts/mobile-ui-design-system.mjs index 42dd6418bd..701c29234d 100644 --- a/scripts/mobile-ui-design-system.mjs +++ b/scripts/mobile-ui-design-system.mjs @@ -177,12 +177,12 @@ function renderIosTokens(contract) { .map(([name, pair]) => ` static let ${camel(name)} = dynamic(light: ${swiftHex(pair.light)}, dark: ${swiftHex(pair.dark)})`) .join('\n'); const typography = Object.entries(contract.typography) - .map(([name, token]) => ` static let ${camel(name)} = MobileTypographyToken(size: ${token.size}, lineHeight: ${token.lineHeight}, weight: .${swiftWeight(token.weight)})`) + .map(([name, token]) => ` static let ${camel(name)} = MobileTypographyToken(size: ${token.size}, lineHeight: ${token.lineHeight}, weight: .${swiftWeight(token.weight)}, textStyle: .${swiftTextStyle(name)})`) .join('\n'); const geometry = renderSwiftNumbers(contract.geometry); const breakpoints = renderSwiftNumbers(contract.breakpoints); const motion = renderSwiftNumbers(contract.motion); - return `// Generated by scripts/mobile-ui-design-system.mjs. Do not edit.\nimport SwiftUI\nimport UIKit\n\nstruct MobileTypographyToken {\n let size: CGFloat\n let lineHeight: CGFloat\n let weight: Font.Weight\n\n var font: Font { .system(size: size, weight: weight) }\n var lineSpacing: CGFloat { max(0, lineHeight - UIFont.systemFont(ofSize: size).lineHeight) }\n}\n\nenum MobileDesignColors {\n${colors}\n\n private static func dynamic(light: UInt32, dark: UInt32) -> Color {\n Color(uiColor: UIColor { traits in\n rgba(traits.userInterfaceStyle == .dark ? dark : light)\n })\n }\n\n private static func rgba(_ value: UInt32) -> UIColor {\n UIColor(\n red: CGFloat((value >> 16) & 0xFF) / 255,\n green: CGFloat((value >> 8) & 0xFF) / 255,\n blue: CGFloat(value & 0xFF) / 255,\n alpha: CGFloat((value >> 24) & 0xFF) / 255\n )\n }\n}\n\nenum MobileDesignTypography {\n${typography}\n}\n\nenum MobileDesignGeometry {\n${geometry}\n}\n\nenum MobileDesignBreakpoints {\n${breakpoints}\n}\n\nenum MobileDesignMotion {\n${motion}\n}\n`; + return `// Generated by scripts/mobile-ui-design-system.mjs. Do not edit.\nimport SwiftUI\nimport UIKit\n\nstruct MobileTypographyToken {\n let size: CGFloat\n let lineHeight: CGFloat\n let weight: UIFont.Weight\n let textStyle: UIFont.TextStyle\n\n private var scaledFont: UIFont {\n UIFontMetrics(forTextStyle: textStyle).scaledFont(\n for: UIFont.systemFont(ofSize: size, weight: weight)\n )\n }\n\n var font: Font { Font(scaledFont) }\n var lineSpacing: CGFloat {\n let scaledLineHeight = UIFontMetrics(forTextStyle: textStyle).scaledValue(for: lineHeight)\n return max(0, scaledLineHeight - scaledFont.lineHeight)\n }\n}\n\nenum MobileDesignColors {\n${colors}\n\n private static func dynamic(light: UInt32, dark: UInt32) -> Color {\n Color(uiColor: UIColor { traits in\n rgba(traits.userInterfaceStyle == .dark ? dark : light)\n })\n }\n\n private static func rgba(_ value: UInt32) -> UIColor {\n UIColor(\n red: CGFloat((value >> 16) & 0xFF) / 255,\n green: CGFloat((value >> 8) & 0xFF) / 255,\n blue: CGFloat(value & 0xFF) / 255,\n alpha: CGFloat((value >> 24) & 0xFF) / 255\n )\n }\n}\n\nenum MobileDesignTypography {\n${typography}\n}\n\nenum MobileDesignGeometry {\n${geometry}\n}\n\nenum MobileDesignBreakpoints {\n${breakpoints}\n}\n\nenum MobileDesignMotion {\n${motion}\n}\n`; } function renderIosScenarios(contract) { @@ -253,6 +253,30 @@ function swiftWeight(weight) { return 'regular'; } +function swiftTextStyle(name) { + const styles = { + display_large: 'largeTitle', + display_medium: 'title1', + display_small: 'title2', + headline_large: 'title1', + headline_medium: 'title2', + headline_small: 'title3', + title_large: 'title2', + conversation_header_title: 'headline', + title_medium: 'headline', + title_small: 'subheadline', + body_large: 'body', + body_medium: 'callout', + body_small: 'footnote', + label_large: 'subheadline', + label_medium: 'footnote', + label_small: 'caption1', + }; + const style = styles[name]; + if (!style) throw new Error(`Missing iOS text style mapping for mobile typography token ${name}.`); + return style; +} + function quoted(value) { return JSON.stringify(value); } diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ComposerBar.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ComposerBar.kt index 163ee33ab7..5519f6be55 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ComposerBar.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ComposerBar.kt @@ -257,7 +257,7 @@ internal fun ComposerBar( horizontalArrangement = Arrangement.spacedBy(actionSpacing), modifier = Modifier .fillMaxWidth() - .height(inputRowHeight), + .heightIn(min = inputRowHeight), ) { // While expanded both side controls move to the row below, // so the field gets the full width for what is being typed. @@ -444,7 +444,7 @@ private fun ModelControl( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(3.dp), modifier = Modifier - .height(34.dp) + .heightIn(min = MobileDesignGeometry.ComposerActionSize) .widthIn(max = 220.dp) .clip(RoundedCornerShape(9.dp)) .clickable(enabled = enabled, onClick = onClick) @@ -543,11 +543,11 @@ private fun ModelSelectorContent( if (!compact) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().height(32.dp), + modifier = Modifier.fillMaxWidth().heightIn(min = MobileDesignGeometry.SelectionCloseSize), ) { Text( stringResource(R.string.model_selector_title), - fontSize = 13.sp, + fontSize = MaterialTheme.typography.labelLarge.fontSize, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f), @@ -575,7 +575,7 @@ private fun ModelSelectorContent( color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier .fillMaxWidth() - .height(MobileDesignGeometry.ComposerModelSelectorRowHeight) + .heightIn(min = MobileDesignGeometry.ComposerModelSelectorRowHeight) .padding(horizontal = 10.dp, vertical = 14.dp), ) } else { @@ -594,7 +594,7 @@ private fun ModelSelectorContent( horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier .fillMaxWidth() - .height(MobileDesignGeometry.ComposerModelSelectorRowHeight) + .heightIn(min = MobileDesignGeometry.ComposerModelSelectorRowHeight) .clip( RoundedCornerShape( MobileDesignGeometry.ComposerModelSelectorRowRadius, @@ -611,7 +611,7 @@ private fun ModelSelectorContent( role = Role.Button selected = option.selected } - .padding(horizontal = 10.dp) + .padding(horizontal = 10.dp, vertical = 8.dp) .testTag(MODEL_SELECTOR_OPTION_TEST_TAG_PREFIX + option.id), ) { Box( @@ -633,18 +633,14 @@ private fun ModelSelectorContent( ) { Text( option.primaryLabel, - fontSize = 13.sp, + fontSize = MaterialTheme.typography.labelLarge.fontSize, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis, ) Text( option.secondaryLabel, - fontSize = 11.sp, + fontSize = MaterialTheme.typography.bodySmall.fontSize, color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, ) } } diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/MarkdownContent.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/MarkdownContent.kt index 102fdfb7be..ee29320f8b 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/MarkdownContent.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/MarkdownContent.kt @@ -124,8 +124,8 @@ private fun MarkdownBlockView( Spacer(Modifier.width(9.dp)) InlineText( inlines = block.inlines, - fontSize = 13.sp, - lineHeight = 18.sp, + fontSize = MaterialTheme.typography.bodyLarge.fontSize, + lineHeight = MaterialTheme.typography.bodyLarge.lineHeight, color = colors.onSurfaceVariant, bold = false, onOpenLink = onOpenLink, @@ -140,8 +140,8 @@ private fun MarkdownBlockView( else -> InlineText( inlines = block.inlines, - fontSize = 14.sp, - lineHeight = 21.sp, + fontSize = MaterialTheme.typography.bodyLarge.fontSize, + lineHeight = MaterialTheme.typography.bodyLarge.lineHeight, color = colors.onSurface, bold = false, onOpenLink = onOpenLink, @@ -228,8 +228,8 @@ private fun MarkdownList(items: List, onOpenLink: (String, Str ) { Text( item.marker, - fontSize = 14.sp, - lineHeight = 21.sp, + fontSize = MaterialTheme.typography.bodyLarge.fontSize, + lineHeight = MaterialTheme.typography.bodyLarge.lineHeight, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.End, modifier = Modifier.width(20.dp), @@ -237,8 +237,8 @@ private fun MarkdownList(items: List, onOpenLink: (String, Str Box(Modifier.weight(1f)) { InlineText( inlines = item.inlines, - fontSize = 14.sp, - lineHeight = 21.sp, + fontSize = MaterialTheme.typography.bodyLarge.fontSize, + lineHeight = MaterialTheme.typography.bodyLarge.lineHeight, color = MaterialTheme.colorScheme.onSurface, bold = false, onOpenLink = onOpenLink, @@ -267,13 +267,13 @@ private fun CodeBlock(language: String, body: String, copyFullText: String?) { Row(modifier = Modifier.fillMaxWidth()) { Text( language.ifBlank { "code" }, - fontSize = 10.sp, + fontSize = MaterialTheme.typography.labelSmall.fontSize, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f), ) Text( copyLabel, - fontSize = 10.sp, + fontSize = MaterialTheme.typography.labelSmall.fontSize, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.clickableText { scope.launch { @@ -310,8 +310,8 @@ private fun ScrollingMonospaceCard(body: String, header: (@Composable () -> Unit Box(Modifier.fillMaxWidth().horizontalScroll(scroll)) { Text( body, - fontSize = 10.sp, - lineHeight = 15.sp, + fontSize = MaterialTheme.typography.labelSmall.fontSize, + lineHeight = MaterialTheme.typography.labelSmall.lineHeight, fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.onSurface, softWrap = false, @@ -328,14 +328,16 @@ private fun ScrollingMonospaceCard(body: String, header: (@Composable () -> Unit private fun Modifier.clickableText(onClick: () -> Unit): Modifier = clickable(onClick = onClick).padding(start = 8.dp) +@Composable private fun headingFontSize(level: Int): TextUnit = when { - level <= 1 -> 16.sp - level == 2 -> 15.sp - else -> 14.sp + level <= 1 -> MaterialTheme.typography.headlineSmall.fontSize + level == 2 -> MaterialTheme.typography.titleMedium.fontSize + else -> MaterialTheme.typography.bodyLarge.fontSize } +@Composable private fun headingLineHeight(level: Int): TextUnit = when { - level <= 1 -> 23.sp - level == 2 -> 22.sp - else -> 20.sp + level <= 1 -> MaterialTheme.typography.headlineSmall.lineHeight + level == 2 -> MaterialTheme.typography.titleMedium.lineHeight + else -> MaterialTheme.typography.bodyLarge.lineHeight } diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/message/ChatMessageChrome.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/message/ChatMessageChrome.kt index 56ad1cf5a0..e24284ee2f 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/message/ChatMessageChrome.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/message/ChatMessageChrome.kt @@ -131,8 +131,7 @@ internal fun ChatUserMessageBubble( if (visibleText.isNotEmpty()) { Text( visibleText, - fontSize = 14.sp, - lineHeight = 20.sp, + style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, ) } diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/preview/MobileDesignGallery.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/preview/MobileDesignGallery.kt index b0c1016b93..ab84696aad 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/preview/MobileDesignGallery.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/preview/MobileDesignGallery.kt @@ -105,6 +105,9 @@ internal fun MobileDesignGallery(scenario: MobilePreviewScenario, dark: Boolean) internal fun mobileDesignScenario(id: String?): MobilePreviewScenario = when (id) { MobilePreviewScenarios.StreamingDark.id -> MobilePreviewScenarios.StreamingDark + MobilePreviewScenarios.NarrowMultiline.id -> MobilePreviewScenarios.NarrowMultiline + MobilePreviewScenarios.FoldContext.id -> MobilePreviewScenarios.FoldContext + MobilePreviewScenarios.LongReading.id -> MobilePreviewScenarios.LongReading MobilePreviewScenarios.ReconnectingWide.id -> MobilePreviewScenarios.ReconnectingWide else -> MobilePreviewScenarios.ConnectedConversation } @@ -145,7 +148,7 @@ private fun PreviewMessageBubble(message: MobilePreviewMessage) { if (message.role == "user") Spacer(Modifier.weight(1f)) Text( text = message.text, - style = MaterialTheme.typography.bodyMedium, + style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier .widthIn(max = MobileDesignGeometry.MessageBubbleMaxWidth) diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/preview/generated/MobilePreviewScenarios.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/preview/generated/MobilePreviewScenarios.kt index 79dc3ae98b..06a79e4a1e 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/preview/generated/MobilePreviewScenarios.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/preview/generated/MobilePreviewScenarios.kt @@ -67,4 +67,52 @@ internal object MobilePreviewScenarios { connectionPhase = "reconnecting", streaming = false, ) + + val NarrowMultiline = MobilePreviewScenario( + id = "narrow-multiline", + title = "Narrow phone with a multiline draft", + description = "Narrow phone with a multiline draft", + appearance = "light", + viewportWidth = 360, + viewportHeight = 800, + headerTitle = "检查移动端长标题、正文与控制目标的可读性", + headerSubtitle = "远程开发工作站 · OpenBitFun / feature/mobile-reading", + messages = listOf(MobilePreviewMessage("user", "三端的组件和样式可以保持一致吗?"), MobilePreviewMessage("assistant", "可以。共享视觉契约,三端继续使用原生渲染。")), + composerDraft = "请检查输入框是否能保留多行中文草稿。\n模型选择后内容应继续保留。\n附件与文本应各自成行。", + composerPlaceholder = "向 OpenBitFun 提问", + connectionPhase = "connected", + streaming = false, + ) + + val FoldContext = MobilePreviewScenario( + id = "fold-context", + title = "Foldable with long remote context", + description = "Foldable with long remote context", + appearance = "light", + viewportWidth = 700, + viewportHeight = 840, + headerTitle = "检查移动端长标题、正文与控制目标的可读性", + headerSubtitle = "远程开发工作站 · OpenBitFun / feature/mobile-reading", + messages = listOf(MobilePreviewMessage("user", "三端的组件和样式可以保持一致吗?"), MobilePreviewMessage("assistant", "可以。共享视觉契约,三端继续使用原生渲染。")), + composerDraft = "请保留当前会话与控制目标。", + composerPlaceholder = "向 OpenBitFun 提问", + connectionPhase = "connected", + streaming = false, + ) + + val LongReading = MobilePreviewScenario( + id = "long-reading", + title = "Long reading content", + description = "Long reading content", + appearance = "light", + viewportWidth = 430, + viewportHeight = 932, + headerTitle = "检查移动端长标题、正文与控制目标的可读性", + headerSubtitle = "远程开发工作站 · OpenBitFun / feature/mobile-reading", + messages = listOf(MobilePreviewMessage("user", "三端的组件和样式可以保持一致吗?"), MobilePreviewMessage("assistant", "移动端的阅读层级需要清晰,正文应该舒适地换行,辅助说明保留足够对比度。\n\n当内容变长时,消息区滚动,输入框仍然可以操作。请在系统大字体设置下检查这一场景。")), + composerDraft = "", + composerPlaceholder = "向 OpenBitFun 提问", + connectionPhase = "connected", + streaming = false, + ) } 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 963d67cc2d..91c808731d 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 @@ -132,13 +132,13 @@ internal object MobileDesignGeometry { val MessageBubbleHorizontalPadding = 14.dp val MessageBubbleVerticalPadding = 11.dp val MessageBubbleRadius = 17.dp - val ComposerActionSize = 44.dp + val ComposerActionSize = 48.dp val ComposerInputHeight = 44.dp val ComposerExpandedInputHeight = 58.dp val ComposerCollapsedHeight = 56.dp val ComposerExpandedInputRowHeight = 60.dp - val ComposerExpandedActionRowHeight = 40.dp - val ComposerExpandedHeight = 108.dp + val ComposerExpandedActionRowHeight = 48.dp + val ComposerExpandedHeight = 120.dp val ComposerCollapsedRadius = 28.dp val ComposerExpandedRadius = 18.dp val ComposerModelSelectorWidth = 330.dp @@ -153,7 +153,7 @@ internal object MobileDesignGeometry { val SheetActionHeight = 48.dp val SelectionTopRadius = 20.dp val SelectionRowHeight = 64.dp - val SelectionCloseSize = 32.dp + val SelectionCloseSize = 48.dp val PopoverWidth = 292.dp val PopoverRadius = 16.dp val PopoverPadding = 12.dp diff --git a/src/apps/mobile/design-system/components/mobile-components.json b/src/apps/mobile/design-system/components/mobile-components.json index 66f1945e78..b37a8d059f 100644 --- a/src/apps/mobile/design-system/components/mobile-components.json +++ b/src/apps/mobile/design-system/components/mobile-components.json @@ -26,7 +26,7 @@ "purpose": "Presents one user or assistant message in the conversation timeline.", "anatomy": ["message_text", "role_surface", "hairline_border"], "states": ["user", "assistant"], - "tokens": ["body_medium", "message_bubble_max_width", "message_bubble_horizontal_padding", "message_bubble_vertical_padding", "message_bubble_radius", "message_spacing", "timeline_top_padding"], + "tokens": ["body_large", "message_bubble_max_width", "message_bubble_horizontal_padding", "message_bubble_vertical_padding", "message_bubble_radius", "message_spacing", "timeline_top_padding"], "platformNotes": "The maximum width is the 276-unit compact HarmonyOS baseline; wide layouts may use a separate responsive policy." }, "composer_bar": { 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 1b84f01583..5f13c3b4d0 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 @@ -273,13 +273,13 @@ export const mobileTokens = { "message_bubble_horizontal_padding": 14, "message_bubble_vertical_padding": 11, "message_bubble_radius": 17, - "composer_action_size": 44, + "composer_action_size": 48, "composer_input_height": 44, "composer_expanded_input_height": 58, "composer_collapsed_height": 56, "composer_expanded_input_row_height": 60, - "composer_expanded_action_row_height": 40, - "composer_expanded_height": 108, + "composer_expanded_action_row_height": 48, + "composer_expanded_height": 120, "composer_collapsed_radius": 28, "composer_expanded_radius": 18, "composer_model_selector_width": 330, @@ -294,7 +294,7 @@ export const mobileTokens = { "sheet_action_height": 48, "selection_top_radius": 20, "selection_row_height": 64, - "selection_close_size": 32, + "selection_close_size": 48, "popover_width": 292, "popover_radius": 16, "popover_padding": 12, @@ -416,7 +416,7 @@ export const mobileComponents = { "assistant" ], "tokens": [ - "body_medium", + "body_large", "message_bubble_max_width", "message_bubble_horizontal_padding", "message_bubble_vertical_padding", @@ -917,6 +917,96 @@ export const mobilePreviewScenarios = { "phase": "reconnecting", "streaming": false } + }, + { + "id": "narrow-multiline", + "title": "Narrow phone with a multiline draft", + "description": "Narrow phone with a multiline draft", + "appearance": "light", + "viewport": { + "width": 360, + "height": 800 + }, + "header": { + "title": "检查移动端长标题、正文与控制目标的可读性", + "subtitle": "远程开发工作站 · OpenBitFun / feature/mobile-reading" + }, + "messages": [ + { + "role": "user", + "text": "三端的组件和样式可以保持一致吗?" + }, + { + "role": "assistant", + "text": "可以。共享视觉契约,三端继续使用原生渲染。" + } + ], + "composer": { + "draft": "请检查输入框是否能保留多行中文草稿。\n模型选择后内容应继续保留。\n附件与文本应各自成行。", + "placeholder": "向 OpenBitFun 提问", + "phase": "connected", + "streaming": false + } + }, + { + "id": "fold-context", + "title": "Foldable with long remote context", + "description": "Foldable with long remote context", + "appearance": "light", + "viewport": { + "width": 700, + "height": 840 + }, + "header": { + "title": "检查移动端长标题、正文与控制目标的可读性", + "subtitle": "远程开发工作站 · OpenBitFun / feature/mobile-reading" + }, + "messages": [ + { + "role": "user", + "text": "三端的组件和样式可以保持一致吗?" + }, + { + "role": "assistant", + "text": "可以。共享视觉契约,三端继续使用原生渲染。" + } + ], + "composer": { + "draft": "请保留当前会话与控制目标。", + "placeholder": "向 OpenBitFun 提问", + "phase": "connected", + "streaming": false + } + }, + { + "id": "long-reading", + "title": "Long reading content", + "description": "Long reading content", + "appearance": "light", + "viewport": { + "width": 430, + "height": 932 + }, + "header": { + "title": "检查移动端长标题、正文与控制目标的可读性", + "subtitle": "远程开发工作站 · OpenBitFun / feature/mobile-reading" + }, + "messages": [ + { + "role": "user", + "text": "三端的组件和样式可以保持一致吗?" + }, + { + "role": "assistant", + "text": "移动端的阅读层级需要清晰,正文应该舒适地换行,辅助说明保留足够对比度。\n\n当内容变长时,消息区滚动,输入框仍然可以操作。请在系统大字体设置下检查这一场景。" + } + ], + "composer": { + "draft": "", + "placeholder": "向 OpenBitFun 提问", + "phase": "connected", + "streaming": false + } } ] }; diff --git a/src/apps/mobile/design-system/preview/preview-scenarios.smoke.test.mjs b/src/apps/mobile/design-system/preview/preview-scenarios.smoke.test.mjs index ad614c59cc..97cda051ee 100644 --- a/src/apps/mobile/design-system/preview/preview-scenarios.smoke.test.mjs +++ b/src/apps/mobile/design-system/preview/preview-scenarios.smoke.test.mjs @@ -7,6 +7,9 @@ const expectedScenarioIds = [ 'connected-conversation', 'streaming-dark', 'reconnecting-wide', + 'narrow-multiline', + 'fold-context', + 'long-reading', ]; const requiredScenarioFields = [ 'id', @@ -33,7 +36,7 @@ const hasOwn = (value, key) => Object.prototype.hasOwnProperty.call(value, key); test('mobile preview scenarios satisfy the deterministic contract', () => { const scenarios = mobilePreviewScenarios.scenarios; - assert.equal(scenarios.length, 3); + assert.equal(scenarios.length, expectedScenarioIds.length); assert.deepEqual( scenarios.map((scenario) => scenario.id), expectedScenarioIds, diff --git a/src/apps/mobile/design-system/scenarios/mobile-preview-scenarios.json b/src/apps/mobile/design-system/scenarios/mobile-preview-scenarios.json index 0c83c00013..e5218787f1 100644 --- a/src/apps/mobile/design-system/scenarios/mobile-preview-scenarios.json +++ b/src/apps/mobile/design-system/scenarios/mobile-preview-scenarios.json @@ -6,38 +6,176 @@ "title": "Connected conversation", "description": "A remote desktop is answering and the composer is ready.", "appearance": "light", - "viewport": { "width": 390, "height": 844 }, - "header": { "title": "统一移动端设计系统", "subtitle": "DESKTOP-KM3L4UI" }, + "viewport": { + "width": 390, + "height": 844 + }, + "header": { + "title": "统一移动端设计系统", + "subtitle": "DESKTOP-KM3L4UI" + }, "messages": [ - { "role": "user", "text": "三端的组件和样式可以保持一致吗?" }, - { "role": "assistant", "text": "可以。共享视觉契约,三端继续使用原生渲染。" } + { + "role": "user", + "text": "三端的组件和样式可以保持一致吗?" + }, + { + "role": "assistant", + "text": "可以。共享视觉契约,三端继续使用原生渲染。" + } ], - "composer": { "draft": "", "placeholder": "向 OpenBitFun 提问", "phase": "connected", "streaming": false } + "composer": { + "draft": "", + "placeholder": "向 OpenBitFun 提问", + "phase": "connected", + "streaming": false + } }, { "id": "streaming-dark", "title": "Streaming in dark mode", "description": "An active turn exercises dark surfaces and the stop action.", "appearance": "dark", - "viewport": { "width": 390, "height": 844 }, - "header": { "title": "跨端视觉校验", "subtitle": "正在由 MacBook Pro 运行" }, + "viewport": { + "width": 390, + "height": 844 + }, + "header": { + "title": "跨端视觉校验", + "subtitle": "正在由 MacBook Pro 运行" + }, "messages": [ - { "role": "user", "text": "比较三端的输入框、消息气泡和标题栏。" }, - { "role": "assistant", "text": "正在生成原生截图,并按相同基线并排展示。" } + { + "role": "user", + "text": "比较三端的输入框、消息气泡和标题栏。" + }, + { + "role": "assistant", + "text": "正在生成原生截图,并按相同基线并排展示。" + } ], - "composer": { "draft": "检查深色模式下的边框对比度", "placeholder": "输入消息", "phase": "connected", "streaming": true } + "composer": { + "draft": "检查深色模式下的边框对比度", + "placeholder": "输入消息", + "phase": "connected", + "streaming": true + } }, { "id": "reconnecting-wide", "title": "Reconnecting on a wide viewport", "description": "A tablet-sized viewport keeps remote blocking states answerable.", "appearance": "light", - "viewport": { "width": 1024, "height": 768 }, - "header": { "title": "远程会话", "subtitle": "正在恢复连接" }, + "viewport": { + "width": 1024, + "height": 768 + }, + "header": { + "title": "远程会话", + "subtitle": "正在恢复连接" + }, "messages": [ - { "role": "assistant", "text": "连接暂时中断。恢复后会从上次游标继续。" } + { + "role": "assistant", + "text": "连接暂时中断。恢复后会从上次游标继续。" + } ], - "composer": { "draft": "", "placeholder": "等待重新连接", "phase": "reconnecting", "streaming": false } + "composer": { + "draft": "", + "placeholder": "等待重新连接", + "phase": "reconnecting", + "streaming": false + } + }, + { + "id": "narrow-multiline", + "title": "Narrow phone with a multiline draft", + "description": "Narrow phone with a multiline draft", + "appearance": "light", + "viewport": { + "width": 360, + "height": 800 + }, + "header": { + "title": "检查移动端长标题、正文与控制目标的可读性", + "subtitle": "远程开发工作站 · OpenBitFun / feature/mobile-reading" + }, + "messages": [ + { + "role": "user", + "text": "三端的组件和样式可以保持一致吗?" + }, + { + "role": "assistant", + "text": "可以。共享视觉契约,三端继续使用原生渲染。" + } + ], + "composer": { + "draft": "请检查输入框是否能保留多行中文草稿。\n模型选择后内容应继续保留。\n附件与文本应各自成行。", + "placeholder": "向 OpenBitFun 提问", + "phase": "connected", + "streaming": false + } + }, + { + "id": "fold-context", + "title": "Foldable with long remote context", + "description": "Foldable with long remote context", + "appearance": "light", + "viewport": { + "width": 700, + "height": 840 + }, + "header": { + "title": "检查移动端长标题、正文与控制目标的可读性", + "subtitle": "远程开发工作站 · OpenBitFun / feature/mobile-reading" + }, + "messages": [ + { + "role": "user", + "text": "三端的组件和样式可以保持一致吗?" + }, + { + "role": "assistant", + "text": "可以。共享视觉契约,三端继续使用原生渲染。" + } + ], + "composer": { + "draft": "请保留当前会话与控制目标。", + "placeholder": "向 OpenBitFun 提问", + "phase": "connected", + "streaming": false + } + }, + { + "id": "long-reading", + "title": "Long reading content", + "description": "Long reading content", + "appearance": "light", + "viewport": { + "width": 430, + "height": 932 + }, + "header": { + "title": "检查移动端长标题、正文与控制目标的可读性", + "subtitle": "远程开发工作站 · OpenBitFun / feature/mobile-reading" + }, + "messages": [ + { + "role": "user", + "text": "三端的组件和样式可以保持一致吗?" + }, + { + "role": "assistant", + "text": "移动端的阅读层级需要清晰,正文应该舒适地换行,辅助说明保留足够对比度。\n\n当内容变长时,消息区滚动,输入框仍然可以操作。请在系统大字体设置下检查这一场景。" + } + ], + "composer": { + "draft": "", + "placeholder": "向 OpenBitFun 提问", + "phase": "connected", + "streaming": false + } } ] } diff --git a/src/apps/mobile/design-system/tokens/mobile-tokens.json b/src/apps/mobile/design-system/tokens/mobile-tokens.json index fef94aba64..458acb7def 100644 --- a/src/apps/mobile/design-system/tokens/mobile-tokens.json +++ b/src/apps/mobile/design-system/tokens/mobile-tokens.json @@ -79,13 +79,13 @@ "message_bubble_horizontal_padding": 14, "message_bubble_vertical_padding": 11, "message_bubble_radius": 17, - "composer_action_size": 44, + "composer_action_size": 48, "composer_input_height": 44, "composer_expanded_input_height": 58, "composer_collapsed_height": 56, "composer_expanded_input_row_height": 60, - "composer_expanded_action_row_height": 40, - "composer_expanded_height": 108, + "composer_expanded_action_row_height": 48, + "composer_expanded_height": 120, "composer_collapsed_radius": 28, "composer_expanded_radius": 18, "composer_model_selector_width": 330, @@ -100,7 +100,7 @@ "sheet_action_height": 48, "selection_top_radius": 20, "selection_row_height": 64, - "selection_close_size": 32, + "selection_close_size": 48, "popover_width": 292, "popover_radius": 16, "popover_padding": 12, 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 a70056a55c..707161e88f 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 @@ -15,7 +15,8 @@ export default class EntryAbility extends UIAbility { const scenarioId = want.parameters?.['openbitfunDesignPreview']; if (scenarioId === 'connected-conversation' || scenarioId === 'streaming-dark' || - scenarioId === 'reconnecting-wide') { + scenarioId === 'reconnecting-wide' || scenarioId === 'narrow-multiline' || + scenarioId === 'fold-context' || scenarioId === 'long-reading') { this.initialPage = 'pages/preview/MobileDesignGallery'; this.previewStorage = new LocalStorage(); this.previewStorage.setOrCreate('scenarioId', scenarioId); 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 a8363f8486..68bafa877a 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 @@ -99,13 +99,13 @@ export class MobileDesignGeometry { static readonly messageBubbleHorizontalPadding: number = 14; static readonly messageBubbleVerticalPadding: number = 11; static readonly messageBubbleRadius: number = 17; - static readonly composerActionSize: number = 44; + static readonly composerActionSize: number = 48; static readonly composerInputHeight: number = 44; static readonly composerExpandedInputHeight: number = 58; static readonly composerCollapsedHeight: number = 56; static readonly composerExpandedInputRowHeight: number = 60; - static readonly composerExpandedActionRowHeight: number = 40; - static readonly composerExpandedHeight: number = 108; + static readonly composerExpandedActionRowHeight: number = 48; + static readonly composerExpandedHeight: number = 120; static readonly composerCollapsedRadius: number = 28; static readonly composerExpandedRadius: number = 18; static readonly composerModelSelectorWidth: number = 330; @@ -120,7 +120,7 @@ export class MobileDesignGeometry { static readonly sheetActionHeight: number = 48; static readonly selectionTopRadius: number = 20; static readonly selectionRowHeight: number = 64; - static readonly selectionCloseSize: number = 32; + static readonly selectionCloseSize: number = 48; static readonly popoverWidth: number = 292; static readonly popoverRadius: number = 16; static readonly popoverPadding: number = 12; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/generated/MobilePreviewScenarios.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/generated/MobilePreviewScenarios.ets index b6b0acf397..bb70b2933e 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/generated/MobilePreviewScenarios.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/generated/MobilePreviewScenarios.ets @@ -104,4 +104,52 @@ export class MobilePreviewScenarios { "reconnecting", false ); + + static readonly narrowMultiline: MobilePreviewScenario = new MobilePreviewScenario( + "narrow-multiline", + "Narrow phone with a multiline draft", + "Narrow phone with a multiline draft", + "light", + 360, + 800, + "检查移动端长标题、正文与控制目标的可读性", + "远程开发工作站 · OpenBitFun / feature/mobile-reading", + [new MobilePreviewMessage("user", "三端的组件和样式可以保持一致吗?"), new MobilePreviewMessage("assistant", "可以。共享视觉契约,三端继续使用原生渲染。")], + "请检查输入框是否能保留多行中文草稿。\n模型选择后内容应继续保留。\n附件与文本应各自成行。", + "向 OpenBitFun 提问", + "connected", + false + ); + + static readonly foldContext: MobilePreviewScenario = new MobilePreviewScenario( + "fold-context", + "Foldable with long remote context", + "Foldable with long remote context", + "light", + 700, + 840, + "检查移动端长标题、正文与控制目标的可读性", + "远程开发工作站 · OpenBitFun / feature/mobile-reading", + [new MobilePreviewMessage("user", "三端的组件和样式可以保持一致吗?"), new MobilePreviewMessage("assistant", "可以。共享视觉契约,三端继续使用原生渲染。")], + "请保留当前会话与控制目标。", + "向 OpenBitFun 提问", + "connected", + false + ); + + static readonly longReading: MobilePreviewScenario = new MobilePreviewScenario( + "long-reading", + "Long reading content", + "Long reading content", + "light", + 430, + 932, + "检查移动端长标题、正文与控制目标的可读性", + "远程开发工作站 · OpenBitFun / feature/mobile-reading", + [new MobilePreviewMessage("user", "三端的组件和样式可以保持一致吗?"), new MobilePreviewMessage("assistant", "移动端的阅读层级需要清晰,正文应该舒适地换行,辅助说明保留足够对比度。\n\n当内容变长时,消息区滚动,输入框仍然可以操作。请在系统大字体设置下检查这一场景。")], + "", + "向 OpenBitFun 提问", + "connected", + false + ); } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageChrome.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageChrome.ets index d38e42b2d1..e80d9381c9 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageChrome.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageChrome.ets @@ -84,7 +84,7 @@ export struct ChatUserMessageBubble { MessageImageGallery({ images: this.item.images, userStyle: true }) } if (this.visibleText().length > 0) { - Text(this.visibleText()).fontSize(MobileDesignTypography.bodyMedium.size).lineHeight(MobileDesignTypography.bodyMedium.lineHeight).fontColor(INK) + Text(this.visibleText()).fontSize(MobileDesignTypography.bodyLarge.size).lineHeight(MobileDesignTypography.bodyLarge.lineHeight).fontColor(INK) } } .padding({ diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets index d1446d1ec3..5895a92a30 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets @@ -28,26 +28,18 @@ export enum ComposerPresentation { } const COMPOSER_ACTION_SIZE: number = MobileDesignGeometry.composerActionSize; -/** ChatGPT's visible send circle is about 32pt inside a 44pt hit target. */ +/** The optical primary surface is separate from its full touch target. */ const COMPOSER_PRIMARY_SURFACE_SIZE: number = 32; const COMPOSER_SEND_TRANSITION_DURATION: number = 200; const COMPOSER_INPUT_HEIGHT: number = MobileDesignGeometry.composerInputHeight; const COMPOSER_EXPANDED_INPUT_HEIGHT: number = MobileDesignGeometry.composerExpandedInputHeight; +const COMPOSER_EXPANDED_INPUT_MAX_HEIGHT: number = + MobileDesignTypography.bodyLarge.lineHeight * 4 + 18; const COMPOSER_COLLAPSED_HEIGHT: number = MobileDesignGeometry.composerCollapsedHeight; -// On a narrow phone, the 44vp controls should nearly fill the pill, matching -// the compact ChatGPT composer instead of inheriting the roomier 56vp shell. -const COMPOSER_COMPACT_COLLAPSED_HEIGHT: number = 52; -// ArkUI's circular action is 44vp. The shared 40vp action-row token is too -// short for this rendered control, so expand the Harmony row and its card by -// the same delta instead of allowing the button to overflow the bottom edge. -const COMPOSER_EXPANDED_ACTION_ROW_HEIGHT: number = Math.max( - MobileDesignGeometry.composerExpandedActionRowHeight, - COMPOSER_ACTION_SIZE -); -const COMPOSER_EXPANDED_HEIGHT: number = MobileDesignGeometry.composerExpandedHeight + - COMPOSER_EXPANDED_ACTION_ROW_HEIGHT - MobileDesignGeometry.composerExpandedActionRowHeight; -/** Matches the compact reference's roughly eight-percent side inset. */ -const COMPOSER_COMPACT_HORIZONTAL_GUTTER: number = 32; +const COMPOSER_COMPACT_COLLAPSED_HEIGHT: number = MobileDesignGeometry.composerCollapsedHeight; +const COMPOSER_EXPANDED_ACTION_ROW_HEIGHT: number = MobileDesignGeometry.composerExpandedActionRowHeight; +const COMPOSER_EXPANDED_HEIGHT: number = MobileDesignGeometry.composerExpandedHeight; +const COMPOSER_COMPACT_HORIZONTAL_GUTTER: number = MobileDesignGeometry.contentGutter; const COMPOSER_IMAGE_CARD_SIZE: number = 64; const COMPOSER_IMAGE_STRIP_TOP_GAP: number = 6; /** Strip height plus its top gap and the parent Column's 2vp row spacing. */ @@ -160,8 +152,12 @@ export struct ComposerBar { .clip(true) } .width('100%') - .height(this.isComposerExpanded() ? MobileDesignGeometry.composerExpandedInputRowHeight : - this.collapsedComposerHeight()) + .constraintSize({ + minHeight: this.isComposerExpanded() ? MobileDesignGeometry.composerExpandedInputRowHeight : + this.collapsedComposerHeight(), + maxHeight: this.isComposerExpanded() ? COMPOSER_EXPANDED_INPUT_MAX_HEIGHT : + this.collapsedComposerHeight() + }) if (this.isComposerExpanded()) { Row({ space: 6 }) { @@ -187,7 +183,7 @@ export struct ComposerBar { } } .width('100%') - .height(this.composerHeight()) + .constraintSize({ minHeight: this.composerHeight() }) .padding({ left: 8, right: 8, @@ -223,9 +219,9 @@ export struct ComposerBar { .opacity(0.68) } .width(16) - .height(32) + .height(COMPOSER_ACTION_SIZE) } - .height(32) + .height(COMPOSER_ACTION_SIZE) .constraintSize({ maxWidth: 220 }) .padding({ left: 4, right: 4 }) .backgroundColor(TRANSPARENT) @@ -305,20 +301,16 @@ export struct ComposerBar { .fontSize(MobileDesignTypography.bodySmall.size) .fontWeight(FontWeight.Medium) .fontColor(INK) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) Text(ConversationModelPresentationPolicy.secondaryLabel(model, RemoteI18n.t('chat.model'))) .fontSize(MobileDesignTypography.labelSmall.size) .fontColor(MUTED) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) } .layoutWeight(1) .alignItems(HorizontalAlign.Start) } .width('100%') - .height(MobileDesignGeometry.composerModelSelectorRowHeight) - .padding({ left: 10, right: 10 }) + .constraintSize({ minHeight: MobileDesignGeometry.composerModelSelectorRowHeight }) + .padding({ left: 10, right: 10, top: 8, bottom: 8 }) .backgroundColor(this.isSelectedModel(model) ? SOFT : TRANSPARENT) .borderRadius(MobileDesignGeometry.composerModelSelectorRowRadius) .onClick(() => { @@ -354,7 +346,10 @@ export struct ComposerBar { TextArea({ placeholder: this.inputPlaceholder(), text: this.inputText }) .id(this.inputId) .layoutWeight(1) - .height(this.isComposerExpanded() ? COMPOSER_EXPANDED_INPUT_HEIGHT : COMPOSER_INPUT_HEIGHT) + .constraintSize({ + minHeight: this.isComposerExpanded() ? COMPOSER_EXPANDED_INPUT_HEIGHT : COMPOSER_INPUT_HEIGHT, + maxHeight: this.isComposerExpanded() ? COMPOSER_EXPANDED_INPUT_MAX_HEIGHT : COMPOSER_INPUT_HEIGHT + }) .fontSize(MobileDesignTypography.bodyLarge.size) .lineHeight(MobileDesignTypography.bodyLarge.lineHeight) .fontColor(INK) @@ -391,7 +386,10 @@ export struct ComposerBar { } } .layoutWeight(1) - .height(this.isComposerExpanded() ? COMPOSER_EXPANDED_INPUT_HEIGHT : COMPOSER_INPUT_HEIGHT) + .constraintSize({ + minHeight: this.isComposerExpanded() ? COMPOSER_EXPANDED_INPUT_HEIGHT : COMPOSER_INPUT_HEIGHT, + maxHeight: this.isComposerExpanded() ? COMPOSER_EXPANDED_INPUT_MAX_HEIGHT : COMPOSER_INPUT_HEIGHT + }) .alignItems(VerticalAlign.Center) .padding({ left: this.isVoiceListening ? 12 : 0, right: 0 }) .backgroundColor(this.isVoiceListening ? SOFT : TRANSPARENT) @@ -573,8 +571,8 @@ export struct ComposerBar { .objectFit(ImageFit.Cover) .borderRadius(14) Text('×') - .width(32) - .height(32) + .width(COMPOSER_ACTION_SIZE) + .height(COMPOSER_ACTION_SIZE) .fontSize(MobileDesignTypography.bodyLarge.size) .fontColor(CARD) .textAlign(TextAlign.Center) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MarkdownContent.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MarkdownContent.ets index 80c506921d..74db99d88c 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MarkdownContent.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MarkdownContent.ets @@ -63,8 +63,8 @@ export struct MarkdownContent { } else { this.InlineText( block.inlines, - MobileDesignTypography.bodyMedium.size, - MobileDesignTypography.bodyMedium.lineHeight, + MobileDesignTypography.bodyLarge.size, + MobileDesignTypography.bodyLarge.lineHeight, INK, false ) @@ -152,16 +152,16 @@ export struct MarkdownContent { } else { Text(item.marker) .width(20) - .fontSize(MobileDesignTypography.bodyMedium.size) - .lineHeight(MobileDesignTypography.bodyMedium.lineHeight) + .fontSize(MobileDesignTypography.bodyLarge.size) + .lineHeight(MobileDesignTypography.bodyLarge.lineHeight) .fontColor(MUTED) .textAlign(TextAlign.End) } Column() { this.InlineText( item.inlines, - MobileDesignTypography.bodyMedium.size, - MobileDesignTypography.bodyMedium.lineHeight, + MobileDesignTypography.bodyLarge.size, + MobileDesignTypography.bodyLarge.lineHeight, INK, false ) @@ -271,7 +271,7 @@ export struct MarkdownContent { if (level === 3) { return MobileDesignTypography.titleSmall.size; } - return MobileDesignTypography.bodyMedium.size; + return MobileDesignTypography.bodyLarge.size; } private headingLineHeight(level: number): number { @@ -284,7 +284,7 @@ export struct MarkdownContent { if (level === 3) { return MobileDesignTypography.titleSmall.lineHeight; } - return MobileDesignTypography.bodyMedium.lineHeight; + return MobileDesignTypography.bodyLarge.lineHeight; } private tableTextAlign(alignment: string): TextAlign { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/MobileDesignGallery.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/MobileDesignGallery.ets index 24f8caf052..3349143a4f 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/MobileDesignGallery.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/MobileDesignGallery.ets @@ -19,6 +19,9 @@ struct MobileDesignGallery { return MobilePreviewScenarios.streamingDark; case 'reconnecting-wide': return MobilePreviewScenarios.reconnectingWide; + case 'narrow-multiline': return MobilePreviewScenarios.narrowMultiline; + case 'fold-context': return MobilePreviewScenarios.foldContext; + case 'long-reading': return MobilePreviewScenarios.longReading; case 'connected-conversation': return MobilePreviewScenarios.connectedConversation; default: @@ -92,9 +95,9 @@ struct MobileDesignGallery { Blank() } Text(message.text) - .fontSize(MobileDesignTypography.bodyMedium.size) + .fontSize(MobileDesignTypography.bodyLarge.size) .fontColor(INK) - .lineHeight(MobileDesignTypography.bodyMedium.lineHeight) + .lineHeight(MobileDesignTypography.bodyLarge.lineHeight) .constraintSize({ maxWidth: message.role === 'user' ? MobileDesignGeometry.messageBubbleMaxWidth : '100%' }) diff --git a/src/apps/mobile/harmonyos/tools/fake-relay.mjs b/src/apps/mobile/harmonyos/tools/fake-relay.mjs index c68ae15ca4..24f8b81f10 100644 --- a/src/apps/mobile/harmonyos/tools/fake-relay.mjs +++ b/src/apps/mobile/harmonyos/tools/fake-relay.mjs @@ -165,10 +165,12 @@ let cancelled = false; let sentMessage = ''; let sentImageCount = 0; let toolApproved = false; +let previewDecisionAttempts = 0; let toolRejected = false; let toolCancelled = false; let questionAnswered = false; let selectedModelId = 'model-primary-preview'; +let selectedReasoningPreset = null; const deletedSessions = new Set(); const assistants = [ @@ -209,7 +211,7 @@ const previewFiles = new Map([ ]); function isScenario(name) { - return SCENARIO === name; + return SCENARIO === name || (SCENARIO === 'mobile-ui-review' && ['model-selection', 'tool-pending-confirmation'].includes(name)); } function assistantResponseContent() { @@ -334,6 +336,13 @@ function currentModelCatalog() { context_window: 128000, enabled: true, capabilities: ['text_chat', 'code_specialized', 'function_calling'], + ...(isScenario('model-selection') ? { reasoning: { + status: 'known', + presets: [ + { id: 'low', label: 'Low', order: 0 }, + { id: 'high', label: 'High', order: 1 }, + ], + } } : {}), }, { id: 'model-fast-preview', @@ -355,6 +364,15 @@ function currentModelCatalog() { enabled: true, capabilities: ['text_chat', 'image_understanding'], }, + ...(SCENARIO === 'mobile-ui-review' ? Array.from({ length: 9 }, (_, index) => ({ + id: `review-model-${index}`, + name: 'Preview provider with a long configuration label', + provider: index % 2 ? 'review-cloud' : 'review-local', + model_name: `Review model ${index + 1} with extended context and reasoning support`, + enabled: true, + capabilities: ['text_chat'], + context_window: 128000, + })) : []), ], default_models: { primary: 'model-primary-preview', @@ -362,6 +380,10 @@ function currentModelCatalog() { image_understanding: 'model-vision-preview', }, session_model_id: selectedModelId, + ...(isScenario('model-selection') ? { + reasoning_preset_selection_supported: true, + session_reasoning_preset: selectedReasoningPreset, + } : {}), }; } @@ -708,10 +730,12 @@ function responseFor(command) { }; case 'set_session_model': selectedModelId = command.model_id || selectedModelId; + if (isScenario('model-selection')) selectedReasoningPreset = command.reasoning_preset ?? null; return { resp: 'ok', session_id: command.session_id, model_id: selectedModelId, + ...(isScenario('model-selection') ? { reasoning_preset: selectedReasoningPreset } : {}), }; case 'get_session_messages': if (command.before_message_id) { @@ -844,6 +868,9 @@ function responseFor(command) { return response; } case 'confirm_tool': + if (SCENARIO === 'mobile-ui-review' && previewDecisionAttempts++ === 0) { + return { resp: 'error', message: 'Preview: decision submission failed; retry is available.' }; + } toolApproved = true; activeTurn = false; return { resp: 'ok' }; @@ -991,6 +1018,9 @@ const server = http.createServer(async (req, res) => { has_more_sessions: currentSessionItems().length > 8, } : responseFor(command); + if (SCENARIO === 'mobile-ui-review' && ['confirm_tool', 'reject_tool'].includes(command.cmd)) { + await new Promise(resolve => setTimeout(resolve, 1500)); + } const payload = encryptJson(sharedKey, response); res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify(payload)); diff --git a/src/apps/mobile/ios/OpenBitFun/App/MobileLaunchConfiguration.swift b/src/apps/mobile/ios/OpenBitFun/App/MobileLaunchConfiguration.swift index 60664dc902..0268889097 100644 --- a/src/apps/mobile/ios/OpenBitFun/App/MobileLaunchConfiguration.swift +++ b/src/apps/mobile/ios/OpenBitFun/App/MobileLaunchConfiguration.swift @@ -205,6 +205,9 @@ enum MobileLaunchConfiguration { switch scenarioID { case MobilePreviewScenarios.streamingDark.id: return MobilePreviewScenarios.streamingDark + case MobilePreviewScenarios.narrowMultiline.id: return MobilePreviewScenarios.narrowMultiline + case MobilePreviewScenarios.foldContext.id: return MobilePreviewScenarios.foldContext + case MobilePreviewScenarios.longReading.id: return MobilePreviewScenarios.longReading case MobilePreviewScenarios.reconnectingWide.id: return MobilePreviewScenarios.reconnectingWide default: diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Chat/ChatTimelineView.swift b/src/apps/mobile/ios/OpenBitFun/Features/Chat/ChatTimelineView.swift index 9afdfd65dd..c408772ffa 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Chat/ChatTimelineView.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Chat/ChatTimelineView.swift @@ -98,9 +98,9 @@ private struct ConversationRowView: View { if !row.images.isEmpty { TimelineImageGrid(images: row.images) } if !row.text.isEmpty { Text(row.text) - .font(MobileDesignTypography.bodyMedium.font) + .font(MobileDesignTypography.bodyLarge.font) .foregroundStyle(OpenBitFunTheme.ink) - .lineSpacing(MobileDesignTypography.bodyMedium.lineSpacing) + .lineSpacing(MobileDesignTypography.bodyLarge.lineSpacing) .textSelection(.enabled) } if row.pending { @@ -157,7 +157,7 @@ private struct EmptyConversationRow: View { VStack(spacing: 8) { Image(systemName: "sparkles").font(.system(size: 23, weight: .medium)) Text(MobileLocalization.text("从这里开始新的对话")) - .font(MobileDesignTypography.bodyMedium.font) + .font(MobileDesignTypography.bodyLarge.font) } .foregroundStyle(OpenBitFunTheme.muted) .frame(maxWidth: .infinity, minHeight: 180) @@ -204,7 +204,7 @@ private struct SubagentBlock: View { Spacer() if running { ProgressView().controlSize(.mini) } Image(systemName: expanded ? "chevron.up" : "chevron.down") - .font(.system(size: 11, weight: .semibold)) + .font(MobileDesignTypography.labelSmall.font) } .foregroundStyle(OpenBitFunTheme.muted) .frame(minHeight: 32) @@ -241,7 +241,7 @@ private struct ThinkingBlock: View { .font(MobileDesignTypography.labelMedium.font) Spacer() Image(systemName: expanded ? "chevron.up" : "chevron.down") - .font(.system(size: 11, weight: .semibold)) + .font(MobileDesignTypography.labelSmall.font) } .foregroundStyle(OpenBitFunTheme.muted) .frame(minHeight: 32) @@ -249,9 +249,9 @@ private struct ThinkingBlock: View { .buttonStyle(.plain) if expanded { Text(text) - .font(MobileDesignTypography.bodyMedium.font) + .font(MobileDesignTypography.bodyLarge.font) .foregroundStyle(OpenBitFunTheme.muted) - .lineSpacing(MobileDesignTypography.bodyMedium.lineSpacing) + .lineSpacing(MobileDesignTypography.bodyLarge.lineSpacing) .textSelection(.enabled) } } @@ -306,12 +306,12 @@ private struct MarkdownBlockView: View { switch block.type { case "heading": Text(inlineString(block.inlines)) - .font(.system(size: headingSize, weight: .bold)) + .font(headingFont) .foregroundStyle(OpenBitFunTheme.ink).textSelection(.enabled) case "quote": Text(inlineString(block.inlines)) - .font(MobileDesignTypography.bodyMedium.font).foregroundStyle(OpenBitFunTheme.muted) - .lineSpacing(MobileDesignTypography.bodyMedium.lineSpacing).padding(.leading, 12) + .font(MobileDesignTypography.bodyLarge.font).foregroundStyle(OpenBitFunTheme.muted) + .lineSpacing(MobileDesignTypography.bodyLarge.lineSpacing).padding(.leading, 12) .overlay(alignment: .leading) { Rectangle().fill(OpenBitFunTheme.line).frame(width: 2) } .textSelection(.enabled) case "list": @@ -321,10 +321,10 @@ private struct MarkdownBlockView: View { Text(item.marker).foregroundStyle(OpenBitFunTheme.muted) .frame(width: 20, alignment: .trailing) Text(inlineString(item.inlines)).foregroundStyle(OpenBitFunTheme.ink) - .lineSpacing(MobileDesignTypography.bodyMedium.lineSpacing) + .lineSpacing(MobileDesignTypography.bodyLarge.lineSpacing) .textSelection(.enabled) } - .font(MobileDesignTypography.bodyMedium.font) + .font(MobileDesignTypography.bodyLarge.font) } } case "code": CodeBlock(language: block.language, code: block.text) @@ -337,13 +337,17 @@ private struct MarkdownBlockView: View { case "divider": Rectangle().fill(OpenBitFunTheme.line).frame(height: 1).padding(.vertical, 3) default: Text(inlineString(block.inlines)) - .font(MobileDesignTypography.bodyMedium.font).foregroundStyle(OpenBitFunTheme.ink) - .lineSpacing(MobileDesignTypography.bodyMedium.lineSpacing).textSelection(.enabled) + .font(MobileDesignTypography.bodyLarge.font).foregroundStyle(OpenBitFunTheme.ink) + .lineSpacing(MobileDesignTypography.bodyLarge.lineSpacing).textSelection(.enabled) } } - private var headingSize: CGFloat { - switch block.level { case 1: 18; case 2: 16; default: 15 } + private var headingFont: Font { + switch block.level { + case 1: MobileDesignTypography.headlineSmall.font + case 2: MobileDesignTypography.titleMedium.font.bold() + default: MobileDesignTypography.bodyLarge.font.bold() + } } private func inlineString(_ inlines: [MarkdownInline]) -> AttributedString { @@ -591,7 +595,7 @@ private struct ToolStatusRow: View { .foregroundStyle(OpenBitFunTheme.ink).lineLimit(1) Spacer(minLength: 4) if tool.phase == "RUNNING" { ProgressView().controlSize(.mini) } - else { Text(statusMark).font(.system(size: 11, weight: .semibold)).foregroundStyle(statusColor) } + else { Text(statusMark).font(MobileDesignTypography.labelSmall.font).foregroundStyle(statusColor) } } .frame(minHeight: 32) } @@ -641,7 +645,7 @@ private struct ToolStatusRow: View { Text(tool.question ?? model.localized("请输入回复")).font(MobileDesignTypography.bodySmall.font) .foregroundStyle(OpenBitFunTheme.ink) TextField(model.localized("回复"), text: $answer, axis: .vertical) - .font(MobileDesignTypography.bodyMedium.font).lineLimit(2...5).padding(10) + .font(MobileDesignTypography.bodyLarge.font).lineLimit(2...5).padding(10) .background(OpenBitFunTheme.card).clipShape(RoundedRectangle(cornerRadius: 11)) .overlay(RoundedRectangle(cornerRadius: 11).stroke(OpenBitFunTheme.line, lineWidth: 1)) Button { model.answerTool(tool.id, answer: answer); answer = "" } label: { diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Chat/ComposerBar.swift b/src/apps/mobile/ios/OpenBitFun/Features/Chat/ComposerBar.swift index b73b8b992e..2bcb6a2d23 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Chat/ComposerBar.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Chat/ComposerBar.swift @@ -138,14 +138,14 @@ struct ComposerBar: View { attachmentAction } inputField(maxLines: expanded ? 4 : 1) - .frame(height: expanded + .frame(minHeight: expanded ? MobileDesignGeometry.composerExpandedInputHeight : MobileDesignGeometry.composerInputHeight) if !expanded { primaryAction } } - .frame(height: expanded + .frame(minHeight: expanded ? MobileDesignGeometry.composerExpandedInputRowHeight : MobileDesignGeometry.composerCollapsedHeight) } @@ -157,14 +157,14 @@ struct ComposerBar: View { Button { modelSelectorOpen = true } label: { HStack(spacing: 3) { Text(selectedModel?.primaryLabel ?? model.localized("模型")) - .font(.system(size: 13, weight: .medium)) + .font(MobileDesignTypography.labelMedium.font) .foregroundStyle(OpenBitFunTheme.ink) .lineLimit(1) Image(systemName: "chevron.down") .font(.system(size: 10, weight: .semibold)) .foregroundStyle(OpenBitFunTheme.muted) } - .frame(height: 34) + .frame(minHeight: MobileDesignGeometry.composerActionSize) .padding(.horizontal, 4) } .buttonStyle(.plain) @@ -174,7 +174,7 @@ struct ComposerBar: View { Spacer(minLength: 0) primaryAction } - .frame(height: MobileDesignGeometry.composerExpandedActionRowHeight) + .frame(minHeight: MobileDesignGeometry.composerExpandedActionRowHeight) .padding(.leading, 2) } @@ -294,15 +294,23 @@ struct ComposerBar: View { ) Button { model.removeComposerImage(id: attachment.id) } label: { - Image(systemName: "xmark") - .font(.system(size: 9, weight: .bold)) - .foregroundStyle(OpenBitFunTheme.contentOnAction) - .frame(width: 20, height: 20) - .background(OpenBitFunTheme.mediaScrim) - .clipShape(Circle()) + ZStack(alignment: .topTrailing) { + Color.clear + Image(systemName: "xmark") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(OpenBitFunTheme.contentOnAction) + .frame(width: 20, height: 20) + .background(OpenBitFunTheme.mediaScrim) + .clipShape(Circle()) + .offset(x: 5, y: -5) + } + .frame( + width: MobileDesignGeometry.composerActionSize, + height: MobileDesignGeometry.composerActionSize + ) + .contentShape(Rectangle()) } .buttonStyle(.plain) - .offset(x: 5, y: -5) .accessibilityLabel(Text(model.localized("移除图片"))) } .padding(.top, 6) @@ -319,7 +327,7 @@ struct ComposerBar: View { if asSheet { HStack(spacing: 0) { Text(model.localized("选择模型")) - .font(.system(size: 13, weight: .medium)) + .font(MobileDesignTypography.labelMedium.font) .foregroundStyle(OpenBitFunTheme.muted) Spacer(minLength: 0) Button { modelSelectorOpen = false } label: { @@ -351,18 +359,19 @@ struct ComposerBar: View { .frame(width: 20, height: 20) VStack(alignment: .leading, spacing: 2) { Text(option.primaryLabel) - .font(.system(size: 13, weight: .medium)) + .font(MobileDesignTypography.labelMedium.font) .foregroundStyle(OpenBitFunTheme.ink) - .lineLimit(1) + .fixedSize(horizontal: false, vertical: true) Text(option.secondaryLabel) - .font(.system(size: 11)) + .font(MobileDesignTypography.bodySmall.font) .foregroundStyle(OpenBitFunTheme.muted) - .lineLimit(1) + .fixedSize(horizontal: false, vertical: true) } Spacer(minLength: 0) } .padding(.horizontal, 10) - .frame(height: MobileDesignGeometry.composerModelSelectorRowHeight) + .padding(.vertical, 8) + .frame(minHeight: MobileDesignGeometry.composerModelSelectorRowHeight) .background(option.selected ? OpenBitFunTheme.soft : OpenBitFunTheme.transparent) .clipShape( RoundedRectangle( diff --git a/src/apps/mobile/ios/OpenBitFun/Features/DesignSystem/GeneratedMobileDesignTokens.swift b/src/apps/mobile/ios/OpenBitFun/Features/DesignSystem/GeneratedMobileDesignTokens.swift index 5e770be879..92fd5ba330 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/DesignSystem/GeneratedMobileDesignTokens.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/DesignSystem/GeneratedMobileDesignTokens.swift @@ -5,10 +5,20 @@ import UIKit struct MobileTypographyToken { let size: CGFloat let lineHeight: CGFloat - let weight: Font.Weight + let weight: UIFont.Weight + let textStyle: UIFont.TextStyle - var font: Font { .system(size: size, weight: weight) } - var lineSpacing: CGFloat { max(0, lineHeight - UIFont.systemFont(ofSize: size).lineHeight) } + private var scaledFont: UIFont { + UIFontMetrics(forTextStyle: textStyle).scaledFont( + for: UIFont.systemFont(ofSize: size, weight: weight) + ) + } + + var font: Font { Font(scaledFont) } + var lineSpacing: CGFloat { + let scaledLineHeight = UIFontMetrics(forTextStyle: textStyle).scaledValue(for: lineHeight) + return max(0, scaledLineHeight - scaledFont.lineHeight) + } } enum MobileDesignColors { @@ -73,22 +83,22 @@ enum MobileDesignColors { } enum MobileDesignTypography { - static let displayLarge = MobileTypographyToken(size: 24, lineHeight: 30, weight: .bold) - static let displayMedium = MobileTypographyToken(size: 22, lineHeight: 28, weight: .bold) - static let displaySmall = MobileTypographyToken(size: 20, lineHeight: 26, weight: .bold) - static let headlineLarge = MobileTypographyToken(size: 22, lineHeight: 28, weight: .bold) - static let headlineMedium = MobileTypographyToken(size: 20, lineHeight: 26, weight: .bold) - static let headlineSmall = MobileTypographyToken(size: 18, lineHeight: 24, weight: .bold) - static let titleLarge = MobileTypographyToken(size: 20, lineHeight: 26, weight: .bold) - static let conversationHeaderTitle = MobileTypographyToken(size: 17, lineHeight: 22, weight: .medium) - static let titleMedium = MobileTypographyToken(size: 17, lineHeight: 22, weight: .medium) - static let titleSmall = MobileTypographyToken(size: 15, lineHeight: 20, weight: .medium) - static let bodyLarge = MobileTypographyToken(size: 16, lineHeight: 24, weight: .regular) - static let bodyMedium = MobileTypographyToken(size: 14, lineHeight: 21, weight: .regular) - static let bodySmall = MobileTypographyToken(size: 13, lineHeight: 19, weight: .regular) - static let labelLarge = MobileTypographyToken(size: 15, lineHeight: 20, weight: .medium) - static let labelMedium = MobileTypographyToken(size: 14, lineHeight: 18, weight: .medium) - static let labelSmall = MobileTypographyToken(size: 12, lineHeight: 16, weight: .regular) + static let displayLarge = MobileTypographyToken(size: 24, lineHeight: 30, weight: .bold, textStyle: .largeTitle) + static let displayMedium = MobileTypographyToken(size: 22, lineHeight: 28, weight: .bold, textStyle: .title1) + static let displaySmall = MobileTypographyToken(size: 20, lineHeight: 26, weight: .bold, textStyle: .title2) + static let headlineLarge = MobileTypographyToken(size: 22, lineHeight: 28, weight: .bold, textStyle: .title1) + static let headlineMedium = MobileTypographyToken(size: 20, lineHeight: 26, weight: .bold, textStyle: .title2) + static let headlineSmall = MobileTypographyToken(size: 18, lineHeight: 24, weight: .bold, textStyle: .title3) + static let titleLarge = MobileTypographyToken(size: 20, lineHeight: 26, weight: .bold, textStyle: .title2) + static let conversationHeaderTitle = MobileTypographyToken(size: 17, lineHeight: 22, weight: .medium, textStyle: .headline) + static let titleMedium = MobileTypographyToken(size: 17, lineHeight: 22, weight: .medium, textStyle: .headline) + static let titleSmall = MobileTypographyToken(size: 15, lineHeight: 20, weight: .medium, textStyle: .subheadline) + static let bodyLarge = MobileTypographyToken(size: 16, lineHeight: 24, weight: .regular, textStyle: .body) + static let bodyMedium = MobileTypographyToken(size: 14, lineHeight: 21, weight: .regular, textStyle: .callout) + static let bodySmall = MobileTypographyToken(size: 13, lineHeight: 19, weight: .regular, textStyle: .footnote) + static let labelLarge = MobileTypographyToken(size: 15, lineHeight: 20, weight: .medium, textStyle: .subheadline) + static let labelMedium = MobileTypographyToken(size: 14, lineHeight: 18, weight: .medium, textStyle: .footnote) + static let labelSmall = MobileTypographyToken(size: 12, lineHeight: 16, weight: .regular, textStyle: .caption1) } enum MobileDesignGeometry { @@ -103,13 +113,13 @@ enum MobileDesignGeometry { static let messageBubbleHorizontalPadding: CGFloat = 14 static let messageBubbleVerticalPadding: CGFloat = 11 static let messageBubbleRadius: CGFloat = 17 - static let composerActionSize: CGFloat = 44 + static let composerActionSize: CGFloat = 48 static let composerInputHeight: CGFloat = 44 static let composerExpandedInputHeight: CGFloat = 58 static let composerCollapsedHeight: CGFloat = 56 static let composerExpandedInputRowHeight: CGFloat = 60 - static let composerExpandedActionRowHeight: CGFloat = 40 - static let composerExpandedHeight: CGFloat = 108 + static let composerExpandedActionRowHeight: CGFloat = 48 + static let composerExpandedHeight: CGFloat = 120 static let composerCollapsedRadius: CGFloat = 28 static let composerExpandedRadius: CGFloat = 18 static let composerModelSelectorWidth: CGFloat = 330 @@ -124,7 +134,7 @@ enum MobileDesignGeometry { static let sheetActionHeight: CGFloat = 48 static let selectionTopRadius: CGFloat = 20 static let selectionRowHeight: CGFloat = 64 - static let selectionCloseSize: CGFloat = 32 + static let selectionCloseSize: CGFloat = 48 static let popoverWidth: CGFloat = 292 static let popoverRadius: CGFloat = 16 static let popoverPadding: CGFloat = 12 diff --git a/src/apps/mobile/ios/OpenBitFun/Features/DesignSystem/GeneratedMobilePreviewScenarios.swift b/src/apps/mobile/ios/OpenBitFun/Features/DesignSystem/GeneratedMobilePreviewScenarios.swift index d0ddbfae5e..e5c2155bf4 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/DesignSystem/GeneratedMobilePreviewScenarios.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/DesignSystem/GeneratedMobilePreviewScenarios.swift @@ -70,4 +70,52 @@ enum MobilePreviewScenarios { connectionPhase: "reconnecting", streaming: false ) + + static let narrowMultiline = MobilePreviewScenario( + id: "narrow-multiline", + title: "Narrow phone with a multiline draft", + description: "Narrow phone with a multiline draft", + appearance: "light", + viewportWidth: 360, + viewportHeight: 800, + headerTitle: "检查移动端长标题、正文与控制目标的可读性", + headerSubtitle: "远程开发工作站 · OpenBitFun / feature/mobile-reading", + messages: [MobilePreviewMessage(role: "user", text: "三端的组件和样式可以保持一致吗?"), MobilePreviewMessage(role: "assistant", text: "可以。共享视觉契约,三端继续使用原生渲染。")], + composerDraft: "请检查输入框是否能保留多行中文草稿。\n模型选择后内容应继续保留。\n附件与文本应各自成行。", + composerPlaceholder: "向 OpenBitFun 提问", + connectionPhase: "connected", + streaming: false + ) + + static let foldContext = MobilePreviewScenario( + id: "fold-context", + title: "Foldable with long remote context", + description: "Foldable with long remote context", + appearance: "light", + viewportWidth: 700, + viewportHeight: 840, + headerTitle: "检查移动端长标题、正文与控制目标的可读性", + headerSubtitle: "远程开发工作站 · OpenBitFun / feature/mobile-reading", + messages: [MobilePreviewMessage(role: "user", text: "三端的组件和样式可以保持一致吗?"), MobilePreviewMessage(role: "assistant", text: "可以。共享视觉契约,三端继续使用原生渲染。")], + composerDraft: "请保留当前会话与控制目标。", + composerPlaceholder: "向 OpenBitFun 提问", + connectionPhase: "connected", + streaming: false + ) + + static let longReading = MobilePreviewScenario( + id: "long-reading", + title: "Long reading content", + description: "Long reading content", + appearance: "light", + viewportWidth: 430, + viewportHeight: 932, + headerTitle: "检查移动端长标题、正文与控制目标的可读性", + headerSubtitle: "远程开发工作站 · OpenBitFun / feature/mobile-reading", + messages: [MobilePreviewMessage(role: "user", text: "三端的组件和样式可以保持一致吗?"), MobilePreviewMessage(role: "assistant", text: "移动端的阅读层级需要清晰,正文应该舒适地换行,辅助说明保留足够对比度。\n\n当内容变长时,消息区滚动,输入框仍然可以操作。请在系统大字体设置下检查这一场景。")], + composerDraft: "", + composerPlaceholder: "向 OpenBitFun 提问", + connectionPhase: "connected", + streaming: false + ) } diff --git a/src/apps/mobile/ios/OpenBitFun/Features/DesignSystem/MobileDesignGallery.swift b/src/apps/mobile/ios/OpenBitFun/Features/DesignSystem/MobileDesignGallery.swift index 4282233bc1..1a1818660c 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/DesignSystem/MobileDesignGallery.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/DesignSystem/MobileDesignGallery.swift @@ -22,6 +22,7 @@ struct MobileDesignGallery: View { previewModel.remoteConnected = true previewModel.remoteSessionSelected = true previewModel.remoteSessions = [session] + previewModel.designGalleryPreview = true previewModel.draft = scenario.composerDraft previewModel.isSending = scenario.streaming _model = StateObject(wrappedValue: previewModel) diff --git a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+GeneralChat.swift b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+GeneralChat.swift index c64f995e70..6fe7c1fb7c 100644 --- a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+GeneralChat.swift +++ b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+GeneralChat.swift @@ -131,6 +131,7 @@ extension MobileAppModel { } func apply(coreState state: GeneralChatUiState) { + if designGalleryPreview { return } generalConfigured = state.configured generalConfigBaseURL = state.config.baseUrl generalConfigModel = state.config.model diff --git a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel.swift b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel.swift index be2bd5e58a..028d698699 100644 --- a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel.swift +++ b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel.swift @@ -93,6 +93,7 @@ final class MobileAppModel: ObservableObject { var accountLoginPreview = false var localActionPreview = false var composerModelPickerPreview = false + var designGalleryPreview = false var remoteCreatePreview = false var directoryFixturePreview = false var pairingGeneration: UInt64 = 0