diff --git a/package.json b/package.json index 5b439043d0..40e9b600ea 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,9 @@ "check:build-prereqs": "node scripts/check-build-prereqs.mjs", "harmony:architecture": "node scripts/check-harmonyos-architecture.mjs", "mobile:architecture": "node scripts/check-mobile-architecture.mjs", + "mobile:ui:generate": "node scripts/mobile-ui-design-system.mjs", + "mobile:ui:check": "node scripts/mobile-ui-design-system.mjs --check && node scripts/mobile-ui-preview.mjs --check", + "mobile:ui:preview": "node scripts/mobile-ui-design-system.mjs && node scripts/mobile-ui-preview.mjs", "check:core-boundaries": "node scripts/check-core-boundaries.mjs", "check:core-boundaries:test": "node --test scripts/check-core-boundaries.test.mjs", "check:github-config": "pnpm --dir src/web-ui exec node ../../scripts/check-github-config.mjs && node --test scripts/check-github-config.test.mjs", diff --git a/scripts/mobile-ui-design-system.mjs b/scripts/mobile-ui-design-system.mjs new file mode 100644 index 0000000000..5b05fe974e --- /dev/null +++ b/scripts/mobile-ui-design-system.mjs @@ -0,0 +1,237 @@ +#!/usr/bin/env node + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const DESIGN_DIR = join(ROOT, 'src', 'apps', 'mobile', 'design-system'); +const TOKENS_PATH = join(DESIGN_DIR, 'tokens', 'mobile-tokens.json'); +const COMPONENTS_PATH = join(DESIGN_DIR, 'components', 'mobile-components.json'); +const SCENARIOS_PATH = join(DESIGN_DIR, 'scenarios', 'mobile-preview-scenarios.json'); +const CHECK = process.argv.includes('--check'); + +const tokens = readJson(TOKENS_PATH); +const components = readJson(COMPONENTS_PATH); +const scenarios = readJson(SCENARIOS_PATH); + +validateContract(tokens, components, scenarios); + +const outputs = new Map([ + [ + join(ROOT, 'src', 'apps', 'mobile', 'harmonyos', 'entry', 'src', 'main', 'resources', 'base', 'element', 'color.json'), + renderHarmonyColors(tokens.colors, 'light'), + ], + [ + join(ROOT, 'src', 'apps', 'mobile', 'harmonyos', 'entry', 'src', 'main', 'resources', 'dark', 'element', 'color.json'), + renderHarmonyColors(tokens.colors, 'dark'), + ], + [ + join(ROOT, 'src', 'apps', 'mobile', 'harmonyos', 'entry', 'src', 'main', 'ets', 'generated', 'MobileDesignTokens.ets'), + renderHarmonyTokens(tokens), + ], + [ + join(ROOT, 'src', 'apps', 'mobile', 'harmonyos', 'entry', 'src', 'main', 'ets', 'generated', 'MobilePreviewScenarios.ets'), + renderHarmonyScenarios(scenarios), + ], + [ + join(ROOT, 'src', 'apps', 'mobile', 'android', 'app', 'src', 'main', 'kotlin', 'com', 'bitfun', 'mobile', 'app', 'ui', 'theme', 'generated', 'MobileDesignTokens.kt'), + renderAndroidTokens(tokens), + ], + [ + join(ROOT, 'src', 'apps', 'mobile', 'android', 'app', 'src', 'main', 'kotlin', 'com', 'bitfun', 'mobile', 'app', 'ui', 'preview', 'generated', 'MobilePreviewScenarios.kt'), + renderAndroidScenarios(scenarios), + ], + [ + join(ROOT, 'src', 'apps', 'mobile', 'shared', 'core-feature', 'src', 'commonMain', 'kotlin', 'com', 'bitfun', 'mobile', 'core', 'feature', 'layout', 'generated', 'MobileDesignBreakpoints.kt'), + renderSharedLayoutTokens(tokens), + ], + [ + join(ROOT, 'src', 'apps', 'mobile', 'ios', 'BitFun', 'Features', 'DesignSystem', 'GeneratedMobileDesignTokens.swift'), + renderIosTokens(tokens), + ], + [ + join(ROOT, 'src', 'apps', 'mobile', 'ios', 'BitFun', 'Features', 'DesignSystem', 'GeneratedMobilePreviewScenarios.swift'), + renderIosScenarios(scenarios), + ], + [ + join(DESIGN_DIR, 'preview', 'generated', 'mobile-design-data.js'), + renderPreviewData(tokens, components, scenarios), + ], +]); + +let changed = 0; +for (const [path, content] of outputs) { + const current = existsSync(path) ? readFileSync(path, 'utf8') : null; + if (current === content) continue; + changed += 1; + const relativePath = path.slice(ROOT.length + 1); + if (CHECK) { + console.error(`[mobile-ui] Generated file is stale: ${relativePath}`); + continue; + } + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, content, 'utf8'); + console.log(`[mobile-ui] Wrote ${relativePath}`); +} + +if (CHECK && changed > 0) { + console.error(`[mobile-ui] ${changed} generated file(s) need regeneration.`); + process.exit(1); +} + +if (changed === 0) { + console.log(`[mobile-ui] ${CHECK ? 'Contract and generated files are in sync' : 'Generated files are already current'}.`); +} + +function readJson(path) { + return JSON.parse(readFileSync(path, 'utf8')); +} + +function validateContract(tokenContract, componentContract, scenarioContract) { + if (tokenContract.version !== 1 || componentContract.version !== 1 || scenarioContract.version !== 1) { + throw new Error('Unsupported mobile design contract version.'); + } + for (const [name, pair] of Object.entries(tokenContract.colors ?? {})) { + for (const appearance of ['light', 'dark']) { + if (!/^#(?:[0-9A-F]{6}|[0-9A-F]{8})$/.test(pair[appearance] ?? '')) { + throw new Error(`Invalid ${appearance} color for ${name}.`); + } + } + } + for (const [name, value] of Object.entries(tokenContract.geometry ?? {})) { + if (!Number.isFinite(value) || value <= 0) throw new Error(`Invalid geometry token ${name}.`); + } + const ids = new Set(); + for (const scenario of scenarioContract.scenarios ?? []) { + if (!scenario.id || ids.has(scenario.id)) throw new Error(`Invalid or duplicate preview scenario id: ${scenario.id}.`); + ids.add(scenario.id); + if (!['light', 'dark'].includes(scenario.appearance)) throw new Error(`Invalid appearance for ${scenario.id}.`); + if (!scenario.viewport?.width || !scenario.viewport?.height) throw new Error(`Missing viewport for ${scenario.id}.`); + } +} + +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 typography = Object.entries(contract.typography) + .map(([name, token]) => ` static readonly ${camel(name)}: MobileTypographyToken = new MobileTypographyToken(${token.size}, ${token.lineHeight}, ${token.weight});`) + .join('\n'); + 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 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`; +} + +function renderHarmonyScenarios(contract) { + const values = contract.scenarios.map((scenario) => ` static readonly ${camel(scenario.id.replaceAll('-', '_'))}: MobilePreviewScenario = new MobilePreviewScenario(\n ${quoted(scenario.id)},\n ${quoted(scenario.title)},\n ${quoted(scenario.description)},\n ${quoted(scenario.appearance)},\n ${scenario.viewport.width},\n ${scenario.viewport.height},\n ${quoted(scenario.header.title)},\n ${quoted(scenario.header.subtitle)},\n [${scenario.messages.map((message) => `new MobilePreviewMessage(${quoted(message.role)}, ${quoted(message.text)})`).join(', ')}],\n ${quoted(scenario.composer.draft)},\n ${quoted(scenario.composer.placeholder)},\n ${quoted(scenario.composer.phase)},\n ${scenario.composer.streaming}\n );`).join('\n\n'); + return `// Generated by scripts/mobile-ui-design-system.mjs. Do not edit.\n\nexport class MobilePreviewMessage {\n readonly role: string;\n readonly text: string;\n\n constructor(role: string, text: string) {\n this.role = role;\n this.text = text;\n }\n}\n\nexport class MobilePreviewScenario {\n readonly id: string;\n readonly title: string;\n readonly description: string;\n readonly appearance: string;\n readonly viewportWidth: number;\n readonly viewportHeight: number;\n readonly headerTitle: string;\n readonly headerSubtitle: string;\n readonly messages: MobilePreviewMessage[];\n readonly composerDraft: string;\n readonly composerPlaceholder: string;\n readonly connectionPhase: string;\n readonly streaming: boolean;\n\n constructor(\n id: string,\n title: string,\n description: string,\n appearance: string,\n viewportWidth: number,\n viewportHeight: number,\n headerTitle: string,\n headerSubtitle: string,\n messages: MobilePreviewMessage[],\n composerDraft: string,\n composerPlaceholder: string,\n connectionPhase: string,\n streaming: boolean\n ) {\n this.id = id;\n this.title = title;\n this.description = description;\n this.appearance = appearance;\n this.viewportWidth = viewportWidth;\n this.viewportHeight = viewportHeight;\n this.headerTitle = headerTitle;\n this.headerSubtitle = headerSubtitle;\n this.messages = messages;\n this.composerDraft = composerDraft;\n this.composerPlaceholder = composerPlaceholder;\n this.connectionPhase = connectionPhase;\n this.streaming = streaming;\n }\n}\n\nexport class MobilePreviewScenarios {\n${values}\n}\n`; +} + +function renderAndroidTokens(contract) { + const lightColors = renderKotlinColors(contract.colors, 'light'); + const darkColors = renderKotlinColors(contract.colors, 'dark'); + const typography = Object.entries(contract.typography) + .map(([name, token]) => ` val ${pascal(name)} = TextStyle(fontSize = ${token.size}.sp, lineHeight = ${token.lineHeight}.sp, fontWeight = ${kotlinWeight(token.weight)})`) + .join('\n'); + const geometry = Object.entries(contract.geometry) + .map(([name, value]) => ` val ${pascal(name)} = ${value}.dp`) + .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.bitfun.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`; +} + +function renderAndroidScenarios(contract) { + const values = contract.scenarios.map((scenario) => ` val ${pascal(scenario.id.replaceAll('-', '_'))} = MobilePreviewScenario(\n id = ${quoted(scenario.id)},\n title = ${quoted(scenario.title)},\n description = ${quoted(scenario.description)},\n appearance = ${quoted(scenario.appearance)},\n viewportWidth = ${scenario.viewport.width},\n viewportHeight = ${scenario.viewport.height},\n headerTitle = ${quoted(scenario.header.title)},\n headerSubtitle = ${quoted(scenario.header.subtitle)},\n messages = listOf(${scenario.messages.map((message) => `MobilePreviewMessage(${quoted(message.role)}, ${quoted(message.text)})`).join(', ')}),\n composerDraft = ${quoted(scenario.composer.draft)},\n composerPlaceholder = ${quoted(scenario.composer.placeholder)},\n connectionPhase = ${quoted(scenario.composer.phase)},\n streaming = ${scenario.composer.streaming},\n )`).join('\n\n'); + return `// Generated by scripts/mobile-ui-design-system.mjs. Do not edit.\npackage com.bitfun.mobile.app.ui.preview.generated\n\ninternal data class MobilePreviewMessage(val role: String, val text: String)\n\ninternal data class MobilePreviewScenario(\n val id: String,\n val title: String,\n val description: String,\n val appearance: String,\n val viewportWidth: Int,\n val viewportHeight: Int,\n val headerTitle: String,\n val headerSubtitle: String,\n val messages: List,\n val composerDraft: String,\n val composerPlaceholder: String,\n val connectionPhase: String,\n val streaming: Boolean,\n)\n\ninternal object MobilePreviewScenarios {\n${values}\n}\n`; +} + +function renderIosTokens(contract) { + const colors = Object.entries(contract.colors) + .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)})`) + .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`; +} + +function renderIosScenarios(contract) { + const values = contract.scenarios.map((scenario) => ` static let ${camel(scenario.id.replaceAll('-', '_'))} = MobilePreviewScenario(\n id: ${quoted(scenario.id)},\n title: ${quoted(scenario.title)},\n description: ${quoted(scenario.description)},\n appearance: ${quoted(scenario.appearance)},\n viewportWidth: ${scenario.viewport.width},\n viewportHeight: ${scenario.viewport.height},\n headerTitle: ${quoted(scenario.header.title)},\n headerSubtitle: ${quoted(scenario.header.subtitle)},\n messages: [${scenario.messages.map((message) => `MobilePreviewMessage(role: ${quoted(message.role)}, text: ${quoted(message.text)})`).join(', ')}],\n composerDraft: ${quoted(scenario.composer.draft)},\n composerPlaceholder: ${quoted(scenario.composer.placeholder)},\n connectionPhase: ${quoted(scenario.composer.phase)},\n streaming: ${scenario.composer.streaming}\n )`).join('\n\n'); + return `// Generated by scripts/mobile-ui-design-system.mjs. Do not edit.\nimport CoreGraphics\n\nstruct MobilePreviewMessage {\n let role: String\n let text: String\n}\n\nstruct MobilePreviewScenario {\n let id: String\n let title: String\n let description: String\n let appearance: String\n let viewportWidth: CGFloat\n let viewportHeight: CGFloat\n let headerTitle: String\n let headerSubtitle: String\n let messages: [MobilePreviewMessage]\n let composerDraft: String\n let composerPlaceholder: String\n let connectionPhase: String\n let streaming: Bool\n}\n\nenum MobilePreviewScenarios {\n${values}\n}\n`; +} + +function renderSharedLayoutTokens(contract) { + const breakpoints = Object.entries(contract.breakpoints) + .map(([name, value]) => ` public const val ${pascal(name)}: Int = ${value}`) + .join('\n'); + return `// Generated by scripts/mobile-ui-design-system.mjs. Do not edit.\npackage com.bitfun.mobile.core.feature.layout.generated\n\npublic object MobileDesignBreakpoints {\n${breakpoints}\n}\n`; +} + +function renderPreviewData(tokenContract, componentContract, scenarioContract) { + return `// Generated by scripts/mobile-ui-design-system.mjs. Do not edit.\nexport const mobileTokens = ${JSON.stringify(tokenContract, null, 2)};\nexport const mobileComponents = ${JSON.stringify(componentContract, null, 2)};\nexport const mobilePreviewScenarios = ${JSON.stringify(scenarioContract, null, 2)};\n`; +} + +function renderNumberProperties(values, prefix) { + return Object.entries(values).map(([name, value]) => `${prefix} ${camel(name)}: number = ${value};`).join('\n'); +} + +function renderKotlinColors(colors, appearance) { + return Object.entries(colors) + .map(([name, pair]) => ` val ${pascal(name)} = Color(0x${normalizeArgb(pair[appearance])})`) + .join('\n'); +} + +function renderKotlinInts(values) { + return Object.entries(values).map(([name, value]) => ` const val ${pascal(name)}: Int = ${value}`).join('\n'); +} + +function renderSwiftNumbers(values) { + return Object.entries(values).map(([name, value]) => ` static let ${camel(name)}: CGFloat = ${value}`).join('\n'); +} + +function normalizeArgb(hex) { + const value = hex.slice(1); + return value.length === 6 ? `FF${value}` : value; +} + +function swiftHex(hex) { + return `0x${normalizeArgb(hex)}`; +} + +function words(name) { + return name.split('_').filter(Boolean); +} + +function camel(name) { + const [head, ...tail] = words(name); + return head + tail.map((word) => word[0].toUpperCase() + word.slice(1)).join(''); +} + +function pascal(name) { + return words(name).map((word) => word[0].toUpperCase() + word.slice(1)).join(''); +} + +function kotlinWeight(weight) { + if (weight >= 700) return 'FontWeight.Bold'; + if (weight >= 500) return 'FontWeight.Medium'; + return 'FontWeight.Normal'; +} + +function swiftWeight(weight) { + if (weight >= 700) return 'bold'; + if (weight >= 500) return 'medium'; + return 'regular'; +} + +function quoted(value) { + return JSON.stringify(value); +} diff --git a/scripts/mobile-ui-preview.mjs b/scripts/mobile-ui-preview.mjs new file mode 100644 index 0000000000..feb89ce259 --- /dev/null +++ b/scripts/mobile-ui-preview.mjs @@ -0,0 +1,93 @@ +#!/usr/bin/env node + +import { createReadStream, existsSync, readFileSync } from 'node:fs'; +import { createServer } from 'node:http'; +import { extname, join, normalize, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { dirname } from 'node:path'; +import { spawn } from 'node:child_process'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const PREVIEW_ROOT = join(ROOT, 'src', 'apps', 'mobile', 'design-system', 'preview'); +const requiredFiles = ['index.html', 'preview.css', 'preview.js', 'generated/mobile-design-data.js']; + +for (const file of requiredFiles) { + const path = join(PREVIEW_ROOT, file); + if (!existsSync(path) || readFileSync(path, 'utf8').trim().length === 0) { + console.error(`[mobile-ui-preview] Missing preview asset: ${file}`); + process.exit(1); + } +} + +if (process.argv.includes('--check')) { + console.log('[mobile-ui-preview] Preview assets are present.'); + process.exit(0); +} + +const portArgIndex = process.argv.indexOf('--port'); +const port = portArgIndex >= 0 ? Number(process.argv[portArgIndex + 1]) : 4178; +const host = '127.0.0.1'; +const url = `http://${host}:${port}`; +const server = createServer((request, response) => { + const requestPath = decodeURIComponent((request.url ?? '/').split('?')[0]); + const relativePath = requestPath === '/' ? 'index.html' : requestPath.replace(/^\/+/, ''); + const path = normalize(join(PREVIEW_ROOT, relativePath)); + if (!path.startsWith(`${PREVIEW_ROOT}/`) && path !== join(PREVIEW_ROOT, 'index.html')) { + response.writeHead(403).end('Forbidden'); + return; + } + if (!existsSync(path)) { + response.writeHead(404).end('Not found'); + return; + } + response.setHeader('X-BitFun-Mobile-Preview', '1'); + response.setHeader('Content-Type', contentType(extname(path))); + createReadStream(path).pipe(response); +}); + +server.on('error', async (error) => { + if (error.code === 'EADDRINUSE' && await isBitFunPreview(url)) { + console.log(`[mobile-ui-preview] Reusing existing preview at ${url}`); + openPreview(url); + process.exit(0); + } + if (error.code === 'EADDRINUSE') { + console.error(`[mobile-ui-preview] Port ${port} is already used by another application. Pass --port to choose another port.`); + } else { + console.error(`[mobile-ui-preview] Failed to start: ${error.message}`); + } + process.exit(1); +}); + +server.listen(port, host, () => { + console.log(`[mobile-ui-preview] ${url}`); + openPreview(url); +}); + +async function isBitFunPreview(target) { + try { + const response = await fetch(target, { signal: AbortSignal.timeout(1500) }); + if (response.headers.get('x-bitfun-mobile-preview') === '1') return true; + return (await response.text()).includes('BitFun Mobile Parity Bench'); + } catch { + return false; + } +} + +function openPreview(target) { + if (!process.argv.includes('--no-open') && process.platform === 'darwin') { + spawn('open', [target], { detached: true, stdio: 'ignore' }).unref(); + } +} + +function contentType(extension) { + return ({ + '.html': 'text/html; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + })[extension] ?? 'application/octet-stream'; +} diff --git a/src/apps/mobile/AGENTS.md b/src/apps/mobile/AGENTS.md index 0c3738619b..bfbfd1e1f2 100644 --- a/src/apps/mobile/AGENTS.md +++ b/src/apps/mobile/AGENTS.md @@ -25,6 +25,28 @@ Native mobile applications are product entrypoints under `src/apps/mobile`. | `ios/` | iOS app, resources, lifecycle, and adapters | | `harmonyos/` | HarmonyOS app, resources, lifecycle, and adapters | | `shared/` | Kotlin Multiplatform core: protocol, crypto, transport, persistence, domain, feature stores | +| `design-system/` | HarmonyOS-derived mobile tokens, component contracts, deterministic preview scenarios, and the desktop comparison surface | + +## Native UI Contract + +HarmonyOS is the visual reference implementation. Stable colors, typography, +geometry, breakpoints, motion durations, component anatomy, and comparison +scenarios are recorded under `design-system/`; Android and iOS consume generated +native constants but continue to render with Compose and SwiftUI respectively. +Do not introduce a shared cross-platform renderer or make generated files the +source of truth. + +- Change the HarmonyOS implementation and the source contract together when a + stable visual fact changes. +- Run `pnpm run mobile:ui:generate` after contract changes and commit the + generated native files. +- Run `pnpm run mobile:ui:check` before pushing to reject generated drift. +- Use `pnpm run mobile:ui:preview` for the local three-column HarmonyOS / Android + / iOS comparison surface. Native captures belong under the documented + `design-system/preview/snapshots/` convention and are local evidence unless a + fixture is intentionally reviewed into the repository. +- Keep safe areas, keyboard behavior, accessibility, navigation gestures, and + platform presentation primitives in each native app. ## Shared Core diff --git a/src/apps/mobile/README.md b/src/apps/mobile/README.md index e1684e2411..cbff814976 100644 --- a/src/apps/mobile/README.md +++ b/src/apps/mobile/README.md @@ -11,5 +11,20 @@ and platform adapters. Product logic and stable contracts should remain in the platform-agnostic Rust layers and be exposed to these apps through explicit interfaces. -The directories are intentionally build-tool agnostic until the native stacks -and minimum supported platform versions are selected. +## Shared visual contract + +HarmonyOS is the current visual baseline. The source contract in +[`design-system/`](design-system/README.md) records the stable HarmonyOS colors, +type scale, geometry, breakpoints, motion, component anatomy, and deterministic +preview scenarios. A generator emits native constants for ArkUI, Compose, and +SwiftUI; each platform still owns its native component implementation. + +```bash +pnpm run mobile:ui:generate +pnpm run mobile:ui:check +pnpm run mobile:ui:preview +``` + +The preview command opens a local three-column desktop surface for HarmonyOS, +Android, and iOS. It renders the same scenario from the contract and can overlay +native simulator or IDE-preview captures for pixel-level comparison. diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/MainActivity.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/MainActivity.kt index 0b9cee85a6..c7c64b52a4 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/MainActivity.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/MainActivity.kt @@ -10,6 +10,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.bitfun.mobile.app.ui.shell.MobileScreen import com.bitfun.mobile.app.platform.AppLocaleController +import com.bitfun.mobile.app.ui.preview.MobileDesignGallery +import com.bitfun.mobile.app.ui.preview.mobileDesignScenario import com.bitfun.mobile.app.ui.theme.BitFunTheme import com.bitfun.mobile.app.viewmodel.AppSettingsViewModel import com.bitfun.mobile.app.viewmodel.AppThemeMode @@ -20,6 +22,11 @@ class MainActivity : ComponentActivity() { super.onCreate(savedInstanceState) enableEdgeToEdge() setContent { + if (intent.getBooleanExtra(DESIGN_PREVIEW_EXTRA, false)) { + val scenario = mobileDesignScenario(intent.getStringExtra(DESIGN_SCENARIO_EXTRA)) + MobileDesignGallery(scenario = scenario, dark = scenario.appearance == "dark") + return@setContent + } val settings: AppSettingsViewModel = viewModel(factory = AppSettingsViewModel.Factory) val theme by settings.theme.collectAsStateWithLifecycle() val dark = when (theme) { @@ -32,4 +39,9 @@ class MainActivity : ComponentActivity() { } } } + + private companion object { + const val DESIGN_PREVIEW_EXTRA = "bitfun.design_preview" + const val DESIGN_SCENARIO_EXTRA = "bitfun.design_scenario" + } } diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/ComposerBar.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/ComposerBar.kt index 1b4413e28c..ccf15a90ec 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/ComposerBar.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/ComposerBar.kt @@ -66,6 +66,7 @@ import com.bitfun.mobile.app.R import com.bitfun.mobile.app.ui.theme.BitFunEaseOut import com.bitfun.mobile.app.ui.theme.MotionQuickMillis import com.bitfun.mobile.app.ui.theme.MotionStructureMillis +import com.bitfun.mobile.app.ui.theme.generated.MobileDesignGeometry import com.bitfun.mobile.core.feature.connection.ConnectionPhase import com.bitfun.mobile.core.feature.session.ChatComposerCapabilities import com.bitfun.mobile.core.feature.session.ChatComposerPolicy @@ -85,12 +86,12 @@ internal const val MAX_COMPOSER_IMAGES: Int = 4 // The measurements come straight from `ComposerBar.ets`, which sizes the bar in // vp — the same unit as dp. Naming them keeps the two files diffable. -private val ActionSize = 40.dp -private val InputHeight = 42.dp -private val ExpandedInputHeight = 74.dp -private val CollapsedBarHeight = 52.dp -private val ExpandedInputRowHeight = 76.dp -private val ExpandedActionRowHeight = 44.dp +private val ActionSize = MobileDesignGeometry.ComposerActionSize +private val InputHeight = MobileDesignGeometry.ComposerInputHeight +private val ExpandedInputHeight = MobileDesignGeometry.ComposerExpandedInputHeight +private val CollapsedBarHeight = MobileDesignGeometry.ComposerCollapsedHeight +private val ExpandedInputRowHeight = MobileDesignGeometry.ComposerExpandedInputRowHeight +private val ExpandedActionRowHeight = MobileDesignGeometry.ComposerExpandedActionRowHeight /** * The input bar, ported from `pages/components/ComposerBar.ets`. @@ -160,7 +161,11 @@ internal fun ComposerBar( easing = BitFunEaseOut, ) val radius by animateDpAsState( - if (expanded || images.isNotEmpty()) 18.dp else 26.dp, + if (expanded || images.isNotEmpty()) { + MobileDesignGeometry.ComposerExpandedRadius + } else { + MobileDesignGeometry.ComposerCollapsedRadius + }, structureSpec, label = "composer-radius", ) @@ -197,7 +202,12 @@ internal fun ComposerBar( Column( modifier = modifier .fillMaxWidth() - .padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 14.dp) + .padding( + start = MobileDesignGeometry.ContentGutter, + end = MobileDesignGeometry.ContentGutter, + top = 8.dp, + bottom = 14.dp, + ) .testTag(COMPOSER_TEST_TAG), ) { Surface( diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/ConversationHeader.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/ConversationHeader.kt index 41eb5a671e..cbdeddcbb1 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/ConversationHeader.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/ConversationHeader.kt @@ -33,6 +33,8 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.bitfun.mobile.app.R import com.bitfun.mobile.app.ui.common.CircleControl +import com.bitfun.mobile.app.ui.theme.generated.MobileDesignGeometry +import com.bitfun.mobile.app.ui.theme.generated.MobileDesignTypography internal const val CONVERSATION_TITLE_TEST_TAG: String = "conversation-title" internal const val CONVERSATION_MENU_TEST_TAG: String = "conversation-menu" @@ -78,8 +80,11 @@ internal fun ConversationHeader( Row( modifier = Modifier .fillMaxWidth() - .height(if (hasSubtitle) 76.dp else 64.dp) - .padding(horizontal = 16.dp, vertical = 8.dp), + .height( + if (hasSubtitle) MobileDesignGeometry.ConversationHeaderHeight + else MobileDesignGeometry.ConversationHeaderCompactHeight, + ) + .padding(horizontal = MobileDesignGeometry.ContentGutter, vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { @@ -110,9 +115,8 @@ internal fun ConversationHeader( ) { Text( title.ifBlank { stringResource(R.string.conversation_title_default) }, - fontSize = if (hasSubtitle) 18.sp else 17.sp, - lineHeight = 22.sp, - fontWeight = FontWeight.Medium, + style = if (hasSubtitle) MobileDesignTypography.ConversationHeaderTitle + else MobileDesignTypography.TitleMedium, textAlign = TextAlign.Center, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -205,7 +209,12 @@ private fun TitleEditor( Row( modifier = Modifier .fillMaxWidth() - .padding(start = 16.dp, end = 16.dp, top = 10.dp, bottom = 8.dp), + .padding( + start = MobileDesignGeometry.ContentGutter, + end = MobileDesignGeometry.ContentGutter, + top = 10.dp, + bottom = 8.dp, + ), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/common/CircleControl.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/common/CircleControl.kt index 4d38a2de3b..30cdc9980c 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/common/CircleControl.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/common/CircleControl.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp +import com.bitfun.mobile.app.ui.theme.generated.MobileDesignGeometry /** * The floating round control the source draws on a page rather than on a bar. @@ -49,7 +50,7 @@ internal fun CircleControl( color = MaterialTheme.colorScheme.surface, border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), shadowElevation = 3.dp, - modifier = modifier.size(44.dp), + modifier = modifier.size(MobileDesignGeometry.ControlTouchSize), ) { Box(contentAlignment = Alignment.Center) { Icon( diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/preview/MobileDesignGallery.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/preview/MobileDesignGallery.kt new file mode 100644 index 0000000000..30bfc8bb47 --- /dev/null +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/preview/MobileDesignGallery.kt @@ -0,0 +1,168 @@ +package com.bitfun.mobile.app.ui.preview + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.bitfun.mobile.app.ui.chat.ComposerBar +import com.bitfun.mobile.app.ui.chat.ConversationHeader +import com.bitfun.mobile.app.ui.preview.generated.MobilePreviewMessage +import com.bitfun.mobile.app.ui.preview.generated.MobilePreviewScenario +import com.bitfun.mobile.app.ui.preview.generated.MobilePreviewScenarios +import com.bitfun.mobile.app.ui.theme.BitFunTheme +import com.bitfun.mobile.app.ui.theme.generated.MobileDesignGeometry +import com.bitfun.mobile.core.feature.connection.ConnectionPhase +import com.bitfun.mobile.core.feature.session.ChatComposerCapabilities +import com.bitfun.mobile.core.feature.session.ModelOption + +@Composable +internal fun MobileDesignGallery(scenario: MobilePreviewScenario, dark: Boolean) { + BitFunTheme(dark = dark) { + Column( + modifier = Modifier + .fillMaxSize() + .statusBarsPadding() + .background(MaterialTheme.colorScheme.background), + ) { + PlatformLabel(scenario) + ConversationHeader( + title = scenario.headerTitle, + contextTitle = scenario.headerSubtitle, + canStop = scenario.streaming, + enabled = true, + onBack = {}, + onOpenSidebar = {}, + onRename = {}, + onStop = {}, + modifier = Modifier.background(MaterialTheme.colorScheme.background), + ) + Column( + verticalArrangement = Arrangement.spacedBy(MobileDesignGeometry.MessageSpacing), + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding( + horizontal = MobileDesignGeometry.ContentGutter, + vertical = MobileDesignGeometry.TimelineTopPadding, + ), + ) { + scenario.messages.forEach { message -> PreviewMessageBubble(message) } + } + ComposerBar( + draft = scenario.composerDraft, + images = emptyList(), + busy = scenario.streaming, + streaming = scenario.streaming, + phase = if (scenario.connectionPhase == "reconnecting") { + ConnectionPhase.RECONNECTING + } else { + ConnectionPhase.CONNECTED + }, + model = ModelOption("preview-model", "BitFun Preview", "Native model", true), + modelOptions = emptyList(), + capabilities = ChatComposerCapabilities.RemoteChat, + placeholder = scenario.composerPlaceholder, + onDraftChange = {}, + onRemoveImage = {}, + onAttach = {}, + onVoice = {}, + onSend = {}, + onStop = {}, + onOpenModels = {}, + modifier = Modifier, + ) + } + } +} + +internal fun mobileDesignScenario(id: String?): MobilePreviewScenario = when (id) { + MobilePreviewScenarios.StreamingDark.id -> MobilePreviewScenarios.StreamingDark + MobilePreviewScenarios.ReconnectingWide.id -> MobilePreviewScenarios.ReconnectingWide + else -> MobilePreviewScenarios.ConnectedConversation +} + +@Composable +private fun PlatformLabel(scenario: MobilePreviewScenario) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxWidth() + .height(MobileDesignGeometry.ConnectionStripHeight) + .border(1.dp, MaterialTheme.colorScheme.outlineVariant) + .padding(horizontal = MobileDesignGeometry.ContentGutter), + ) { + Text("Android", style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Medium) + Text( + "NATIVE", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(10.dp)) + .padding(horizontal = 8.dp, vertical = 4.dp), + ) + Spacer(Modifier.weight(1f)) + Text( + "${scenario.viewportWidth} × ${scenario.viewportHeight}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun PreviewMessageBubble(message: MobilePreviewMessage) { + Row(modifier = Modifier.fillMaxWidth()) { + if (message.role == "user") Spacer(Modifier.weight(1f)) + Text( + text = message.text, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .widthIn(max = MobileDesignGeometry.MessageBubbleMaxWidth) + .background( + if (message.role == "user") MaterialTheme.colorScheme.surfaceVariant + else MaterialTheme.colorScheme.surface, + RoundedCornerShape(MobileDesignGeometry.MessageBubbleRadius), + ) + .border( + 1.dp, + MaterialTheme.colorScheme.outlineVariant, + RoundedCornerShape(MobileDesignGeometry.MessageBubbleRadius), + ) + .padding( + horizontal = MobileDesignGeometry.MessageBubbleHorizontalPadding, + vertical = MobileDesignGeometry.MessageBubbleVerticalPadding, + ), + ) + if (message.role != "user") Spacer(Modifier.weight(1f)) + } +} + +@Preview(name = "BitFun Mobile · Compact", widthDp = 390, heightDp = 844, showBackground = true) +@Composable +private fun MobileDesignCompactPreview() { + MobileDesignGallery(MobilePreviewScenarios.ConnectedConversation, dark = false) +} + +@Preview(name = "BitFun Mobile · Dark", widthDp = 390, heightDp = 844, showBackground = true) +@Composable +private fun MobileDesignDarkPreview() { + MobileDesignGallery(MobilePreviewScenarios.StreamingDark, dark = true) +} diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/preview/generated/MobilePreviewScenarios.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/preview/generated/MobilePreviewScenarios.kt new file mode 100644 index 0000000000..f40228c155 --- /dev/null +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/preview/generated/MobilePreviewScenarios.kt @@ -0,0 +1,70 @@ +// Generated by scripts/mobile-ui-design-system.mjs. Do not edit. +package com.bitfun.mobile.app.ui.preview.generated + +internal data class MobilePreviewMessage(val role: String, val text: String) + +internal data class MobilePreviewScenario( + val id: String, + val title: String, + val description: String, + val appearance: String, + val viewportWidth: Int, + val viewportHeight: Int, + val headerTitle: String, + val headerSubtitle: String, + val messages: List, + val composerDraft: String, + val composerPlaceholder: String, + val connectionPhase: String, + val streaming: Boolean, +) + +internal object MobilePreviewScenarios { + val ConnectedConversation = MobilePreviewScenario( + id = "connected-conversation", + title = "Connected conversation", + description = "A remote desktop is answering and the composer is ready.", + appearance = "light", + viewportWidth = 390, + viewportHeight = 844, + headerTitle = "统一移动端设计系统", + headerSubtitle = "DESKTOP-KM3L4UI", + messages = listOf(MobilePreviewMessage("user", "三端的组件和样式可以保持一致吗?"), MobilePreviewMessage("assistant", "可以。共享视觉契约,三端继续使用原生渲染。")), + composerDraft = "", + composerPlaceholder = "向 BitFun 提问", + connectionPhase = "connected", + streaming = false, + ) + + val StreamingDark = MobilePreviewScenario( + id = "streaming-dark", + title = "Streaming in dark mode", + description = "An active turn exercises dark surfaces and the stop action.", + appearance = "dark", + viewportWidth = 390, + viewportHeight = 844, + headerTitle = "跨端视觉校验", + headerSubtitle = "正在由 MacBook Pro 运行", + messages = listOf(MobilePreviewMessage("user", "比较三端的输入框、消息气泡和标题栏。"), MobilePreviewMessage("assistant", "正在生成原生截图,并按相同基线并排展示。")), + composerDraft = "检查深色模式下的边框对比度", + composerPlaceholder = "输入消息", + connectionPhase = "connected", + streaming = true, + ) + + val ReconnectingWide = MobilePreviewScenario( + id = "reconnecting-wide", + title = "Reconnecting on a wide viewport", + description = "A tablet-sized viewport keeps remote blocking states answerable.", + appearance = "light", + viewportWidth = 1024, + viewportHeight = 768, + headerTitle = "远程会话", + headerSubtitle = "正在恢复连接", + messages = listOf(MobilePreviewMessage("assistant", "连接暂时中断。恢复后会从上次游标继续。")), + composerDraft = "", + composerPlaceholder = "等待重新连接", + connectionPhase = "reconnecting", + streaming = false, + ) +} diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/theme/BitFunMotion.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/theme/BitFunMotion.kt index b7d44d53fa..514b601871 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/theme/BitFunMotion.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/theme/BitFunMotion.kt @@ -1,10 +1,11 @@ package com.bitfun.mobile.app.ui.theme import androidx.compose.animation.core.CubicBezierEasing +import com.bitfun.mobile.app.ui.theme.generated.MobileDesignMotion /** Motion values shared with the HarmonyOS presentation components. */ -internal const val MotionQuickMillis: Int = 180 -internal const val MotionStructureMillis: Int = 220 +internal const val MotionQuickMillis: Int = MobileDesignMotion.Quick +internal const val MotionStructureMillis: Int = MobileDesignMotion.Structure internal const val MotionDrawerScrimMillis: Int = 210 internal const val MotionDrawerOpenMillis: Int = 320 internal const val MotionDrawerCloseMillis: Int = 250 diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/theme/Theme.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/theme/Theme.kt index f7cae0b30f..26934c7015 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/theme/Theme.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/theme/Theme.kt @@ -8,9 +8,8 @@ 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.text.TextStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.sp +import com.bitfun.mobile.app.ui.theme.generated.MobileDesignColors +import com.bitfun.mobile.app.ui.theme.generated.MobileDesignTypography /** * The palette, ported from the HarmonyOS client's `Theme.ets` plus its @@ -27,92 +26,94 @@ import androidx.compose.ui.unit.sp * behind because it decorates a viewfinder HarmonyOS draws itself and Play * Services draws for us. A colour nothing reads is a colour nobody maintains. */ -private val InkLight = Color(0xFF171717) -private val InkDark = Color(0xFFF4F3EF) -private val White = Color(0xFFFFFFFF) +private val LightTokens = MobileDesignColors.Light +private val DarkTokens = MobileDesignColors.Dark +private val InkLight = LightTokens.Ink +private val InkDark = DarkTokens.Ink +private val White = LightTokens.PrimaryActionText private val LightScheme = lightColorScheme( - primary = Color(0xFF111111), // primary_action + primary = LightTokens.PrimaryAction, onPrimary = White, // primary_action_text - primaryContainer = Color(0xFFF4F3F0), // soft + primaryContainer = LightTokens.Soft, onPrimaryContainer = InkLight, - secondary = Color(0xFF111111), // accent + secondary = LightTokens.Accent, onSecondary = White, - secondaryContainer = Color(0xFFF4F3F0), + secondaryContainer = LightTokens.Soft, onSecondaryContainer = InkLight, // file_link: the one saturated hue in the palette. Material has no link // role, so it lands on tertiary — which is also the "busy" connection dot. - tertiary = Color(0xFF2563EB), + tertiary = LightTokens.FileLink, onTertiary = White, - background = Color(0xFFFDFDFB), // page_bg + background = LightTokens.PageBg, onBackground = InkLight, - surface = White, // card + surface = LightTokens.Card, onSurface = InkLight, - surfaceVariant = Color(0xFFF4F3F0), // soft - onSurfaceVariant = Color(0xFF706F6A), // muted + surfaceVariant = LightTokens.Soft, + onSurfaceVariant = LightTokens.Muted, // The whole container family, not only the two a card reads. Material fills // any role left unset from its own purple baseline, and the roles nothing in // this app names by hand are exactly the ones its components reach for on // their own — `ModalBottomSheet` takes surfaceContainerLow, elevation takes // surfaceTint, a snackbar takes inverseSurface. Leaving them out painted a // lilac sheet under a paper-coloured page. - surfaceContainerLowest = White, - surfaceContainerLow = Color(0xFFF7F7F5), // floating_panel_bg - surfaceContainer = Color(0xFFF7F7F5), // floating_panel_bg - surfaceContainerHigh = Color(0xFFF4F3F0), // soft - surfaceContainerHighest = Color(0xFFEFEEE9), - surfaceBright = Color(0xFFFDFDFB), - surfaceDim = Color(0xFFEFEEE9), + surfaceContainerLowest = LightTokens.Card, + surfaceContainerLow = LightTokens.FloatingPanelBg, + surfaceContainer = LightTokens.FloatingPanelBg, + surfaceContainerHigh = LightTokens.Soft, + surfaceContainerHighest = LightTokens.Line, + surfaceBright = LightTokens.PageBg, + surfaceDim = LightTokens.Line, // No tint: the source's cards are flat fills, and a tinted overlay would put // the ink colour back over every raised surface. - surfaceTint = White, - inverseSurface = Color(0xFF2D2C28), + surfaceTint = LightTokens.Card, + inverseSurface = DarkTokens.Soft, inverseOnSurface = InkDark, - inversePrimary = Color(0xFFE9E7E2), - outline = Color(0xFFA5A39B), // subtle - outlineVariant = Color(0xFFE9E7E2), // line - error = Color(0xFFE04F4F), // red + inversePrimary = LightTokens.Line, + outline = LightTokens.Subtle, + outlineVariant = LightTokens.Line, + error = LightTokens.Red, onError = White, // The HarmonyOS palette has no error container; rather than invent a hue, // a failure card is the same soft surface with the error colour on it. - errorContainer = Color(0xFFF4F3F0), - onErrorContainer = Color(0xFFE04F4F), + errorContainer = LightTokens.Soft, + onErrorContainer = LightTokens.Red, ) private val DarkScheme = darkColorScheme( - primary = Color(0xFF454540), // primary_action + primary = DarkTokens.PrimaryAction, onPrimary = White, - primaryContainer = Color(0xFF2D2C28), // soft + primaryContainer = DarkTokens.Soft, onPrimaryContainer = InkDark, - secondary = Color(0xFF5B5954), // accent + secondary = DarkTokens.Accent, onSecondary = White, - secondaryContainer = Color(0xFF2D2C28), + secondaryContainer = DarkTokens.Soft, onSecondaryContainer = InkDark, - tertiary = Color(0xFF60A5FA), // file_link - onTertiary = Color(0xFF151514), - background = Color(0xFF151514), // page_bg + tertiary = DarkTokens.FileLink, + onTertiary = DarkTokens.PageBg, + background = DarkTokens.PageBg, onBackground = InkDark, - surface = Color(0xFF252522), // card + surface = DarkTokens.Card, onSurface = InkDark, - surfaceVariant = Color(0xFF2D2C28), // soft - onSurfaceVariant = Color(0xFFAAA8A0), // muted - surfaceContainerLowest = Color(0xFF101010), - surfaceContainerLow = Color(0xFF1E1E1C), // floating_panel_bg - surfaceContainer = Color(0xFF1E1E1C), // floating_panel_bg - surfaceContainerHigh = Color(0xFF2D2C28), // soft - surfaceContainerHighest = Color(0xFF35342F), - surfaceBright = Color(0xFF3A3936), - surfaceDim = Color(0xFF151514), - surfaceTint = Color(0xFF252522), + surfaceVariant = DarkTokens.Soft, + onSurfaceVariant = DarkTokens.Muted, + surfaceContainerLowest = DarkTokens.StartWindowBackground, + surfaceContainerLow = DarkTokens.FloatingPanelBg, + surfaceContainer = DarkTokens.FloatingPanelBg, + surfaceContainerHigh = DarkTokens.Soft, + surfaceContainerHighest = DarkTokens.Line, + surfaceBright = DarkTokens.Accent, + surfaceDim = DarkTokens.PageBg, + surfaceTint = DarkTokens.Card, inverseSurface = InkDark, - inverseOnSurface = Color(0xFF252522), - inversePrimary = Color(0xFF363531), - outline = Color(0xFF77756E), // subtle - outlineVariant = Color(0xFF363531), // line - error = Color(0xFFFF6B6B), // red + inverseOnSurface = DarkTokens.Card, + inversePrimary = DarkTokens.Line, + outline = DarkTokens.Subtle, + outlineVariant = DarkTokens.Line, + error = DarkTokens.Red, onError = White, - errorContainer = Color(0xFF2D2C28), - onErrorContainer = Color(0xFFFF6B6B), + errorContainer = DarkTokens.Soft, + onErrorContainer = DarkTokens.Red, ) /** @@ -152,42 +153,42 @@ internal data class CodeSyntaxColors( ) private val LightExtras = BitFunColors( - success = Color(0xFF27C46A), - heroBackground = Color(0xFFE6EDFF), - heroSurface = Color(0xFFF8FAFF), - heroAccent = Color(0xFF9DB4FF), - heroSecondary = Color(0xFFC9C5FF), + success = LightTokens.Green, + heroBackground = LightTokens.ConnectHeroBg, + heroSurface = LightTokens.ConnectHeroSurface, + heroAccent = LightTokens.ConnectHeroAccent, + heroSecondary = LightTokens.ConnectHeroSecondary, code = CodeSyntaxColors( - lineNumber = Color(0xFFAAA69D), - keyword = Color(0xFF8F3F71), - string = Color(0xFF477A4A), - number = Color(0xFF9A5B13), - comment = Color(0xFF7A8078), - function = Color(0xFF2C6693), - type = Color(0xFF865A20), - constant = Color(0xFFA04444), - property = Color(0xFF466D78), - targetBackground = Color(0xFFFFF1BE), + lineNumber = LightTokens.CodeLineNumber, + keyword = LightTokens.CodeKeyword, + string = LightTokens.CodeString, + number = LightTokens.CodeNumber, + comment = LightTokens.CodeComment, + function = LightTokens.CodeFunction, + type = LightTokens.CodeType, + constant = LightTokens.CodeConstant, + property = LightTokens.CodeProperty, + targetBackground = LightTokens.CodeTargetBg, ), ) private val DarkExtras = BitFunColors( - success = Color(0xFF3BD47B), - heroBackground = Color(0xFF2B2B29), - heroSurface = Color(0xFF252522), - heroAccent = Color(0xFF4A4944), - heroSecondary = Color(0xFF3C3B38), + success = DarkTokens.Green, + heroBackground = DarkTokens.ConnectHeroBg, + heroSurface = DarkTokens.ConnectHeroSurface, + heroAccent = DarkTokens.ConnectHeroAccent, + heroSecondary = DarkTokens.ConnectHeroSecondary, code = CodeSyntaxColors( - lineNumber = Color(0xFF77756E), - keyword = Color(0xFFD99AC4), - string = Color(0xFF9BCB9D), - number = Color(0xFFE3B36D), - comment = Color(0xFF96958D), - function = Color(0xFF8CBCE0), - type = Color(0xFFD5B27F), - constant = Color(0xFFE79A9A), - property = Color(0xFF9CC8D0), - targetBackground = Color(0xFF5A4E24), + lineNumber = DarkTokens.CodeLineNumber, + keyword = DarkTokens.CodeKeyword, + string = DarkTokens.CodeString, + number = DarkTokens.CodeNumber, + comment = DarkTokens.CodeComment, + function = DarkTokens.CodeFunction, + type = DarkTokens.CodeType, + constant = DarkTokens.CodeConstant, + property = DarkTokens.CodeProperty, + targetBackground = DarkTokens.CodeTargetBg, ), ) @@ -199,21 +200,21 @@ private val LocalBitFunColors = staticCompositionLocalOf { LightExtras } * Material control start from the same geometry as the ArkUI counterpart. */ private val BitFunTypography = androidx.compose.material3.Typography( - displayLarge = TextStyle(fontSize = 24.sp, lineHeight = 30.sp, fontWeight = FontWeight.Bold), - displayMedium = TextStyle(fontSize = 22.sp, lineHeight = 28.sp, fontWeight = FontWeight.Bold), - displaySmall = TextStyle(fontSize = 20.sp, lineHeight = 26.sp, fontWeight = FontWeight.Bold), - headlineLarge = TextStyle(fontSize = 22.sp, lineHeight = 28.sp, fontWeight = FontWeight.Bold), - headlineMedium = TextStyle(fontSize = 20.sp, lineHeight = 26.sp, fontWeight = FontWeight.Bold), - headlineSmall = TextStyle(fontSize = 18.sp, lineHeight = 24.sp, fontWeight = FontWeight.Bold), - titleLarge = TextStyle(fontSize = 20.sp, lineHeight = 26.sp, fontWeight = FontWeight.Bold), - titleMedium = TextStyle(fontSize = 17.sp, lineHeight = 22.sp, fontWeight = FontWeight.Medium), - titleSmall = TextStyle(fontSize = 15.sp, lineHeight = 20.sp, fontWeight = FontWeight.Medium), - bodyLarge = TextStyle(fontSize = 16.sp, lineHeight = 24.sp), - bodyMedium = TextStyle(fontSize = 14.sp, lineHeight = 21.sp), - bodySmall = TextStyle(fontSize = 13.sp, lineHeight = 19.sp), - labelLarge = TextStyle(fontSize = 15.sp, lineHeight = 20.sp, fontWeight = FontWeight.Medium), - labelMedium = TextStyle(fontSize = 14.sp, lineHeight = 18.sp, fontWeight = FontWeight.Medium), - labelSmall = TextStyle(fontSize = 12.sp, lineHeight = 16.sp), + displayLarge = MobileDesignTypography.DisplayLarge, + displayMedium = MobileDesignTypography.DisplayMedium, + displaySmall = MobileDesignTypography.DisplaySmall, + headlineLarge = MobileDesignTypography.HeadlineLarge, + headlineMedium = MobileDesignTypography.HeadlineMedium, + headlineSmall = MobileDesignTypography.HeadlineSmall, + titleLarge = MobileDesignTypography.TitleLarge, + titleMedium = MobileDesignTypography.TitleMedium, + titleSmall = MobileDesignTypography.TitleSmall, + bodyLarge = MobileDesignTypography.BodyLarge, + bodyMedium = MobileDesignTypography.BodyMedium, + bodySmall = MobileDesignTypography.BodySmall, + labelLarge = MobileDesignTypography.LabelLarge, + labelMedium = MobileDesignTypography.LabelMedium, + labelSmall = MobileDesignTypography.LabelSmall, ) /** The extra palette for the theme in scope. Reads like `MaterialTheme.colorScheme`. */ diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/theme/generated/MobileDesignTokens.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/theme/generated/MobileDesignTokens.kt new file mode 100644 index 0000000000..5fc01d3500 --- /dev/null +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/theme/generated/MobileDesignTokens.kt @@ -0,0 +1,133 @@ +// Generated by scripts/mobile-ui-design-system.mjs. Do not edit. +package com.bitfun.mobile.app.ui.theme.generated + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +internal object MobileDesignColors { + object Light { + val StartWindowBackground = Color(0xFFFFFFFF) + val PageBg = Color(0xFFFDFDFB) + val PageBgFade = Color(0x00FDFDFB) + val Ink = Color(0xFF171717) + val Muted = Color(0xFF706F6A) + val Subtle = Color(0xFFA5A39B) + val Line = Color(0xFFE9E7E2) + val Card = Color(0xFFFFFFFF) + val Accent = Color(0xFF111111) + val FileLink = Color(0xFF2563EB) + val PrimaryAction = Color(0xFF111111) + val PrimaryActionText = Color(0xFFFFFFFF) + val ConnectHeroBg = Color(0xFFE6EDFF) + val ConnectHeroAccent = Color(0xFF9DB4FF) + val ConnectHeroSecondary = Color(0xFFC9C5FF) + val ConnectHeroSurface = Color(0xFFF8FAFF) + val ConnectScanAccent = Color(0xFFFFD021) + val ModalScrim = Color(0x99000000) + val Soft = Color(0xFFF4F3F0) + val FloatingPanelBg = Color(0xFFF7F7F5) + val Green = Color(0xFF27C46A) + val Red = Color(0xFFE04F4F) + val CodeLineNumber = Color(0xFFAAA69D) + val CodeKeyword = Color(0xFF8F3F71) + val CodeString = Color(0xFF477A4A) + val CodeNumber = Color(0xFF9A5B13) + val CodeComment = Color(0xFF7A8078) + val CodeFunction = Color(0xFF2C6693) + val CodeType = Color(0xFF865A20) + val CodeConstant = Color(0xFFA04444) + val CodeProperty = Color(0xFF466D78) + val CodeTargetBg = Color(0xFFFFF1BE) + } + + object Dark { + val StartWindowBackground = Color(0xFF000000) + val PageBg = Color(0xFF151514) + val PageBgFade = Color(0x00151514) + val Ink = Color(0xFFF4F3EF) + val Muted = Color(0xFFAAA8A0) + val Subtle = Color(0xFF77756E) + val Line = Color(0xFF363531) + val Card = Color(0xFF252522) + val Accent = Color(0xFF5B5954) + val FileLink = Color(0xFF60A5FA) + val PrimaryAction = Color(0xFF454540) + val PrimaryActionText = Color(0xFFFFFFFF) + val ConnectHeroBg = Color(0xFF2B2B29) + val ConnectHeroAccent = Color(0xFF4A4944) + val ConnectHeroSecondary = Color(0xFF3C3B38) + val ConnectHeroSurface = Color(0xFF252522) + val ConnectScanAccent = Color(0xFFFFD021) + val ModalScrim = Color(0x99000000) + val Soft = Color(0xFF2D2C28) + val FloatingPanelBg = Color(0xFF1E1E1C) + val Green = Color(0xFF3BD47B) + val Red = Color(0xFFFF6B6B) + val CodeLineNumber = Color(0xFF77756E) + val CodeKeyword = Color(0xFFD99AC4) + val CodeString = Color(0xFF9BCB9D) + val CodeNumber = Color(0xFFE3B36D) + val CodeComment = Color(0xFF96958D) + val CodeFunction = Color(0xFF8CBCE0) + val CodeType = Color(0xFFD5B27F) + val CodeConstant = Color(0xFFE79A9A) + val CodeProperty = Color(0xFF9CC8D0) + val CodeTargetBg = Color(0xFF5A4E24) + } +} + +internal object MobileDesignTypography { + val DisplayLarge = TextStyle(fontSize = 24.sp, lineHeight = 30.sp, fontWeight = FontWeight.Bold) + val DisplayMedium = TextStyle(fontSize = 22.sp, lineHeight = 28.sp, fontWeight = FontWeight.Bold) + val DisplaySmall = TextStyle(fontSize = 20.sp, lineHeight = 26.sp, fontWeight = FontWeight.Bold) + val HeadlineLarge = TextStyle(fontSize = 22.sp, lineHeight = 28.sp, fontWeight = FontWeight.Bold) + val HeadlineMedium = TextStyle(fontSize = 20.sp, lineHeight = 26.sp, fontWeight = FontWeight.Bold) + val HeadlineSmall = TextStyle(fontSize = 18.sp, lineHeight = 24.sp, fontWeight = FontWeight.Bold) + val TitleLarge = TextStyle(fontSize = 20.sp, lineHeight = 26.sp, fontWeight = FontWeight.Bold) + val ConversationHeaderTitle = TextStyle(fontSize = 18.sp, lineHeight = 22.sp, fontWeight = FontWeight.Medium) + val TitleMedium = TextStyle(fontSize = 17.sp, lineHeight = 22.sp, fontWeight = FontWeight.Medium) + val TitleSmall = TextStyle(fontSize = 15.sp, lineHeight = 20.sp, fontWeight = FontWeight.Medium) + val BodyLarge = TextStyle(fontSize = 16.sp, lineHeight = 24.sp, fontWeight = FontWeight.Normal) + val BodyMedium = TextStyle(fontSize = 14.sp, lineHeight = 21.sp, fontWeight = FontWeight.Normal) + val BodySmall = TextStyle(fontSize = 13.sp, lineHeight = 19.sp, fontWeight = FontWeight.Normal) + val LabelLarge = TextStyle(fontSize = 15.sp, lineHeight = 20.sp, fontWeight = FontWeight.Medium) + val LabelMedium = TextStyle(fontSize = 14.sp, lineHeight = 18.sp, fontWeight = FontWeight.Medium) + val LabelSmall = TextStyle(fontSize = 12.sp, lineHeight = 16.sp, fontWeight = FontWeight.Normal) +} + +internal object MobileDesignGeometry { + val ConversationHeaderHeight = 76.dp + val ConversationHeaderCompactHeight = 64.dp + val ControlTouchSize = 44.dp + val ContentGutter = 16.dp + val ConnectionStripHeight = 48.dp + val TimelineTopPadding = 22.dp + val MessageSpacing = 12.dp + val MessageBubbleMaxWidth = 276.dp + val MessageBubbleHorizontalPadding = 14.dp + val MessageBubbleVerticalPadding = 11.dp + val MessageBubbleRadius = 17.dp + val ComposerActionSize = 40.dp + val ComposerInputHeight = 42.dp + val ComposerExpandedInputHeight = 74.dp + val ComposerCollapsedHeight = 52.dp + val ComposerExpandedInputRowHeight = 76.dp + val ComposerExpandedActionRowHeight = 44.dp + val ComposerExpandedHeight = 126.dp + val ComposerCollapsedRadius = 26.dp + val ComposerExpandedRadius = 18.dp +} + +internal object MobileDesignBreakpoints { + const val Wide: Int = 600 + const val ExtraWide: Int = 840 + const val Xl: Int = 1440 +} + +internal object MobileDesignMotion { + const val Quick: Int = 180 + const val Structure: Int = 220 +} diff --git a/src/apps/mobile/design-system/README.md b/src/apps/mobile/design-system/README.md new file mode 100644 index 0000000000..4e3a58f75a --- /dev/null +++ b/src/apps/mobile/design-system/README.md @@ -0,0 +1,58 @@ +# BitFun Mobile Design System + +This directory is the source-neutral visual contract for the native HarmonyOS, +Android, and iOS applications. It owns stable visual facts and deterministic +preview scenarios; it does not implement a cross-platform renderer. + +## Ownership + +- `tokens/mobile-tokens.json`: the single source for semantic colors, + typography, shared geometry, breakpoints, and motion durations. +- `components/mobile-components.json`: component anatomy, states, and the token + roles each native implementation must consume. +- `scenarios/mobile-preview-scenarios.json`: deterministic states rendered by + native preview galleries and the desktop comparison tool. +- `preview/`: the local three-column inspection surface. It can show the + contract fallback immediately and accepts native screenshots for overlay or + side-by-side inspection. + +Generated platform files are checked in so IDE previews and native builds do +not require Node.js. Change the contract, then run: + +```bash +pnpm run mobile:ui:generate +pnpm run mobile:ui:check +pnpm run mobile:ui:preview +``` + +Do not edit generated files by hand. Native components remain responsible for +safe areas, keyboard behavior, accessibility bridges, navigation gestures, and +platform presentation primitives. + +## Simulator captures + +The native galleries can be launched without changing the normal app path: + +```bash +# Android (after installing the debug APK) +adb shell am force-stop com.bitfun.mobile.debug +adb shell am start \ + -n com.bitfun.mobile.debug/com.bitfun.mobile.app.MainActivity \ + --ez bitfun.design_preview true \ + --es bitfun.design_scenario connected-conversation + +# iOS Simulator (after installing the simulator app) +xcrun simctl launch booted com.bitfun.mobile.ios \ + --design-preview connected-conversation + +# HarmonyOS emulator (after installing a locally signed debug HAP) +hdc -t shell aa force-stop com.bitfun.app +hdc -t shell aa start \ + -a EntryAbility -b com.bitfun.app \ + --ps bitfunDesignPreview connected-conversation +``` + +Valid scenario ids come from `scenarios/mobile-preview-scenarios.json`. Save +captures using the convention documented in `preview/snapshots/README.md`, then +open the desktop comparison surface to inspect them beside the HarmonyOS +baseline. diff --git a/src/apps/mobile/design-system/components/mobile-components.json b/src/apps/mobile/design-system/components/mobile-components.json new file mode 100644 index 0000000000..f4436b8a17 --- /dev/null +++ b/src/apps/mobile/design-system/components/mobile-components.json @@ -0,0 +1,33 @@ +{ + "version": 1, + "components": { + "circle_control": { + "purpose": "A floating primary navigation or overflow control.", + "anatomy": ["touch_target", "centered_glyph", "hairline_border", "soft_shadow"], + "states": ["enabled", "pressed", "disabled", "focused"], + "tokens": ["control_touch_size", "card", "line", "ink"], + "platformNotes": "Keep the glyph's optical box separate from the touch target." + }, + "conversation_header": { + "purpose": "Identifies the active conversation and the device or workspace answering it.", + "anatomy": ["leading_control", "title", "context_subtitle", "trailing_control"], + "states": ["single_line", "with_context", "renaming", "actions_open"], + "tokens": ["conversation_header_height", "conversation_header_compact_height", "conversation_header_title", "content_gutter", "control_touch_size"], + "platformNotes": "Compact and wide layouts preserve the same control geometry and action meaning." + }, + "message_bubble": { + "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"], + "platformNotes": "The maximum width is the 276-unit compact HarmonyOS baseline; wide layouts may use a separate responsive policy." + }, + "composer_bar": { + "purpose": "Collects the next instruction and exposes one unambiguous primary action.", + "anatomy": ["attachment_action", "text_input", "model_control", "primary_action"], + "states": ["empty", "draft", "focused", "streaming", "disconnected", "with_attachments"], + "tokens": ["composer_action_size", "composer_collapsed_height", "composer_expanded_height", "composer_collapsed_radius", "composer_expanded_radius"], + "platformNotes": "Keyboard, dictation, and attachment pickers remain native adapters." + } + } +} 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 new file mode 100644 index 0000000000..b04f70be56 --- /dev/null +++ b/src/apps/mobile/design-system/preview/generated/mobile-design-data.js @@ -0,0 +1,439 @@ +// Generated by scripts/mobile-ui-design-system.mjs. Do not edit. +export const mobileTokens = { + "version": 1, + "identity": { + "name": "BitFun Mobile", + "signature": "Paper-and-ink conversation chrome with a single blue information accent" + }, + "colors": { + "start_window_background": { + "light": "#FFFFFF", + "dark": "#000000" + }, + "page_bg": { + "light": "#FDFDFB", + "dark": "#151514" + }, + "page_bg_fade": { + "light": "#00FDFDFB", + "dark": "#00151514" + }, + "ink": { + "light": "#171717", + "dark": "#F4F3EF" + }, + "muted": { + "light": "#706F6A", + "dark": "#AAA8A0" + }, + "subtle": { + "light": "#A5A39B", + "dark": "#77756E" + }, + "line": { + "light": "#E9E7E2", + "dark": "#363531" + }, + "card": { + "light": "#FFFFFF", + "dark": "#252522" + }, + "accent": { + "light": "#111111", + "dark": "#5B5954" + }, + "file_link": { + "light": "#2563EB", + "dark": "#60A5FA" + }, + "primary_action": { + "light": "#111111", + "dark": "#454540" + }, + "primary_action_text": { + "light": "#FFFFFF", + "dark": "#FFFFFF" + }, + "connect_hero_bg": { + "light": "#E6EDFF", + "dark": "#2B2B29" + }, + "connect_hero_accent": { + "light": "#9DB4FF", + "dark": "#4A4944" + }, + "connect_hero_secondary": { + "light": "#C9C5FF", + "dark": "#3C3B38" + }, + "connect_hero_surface": { + "light": "#F8FAFF", + "dark": "#252522" + }, + "connect_scan_accent": { + "light": "#FFD021", + "dark": "#FFD021" + }, + "modal_scrim": { + "light": "#99000000", + "dark": "#99000000" + }, + "soft": { + "light": "#F4F3F0", + "dark": "#2D2C28" + }, + "floating_panel_bg": { + "light": "#F7F7F5", + "dark": "#1E1E1C" + }, + "green": { + "light": "#27C46A", + "dark": "#3BD47B" + }, + "red": { + "light": "#E04F4F", + "dark": "#FF6B6B" + }, + "code_line_number": { + "light": "#AAA69D", + "dark": "#77756E" + }, + "code_keyword": { + "light": "#8F3F71", + "dark": "#D99AC4" + }, + "code_string": { + "light": "#477A4A", + "dark": "#9BCB9D" + }, + "code_number": { + "light": "#9A5B13", + "dark": "#E3B36D" + }, + "code_comment": { + "light": "#7A8078", + "dark": "#96958D" + }, + "code_function": { + "light": "#2C6693", + "dark": "#8CBCE0" + }, + "code_type": { + "light": "#865A20", + "dark": "#D5B27F" + }, + "code_constant": { + "light": "#A04444", + "dark": "#E79A9A" + }, + "code_property": { + "light": "#466D78", + "dark": "#9CC8D0" + }, + "code_target_bg": { + "light": "#FFF1BE", + "dark": "#5A4E24" + } + }, + "typography": { + "display_large": { + "size": 24, + "lineHeight": 30, + "weight": 700 + }, + "display_medium": { + "size": 22, + "lineHeight": 28, + "weight": 700 + }, + "display_small": { + "size": 20, + "lineHeight": 26, + "weight": 700 + }, + "headline_large": { + "size": 22, + "lineHeight": 28, + "weight": 700 + }, + "headline_medium": { + "size": 20, + "lineHeight": 26, + "weight": 700 + }, + "headline_small": { + "size": 18, + "lineHeight": 24, + "weight": 700 + }, + "title_large": { + "size": 20, + "lineHeight": 26, + "weight": 700 + }, + "conversation_header_title": { + "size": 18, + "lineHeight": 22, + "weight": 500 + }, + "title_medium": { + "size": 17, + "lineHeight": 22, + "weight": 500 + }, + "title_small": { + "size": 15, + "lineHeight": 20, + "weight": 500 + }, + "body_large": { + "size": 16, + "lineHeight": 24, + "weight": 400 + }, + "body_medium": { + "size": 14, + "lineHeight": 21, + "weight": 400 + }, + "body_small": { + "size": 13, + "lineHeight": 19, + "weight": 400 + }, + "label_large": { + "size": 15, + "lineHeight": 20, + "weight": 500 + }, + "label_medium": { + "size": 14, + "lineHeight": 18, + "weight": 500 + }, + "label_small": { + "size": 12, + "lineHeight": 16, + "weight": 400 + } + }, + "geometry": { + "conversation_header_height": 76, + "conversation_header_compact_height": 64, + "control_touch_size": 44, + "content_gutter": 16, + "connection_strip_height": 48, + "timeline_top_padding": 22, + "message_spacing": 12, + "message_bubble_max_width": 276, + "message_bubble_horizontal_padding": 14, + "message_bubble_vertical_padding": 11, + "message_bubble_radius": 17, + "composer_action_size": 40, + "composer_input_height": 42, + "composer_expanded_input_height": 74, + "composer_collapsed_height": 52, + "composer_expanded_input_row_height": 76, + "composer_expanded_action_row_height": 44, + "composer_expanded_height": 126, + "composer_collapsed_radius": 26, + "composer_expanded_radius": 18 + }, + "breakpoints": { + "wide": 600, + "extra_wide": 840, + "xl": 1440 + }, + "motion": { + "quick": 180, + "structure": 220 + } +}; +export const mobileComponents = { + "version": 1, + "components": { + "circle_control": { + "purpose": "A floating primary navigation or overflow control.", + "anatomy": [ + "touch_target", + "centered_glyph", + "hairline_border", + "soft_shadow" + ], + "states": [ + "enabled", + "pressed", + "disabled", + "focused" + ], + "tokens": [ + "control_touch_size", + "card", + "line", + "ink" + ], + "platformNotes": "Keep the glyph's optical box separate from the touch target." + }, + "conversation_header": { + "purpose": "Identifies the active conversation and the device or workspace answering it.", + "anatomy": [ + "leading_control", + "title", + "context_subtitle", + "trailing_control" + ], + "states": [ + "single_line", + "with_context", + "renaming", + "actions_open" + ], + "tokens": [ + "conversation_header_height", + "conversation_header_compact_height", + "conversation_header_title", + "content_gutter", + "control_touch_size" + ], + "platformNotes": "Compact and wide layouts preserve the same control geometry and action meaning." + }, + "message_bubble": { + "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" + ], + "platformNotes": "The maximum width is the 276-unit compact HarmonyOS baseline; wide layouts may use a separate responsive policy." + }, + "composer_bar": { + "purpose": "Collects the next instruction and exposes one unambiguous primary action.", + "anatomy": [ + "attachment_action", + "text_input", + "model_control", + "primary_action" + ], + "states": [ + "empty", + "draft", + "focused", + "streaming", + "disconnected", + "with_attachments" + ], + "tokens": [ + "composer_action_size", + "composer_collapsed_height", + "composer_expanded_height", + "composer_collapsed_radius", + "composer_expanded_radius" + ], + "platformNotes": "Keyboard, dictation, and attachment pickers remain native adapters." + } + } +}; +export const mobilePreviewScenarios = { + "version": 1, + "scenarios": [ + { + "id": "connected-conversation", + "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" + }, + "messages": [ + { + "role": "user", + "text": "三端的组件和样式可以保持一致吗?" + }, + { + "role": "assistant", + "text": "可以。共享视觉契约,三端继续使用原生渲染。" + } + ], + "composer": { + "draft": "", + "placeholder": "向 BitFun 提问", + "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 运行" + }, + "messages": [ + { + "role": "user", + "text": "比较三端的输入框、消息气泡和标题栏。" + }, + { + "role": "assistant", + "text": "正在生成原生截图,并按相同基线并排展示。" + } + ], + "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": "正在恢复连接" + }, + "messages": [ + { + "role": "assistant", + "text": "连接暂时中断。恢复后会从上次游标继续。" + } + ], + "composer": { + "draft": "", + "placeholder": "等待重新连接", + "phase": "reconnecting", + "streaming": false + } + } + ] +}; diff --git a/src/apps/mobile/design-system/preview/index.html b/src/apps/mobile/design-system/preview/index.html new file mode 100644 index 0000000000..113418417e --- /dev/null +++ b/src/apps/mobile/design-system/preview/index.html @@ -0,0 +1,53 @@ + + + + + + BitFun Mobile Parity Bench + + + +
+
+
+

BITFUN MOBILE · NATIVE PARITY

+

三端视觉校准台

+

HarmonyOS 是基准。Android 与 iOS 使用同一契约、同一场景,各自保持原生渲染。

+
+
HarmonyOS baseline
+
+ +
+ + + + +
+ +
+ + +
+ +
+
+ + + diff --git a/src/apps/mobile/design-system/preview/preview.css b/src/apps/mobile/design-system/preview/preview.css new file mode 100644 index 0000000000..fd8d1f8c40 --- /dev/null +++ b/src/apps/mobile/design-system/preview/preview.css @@ -0,0 +1,412 @@ +:root { + color-scheme: light; + font-family: Inter, "SF Pro Text", "HarmonyOS Sans", system-ui, sans-serif; + background: #efeee9; + color: #171717; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + min-width: 1040px; + min-height: 100vh; + background: + linear-gradient(#0000 31px, rgb(23 23 23 / 0.035) 32px), + #efeee9; + background-size: 100% 32px; +} + +button, input, select { font: inherit; } + +.bench-shell { + max-width: 1640px; + margin: 0 auto; + padding: 38px 34px 54px; +} + +.bench-header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 32px; + padding: 0 4px 28px; +} + +.eyebrow { + margin: 0 0 11px; + color: #706f6a; + font: 600 11px/1.2 ui-monospace, "SFMono-Regular", monospace; + letter-spacing: 0.13em; +} + +h1 { + margin: 0; + font-size: clamp(30px, 3vw, 48px); + line-height: 1; + letter-spacing: -0.045em; +} + +.intro { + max-width: 720px; + margin: 13px 0 0; + color: #595853; + font-size: 14px; + line-height: 1.7; +} + +.baseline-key { + display: flex; + align-items: center; + gap: 9px; + padding: 9px 12px; + border: 1px solid #d9d7d1; + border-radius: 999px; + background: #f7f7f5; + color: #4b4a46; + font: 600 11px/1 ui-monospace, monospace; +} + +.baseline-key span { + width: 8px; + height: 8px; + border-radius: 50%; + background: #2563eb; + box-shadow: 0 0 0 4px rgb(37 99 235 / 0.13); +} + +.toolbar { + display: grid; + grid-template-columns: minmax(230px, 1.5fr) minmax(150px, 0.7fr) minmax(260px, 1fr) auto; + gap: 18px; + align-items: end; + padding: 18px 20px; + border: 1px solid #d9d7d1; + border-radius: 18px 18px 0 0; + background: rgb(253 253 251 / 0.9); + backdrop-filter: blur(16px); +} + +.toolbar label:not(.check-control) { + display: grid; + gap: 7px; +} + +.toolbar label > span { + color: #706f6a; + font: 600 11px/1.2 ui-monospace, monospace; + letter-spacing: 0.05em; +} + +select { + width: 100%; + height: 38px; + padding: 0 34px 0 12px; + border: 1px solid #d9d7d1; + border-radius: 10px; + background: #fff; + color: #171717; +} + +.range-control { + grid-template-columns: 1fr auto; +} + +.range-control span { grid-column: 1 / -1; } +.range-control input { width: 100%; accent-color: #171717; } +.range-control output { color: #706f6a; font: 12px ui-monospace, monospace; } + +.check-control { + display: flex; + align-items: center; + gap: 9px; + height: 38px; + white-space: nowrap; +} + +.check-control input { width: 16px; height: 16px; accent-color: #171717; } + +.scenario-note { + display: flex; + gap: 12px; + align-items: baseline; + min-height: 46px; + padding: 14px 20px; + border: 1px solid #d9d7d1; + border-top: 0; + background: #f7f7f5; + color: #706f6a; + font-size: 12px; +} + +.scenario-note strong { color: #171717; } + +.platform-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px; + padding-top: 14px; +} + +.platform-card { + min-width: 0; + overflow: hidden; + border: 1px solid #d3d1cb; + border-radius: 20px; + background: #fdfdfb; + box-shadow: 0 18px 50px rgb(23 23 23 / 0.07); +} + +.platform-card[data-platform="harmonyos"] { border-top: 3px solid #2563eb; } + +.platform-heading { + display: flex; + align-items: center; + gap: 10px; + min-height: 62px; + padding: 12px 15px; + border-bottom: 1px solid #e9e7e2; +} + +.platform-mark { + display: grid; + place-items: center; + width: 34px; + height: 34px; + border-radius: 10px; + background: #171717; + color: #fff; + font: 700 12px/1 ui-monospace, monospace; +} + +.platform-card[data-platform="harmonyos"] .platform-mark { background: #2563eb; } + +.platform-title { min-width: 0; } +.platform-title strong { display: block; font-size: 13px; } +.platform-title span { display: block; margin-top: 3px; color: #7d7b75; font-size: 10px; } + +.capture-button { + margin-left: auto; + padding: 8px 10px; + border: 1px solid #d9d7d1; + border-radius: 9px; + background: #fff; + color: #45443f; + cursor: pointer; + font-size: 11px; +} + +.capture-button:hover { border-color: #a5a39b; } +.capture-button input { display: none; } + +.viewport-stage { + position: relative; + display: grid; + place-items: center; + min-height: 690px; + padding: 22px; + overflow: hidden; + background: #e9e7e2; +} + +.device-screen { + --screen-scale: 1; + position: relative; + width: min(100%, 390px); + aspect-ratio: var(--viewport-width) / var(--viewport-height); + max-height: 646px; + overflow: hidden; + border: 1px solid rgb(23 23 23 / 0.25); + border-radius: 28px; + background: var(--page-bg); + color: var(--ink); + box-shadow: 0 20px 60px rgb(23 23 23 / 0.18); + container-type: inline-size; +} + +.device-screen.wide { + width: 100%; + max-height: 500px; + border-radius: 18px; +} + +.platform-card[data-platform="harmonyos"] .device-screen { font-family: "HarmonyOS Sans", system-ui, sans-serif; } +.platform-card[data-platform="android"] .device-screen { font-family: Roboto, system-ui, sans-serif; } +.platform-card[data-platform="ios"] .device-screen { font-family: -apple-system, BlinkMacSystemFont, sans-serif; } + +.contract-render { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; +} + +.screen-meta { + display: flex; + align-items: center; + gap: 6px; + height: calc(var(--connection-strip-height) * 1px); + padding: 0 calc(var(--content-gutter) * 1px); + border-bottom: 1px solid var(--line); + font-size: 11px; +} + +.screen-meta strong { font-size: 12px; } +.screen-meta span { margin-left: auto; color: var(--muted); font-family: ui-monospace, monospace; font-size: 9px; } + +.conversation-header { + display: grid; + grid-template-columns: calc(var(--control-touch-size) * 1px) 1fr calc(var(--control-touch-size) * 1px); + align-items: center; + gap: 8px; + height: calc(var(--conversation-header-height) * 1px); + padding: 8px calc(var(--content-gutter) * 1px); +} + +.circle-control { + display: grid; + place-items: center; + width: calc(var(--control-touch-size) * 1px); + height: calc(var(--control-touch-size) * 1px); + border: 1px solid var(--line); + border-radius: 50%; + background: var(--card); + box-shadow: 0 3px 10px rgb(23 23 23 / 0.08); + color: var(--ink); + font-size: 18px; +} + +.header-copy { min-width: 0; text-align: center; } +.header-copy strong, .header-copy span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.header-copy strong { + font-size: calc(var(--conversation-header-title-size) * 1px); + line-height: calc(var(--conversation-header-title-line-height) * 1px); + font-weight: var(--conversation-header-title-weight); +} +.header-copy span { + margin-top: 3px; + color: var(--muted); + font-size: calc(var(--label-medium-size) * 1px); + line-height: calc(var(--label-medium-line-height) * 1px); + font-weight: var(--label-medium-weight); +} + +.timeline { + flex: 1; + display: flex; + flex-direction: column; + gap: calc(var(--message-spacing) * 1px); + min-height: 0; + padding: calc(var(--timeline-top-padding) * 1px) calc(var(--content-gutter) * 1px); +} + +.message { + max-width: calc(var(--message-bubble-max-width) * 1px); + padding: + calc(var(--message-bubble-vertical-padding) * 1px) + calc(var(--message-bubble-horizontal-padding) * 1px); + border: 1px solid var(--line); + border-radius: calc(var(--message-bubble-radius) * 1px); + background: var(--card); + font-size: calc(var(--body-medium-size) * 1px); + line-height: calc(var(--body-medium-line-height) * 1px); + font-weight: var(--body-medium-weight); +} + +.message.user { align-self: flex-end; background: var(--soft); } + +.connection-note { + align-self: center; + padding: 5px 9px; + border-radius: 999px; + background: var(--soft); + color: var(--muted); + font: 10px/1.2 ui-monospace, monospace; +} + +.composer-zone { padding: 8px calc(var(--content-gutter) * 1px) 14px; } + +.composer { + display: grid; + grid-template-columns: calc(var(--composer-action-size) * 1px) 1fr calc(var(--composer-action-size) * 1px); + align-items: center; + gap: 5px; + min-height: calc(var(--composer-collapsed-height) * 1px); + padding: 0 8px; + border: 0; + border-radius: calc(var(--composer-collapsed-radius) * 1px); + background: var(--card); + box-shadow: 0 2px 10px rgb(23 23 23 / 0.08); +} + +.composer.has-draft { min-height: 76px; border-radius: calc(var(--composer-expanded-radius) * 1px); } +.composer button { width: 40px; height: 40px; border: 0; background: transparent; color: var(--ink); font-size: 19px; } +.composer-copy { + min-width: 0; + color: var(--muted); + font-size: calc(var(--body-large-size) * 1px); + line-height: calc(var(--body-large-line-height) * 1px); + font-weight: var(--body-large-weight); +} +.composer.has-draft .composer-copy { color: var(--ink); } +.composer .primary { border-radius: 50%; background: var(--primary-action); color: var(--primary-action-text); } + +.native-shot { + position: absolute; + inset: 0; + z-index: 3; + display: none; + width: 100%; + height: 100%; + object-fit: contain; + background: #111; + opacity: var(--native-opacity, 1); +} + +.device-screen.has-native .native-shot { display: block; } + +.alignment-grid { + pointer-events: none; + position: absolute; + inset: 0; + z-index: 5; + display: none; + background-image: + linear-gradient(rgb(37 99 235 / 0.2) 1px, transparent 1px), + linear-gradient(90deg, rgb(37 99 235 / 0.14) 1px, transparent 1px); + background-size: 8px 8px; +} + +.show-grid .alignment-grid { display: block; } + +.capture-status { + display: grid; + grid-template-columns: auto 1fr; + gap: 12px; + padding: 10px 15px; + border-top: 1px solid #e9e7e2; + color: #77756e; + font: 10px/1.4 ui-monospace, monospace; +} + +.capture-status strong { color: #45443f; } +.capture-status span { text-align: right; } +.capture-status em { + grid-column: 1 / -1; + color: #706f6a; + font-style: normal; + text-align: right; +} +.capture-aspect-warning .capture-status strong { color: #9a5b13; } + +@media (max-width: 1180px) { + .bench-shell { padding-inline: 20px; } + .viewport-stage { min-height: 620px; padding-inline: 12px; } +} + +@media (prefers-reduced-motion: no-preference) { + .platform-card { animation: settle 360ms ease-out both; } + .platform-card:nth-child(2) { animation-delay: 45ms; } + .platform-card:nth-child(3) { animation-delay: 90ms; } + @keyframes settle { from { opacity: 0; transform: translateY(8px); } } +} diff --git a/src/apps/mobile/design-system/preview/preview.js b/src/apps/mobile/design-system/preview/preview.js new file mode 100644 index 0000000000..51c4358329 --- /dev/null +++ b/src/apps/mobile/design-system/preview/preview.js @@ -0,0 +1,213 @@ +import { mobilePreviewScenarios, mobileTokens } from './generated/mobile-design-data.js'; + +const platforms = [ + { id: 'harmonyos', captureId: 'harmony', name: 'HarmonyOS', mark: 'H', note: 'ArkUI · baseline' }, + { id: 'android', name: 'Android', mark: 'A', note: 'Jetpack Compose' }, + { id: 'ios', name: 'iOS', mark: 'i', note: 'SwiftUI' }, +]; + +const scenarioSelect = document.querySelector('#scenario-select'); +const appearanceSelect = document.querySelector('#appearance-select'); +const opacityControl = document.querySelector('#opacity-control'); +const opacityOutput = document.querySelector('#opacity-output'); +const gridControl = document.querySelector('#grid-control'); +const platformGrid = document.querySelector('#platform-grid'); +const nativeCaptures = new Map(); + +for (const scenario of mobilePreviewScenarios.scenarios) { + const option = document.createElement('option'); + option.value = scenario.id; + option.textContent = scenario.title; + scenarioSelect.append(option); +} + +scenarioSelect.addEventListener('change', render); +appearanceSelect.addEventListener('change', render); +gridControl.addEventListener('change', () => { + platformGrid.classList.toggle('show-grid', gridControl.checked); +}); +opacityControl.addEventListener('input', () => { + const opacity = Number(opacityControl.value) / 100; + opacityOutput.textContent = `${opacityControl.value}%`; + document.documentElement.style.setProperty('--native-opacity', String(opacity)); +}); + +render(); + +function render() { + nativeCaptures.clear(); + const scenario = mobilePreviewScenarios.scenarios.find((item) => item.id === scenarioSelect.value) + ?? mobilePreviewScenarios.scenarios[0]; + const appearance = appearanceSelect.value === 'scenario' ? scenario.appearance : appearanceSelect.value; + document.querySelector('#scenario-title').textContent = scenario.title; + document.querySelector('#scenario-description').textContent = scenario.description; + platformGrid.replaceChildren(...platforms.map((platform) => platformCard(platform, scenario, appearance))); + platformGrid.classList.toggle('show-grid', gridControl.checked); +} + +function platformCard(platform, scenario, appearance) { + const card = document.createElement('article'); + card.className = 'platform-card'; + card.dataset.platform = platform.id; + card.innerHTML = ` +
+ +
+ +
+
+
+
+
+
+
+
+
•••
+
+
+
+
+ +
+ +
+
+
+ +
+
+
+
CONTRACT RENDER等待原生截图
+ `; + + card.querySelector('.platform-title strong').textContent = platform.name; + card.querySelector('.platform-title span').textContent = platform.note; + + const screen = card.querySelector('.device-screen'); + applyTokens(screen, appearance); + screen.style.setProperty('--viewport-width', String(scenario.viewport.width)); + screen.style.setProperty('--viewport-height', String(scenario.viewport.height)); + screen.classList.toggle('wide', scenario.viewport.width >= mobileTokens.breakpoints.wide); + + card.querySelector('.screen-meta strong').textContent = platform.name; + card.querySelector('.screen-meta span').textContent = `${scenario.viewport.width} × ${scenario.viewport.height}`; + card.querySelector('.header-copy strong').textContent = scenario.header.title; + card.querySelector('.header-copy span').textContent = scenario.header.subtitle; + + const timeline = card.querySelector('.timeline'); + for (const message of scenario.messages) { + const bubble = document.createElement('div'); + bubble.className = `message ${message.role}`; + bubble.textContent = message.text; + timeline.append(bubble); + } + if (scenario.composer.phase === 'reconnecting') { + const note = document.createElement('div'); + note.className = 'connection-note'; + note.textContent = 'RECONNECTING · CURSOR PRESERVED'; + timeline.append(note); + } + + const composer = card.querySelector('.composer'); + const hasDraft = scenario.composer.draft.length > 0; + composer.classList.toggle('has-draft', hasDraft); + card.querySelector('.composer-copy').textContent = scenario.composer.draft || scenario.composer.placeholder; + card.querySelector('.composer .primary').textContent = scenario.composer.streaming ? '■' : hasDraft ? '↑' : '●'; + + const fileInput = card.querySelector('input[type="file"]'); + fileInput.addEventListener('change', () => { + const [file] = fileInput.files; + if (file) useNativeShot(card, URL.createObjectURL(file), file.name); + }); + tryConventionalScreenshot(card, platform.captureId ?? platform.id, scenario.id); + return card; +} + +function applyTokens(element, appearance) { + for (const [name, pair] of Object.entries(mobileTokens.colors)) { + element.style.setProperty(`--${name.replaceAll('_', '-')}`, pair[appearance]); + } + for (const [name, value] of Object.entries(mobileTokens.geometry)) { + element.style.setProperty(`--${name.replaceAll('_', '-')}`, String(value)); + } + for (const [name, token] of Object.entries(mobileTokens.typography)) { + const prefix = `--${name.replaceAll('_', '-')}`; + element.style.setProperty(`${prefix}-size`, String(token.size)); + element.style.setProperty(`${prefix}-line-height`, String(token.lineHeight)); + element.style.setProperty(`${prefix}-weight`, String(token.weight)); + } +} + +function tryConventionalScreenshot(card, platform, scenario) { + const path = `./snapshots/${scenario}/${platform}.png`; + const image = new Image(); + image.onload = () => useNativeShot(card, path, `${platform}.png`); + image.src = path; +} + +function useNativeShot(card, source, label) { + const screen = card.querySelector('.device-screen'); + const image = card.querySelector('.native-shot'); + image.addEventListener('load', () => { + const expectedAspect = Number(screen.style.getPropertyValue('--viewport-width')) + / Number(screen.style.getPropertyValue('--viewport-height')); + const captureAspect = image.naturalWidth / image.naturalHeight; + const aspectDelta = Math.abs(captureAspect / expectedAspect - 1) * 100; + card.querySelector('.capture-status span').textContent = + `${label} · ${image.naturalWidth}×${image.naturalHeight} · 画幅差 ${aspectDelta.toFixed(1)}%`; + card.classList.toggle('capture-aspect-warning', aspectDelta >= 1); + if (card.isConnected) { + nativeCaptures.set(card.dataset.platform, { card, image }); + updatePixelDeltas(); + } + }, { once: true }); + image.src = source; + image.alt = `${card.dataset.platform} native screenshot`; + screen.classList.add('has-native'); + card.querySelector('.capture-status strong').textContent = 'NATIVE CAPTURE'; + card.querySelector('.capture-status span').textContent = `${label} · 读取尺寸…`; +} + +function updatePixelDeltas() { + const baseline = nativeCaptures.get('harmonyos'); + if (!baseline) return; + baseline.card.querySelector('.capture-delta, .capture-status em').textContent = 'PIXEL BASELINE'; + for (const platform of ['android', 'ios']) { + const capture = nativeCaptures.get(platform); + if (!capture) continue; + const delta = significantPixelDelta(baseline.image, capture.image); + capture.card.querySelector('.capture-delta, .capture-status em').textContent = `全帧像素差 ${delta.toFixed(1)}%`; + } +} + +function significantPixelDelta(baseline, candidate) { + const width = 195; + const height = 422; + const referencePixels = containedPixels(baseline, width, height); + const candidatePixels = containedPixels(candidate, width, height); + let changed = 0; + const pixelCount = width * height; + for (let index = 0; index < referencePixels.length; index += 4) { + const channelDelta = ( + Math.abs(referencePixels[index] - candidatePixels[index]) + + Math.abs(referencePixels[index + 1] - candidatePixels[index + 1]) + + Math.abs(referencePixels[index + 2] - candidatePixels[index + 2]) + ) / 3; + if (channelDelta >= 16) changed += 1; + } + return changed / pixelCount * 100; +} + +function containedPixels(image, width, height) { + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext('2d', { willReadFrequently: true }); + context.fillStyle = '#111111'; + context.fillRect(0, 0, width, height); + const scale = Math.min(width / image.naturalWidth, height / image.naturalHeight); + const drawWidth = image.naturalWidth * scale; + const drawHeight = image.naturalHeight * scale; + context.drawImage(image, (width - drawWidth) / 2, (height - drawHeight) / 2, drawWidth, drawHeight); + return context.getImageData(0, 0, width, height).data; +} diff --git a/src/apps/mobile/design-system/preview/snapshots/.gitignore b/src/apps/mobile/design-system/preview/snapshots/.gitignore new file mode 100644 index 0000000000..e33609d251 --- /dev/null +++ b/src/apps/mobile/design-system/preview/snapshots/.gitignore @@ -0,0 +1 @@ +*.png diff --git a/src/apps/mobile/design-system/preview/snapshots/README.md b/src/apps/mobile/design-system/preview/snapshots/README.md new file mode 100644 index 0000000000..d8368a4140 --- /dev/null +++ b/src/apps/mobile/design-system/preview/snapshots/README.md @@ -0,0 +1,20 @@ +# Native preview captures + +The desktop comparison tool looks for optional PNG captures at: + +```text +snapshots//.png +``` + +`platform` is `harmony`, `android`, or `ios`. For example: + +```text +snapshots/connected-conversation/harmony.png +snapshots/connected-conversation/android.png +snapshots/connected-conversation/ios.png +``` + +Use the matching generated native preview gallery and the same scenario id. +Captures can also be selected directly from each column in the browser. Local +captures are visual evidence and should not be committed unless they are being +reviewed as deliberate regression fixtures. diff --git a/src/apps/mobile/design-system/scenarios/mobile-preview-scenarios.json b/src/apps/mobile/design-system/scenarios/mobile-preview-scenarios.json new file mode 100644 index 0000000000..fd25544628 --- /dev/null +++ b/src/apps/mobile/design-system/scenarios/mobile-preview-scenarios.json @@ -0,0 +1,43 @@ +{ + "version": 1, + "scenarios": [ + { + "id": "connected-conversation", + "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" }, + "messages": [ + { "role": "user", "text": "三端的组件和样式可以保持一致吗?" }, + { "role": "assistant", "text": "可以。共享视觉契约,三端继续使用原生渲染。" } + ], + "composer": { "draft": "", "placeholder": "向 BitFun 提问", "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 运行" }, + "messages": [ + { "role": "user", "text": "比较三端的输入框、消息气泡和标题栏。" }, + { "role": "assistant", "text": "正在生成原生截图,并按相同基线并排展示。" } + ], + "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": "正在恢复连接" }, + "messages": [ + { "role": "assistant", "text": "连接暂时中断。恢复后会从上次游标继续。" } + ], + "composer": { "draft": "", "placeholder": "等待重新连接", "phase": "reconnecting", "streaming": false } + } + ] +} diff --git a/src/apps/mobile/design-system/tokens/mobile-tokens.json b/src/apps/mobile/design-system/tokens/mobile-tokens.json new file mode 100644 index 0000000000..cc06b2e1ae --- /dev/null +++ b/src/apps/mobile/design-system/tokens/mobile-tokens.json @@ -0,0 +1,90 @@ +{ + "version": 1, + "identity": { + "name": "BitFun Mobile", + "signature": "Paper-and-ink conversation chrome with a single blue information accent" + }, + "colors": { + "start_window_background": { "light": "#FFFFFF", "dark": "#000000" }, + "page_bg": { "light": "#FDFDFB", "dark": "#151514" }, + "page_bg_fade": { "light": "#00FDFDFB", "dark": "#00151514" }, + "ink": { "light": "#171717", "dark": "#F4F3EF" }, + "muted": { "light": "#706F6A", "dark": "#AAA8A0" }, + "subtle": { "light": "#A5A39B", "dark": "#77756E" }, + "line": { "light": "#E9E7E2", "dark": "#363531" }, + "card": { "light": "#FFFFFF", "dark": "#252522" }, + "accent": { "light": "#111111", "dark": "#5B5954" }, + "file_link": { "light": "#2563EB", "dark": "#60A5FA" }, + "primary_action": { "light": "#111111", "dark": "#454540" }, + "primary_action_text": { "light": "#FFFFFF", "dark": "#FFFFFF" }, + "connect_hero_bg": { "light": "#E6EDFF", "dark": "#2B2B29" }, + "connect_hero_accent": { "light": "#9DB4FF", "dark": "#4A4944" }, + "connect_hero_secondary": { "light": "#C9C5FF", "dark": "#3C3B38" }, + "connect_hero_surface": { "light": "#F8FAFF", "dark": "#252522" }, + "connect_scan_accent": { "light": "#FFD021", "dark": "#FFD021" }, + "modal_scrim": { "light": "#99000000", "dark": "#99000000" }, + "soft": { "light": "#F4F3F0", "dark": "#2D2C28" }, + "floating_panel_bg": { "light": "#F7F7F5", "dark": "#1E1E1C" }, + "green": { "light": "#27C46A", "dark": "#3BD47B" }, + "red": { "light": "#E04F4F", "dark": "#FF6B6B" }, + "code_line_number": { "light": "#AAA69D", "dark": "#77756E" }, + "code_keyword": { "light": "#8F3F71", "dark": "#D99AC4" }, + "code_string": { "light": "#477A4A", "dark": "#9BCB9D" }, + "code_number": { "light": "#9A5B13", "dark": "#E3B36D" }, + "code_comment": { "light": "#7A8078", "dark": "#96958D" }, + "code_function": { "light": "#2C6693", "dark": "#8CBCE0" }, + "code_type": { "light": "#865A20", "dark": "#D5B27F" }, + "code_constant": { "light": "#A04444", "dark": "#E79A9A" }, + "code_property": { "light": "#466D78", "dark": "#9CC8D0" }, + "code_target_bg": { "light": "#FFF1BE", "dark": "#5A4E24" } + }, + "typography": { + "display_large": { "size": 24, "lineHeight": 30, "weight": 700 }, + "display_medium": { "size": 22, "lineHeight": 28, "weight": 700 }, + "display_small": { "size": 20, "lineHeight": 26, "weight": 700 }, + "headline_large": { "size": 22, "lineHeight": 28, "weight": 700 }, + "headline_medium": { "size": 20, "lineHeight": 26, "weight": 700 }, + "headline_small": { "size": 18, "lineHeight": 24, "weight": 700 }, + "title_large": { "size": 20, "lineHeight": 26, "weight": 700 }, + "conversation_header_title": { "size": 18, "lineHeight": 22, "weight": 500 }, + "title_medium": { "size": 17, "lineHeight": 22, "weight": 500 }, + "title_small": { "size": 15, "lineHeight": 20, "weight": 500 }, + "body_large": { "size": 16, "lineHeight": 24, "weight": 400 }, + "body_medium": { "size": 14, "lineHeight": 21, "weight": 400 }, + "body_small": { "size": 13, "lineHeight": 19, "weight": 400 }, + "label_large": { "size": 15, "lineHeight": 20, "weight": 500 }, + "label_medium": { "size": 14, "lineHeight": 18, "weight": 500 }, + "label_small": { "size": 12, "lineHeight": 16, "weight": 400 } + }, + "geometry": { + "conversation_header_height": 76, + "conversation_header_compact_height": 64, + "control_touch_size": 44, + "content_gutter": 16, + "connection_strip_height": 48, + "timeline_top_padding": 22, + "message_spacing": 12, + "message_bubble_max_width": 276, + "message_bubble_horizontal_padding": 14, + "message_bubble_vertical_padding": 11, + "message_bubble_radius": 17, + "composer_action_size": 40, + "composer_input_height": 42, + "composer_expanded_input_height": 74, + "composer_collapsed_height": 52, + "composer_expanded_input_row_height": 76, + "composer_expanded_action_row_height": 44, + "composer_expanded_height": 126, + "composer_collapsed_radius": 26, + "composer_expanded_radius": 18 + }, + "breakpoints": { + "wide": 600, + "extra_wide": 840, + "xl": 1440 + }, + "motion": { + "quick": 180, + "structure": 220 + } +} 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 39f49ed105..729c9f70b0 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 @@ -7,8 +7,12 @@ const TAG = 'BitFunRemote'; export default class EntryAbility extends UIAbility { private mainWindow?: window.Window; + private initialPage: string = 'pages/AppRoot'; onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { + if (want.parameters?.['bitfunDesignPreview'] === 'connected-conversation') { + this.initialPage = 'pages/preview/MobileDesignGallery'; + } try { this.context.getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET); } catch (err) { @@ -33,7 +37,7 @@ export default class EntryAbility extends UIAbility { hilog.error(DOMAIN, TAG, 'Failed to configure window bars. Cause: %{public}s', JSON.stringify(err)); } - windowStage.loadContent('pages/AppRoot', (err) => { + windowStage.loadContent(this.initialPage, (err) => { if (err.code) { hilog.error(DOMAIN, TAG, 'Failed to load the content. Cause: %{public}s', JSON.stringify(err)); return; 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 new file mode 100644 index 0000000000..b476885024 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/generated/MobileDesignTokens.ets @@ -0,0 +1,66 @@ +// Generated by scripts/mobile-ui-design-system.mjs. Do not edit. + +export class MobileTypographyToken { + readonly size: number; + readonly lineHeight: number; + readonly weight: number; + + constructor(size: number, lineHeight: number, weight: number) { + this.size = size; + this.lineHeight = lineHeight; + this.weight = weight; + } +} + +export class MobileDesignTypography { + static readonly displayLarge: MobileTypographyToken = new MobileTypographyToken(24, 30, 700); + static readonly displayMedium: MobileTypographyToken = new MobileTypographyToken(22, 28, 700); + static readonly displaySmall: MobileTypographyToken = new MobileTypographyToken(20, 26, 700); + static readonly headlineLarge: MobileTypographyToken = new MobileTypographyToken(22, 28, 700); + static readonly headlineMedium: MobileTypographyToken = new MobileTypographyToken(20, 26, 700); + static readonly headlineSmall: MobileTypographyToken = new MobileTypographyToken(18, 24, 700); + static readonly titleLarge: MobileTypographyToken = new MobileTypographyToken(20, 26, 700); + static readonly conversationHeaderTitle: MobileTypographyToken = new MobileTypographyToken(18, 22, 500); + static readonly titleMedium: MobileTypographyToken = new MobileTypographyToken(17, 22, 500); + static readonly titleSmall: MobileTypographyToken = new MobileTypographyToken(15, 20, 500); + static readonly bodyLarge: MobileTypographyToken = new MobileTypographyToken(16, 24, 400); + static readonly bodyMedium: MobileTypographyToken = new MobileTypographyToken(14, 21, 400); + static readonly bodySmall: MobileTypographyToken = new MobileTypographyToken(13, 19, 400); + static readonly labelLarge: MobileTypographyToken = new MobileTypographyToken(15, 20, 500); + static readonly labelMedium: MobileTypographyToken = new MobileTypographyToken(14, 18, 500); + static readonly labelSmall: MobileTypographyToken = new MobileTypographyToken(12, 16, 400); +} + +export class MobileDesignGeometry { + static readonly conversationHeaderHeight: number = 76; + static readonly conversationHeaderCompactHeight: number = 64; + static readonly controlTouchSize: number = 44; + static readonly contentGutter: number = 16; + static readonly connectionStripHeight: number = 48; + static readonly timelineTopPadding: number = 22; + static readonly messageSpacing: number = 12; + static readonly messageBubbleMaxWidth: number = 276; + static readonly messageBubbleHorizontalPadding: number = 14; + static readonly messageBubbleVerticalPadding: number = 11; + static readonly messageBubbleRadius: number = 17; + static readonly composerActionSize: number = 40; + static readonly composerInputHeight: number = 42; + static readonly composerExpandedInputHeight: number = 74; + static readonly composerCollapsedHeight: number = 52; + static readonly composerExpandedInputRowHeight: number = 76; + static readonly composerExpandedActionRowHeight: number = 44; + static readonly composerExpandedHeight: number = 126; + static readonly composerCollapsedRadius: number = 26; + static readonly composerExpandedRadius: number = 18; +} + +export class MobileDesignBreakpoints { + static readonly wide: number = 600; + static readonly extraWide: number = 840; + static readonly xl: number = 1440; +} + +export class MobileDesignMotion { + static readonly quick: number = 180; + static readonly structure: number = 220; +} 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 new file mode 100644 index 0000000000..3857868df2 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/generated/MobilePreviewScenarios.ets @@ -0,0 +1,107 @@ +// Generated by scripts/mobile-ui-design-system.mjs. Do not edit. + +export class MobilePreviewMessage { + readonly role: string; + readonly text: string; + + constructor(role: string, text: string) { + this.role = role; + this.text = text; + } +} + +export class MobilePreviewScenario { + readonly id: string; + readonly title: string; + readonly description: string; + readonly appearance: string; + readonly viewportWidth: number; + readonly viewportHeight: number; + readonly headerTitle: string; + readonly headerSubtitle: string; + readonly messages: MobilePreviewMessage[]; + readonly composerDraft: string; + readonly composerPlaceholder: string; + readonly connectionPhase: string; + readonly streaming: boolean; + + constructor( + id: string, + title: string, + description: string, + appearance: string, + viewportWidth: number, + viewportHeight: number, + headerTitle: string, + headerSubtitle: string, + messages: MobilePreviewMessage[], + composerDraft: string, + composerPlaceholder: string, + connectionPhase: string, + streaming: boolean + ) { + this.id = id; + this.title = title; + this.description = description; + this.appearance = appearance; + this.viewportWidth = viewportWidth; + this.viewportHeight = viewportHeight; + this.headerTitle = headerTitle; + this.headerSubtitle = headerSubtitle; + this.messages = messages; + this.composerDraft = composerDraft; + this.composerPlaceholder = composerPlaceholder; + this.connectionPhase = connectionPhase; + this.streaming = streaming; + } +} + +export class MobilePreviewScenarios { + static readonly connectedConversation: MobilePreviewScenario = new MobilePreviewScenario( + "connected-conversation", + "Connected conversation", + "A remote desktop is answering and the composer is ready.", + "light", + 390, + 844, + "统一移动端设计系统", + "DESKTOP-KM3L4UI", + [new MobilePreviewMessage("user", "三端的组件和样式可以保持一致吗?"), new MobilePreviewMessage("assistant", "可以。共享视觉契约,三端继续使用原生渲染。")], + "", + "向 BitFun 提问", + "connected", + false + ); + + static readonly streamingDark: MobilePreviewScenario = new MobilePreviewScenario( + "streaming-dark", + "Streaming in dark mode", + "An active turn exercises dark surfaces and the stop action.", + "dark", + 390, + 844, + "跨端视觉校验", + "正在由 MacBook Pro 运行", + [new MobilePreviewMessage("user", "比较三端的输入框、消息气泡和标题栏。"), new MobilePreviewMessage("assistant", "正在生成原生截图,并按相同基线并排展示。")], + "检查深色模式下的边框对比度", + "输入消息", + "connected", + true + ); + + static readonly reconnectingWide: MobilePreviewScenario = new MobilePreviewScenario( + "reconnecting-wide", + "Reconnecting on a wide viewport", + "A tablet-sized viewport keeps remote blocking states answerable.", + "light", + 1024, + 768, + "远程会话", + "正在恢复连接", + [new MobilePreviewMessage("assistant", "连接暂时中断。恢复后会从上次游标继续。")], + "", + "等待重新连接", + "reconnecting", + false + ); +} 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 0fb9fe4834..55a0badfd0 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 @@ -1,4 +1,5 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { MobileDesignGeometry, MobileDesignMotion } from '../../generated/MobileDesignTokens'; import { ChatComposerPolicy, ComposerPrimaryAction } from '../../services/ChatComposerPolicy'; import { ChatComposerCapabilities, REMOTE_CHAT_COMPOSER_CAPABILITIES } from '../state/ChatComposerCapabilities'; import { @@ -15,11 +16,11 @@ export enum ComposerPresentation { Create = 'create' } -const COMPOSER_ACTION_SIZE: number = 40; -const COMPOSER_INPUT_HEIGHT: number = 42; -const COMPOSER_EXPANDED_INPUT_HEIGHT: number = 74; -const COMPOSER_COLLAPSED_HEIGHT: number = 52; -const COMPOSER_EXPANDED_HEIGHT: number = 126; +const COMPOSER_ACTION_SIZE: number = MobileDesignGeometry.composerActionSize; +const COMPOSER_INPUT_HEIGHT: number = MobileDesignGeometry.composerInputHeight; +const COMPOSER_EXPANDED_INPUT_HEIGHT: number = MobileDesignGeometry.composerExpandedInputHeight; +const COMPOSER_COLLAPSED_HEIGHT: number = MobileDesignGeometry.composerCollapsedHeight; +const COMPOSER_EXPANDED_HEIGHT: number = MobileDesignGeometry.composerExpandedHeight; 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. */ @@ -79,8 +80,8 @@ export struct ComposerBar { .constraintSize({ maxWidth: this.presentation === ComposerPresentation.Floating ? 760 : 10000 }) .alignSelf(ItemAlign.Center) .padding({ - left: this.presentation === ComposerPresentation.Floating ? 24 : 16, - right: this.presentation === ComposerPresentation.Floating ? 24 : 16, + left: this.presentation === ComposerPresentation.Floating ? 24 : MobileDesignGeometry.contentGutter, + right: this.presentation === ComposerPresentation.Floating ? 24 : MobileDesignGeometry.contentGutter, top: 8, bottom: this.presentation === ComposerPresentation.Floating ? 18 : 14 }) @@ -116,7 +117,8 @@ export struct ComposerBar { .clip(true) } .width('100%') - .height(this.isComposerExpanded() ? 76 : 52) + .height(this.isComposerExpanded() ? MobileDesignGeometry.composerExpandedInputRowHeight : + MobileDesignGeometry.composerCollapsedHeight) if (this.isComposerExpanded()) { Row({ space: 6 }) { @@ -130,12 +132,12 @@ export struct ComposerBar { this.PrimaryActionButton() } .width('100%') - .height(44) + .height(MobileDesignGeometry.composerExpandedActionRowHeight) .padding({ left: 2, right: 0 }) .alignItems(VerticalAlign.Center) .transition(TransitionEffect.translate({ x: 0, y: 8 }) .combine(TransitionEffect.opacity(0)) - .animation({ duration: 180, curve: Curve.EaseOut })) + .animation({ duration: MobileDesignMotion.quick, curve: Curve.EaseOut })) } } .width('100%') @@ -153,7 +155,7 @@ export struct ComposerBar { color: this.presentation === ComposerPresentation.Floating ? '#18000000' : '#0D000000', offsetY: this.presentation === ComposerPresentation.Floating ? 6 : 2 }) - .animation({ duration: 220, curve: Curve.EaseOut }) + .animation({ duration: MobileDesignMotion.structure, curve: Curve.EaseOut }) } @Builder @@ -541,9 +543,10 @@ export struct ComposerBar { // The pill radius only reads as a pill while the card is one row tall. private composerRadius(): number { if (this.isComposerExpanded() || this.selectedImages.length > 0) { - return 18; + return MobileDesignGeometry.composerExpandedRadius; } - return this.presentation === ComposerPresentation.Floating ? 18 : 26; + return this.presentation === ComposerPresentation.Floating ? + MobileDesignGeometry.composerExpandedRadius : MobileDesignGeometry.composerCollapsedRadius; } private isComposerExpanded(): boolean { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationHeader.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationHeader.ets index a2f5606373..bbf6a378fc 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationHeader.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationHeader.ets @@ -1,4 +1,5 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { MobileDesignGeometry, MobileDesignTypography } from '../../generated/MobileDesignTokens'; import { ACCENT, CARD, INK, LINE, MUTED, PAGE_BG, PRIMARY_ACTION_TEXT, SOFT } from './Theme'; import { CompactMenuButton } from './CompactMenuButton'; import { TemplateIcon } from './TemplateIcon'; @@ -44,9 +45,15 @@ export struct ConversationHeader { this.TrailingControl() } .width('100%') - .height(this.hasSubtitle() ? 76 : 64) + .height(this.hasSubtitle() ? MobileDesignGeometry.conversationHeaderHeight : + MobileDesignGeometry.conversationHeaderCompactHeight) .alignItems(VerticalAlign.Center) - .padding({ left: 16, right: 16, top: 8, bottom: 8 }) + .padding({ + left: MobileDesignGeometry.contentGutter, + right: MobileDesignGeometry.contentGutter, + top: 8, + bottom: 8 + }) .backgroundColor(PAGE_BG) } @@ -54,7 +61,10 @@ export struct ConversationHeader { private TitleBlock() { Column({ space: 3 }) { Text(this.resolvedTitle()) - .fontSize(this.hasSubtitle() ? 18 : 17) + .fontSize(this.hasSubtitle() ? MobileDesignTypography.conversationHeaderTitle.size : + MobileDesignTypography.titleMedium.size) + .lineHeight(this.hasSubtitle() ? MobileDesignTypography.conversationHeaderTitle.lineHeight : + MobileDesignTypography.titleMedium.lineHeight) .fontWeight(FontWeight.Medium) .fontColor(INK) .maxLines(1) @@ -69,7 +79,8 @@ export struct ConversationHeader { }) if (this.hasSubtitle()) { Text(this.subtitle) - .fontSize(14) + .fontSize(MobileDesignTypography.labelMedium.size) + .lineHeight(MobileDesignTypography.labelMedium.lineHeight) .fontColor(MUTED) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) @@ -85,7 +96,7 @@ export struct ConversationHeader { if (this.showSidebarRestoreButton) { SidebarToggleButton({ restore: true, - controlSize: 44, + controlSize: MobileDesignGeometry.controlTouchSize, onToggle: this.onRestoreSidebar }) } else if (this.showBackButton) { @@ -96,11 +107,11 @@ export struct ConversationHeader { iconHeight: 23 }) } - .width(44) - .height(44) + .width(MobileDesignGeometry.controlTouchSize) + .height(MobileDesignGeometry.controlTouchSize) .backgroundColor(CARD) .border({ width: 1, color: LINE }) - .borderRadius(22) + .borderRadius(MobileDesignGeometry.controlTouchSize / 2) .shadow({ radius: 10, color: LINE, offsetY: 3 }) .accessibilityText(RemoteI18n.t('common.back')) .onClick(() => { @@ -108,13 +119,15 @@ export struct ConversationHeader { }) } else if (this.showSidebarButton) { CompactMenuButton({ - controlSize: 44, + controlSize: MobileDesignGeometry.controlTouchSize, onOpen: () => { this.onOpenSidebar(); } }) } else { - Blank().width(44).height(44) + Blank() + .width(MobileDesignGeometry.controlTouchSize) + .height(MobileDesignGeometry.controlTouchSize) } } @@ -128,11 +141,11 @@ export struct ConversationHeader { iconHeight: 7 }) } - .width(44) - .height(44) + .width(MobileDesignGeometry.controlTouchSize) + .height(MobileDesignGeometry.controlTouchSize) .backgroundColor(CARD) .border({ width: 1, color: LINE }) - .borderRadius(22) + .borderRadius(MobileDesignGeometry.controlTouchSize / 2) .shadow({ radius: 10, color: LINE, offsetY: 3 }) .accessibilityText(RemoteI18n.t('sidebar.more')) .bindPopup(this.showActionsMenu, { @@ -154,7 +167,9 @@ export struct ConversationHeader { this.onOpenActions(); }) } else { - Blank().width(44).height(44) + Blank() + .width(MobileDesignGeometry.controlTouchSize) + .height(MobileDesignGeometry.controlTouchSize) } } @@ -200,7 +215,12 @@ export struct ConversationHeader { }) } .width('100%') - .padding({ left: 16, right: 16, top: 10, bottom: 8 }) + .padding({ + left: MobileDesignGeometry.contentGutter, + right: MobileDesignGeometry.contentGutter, + top: 10, + bottom: 8 + }) .backgroundColor(PAGE_BG) } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConversationLayoutPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConversationLayoutPolicy.ets index 855cb87043..d3a686d225 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConversationLayoutPolicy.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConversationLayoutPolicy.ets @@ -1,3 +1,5 @@ +import { MobileDesignBreakpoints } from '../../generated/MobileDesignTokens'; + export class ConversationLayoutCrease { readonly left: number; readonly width: number; @@ -75,9 +77,9 @@ export class ConversationLayoutGeometry { export class ConversationLayoutPolicy { /** Official GridRow sm|md boundary: 320 / 600 / 840 / 1440 vp. */ - static readonly MD_MIN_WIDTH: number = 600; - static readonly LG_MIN_WIDTH: number = 840; - static readonly XL_MIN_WIDTH: number = 1440; + static readonly MD_MIN_WIDTH: number = MobileDesignBreakpoints.wide; + static readonly LG_MIN_WIDTH: number = MobileDesignBreakpoints.extraWide; + static readonly XL_MIN_WIDTH: number = MobileDesignBreakpoints.xl; static readonly WIDE_LAYOUT_MIN_WIDTH: number = 600; static readonly EXTRA_WIDE_MIN_WIDTH: number = 840; static readonly FALLBACK_MASTER_PANE_WIDTH: number = 344; 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 new file mode 100644 index 0000000000..e9dad8f417 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/MobileDesignGallery.ets @@ -0,0 +1,125 @@ +import { MobileDesignGeometry, MobileDesignTypography } from '../../generated/MobileDesignTokens'; +import { + MobilePreviewMessage, + MobilePreviewScenario, + MobilePreviewScenarios +} from '../../generated/MobilePreviewScenarios'; +import { ComposerBar, ComposerPresentation } from '../components/ComposerBar'; +import { ConversationHeader } from '../components/ConversationHeader'; +import { CARD, INK, LINE, MUTED, PAGE_BG, SOFT } from '../components/Theme'; + +@Entry +@ComponentV2 +struct MobileDesignGallery { + @Param scenario: MobilePreviewScenario = MobilePreviewScenarios.connectedConversation; + + build() { + Column() { + this.PlatformLabel() + ConversationHeader({ + title: this.scenario.headerTitle, + subtitle: this.scenario.headerSubtitle, + showSidebarButton: true, + showActions: true + }) + Column({ space: MobileDesignGeometry.messageSpacing }) { + ForEach(this.scenario.messages, (message: MobilePreviewMessage) => { + this.MessageBubble(message) + }, (message: MobilePreviewMessage) => `${message.role}:${message.text}`) + } + .layoutWeight(1) + .width('100%') + .alignItems(HorizontalAlign.Start) + .padding({ + left: MobileDesignGeometry.contentGutter, + right: MobileDesignGeometry.contentGutter, + top: MobileDesignGeometry.timelineTopPadding + }) + ComposerBar({ + presentation: ComposerPresentation.Compact, + chatInput: this.scenario.composerDraft, + isBusy: this.scenario.streaming, + canStop: this.scenario.streaming, + connectionState: this.scenario.connectionPhase + }) + } + .width('100%') + .height('100%') + .backgroundColor(PAGE_BG) + } + + @Builder + private PlatformLabel() { + Row({ space: 8 }) { + Text('HarmonyOS') + .fontSize(MobileDesignTypography.labelMedium.size) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + Text('BASELINE') + .fontSize(MobileDesignTypography.labelSmall.size) + .fontColor(MUTED) + .padding({ left: 8, right: 8, top: 4, bottom: 4 }) + .backgroundColor(SOFT) + .borderRadius(10) + Blank() + Text(`${this.scenario.viewportWidth} × ${this.scenario.viewportHeight}`) + .fontSize(MobileDesignTypography.labelSmall.size) + .fontColor(MUTED) + } + .width('100%') + .height(MobileDesignGeometry.connectionStripHeight) + .padding({ left: MobileDesignGeometry.contentGutter, right: MobileDesignGeometry.contentGutter }) + .border({ width: { bottom: 1 }, color: LINE }) + } + + @Builder + private MessageBubble(message: MobilePreviewMessage) { + Row() { + if (message.role === 'user') { + Blank() + } + Text(message.text) + .fontSize(MobileDesignTypography.bodyMedium.size) + .fontColor(INK) + .lineHeight(MobileDesignTypography.bodyMedium.lineHeight) + .constraintSize({ maxWidth: MobileDesignGeometry.messageBubbleMaxWidth }) + .padding({ + left: MobileDesignGeometry.messageBubbleHorizontalPadding, + right: MobileDesignGeometry.messageBubbleHorizontalPadding, + top: MobileDesignGeometry.messageBubbleVerticalPadding, + bottom: MobileDesignGeometry.messageBubbleVerticalPadding + }) + .backgroundColor(message.role === 'user' ? SOFT : CARD) + .border({ width: 1, color: LINE }) + .borderRadius(MobileDesignGeometry.messageBubbleRadius) + if (message.role !== 'user') { + Blank() + } + } + .width('100%') + } +} + +@Preview({ + title: 'BitFun Mobile · Compact', + width: 390, + height: 844 +}) +@ComponentV2 +struct MobileDesignCompactPreview { + build() { + MobileDesignGallery({ scenario: MobilePreviewScenarios.connectedConversation }) + } +} + +@Preview({ + title: 'BitFun Mobile · Wide', + width: 1024, + height: 768 +}) +@ComponentV2 +struct MobileDesignWidePreview { + build() { + MobileDesignGallery({ scenario: MobilePreviewScenarios.reconnectingWide }) + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/resources/base/profile/main_pages.json b/src/apps/mobile/harmonyos/entry/src/main/resources/base/profile/main_pages.json index 85c352fc10..e32685040b 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/resources/base/profile/main_pages.json +++ b/src/apps/mobile/harmonyos/entry/src/main/resources/base/profile/main_pages.json @@ -1,5 +1,6 @@ { "src": [ - "pages/AppRoot" + "pages/AppRoot", + "pages/preview/MobileDesignGallery" ] } diff --git a/src/apps/mobile/ios/BitFun.xcodeproj/project.pbxproj b/src/apps/mobile/ios/BitFun.xcodeproj/project.pbxproj index 7aeef2f7cd..17906867fc 100644 --- a/src/apps/mobile/ios/BitFun.xcodeproj/project.pbxproj +++ b/src/apps/mobile/ios/BitFun.xcodeproj/project.pbxproj @@ -18,6 +18,9 @@ A10000000000000000000010 /* MobileCoreAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000010 /* MobileCoreAdapter.swift */; }; A10000000000000000000011 /* BitFunMobileCore.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = B10000000000000000000011 /* BitFunMobileCore.xcframework */; }; A10000000000000000000012 /* libsqlite3.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = B10000000000000000000012 /* libsqlite3.tbd */; }; + A10000000000000000000013 /* GeneratedMobileDesignTokens.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000013 /* GeneratedMobileDesignTokens.swift */; }; + A10000000000000000000014 /* GeneratedMobilePreviewScenarios.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000014 /* GeneratedMobilePreviewScenarios.swift */; }; + A10000000000000000000015 /* MobileDesignGallery.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000015 /* MobileDesignGallery.swift */; }; A10000000000000000000009 /* Resources.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000009 /* Resources.xcassets */; }; /* End PBXBuildFile section */ @@ -34,6 +37,9 @@ B10000000000000000000010 /* MobileCoreAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileCoreAdapter.swift; sourceTree = ""; }; B10000000000000000000011 /* BitFunMobileCore.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = "../../shared/core-feature/build/XCFrameworks/debug/BitFunMobileCore.xcframework"; sourceTree = ""; }; B10000000000000000000012 /* libsqlite3.tbd */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.text-based-dylib-definition; name = libsqlite3.tbd; path = "$(SDKROOT)/usr/lib/libsqlite3.tbd"; sourceTree = SDKROOT; }; + B10000000000000000000013 /* GeneratedMobileDesignTokens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneratedMobileDesignTokens.swift; sourceTree = ""; }; + B10000000000000000000014 /* GeneratedMobilePreviewScenarios.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneratedMobilePreviewScenarios.swift; sourceTree = ""; }; + B10000000000000000000015 /* MobileDesignGallery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileDesignGallery.swift; sourceTree = ""; }; B10000000000000000000009 /* Resources.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Resources.xcassets; sourceTree = ""; }; /* End PBXFileReference section */ @@ -45,11 +51,12 @@ D10000000000000000000000 = {isa = PBXGroup; children = (D10000000000000000000001 /* BitFun */, D10000000000000000000009 /* Products */); sourceTree = ""; }; D10000000000000000000001 /* BitFun */ = {isa = PBXGroup; children = (D10000000000000000000002 /* App */, D10000000000000000000003 /* Features */, D10000000000000000000008 /* Infrastructure */, B10000000000000000000009 /* Resources.xcassets */, B10000000000000000000011 /* BitFunMobileCore.xcframework */, B10000000000000000000012 /* libsqlite3.tbd */); path = BitFun; sourceTree = ""; }; D10000000000000000000002 /* App */ = {isa = PBXGroup; children = (B10000000000000000000001 /* BitFunApp.swift */); path = App; sourceTree = ""; }; - D10000000000000000000003 /* Features */ = {isa = PBXGroup; children = (D10000000000000000000004 /* Chat */, D10000000000000000000005 /* Shell */); path = Features; sourceTree = ""; }; + D10000000000000000000003 /* Features */ = {isa = PBXGroup; children = (D10000000000000000000004 /* Chat */, D10000000000000000000005 /* Shell */, D10000000000000000000010 /* DesignSystem */); path = Features; sourceTree = ""; }; D10000000000000000000004 /* Chat */ = {isa = PBXGroup; children = (B10000000000000000000005 /* ConversationHeader.swift */, B10000000000000000000006 /* ChatTimelineView.swift */, B10000000000000000000007 /* ComposerBar.swift */); path = Chat; sourceTree = ""; }; D10000000000000000000005 /* Shell */ = {isa = PBXGroup; children = (B10000000000000000000003 /* BitFunTheme.swift */, B10000000000000000000004 /* SidebarView.swift */, B10000000000000000000008 /* MobileShellView.swift */); path = Shell; sourceTree = ""; }; D10000000000000000000008 /* Infrastructure */ = {isa = PBXGroup; children = (B10000000000000000000002 /* MobileAppModel.swift */, B10000000000000000000010 /* MobileCoreAdapter.swift */); path = Infrastructure; sourceTree = ""; }; D10000000000000000000009 /* Products */ = {isa = PBXGroup; children = (B10000000000000000000000 /* BitFun.app */); name = Products; sourceTree = ""; }; + D10000000000000000000010 /* DesignSystem */ = {isa = PBXGroup; children = (B10000000000000000000013 /* GeneratedMobileDesignTokens.swift */, B10000000000000000000014 /* GeneratedMobilePreviewScenarios.swift */, B10000000000000000000015 /* MobileDesignGallery.swift */); path = DesignSystem; sourceTree = ""; }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -61,7 +68,7 @@ /* End PBXProject section */ /* Begin PBXSourcesBuildPhase section */ - C10000000000000000000002 /* Sources */ = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (A10000000000000000000001, A10000000000000000000002, A10000000000000000000003, A10000000000000000000004, A10000000000000000000005, A10000000000000000000006, A10000000000000000000007, A10000000000000000000008, A10000000000000000000010); runOnlyForDeploymentPostprocessing = 0; }; + C10000000000000000000002 /* Sources */ = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (A10000000000000000000001, A10000000000000000000002, A10000000000000000000003, A10000000000000000000004, A10000000000000000000005, A10000000000000000000006, A10000000000000000000007, A10000000000000000000008, A10000000000000000000010, A10000000000000000000013, A10000000000000000000014, A10000000000000000000015); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin PBXResourcesBuildPhase section */ diff --git a/src/apps/mobile/ios/BitFun/App/BitFunApp.swift b/src/apps/mobile/ios/BitFun/App/BitFunApp.swift index bdec50e830..3ae79ea193 100644 --- a/src/apps/mobile/ios/BitFun/App/BitFunApp.swift +++ b/src/apps/mobile/ios/BitFun/App/BitFunApp.swift @@ -3,11 +3,30 @@ import SwiftUI @main struct BitFunApp: App { @StateObject private var model = MobileAppModel.launchConfigured + private let designPreviewScenario = Self.resolveDesignPreviewScenario() var body: some Scene { WindowGroup { - MobileShellView(model: model) - .preferredColorScheme(.light) + if let scenario = designPreviewScenario { + MobileDesignGallery(scenario: scenario) + .preferredColorScheme(scenario.appearance == "dark" ? .dark : .light) + } else { + MobileShellView(model: model) + } + } + } + + private static func resolveDesignPreviewScenario() -> MobilePreviewScenario? { + let arguments = ProcessInfo.processInfo.arguments + guard let marker = arguments.firstIndex(of: "--design-preview") else { return nil } + let scenarioID = arguments.indices.contains(marker + 1) ? arguments[marker + 1] : "connected-conversation" + switch scenarioID { + case MobilePreviewScenarios.streamingDark.id: + return MobilePreviewScenarios.streamingDark + case MobilePreviewScenarios.reconnectingWide.id: + return MobilePreviewScenarios.reconnectingWide + default: + return MobilePreviewScenarios.connectedConversation } } } diff --git a/src/apps/mobile/ios/BitFun/Features/Chat/ChatTimelineView.swift b/src/apps/mobile/ios/BitFun/Features/Chat/ChatTimelineView.swift index 189a10aae4..905ad57424 100644 --- a/src/apps/mobile/ios/BitFun/Features/Chat/ChatTimelineView.swift +++ b/src/apps/mobile/ios/BitFun/Features/Chat/ChatTimelineView.swift @@ -22,8 +22,8 @@ struct ChatTimelineView: View { .padding(.vertical, 15) } } - .padding(.horizontal, 16) - .padding(.top, 8) + .padding(.horizontal, MobileDesignGeometry.contentGutter) + .padding(.top, MobileDesignGeometry.timelineTopPadding) .padding(.bottom, 14) } .onChange(of: model.messages.count) { _ in @@ -42,18 +42,23 @@ private struct ChatMessageBubble: View { var body: some View { VStack(alignment: message.role == .user ? .trailing : .leading, spacing: 0) { Text(message.text) - .font(.system(size: 15, weight: .regular)) + .font(MobileDesignTypography.bodyMedium.font) .foregroundStyle(BitFunTheme.ink) - .lineSpacing(4) - .padding(.horizontal, 14) - .padding(.vertical, 11) + .lineSpacing(MobileDesignTypography.bodyMedium.lineSpacing) + .padding(.horizontal, MobileDesignGeometry.messageBubbleHorizontalPadding) + .padding(.vertical, MobileDesignGeometry.messageBubbleVerticalPadding) .background(message.role == .user ? BitFunTheme.soft : BitFunTheme.card) - .clipShape(RoundedRectangle(cornerRadius: 17)) - .overlay(RoundedRectangle(cornerRadius: 17).stroke(BitFunTheme.line, lineWidth: 1)) - .frame(maxWidth: 320, alignment: message.role == .user ? .trailing : .leading) + .clipShape(RoundedRectangle(cornerRadius: MobileDesignGeometry.messageBubbleRadius)) + .overlay( + RoundedRectangle(cornerRadius: MobileDesignGeometry.messageBubbleRadius) + .stroke(BitFunTheme.line, lineWidth: 1) + ) + .frame( + maxWidth: MobileDesignGeometry.messageBubbleMaxWidth, + alignment: message.role == .user ? .trailing : .leading + ) } .frame(maxWidth: .infinity, alignment: message.role == .user ? .trailing : .leading) - .padding(.top, message.role == .user ? 8 : 2) - .padding(.bottom, message.role == .user ? 12 : 10) + .padding(.bottom, MobileDesignGeometry.messageSpacing) } } diff --git a/src/apps/mobile/ios/BitFun/Features/Chat/ComposerBar.swift b/src/apps/mobile/ios/BitFun/Features/Chat/ComposerBar.swift index adbc70ada5..1796c4e665 100644 --- a/src/apps/mobile/ios/BitFun/Features/Chat/ComposerBar.swift +++ b/src/apps/mobile/ios/BitFun/Features/Chat/ComposerBar.swift @@ -5,18 +5,25 @@ struct ComposerBar: View { @FocusState private var focused: Bool var body: some View { + let placeholder = model.surface == .remote + ? "向 BitFun 提问" + : (model.localSessionSelected ? "输入消息" : "问问 BitFun") HStack(spacing: 5) { Button { } label: { ReferenceGlyph(assetName: "ComposerPlusGlyph", width: 18, height: 18) - .frame(width: 40, height: 40) + .frame( + width: MobileDesignGeometry.composerActionSize, + height: MobileDesignGeometry.composerActionSize + ) } .buttonStyle(.plain) TextField( - model.surface == .remote ? "向 BitFun 提问" : (model.localSessionSelected ? "输入消息" : "问问 BitFun"), + "", text: $model.draft, + prompt: Text(placeholder).foregroundColor(BitFunTheme.muted), axis: .vertical ) - .font(.system(size: 15)) + .font(MobileDesignTypography.bodyLarge.font) .foregroundStyle(BitFunTheme.ink) .lineLimit(1...4) .focused($focused) @@ -28,27 +35,35 @@ struct ComposerBar: View { Image(systemName: "stop.fill") .font(.system(size: 16, weight: .bold)) .foregroundStyle(BitFunTheme.accent) - .frame(width: 40, height: 40) + .frame( + width: MobileDesignGeometry.composerActionSize, + height: MobileDesignGeometry.composerActionSize + ) } else if model.draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { ReferenceGlyph(assetName: "ComposerMicGlyph", width: 16, height: 19) .foregroundStyle(BitFunTheme.muted) - .frame(width: 40, height: 40) + .frame( + width: MobileDesignGeometry.composerActionSize, + height: MobileDesignGeometry.composerActionSize + ) } else { Image(systemName: "arrow.up") .font(.system(size: 16, weight: .bold)) .foregroundStyle(BitFunTheme.accent) - .frame(width: 40, height: 40) + .frame( + width: MobileDesignGeometry.composerActionSize, + height: MobileDesignGeometry.composerActionSize + ) } } .buttonStyle(.plain) } .padding(.horizontal, 8) - .frame(minHeight: 52) + .frame(minHeight: MobileDesignGeometry.composerCollapsedHeight) .background(BitFunTheme.card) - .clipShape(RoundedRectangle(cornerRadius: 20)) - .overlay(RoundedRectangle(cornerRadius: 20).stroke(BitFunTheme.line, lineWidth: 1)) - .shadow(color: .black.opacity(0.07), radius: 10, y: 2) - .padding(.horizontal, 16) + .clipShape(RoundedRectangle(cornerRadius: MobileDesignGeometry.composerCollapsedRadius)) + .shadow(color: .black.opacity(0.05), radius: 10, y: 2) + .padding(.horizontal, MobileDesignGeometry.contentGutter) .padding(.top, 8) .padding(.bottom, 14) .background(BitFunTheme.page) diff --git a/src/apps/mobile/ios/BitFun/Features/Chat/ConversationHeader.swift b/src/apps/mobile/ios/BitFun/Features/Chat/ConversationHeader.swift index c4f4a86928..0f80c071ea 100644 --- a/src/apps/mobile/ios/BitFun/Features/Chat/ConversationHeader.swift +++ b/src/apps/mobile/ios/BitFun/Features/Chat/ConversationHeader.swift @@ -2,13 +2,24 @@ import SwiftUI struct ConversationHeader: View { @ObservedObject var model: MobileAppModel + var contextTitle: String? = nil @State private var menuOpen = false + private var resolvedSubtitle: String? { + if let contextTitle, !contextTitle.isEmpty { return contextTitle } + if model.surface == .local && model.localSessionSelected { return "本地会话" } + if model.remoteConnected && model.remoteSessionSelected { return "DESKTOP-KM3L4UI" } + return nil + } + var body: some View { HStack(spacing: 8) { Button { model.drawerOpen = true } label: { ReferenceGlyph(assetName: "MenuGlyph", width: 23, height: 18) - .frame(width: 44, height: 44) + .frame( + width: MobileDesignGeometry.controlTouchSize, + height: MobileDesignGeometry.controlTouchSize + ) .background(BitFunTheme.card) .overlay(Circle().stroke(BitFunTheme.line, lineWidth: 1)) .clipShape(Circle()) @@ -17,16 +28,16 @@ struct ConversationHeader: View { .buttonStyle(.plain) VStack(spacing: 3) { Text(model.selectedSession?.title ?? "BitFun") - .font(.system(size: 17, weight: .medium)) + .font( + (resolvedSubtitle == nil + ? MobileDesignTypography.titleMedium + : MobileDesignTypography.conversationHeaderTitle).font + ) .foregroundStyle(BitFunTheme.ink) .lineLimit(1) - if model.surface == .local && model.localSessionSelected { - Text("本地会话") - .font(.system(size: 14)) - .foregroundStyle(BitFunTheme.muted) - } else if model.remoteConnected && model.remoteSessionSelected { - Text("DESKTOP-KM3L4UI") - .font(.system(size: 14)) + if let resolvedSubtitle { + Text(resolvedSubtitle) + .font(MobileDesignTypography.labelMedium.font) .foregroundStyle(BitFunTheme.muted) } } @@ -40,16 +51,22 @@ struct ConversationHeader: View { Button("归档会话") { } } label: { ReferenceGlyph(assetName: "MoreGlyph", width: 23, height: 7) - .frame(width: 44, height: 44) + .frame( + width: MobileDesignGeometry.controlTouchSize, + height: MobileDesignGeometry.controlTouchSize + ) .background(BitFunTheme.card) .overlay(Circle().stroke(BitFunTheme.line, lineWidth: 1)) .clipShape(Circle()) .shadow(color: .black.opacity(0.07), radius: 8, y: 3) } } - .frame(height: 76) - .padding(.horizontal, 16) - .padding(.vertical, 8) + .frame( + height: resolvedSubtitle == nil + ? MobileDesignGeometry.conversationHeaderCompactHeight + : MobileDesignGeometry.conversationHeaderHeight + ) + .padding(.horizontal, MobileDesignGeometry.contentGutter) .background(BitFunTheme.page) } } diff --git a/src/apps/mobile/ios/BitFun/Features/DesignSystem/GeneratedMobileDesignTokens.swift b/src/apps/mobile/ios/BitFun/Features/DesignSystem/GeneratedMobileDesignTokens.swift new file mode 100644 index 0000000000..f9415dbbe9 --- /dev/null +++ b/src/apps/mobile/ios/BitFun/Features/DesignSystem/GeneratedMobileDesignTokens.swift @@ -0,0 +1,115 @@ +// Generated by scripts/mobile-ui-design-system.mjs. Do not edit. +import SwiftUI +import UIKit + +struct MobileTypographyToken { + let size: CGFloat + let lineHeight: CGFloat + let weight: Font.Weight + + var font: Font { .system(size: size, weight: weight) } + var lineSpacing: CGFloat { max(0, lineHeight - UIFont.systemFont(ofSize: size).lineHeight) } +} + +enum MobileDesignColors { + static let startWindowBackground = dynamic(light: 0xFFFFFFFF, dark: 0xFF000000) + static let pageBg = dynamic(light: 0xFFFDFDFB, dark: 0xFF151514) + static let pageBgFade = dynamic(light: 0x00FDFDFB, dark: 0x00151514) + static let ink = dynamic(light: 0xFF171717, dark: 0xFFF4F3EF) + static let muted = dynamic(light: 0xFF706F6A, dark: 0xFFAAA8A0) + static let subtle = dynamic(light: 0xFFA5A39B, dark: 0xFF77756E) + static let line = dynamic(light: 0xFFE9E7E2, dark: 0xFF363531) + static let card = dynamic(light: 0xFFFFFFFF, dark: 0xFF252522) + static let accent = dynamic(light: 0xFF111111, dark: 0xFF5B5954) + static let fileLink = dynamic(light: 0xFF2563EB, dark: 0xFF60A5FA) + static let primaryAction = dynamic(light: 0xFF111111, dark: 0xFF454540) + static let primaryActionText = dynamic(light: 0xFFFFFFFF, dark: 0xFFFFFFFF) + static let connectHeroBg = dynamic(light: 0xFFE6EDFF, dark: 0xFF2B2B29) + static let connectHeroAccent = dynamic(light: 0xFF9DB4FF, dark: 0xFF4A4944) + static let connectHeroSecondary = dynamic(light: 0xFFC9C5FF, dark: 0xFF3C3B38) + static let connectHeroSurface = dynamic(light: 0xFFF8FAFF, dark: 0xFF252522) + static let connectScanAccent = dynamic(light: 0xFFFFD021, dark: 0xFFFFD021) + static let modalScrim = dynamic(light: 0x99000000, dark: 0x99000000) + static let soft = dynamic(light: 0xFFF4F3F0, dark: 0xFF2D2C28) + static let floatingPanelBg = dynamic(light: 0xFFF7F7F5, dark: 0xFF1E1E1C) + static let green = dynamic(light: 0xFF27C46A, dark: 0xFF3BD47B) + static let red = dynamic(light: 0xFFE04F4F, dark: 0xFFFF6B6B) + static let codeLineNumber = dynamic(light: 0xFFAAA69D, dark: 0xFF77756E) + static let codeKeyword = dynamic(light: 0xFF8F3F71, dark: 0xFFD99AC4) + static let codeString = dynamic(light: 0xFF477A4A, dark: 0xFF9BCB9D) + static let codeNumber = dynamic(light: 0xFF9A5B13, dark: 0xFFE3B36D) + static let codeComment = dynamic(light: 0xFF7A8078, dark: 0xFF96958D) + static let codeFunction = dynamic(light: 0xFF2C6693, dark: 0xFF8CBCE0) + static let codeType = dynamic(light: 0xFF865A20, dark: 0xFFD5B27F) + static let codeConstant = dynamic(light: 0xFFA04444, dark: 0xFFE79A9A) + static let codeProperty = dynamic(light: 0xFF466D78, dark: 0xFF9CC8D0) + static let codeTargetBg = dynamic(light: 0xFFFFF1BE, dark: 0xFF5A4E24) + + private static func dynamic(light: UInt32, dark: UInt32) -> Color { + Color(uiColor: UIColor { traits in + rgba(traits.userInterfaceStyle == .dark ? dark : light) + }) + } + + private static func rgba(_ value: UInt32) -> UIColor { + UIColor( + red: CGFloat((value >> 16) & 0xFF) / 255, + green: CGFloat((value >> 8) & 0xFF) / 255, + blue: CGFloat(value & 0xFF) / 255, + alpha: CGFloat((value >> 24) & 0xFF) / 255 + ) + } +} + +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: 18, 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) +} + +enum MobileDesignGeometry { + static let conversationHeaderHeight: CGFloat = 76 + static let conversationHeaderCompactHeight: CGFloat = 64 + static let controlTouchSize: CGFloat = 44 + static let contentGutter: CGFloat = 16 + static let connectionStripHeight: CGFloat = 48 + static let timelineTopPadding: CGFloat = 22 + static let messageSpacing: CGFloat = 12 + static let messageBubbleMaxWidth: CGFloat = 276 + static let messageBubbleHorizontalPadding: CGFloat = 14 + static let messageBubbleVerticalPadding: CGFloat = 11 + static let messageBubbleRadius: CGFloat = 17 + static let composerActionSize: CGFloat = 40 + static let composerInputHeight: CGFloat = 42 + static let composerExpandedInputHeight: CGFloat = 74 + static let composerCollapsedHeight: CGFloat = 52 + static let composerExpandedInputRowHeight: CGFloat = 76 + static let composerExpandedActionRowHeight: CGFloat = 44 + static let composerExpandedHeight: CGFloat = 126 + static let composerCollapsedRadius: CGFloat = 26 + static let composerExpandedRadius: CGFloat = 18 +} + +enum MobileDesignBreakpoints { + static let wide: CGFloat = 600 + static let extraWide: CGFloat = 840 + static let xl: CGFloat = 1440 +} + +enum MobileDesignMotion { + static let quick: CGFloat = 180 + static let structure: CGFloat = 220 +} diff --git a/src/apps/mobile/ios/BitFun/Features/DesignSystem/GeneratedMobilePreviewScenarios.swift b/src/apps/mobile/ios/BitFun/Features/DesignSystem/GeneratedMobilePreviewScenarios.swift new file mode 100644 index 0000000000..03b9e16514 --- /dev/null +++ b/src/apps/mobile/ios/BitFun/Features/DesignSystem/GeneratedMobilePreviewScenarios.swift @@ -0,0 +1,73 @@ +// Generated by scripts/mobile-ui-design-system.mjs. Do not edit. +import CoreGraphics + +struct MobilePreviewMessage { + let role: String + let text: String +} + +struct MobilePreviewScenario { + let id: String + let title: String + let description: String + let appearance: String + let viewportWidth: CGFloat + let viewportHeight: CGFloat + let headerTitle: String + let headerSubtitle: String + let messages: [MobilePreviewMessage] + let composerDraft: String + let composerPlaceholder: String + let connectionPhase: String + let streaming: Bool +} + +enum MobilePreviewScenarios { + static let connectedConversation = MobilePreviewScenario( + id: "connected-conversation", + title: "Connected conversation", + description: "A remote desktop is answering and the composer is ready.", + appearance: "light", + viewportWidth: 390, + viewportHeight: 844, + headerTitle: "统一移动端设计系统", + headerSubtitle: "DESKTOP-KM3L4UI", + messages: [MobilePreviewMessage(role: "user", text: "三端的组件和样式可以保持一致吗?"), MobilePreviewMessage(role: "assistant", text: "可以。共享视觉契约,三端继续使用原生渲染。")], + composerDraft: "", + composerPlaceholder: "向 BitFun 提问", + connectionPhase: "connected", + streaming: false + ) + + static let streamingDark = MobilePreviewScenario( + id: "streaming-dark", + title: "Streaming in dark mode", + description: "An active turn exercises dark surfaces and the stop action.", + appearance: "dark", + viewportWidth: 390, + viewportHeight: 844, + headerTitle: "跨端视觉校验", + headerSubtitle: "正在由 MacBook Pro 运行", + messages: [MobilePreviewMessage(role: "user", text: "比较三端的输入框、消息气泡和标题栏。"), MobilePreviewMessage(role: "assistant", text: "正在生成原生截图,并按相同基线并排展示。")], + composerDraft: "检查深色模式下的边框对比度", + composerPlaceholder: "输入消息", + connectionPhase: "connected", + streaming: true + ) + + static let reconnectingWide = MobilePreviewScenario( + id: "reconnecting-wide", + title: "Reconnecting on a wide viewport", + description: "A tablet-sized viewport keeps remote blocking states answerable.", + appearance: "light", + viewportWidth: 1024, + viewportHeight: 768, + headerTitle: "远程会话", + headerSubtitle: "正在恢复连接", + messages: [MobilePreviewMessage(role: "assistant", text: "连接暂时中断。恢复后会从上次游标继续。")], + composerDraft: "", + composerPlaceholder: "等待重新连接", + connectionPhase: "reconnecting", + streaming: false + ) +} diff --git a/src/apps/mobile/ios/BitFun/Features/DesignSystem/MobileDesignGallery.swift b/src/apps/mobile/ios/BitFun/Features/DesignSystem/MobileDesignGallery.swift new file mode 100644 index 0000000000..7e1609c89b --- /dev/null +++ b/src/apps/mobile/ios/BitFun/Features/DesignSystem/MobileDesignGallery.swift @@ -0,0 +1,73 @@ +import SwiftUI + +struct MobileDesignGallery: View { + let scenario: MobilePreviewScenario + @StateObject private var model: MobileAppModel + + init(scenario: MobilePreviewScenario) { + self.scenario = scenario + let session = ChatSession(id: UUID(), title: scenario.headerTitle, updatedLabel: "刚刚") + let previewModel = MobileAppModel( + sessions: [session], + selectedSessionID: session.id, + messages: scenario.messages.map { message in + ChatMessage( + id: UUID(), + role: message.role == "user" ? .user : .assistant, + text: message.text + ) + } + ) + previewModel.surface = .remote + previewModel.remoteConnected = true + previewModel.remoteSessionSelected = true + previewModel.remoteSessions = [session] + previewModel.draft = scenario.composerDraft + previewModel.isSending = scenario.streaming + _model = StateObject(wrappedValue: previewModel) + } + + var body: some View { + VStack(spacing: 0) { + platformLabel + ConversationHeader(model: model, contextTitle: scenario.headerSubtitle) + ChatTimelineView(model: model) + ComposerBar(model: model) + } + .background(BitFunTheme.page) + } + + private var platformLabel: some View { + HStack(spacing: 8) { + Text("iOS") + .font(MobileDesignTypography.labelMedium.font) + .fontWeight(.medium) + Text("NATIVE") + .font(MobileDesignTypography.labelSmall.font) + .foregroundStyle(BitFunTheme.muted) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(BitFunTheme.soft) + .clipShape(Capsule()) + Spacer() + Text("\(Int(scenario.viewportWidth)) × \(Int(scenario.viewportHeight))") + .font(MobileDesignTypography.labelSmall.font) + .foregroundStyle(BitFunTheme.muted) + } + .frame(height: MobileDesignGeometry.connectionStripHeight) + .padding(.horizontal, MobileDesignGeometry.contentGutter) + .overlay(alignment: .bottom) { + Rectangle().fill(BitFunTheme.line).frame(height: 1) + } + } +} + +#Preview("BitFun Mobile · Compact") { + MobileDesignGallery(scenario: MobilePreviewScenarios.connectedConversation) + .preferredColorScheme(.light) +} + +#Preview("BitFun Mobile · Dark") { + MobileDesignGallery(scenario: MobilePreviewScenarios.streamingDark) + .preferredColorScheme(.dark) +} diff --git a/src/apps/mobile/ios/BitFun/Features/Shell/BitFunTheme.swift b/src/apps/mobile/ios/BitFun/Features/Shell/BitFunTheme.swift index 9d4e604338..79e5b3bce8 100644 --- a/src/apps/mobile/ios/BitFun/Features/Shell/BitFunTheme.swift +++ b/src/apps/mobile/ios/BitFun/Features/Shell/BitFunTheme.swift @@ -1,21 +1,21 @@ import SwiftUI enum BitFunTheme { - // These values mirror harmonyos/entry/src/main/resources/base/element/color.json. - static let page = Color(red: 253 / 255, green: 253 / 255, blue: 251 / 255) - static let card = Color.white - static let soft = Color(red: 244 / 255, green: 243 / 255, blue: 240 / 255) - static let ink = Color(red: 23 / 255, green: 23 / 255, blue: 23 / 255) - static let muted = Color(red: 112 / 255, green: 111 / 255, blue: 106 / 255) - static let line = Color(red: 233 / 255, green: 231 / 255, blue: 226 / 255) - static let accent = Color(red: 17 / 255, green: 17 / 255, blue: 17 / 255) - static let green = Color(red: 39 / 255, green: 196 / 255, blue: 106 / 255) - static let red = Color(red: 224 / 255, green: 79 / 255, blue: 79 / 255) + // Generated from the HarmonyOS baseline through the mobile design contract. + static let page = MobileDesignColors.pageBg + static let card = MobileDesignColors.card + static let soft = MobileDesignColors.soft + static let ink = MobileDesignColors.ink + static let muted = MobileDesignColors.muted + static let line = MobileDesignColors.line + static let accent = MobileDesignColors.accent + static let green = MobileDesignColors.green + static let red = MobileDesignColors.red } struct CircleControl: View { let systemName: String - var size: CGFloat = 44 + var size: CGFloat = MobileDesignGeometry.controlTouchSize var glyphSize: CGFloat = 18 var action: () -> Void diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/layout/ConversationLayoutPolicy.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/layout/ConversationLayoutPolicy.kt index 8a86105e3f..526fbd954f 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/layout/ConversationLayoutPolicy.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/layout/ConversationLayoutPolicy.kt @@ -1,5 +1,7 @@ package com.bitfun.mobile.core.feature.layout +import com.bitfun.mobile.core.feature.layout.generated.MobileDesignBreakpoints + /** * A hinge crossing the window, in the same density-independent units as the * viewport width it is measured against. @@ -50,9 +52,9 @@ private data class LayoutSegment(val left: Int, val width: Int) */ public object ConversationLayoutPolicy { /** Official GridRow sm|md|lg|xl boundaries used by the HarmonyOS surface. */ - public const val MD_MIN_WIDTH: Int = 600 - public const val LG_MIN_WIDTH: Int = 840 - public const val XL_MIN_WIDTH: Int = 1440 + public const val MD_MIN_WIDTH: Int = MobileDesignBreakpoints.Wide + public const val LG_MIN_WIDTH: Int = MobileDesignBreakpoints.ExtraWide + public const val XL_MIN_WIDTH: Int = MobileDesignBreakpoints.Xl public const val WIDE_LAYOUT_MIN_WIDTH: Int = MD_MIN_WIDTH public const val EXTRA_WIDE_MIN_WIDTH: Int = LG_MIN_WIDTH diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/layout/generated/MobileDesignBreakpoints.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/layout/generated/MobileDesignBreakpoints.kt new file mode 100644 index 0000000000..26a73e1045 --- /dev/null +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/layout/generated/MobileDesignBreakpoints.kt @@ -0,0 +1,8 @@ +// Generated by scripts/mobile-ui-design-system.mjs. Do not edit. +package com.bitfun.mobile.core.feature.layout.generated + +public object MobileDesignBreakpoints { + public const val Wide: Int = 600 + public const val ExtraWide: Int = 840 + public const val Xl: Int = 1440 +}