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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
237 changes: 237 additions & 0 deletions scripts/mobile-ui-design-system.mjs

Large diffs are not rendered by default.

93 changes: 93 additions & 0 deletions scripts/mobile-ui-preview.mjs
Original file line number Diff line number Diff line change
@@ -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 <number> 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('<title>BitFun Mobile Parity Bench</title>');
} 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';
}
22 changes: 22 additions & 0 deletions src/apps/mobile/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
19 changes: 17 additions & 2 deletions src/apps/mobile/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand All @@ -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"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`.
Expand Down Expand Up @@ -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",
)
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
) {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading