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
28 changes: 26 additions & 2 deletions scripts/mobile-ui-design-system.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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 {
Expand All @@ -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,
Expand All @@ -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(
Expand All @@ -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,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -228,17 +228,17 @@ private fun MarkdownList(items: List<MarkdownListItem>, 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),
)
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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Loading
Loading