17 Commits
Author SHA1 Message Date
Xisheng-Zhao 8e71e83088 Finalize independent Android public repository 2026-06-07 01:35:52 +08:00
Xisheng-Zhao 867d9b3251 Prepare independent Android public release 2026-06-07 01:35:37 +08:00
Xisheng-Zhao 1e3f75455c Prepare public-only GitHub publication 2026-06-03 18:45:29 +08:00
Xisheng-Zhao f4b48dd2a4 Complete public analytics SDK setup 2026-06-03 18:16:40 +08:00
Xisheng-Zhao 29d544a09e Wire Umeng public app key and preinit 2026-06-03 17:50:56 +08:00
Xisheng-Zhao 718d9838b9 Prepare Multi-Casual public analytics release 2026-06-03 02:15:20 +08:00
Xisheng-Zhao 3ba5dced83 Add analytics tab view analytics 2026-05-18 17:46:23 +08:00
Xisheng-Zhao a770934b71 add launcher icons and clean inbox rows 2026-05-18 00:08:56 +08:00
Xisheng-Zhao 798985f309 fix issue mention links in markdown 2026-05-17 18:48:20 +08:00
Xisheng-Zhao 7fa5d5872d support rich issue comment links 2026-05-17 18:32:17 +08:00
Xisheng-Zhao c639b614fd align issue comment attachment controls 2026-05-17 18:07:36 +08:00
Xisheng-Zhao 972b11da90 polish issue detail screenshot feedback 2026-05-17 17:51:37 +08:00
Xisheng-Zhao 707c4c74f3 verify feature sync selectable text and localization 2026-05-17 05:27:21 +08:00
Xisheng-Zhao 65f209a853 add android public and public build flavors 2026-05-17 05:16:51 +08:00
Xisheng-Zhao 443508e354 add markdown table parity verification 2026-05-17 05:03:47 +08:00
Xisheng-Zhao 1161712038 polish Android iOS-style issue skills and forms 2026-05-17 04:55:52 +08:00
Xisheng-Zhao 60cdebfa01 initial android app snapshot 2026-05-17 04:20:20 +08:00
86 changed files with 722 additions and 5907 deletions
-48
View File
@@ -1,48 +0,0 @@
name: Deploy Marketing Site
on:
push:
branches: [main]
paths:
- 'site/**'
- 'site-internal/**'
- '.github/workflows/deploy-site.yml'
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: |
site/package-lock.json
site-internal/package-lock.json
- run: cd site && npm install && VITE_BASE=/multi-casual-android/ npm run build
- run: cd site-internal && npm install && VITE_BASE=/multi-casual-android/xiaomi/ npm run build
- run: mkdir -p site/dist/xiaomi && cp -R site-internal/dist/. site/dist/xiaomi/
- uses: actions/upload-pages-artifact@v3
with:
path: site/dist
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v4
-160
View File
@@ -1,160 +0,0 @@
# Changelog
## [Unreleased] - 2026-06-11
### Added
#### 自定义服务器配置功能 (Custom Server Configuration)
- **服务器配置管理**
- 新增 `ServerConfigManager.kt` - 使用 DataStore 持久化存储服务器配置
- 新增 `ServerConfigHolder.kt` - 全局单例提供运行时配置访问
- 支持配置自定义 API、Web、WebSocket 服务器地址
- 支持从单个域名自动派生三个 URL(简单模式)
- 支持手动配置每个 URL(高级模式)
- 重置为默认 Multica 公开服务器功能
- **用户界面**
- 登录页面添加"自定义服务器"入口
- 设置页面添加"开发者选项 → 自定义服务器"入口
- Cupertino 风格的服务器配置页面
- 实时配置验证和错误提示
- 中英文双语支持
- **依赖注入改造**
- `ApiClient.java` 新增 `UrlProvider` 接口
- 支持运行时动态切换服务器地址
- `MainActivity.java``ComposePilotActivity.kt` 适配新的 URL 提供方式
- **应用启动优化**
- `MulticaApplication.java` 添加配置预加载
- 确保 ApiClient 创建前配置已就绪
#### 记住登录状态功能 (Remember Me)
- **持久化登录选项**
- `AuthStore.java` 新增 `rememberLogin()``setRememberLogin()` 方法
- 登录界面添加"记住登录状态"开关(默认开启)
- 支持中英文:"记住登录状态" / "Remember me"
- **自动登出机制**
- `ComposePilotActivity` 新增 `onStop()` 生命周期管理
- 未勾选"记住登录"时,应用退出自动清空 token
- 勾选时保持登录状态持久化
### Changed
#### 网络安全配置
- **HTTP 支持**
- `app/build.gradle` - 将 `usesCleartextTraffic``false` 改为 `true`
- 支持自托管场景使用 HTTP 协议(包括本地测试)
- 允许连接到非 HTTPS 的自定义服务器
#### 登录流程优化
- 保存配置时会自动清空旧 token,要求重新登录(安全考虑)
- 重置服务器配置时同样清空 token
### Technical Details
#### 新增文件
```
app/src/main/java/ai/multica/app/
├── ServerConfigManager.kt # 服务器配置管理(DataStore
└── ServerConfigHolder.kt # 全局配置单例
```
#### 修改文件
```
app/build.gradle # DataStore 依赖 + cleartext 流量配置
app/src/main/AndroidManifest.xml # usesCleartextTraffic 占位符
app/src/main/java/ai/multica/app/
├── ApiClient.java # UrlProvider 接口 + 动态 URL
├── AuthStore.java # rememberLogin 存储
├── MainActivity.java # ApiClient 构造注入 UrlProvider
├── MulticaApplication.java # 配置预加载
└── ComposePilotActivity.kt # 服务器配置 UI + 记住登录 UI + onStop
```
#### 新增依赖
```gradle
implementation "androidx.datastore:datastore-preferences:1.0.0"
```
#### 代码统计
- 新增代码:约 450 行
- 修改代码:约 80 行
- 新增文件:2 个
- 修改文件:7 个
### Security Considerations
1. **服务器切换安全**
- 切换服务器时强制清空 token,防止 token 泄露到错误服务器
- 配置保存后要求用户重新登录验证
2. **HTTP 流量警告**
- 允许 cleartext HTTP 是为了支持自托管和本地测试
- 生产环境建议使用 HTTPS
3. **登录状态管理**
- 默认"记住登录"开启,保持用户体验
- 提供选项关闭,支持共享设备场景
### User Experience
#### 自定义服务器工作流
1. 登录页或设置页点击"自定义服务器"
2. 简单模式:输入域名(如 `example.com`)自动派生三个 URL
3. 高级模式:手动配置每个 URL(支持非标准端口)
4. 保存后自动跳转登录页
5. 使用自定义服务器账号登录
#### 记住登录工作流
1. 登录时勾选/取消"记住登录状态"
2. 勾选:关闭应用后保持登录
3. 不勾选:关闭应用后自动登出
### Known Issues & Limitations
1. **构建环境**
- Gradle wrapper 在某些 macOS 环境下可能遇到权限问题
- 建议使用 Android Studio 进行构建
2. **服务器兼容性**
- 假设自托管服务完全兼容 Multica API
- 未实现 API 版本检测和兼容性验证
3. **连通性测试**
- 保存配置时仅做 URL 格式验证
- 不验证服务器可访问性(运行时连接失败才报错)
### Future Improvements (TODO)
- [ ] 添加服务器连通性测试(保存前验证)
- [ ] 使用 CompositionLocal 替代全局单例 `ServerConfigHolder`
- [ ] 实现 API 版本检测和兼容性提示
- [ ] 配置导入导出功能(便于团队共享)
- [ ] 多环境预设配置模板
### Testing Checklist
- [x] 简单模式域名派生 URL
- [x] 高级模式手动配置 URL
- [x] 重置为默认服务器
- [x] 配置持久化(应用重启)
- [x] 登录页服务器配置入口
- [x] 设置页服务器配置入口
- [x] HTTP cleartext 流量支持
- [x] 记住登录状态 UI
- [ ] 记住登录功能测试(需要编译安装)
- [ ] 自动登出测试(需要编译安装)
- [ ] 自定义服务器登录测试(需要自托管实例)
---
## Notes
本次更新主要面向以下用户场景:
1. **企业自托管部署** - 使用自有服务器运行 Multica
2. **本地开发测试** - 连接本地开发环境进行调试
3. **共享设备使用** - 支持不记住登录状态,保护隐私
所有功能均保持向后兼容,默认使用 Multica 公开服务器,普通用户无感知。
+8 -33
View File
@@ -1,21 +1,15 @@
<div align="center">
# Multi-Casual Android
An independent Android companion app for Multica users.
[![release](https://img.shields.io/badge/release-v0.1.5-E4DCC5?style=flat-square&labelColor=2B2A28)](https://github.com/park0er/multi-casual-android/releases/latest) [![cn site](https://img.shields.io/badge/cn%20site-multi--casual--china--8temdtzs.zh--cn.edgeone.cool-E4DCC5?style=flat-square&labelColor=2B2A28)](https://multi-casual-china-8temdtzs.zh-cn.edgeone.cool/) [![global site](https://img.shields.io/badge/global%20site-multi--casual--android.pages.dev-E4DCC5?style=flat-square&labelColor=2B2A28)](https://multi-casual-android.pages.dev/)
English | [简体中文](#简体中文)
</div>
An independent Android companion app for Multica users.
This repository publishes an Android app that works with the Multica backend service. It is not maintained, endorsed, or published by the Multica project maintainers, and it should not be presented as an Android app from the upstream Multica project.
The app exists for users who already use Multica and want the core workspace workflow on Android: issue browsing, issue details, comments, projects, agents, skills, labels, and settings.
- Multica website: https://multica.ai
- Multica web app: https://app.multica.ai
- Service API used by this app: `https://api.multica.ai`
- Android package: `ai.multicasual.app`
@@ -25,7 +19,7 @@ Multica is the original product and backend service. Multi-Casual Android is an
The name “Multi-Casual” is used here to describe this Android client and its compatibility with Multica workflows. Product ownership, trademarks, service availability, and backend behavior remain with the Multica project and its maintainers.
This project follows the public guidance shared by the Multica maintainers about keeping Android app work independent and clearly attributed. See the related discussion: https://github.com/multica-ai/multica/issues/2285#issuecomment-4414513596
If you represent Multica and would like wording or attribution adjusted, please open an issue. The goal is to keep the relationship accurate, respectful, and clear.
## What this app does
@@ -46,16 +40,6 @@ After consent, the public Android build reports only the `app_open` event:
You can also build the app without analytics keys. In that case, analytics providers will not start.
## Download signed APK
For normal installation, download the signed release APK from GitHub Releases:
- Latest release: https://github.com/park0er/multi-casual-android/releases/latest
- Current signed APK: `Multi-Casual-Android-v0.1.5.apk`
- SHA256: `3f50276216d866c0e405e1e99f38f2f2c1c3d5b4a4e79dc092495c7c6a3ebe49`
The debug APK produced by local development commands is not intended for public distribution.
## Build
Requirements:
@@ -71,7 +55,7 @@ cp local.properties.example local.properties
Optional analytics keys can be filled in locally. Do not commit real keys.
Build a local debug APK for development:
Build the Android APK:
```bash
JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" \
@@ -131,6 +115,7 @@ If you distribute a modified APK or other binary based on this project, keep the
这个 App 面向已经使用 Multica 的用户,把核心 workspace 工作流带到 Android 上:Issue 浏览、Issue 详情、评论、项目、Agent、Skill、Label 和设置等能力。
- Multica 网站:https://multica.ai
- Multica Web 应用:https://app.multica.ai
- 本 App 使用的服务 API`https://api.multica.ai`
- Android 包名:`ai.multicasual.app`
@@ -140,7 +125,7 @@ Multica 是原始产品和后端服务。Multi-Casual Android 是面向该服务
这里使用 “Multi-Casual” 名称,是为了说明这个 Android 客户端及其与 Multica 工作流的兼容关系。产品归属、商标、服务可用性和后端行为都仍属于 Multica 项目及其维护者。
这个项目遵循了 Multica maintainers 在公开 issue 中给出的 guidanceAndroid app 工作应保持独立维护、清晰署名并尊重原项目边界。相关讨论见:https://github.com/multica-ai/multica/issues/2285#issuecomment-4414513596
如果你代表 Multica,并希望调整措辞或署名方式,欢迎开 issue。这个仓库希望把关系说明得准确、礼貌、清楚。
## App 能做什么
@@ -157,20 +142,10 @@ Analytics 是 opt-in。App 会先请求用户同意,然后才会上报 analyti
- 中国大陆路线:友盟
- 全球路线:PostHog
- App 只上报打开事件,绝不会上报页面访问、聊天内容、文件内容、邮箱、token 或精确位置等任何其余信息,源码公开可扫描隐私保证
- App 不会有意上报页面访问、聊天内容、文件内容、邮箱、token 或精确位置
如果本地不配置 analytics keyanalytics provider 不会启动。
## 下载签名 APK
普通用户安装时,请从 GitHub Releases 下载签名 release APK
- 最新 releasehttps://github.com/park0er/multi-casual-android/releases/latest
- 当前签名 APK`Multi-Casual-Android-v0.1.5.apk`
- SHA256`3f50276216d866c0e405e1e99f38f2f2c1c3d5b4a4e79dc092495c7c6a3ebe49`
本地开发命令生成的 debug APK 不用于公开分发。
## 构建
环境要求:
@@ -186,7 +161,7 @@ cp local.properties.example local.properties
可选 analytics key 只填在本地。不要提交真实 key。
构建本地开发用 debug APK
构建 Android APK
```bash
JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" \
+6
View File
@@ -19,6 +19,12 @@ This notice is informational and does not replace the license text shipped by ea
| PostHog Android SDK | `com.posthog:posthog-android`, `com.posthog:posthog` | MIT |
| Guava listenablefuture placeholder | `com.google.guava:listenablefuture` | Apache-2.0; Maven POM has limited license metadata |
## Website dependencies
The website source uses React, Vite, Motion, TypeScript, Lightning CSS, and related build-time packages. The lockfile currently contains MIT, Apache-2.0, MPL-2.0, ISC, BSD-3-Clause, and 0BSD packages.
These website dependencies are not bundled into the Android APK.
## Notes for redistributors
If you redistribute a binary build, keep this file, the GPL-3.0-only `LICENSE`, and any dependency notices required by the upstream libraries you include.
+3 -4
View File
@@ -51,8 +51,8 @@ android {
applicationId "ai.multicasual.app"
minSdk 24
targetSdk 36
versionCode 3
versionName "0.1.2"
versionCode 2
versionName "0.1.1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
@@ -89,7 +89,7 @@ android {
applicationId "ai.multicasual.app"
resValue "string", "app_name", "Multi-Casual"
manifestPlaceholders = [
usesCleartextTraffic: "true",
usesCleartextTraffic: "false",
deepLinkScheme: "multi-casual",
umengDeepLinkScheme: "um.${umengPublicAppKey}"
]
@@ -122,7 +122,6 @@ dependencies {
publicImplementation "com.umeng.umsdk:common:9.9.2"
publicImplementation "com.umeng.umsdk:asms:1.8.7.2"
publicImplementation "com.posthog:posthog-android:3.47.0"
implementation "androidx.datastore:datastore-preferences:1.1.1"
implementation platform("androidx.compose:compose-bom:2024.10.01")
implementation "androidx.activity:activity-compose:1.9.3"
implementation "androidx.compose.foundation:foundation"
@@ -21,18 +21,10 @@ final class ApiClient {
T parse(JSONObject json) throws Exception;
}
interface UrlProvider {
String getApiBaseUrl();
String getWebBaseUrl();
String getWsBaseUrl();
}
private final AuthStore authStore;
private final UrlProvider urlProvider;
ApiClient(AuthStore authStore, UrlProvider urlProvider) {
ApiClient(AuthStore authStore) {
this.authStore = authStore;
this.urlProvider = urlProvider;
}
void sendCode(String email) throws Exception {
@@ -756,18 +748,6 @@ final class ApiClient {
return parseArray(extractArray(json, "agents"), Models.Agent::new);
}
List<Models.Squad> squads(String workspaceId) throws Exception {
return squads(workspaceId, false);
}
List<Models.Squad> squads(String workspaceId, boolean includeArchived) throws Exception {
JSONObject query = query(null, "workspace_id", workspaceId);
if (includeArchived) query.put("include_archived", "true");
JSONObject json = requestObject("GET", "/api/squads", query, null);
return parseArray(extractArray(json, "squads"), Models.Squad::new);
}
List<Models.Runtime> runtimes(String workspaceId) throws Exception {
JSONObject json = requestObject("GET", "/api/runtimes", query(null, "workspace_id", workspaceId), null);
return parseArray(extractArray(json, "runtimes"), Models.Runtime::new);
@@ -1387,7 +1367,7 @@ final class ApiClient {
private JSONObject requestMultipart(String path, JSONObject query, JSONObject fields, String fieldName, String filename,
String contentType, byte[] data) throws Exception {
String boundary = "Boundary-" + System.currentTimeMillis();
URL url = new URL(urlProvider.getApiBaseUrl() + path + queryString(query));
URL url = new URL(BuildConfig.MULTICA_API_BASE_URL + path + queryString(query));
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("POST");
conn.setConnectTimeout(15000);
@@ -1448,7 +1428,7 @@ final class ApiClient {
private JSONObject requestObject(String method, String path, JSONObject query, JSONObject body,
Map<String, String> extraHeaders, int connectTimeoutMs, int readTimeoutMs) throws Exception {
URL url = new URL(urlProvider.getApiBaseUrl() + path + queryString(query));
URL url = new URL(BuildConfig.MULTICA_API_BASE_URL + path + queryString(query));
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod(method);
conn.setConnectTimeout(connectTimeoutMs);
@@ -1,86 +0,0 @@
package ai.multica.app;
import java.net.URI;
final class AttachmentPreview {
static final String BASE_URL = "https://attachment-preview.local/";
enum Kind {
MARKDOWN,
HTML,
XML,
DOWNLOAD
}
private AttachmentPreview() {}
static Kind kindFor(String contentType, String filename) {
String type = mediaType(contentType);
if (isMarkdownType(type)) return Kind.MARKDOWN;
if (isHtmlType(type)) return Kind.HTML;
if (isXmlType(type)) return Kind.XML;
String name = filename == null ? "" : filename.trim().toLowerCase(java.util.Locale.ROOT);
if (name.endsWith(".md") || name.endsWith(".markdown")) return Kind.MARKDOWN;
if (name.endsWith(".html") || name.endsWith(".htm")) return Kind.HTML;
if (name.endsWith(".xml")) return Kind.XML;
return Kind.DOWNLOAD;
}
static boolean isLocalPreviewUrl(String url) {
if (url == null || url.trim().isEmpty()) return false;
try {
URI uri = URI.create(url.trim());
String scheme = uri.getScheme();
String host = uri.getHost();
return "https".equalsIgnoreCase(scheme) && "attachment-preview.local".equalsIgnoreCase(host);
} catch (IllegalArgumentException ignored) {
return false;
}
}
static boolean isWebViewInternalDocumentUrl(String url) {
if (url == null) return false;
String value = url.trim().toLowerCase(java.util.Locale.ROOT);
return value.equals("about:blank") || value.startsWith("data:text/html");
}
static String xmlPreviewDocument(String xml) {
return "<!doctype html><html><head><meta charset=\"utf-8\">"
+ "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">"
+ "<style>body{margin:0;padding:16px;background:#fff;color:#111;font:14px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace;}pre{white-space:pre-wrap;word-break:break-word;margin:0;}</style>"
+ "</head><body><pre>"
+ escapeHtml(xml == null ? "" : xml)
+ "</pre></body></html>";
}
private static String mediaType(String contentType) {
if (contentType == null) return "";
int semicolon = contentType.indexOf(';');
String value = semicolon >= 0 ? contentType.substring(0, semicolon) : contentType;
return value.trim().toLowerCase(java.util.Locale.ROOT);
}
private static boolean isMarkdownType(String type) {
return "text/markdown".equals(type)
|| "text/x-markdown".equals(type)
|| "application/markdown".equals(type)
|| "application/x-markdown".equals(type);
}
private static boolean isHtmlType(String type) {
return "text/html".equals(type) || "application/xhtml+xml".equals(type);
}
private static boolean isXmlType(String type) {
return "text/xml".equals(type) || "application/xml".equals(type) || type.endsWith("+xml");
}
private static String escapeHtml(String value) {
return value.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&#39;");
}
}
@@ -13,7 +13,6 @@ final class AuthStore {
private static final String LANGUAGE = "language";
private static final String INBOX_CACHE_PREFIX = "inbox_cache_";
private static final String CLOUDFRONT_COOKIE_HEADER = "cloudfront_cookie_header";
private static final String REMEMBER_LOGIN = "remember_login";
private final SharedPreferences prefs;
@@ -83,14 +82,6 @@ final class AuthStore {
prefs.edit().putString(CLOUDFRONT_COOKIE_HEADER, joinCookieHeader(cookies)).apply();
}
boolean rememberLogin() {
return prefs.getBoolean(REMEMBER_LOGIN, true);
}
void setRememberLogin(boolean remember) {
prefs.edit().putBoolean(REMEMBER_LOGIN, remember).apply();
}
private static Map<String, String> parseCookieHeader(String header) {
Map<String, String> cookies = new LinkedHashMap<>();
if (header == null || header.trim().isEmpty()) return cookies;
@@ -1,57 +0,0 @@
package ai.multica.app;
final class ChatExperiencePolicy {
private static final float CHAT_FOCUSED_COMPOSER_RATIO = 0.22f;
private static final float ISSUE_FOCUSED_COMPOSER_RATIO = 0.18f;
private static final int MIN_CHAT_FOCUSED_COMPOSER_HEIGHT = 132;
private static final int MAX_CHAT_FOCUSED_COMPOSER_HEIGHT = 184;
private static final int MIN_ISSUE_FOCUSED_COMPOSER_HEIGHT = 112;
private static final int MAX_ISSUE_FOCUSED_COMPOSER_HEIGHT = 152;
enum Route {
CHAT,
ISSUE_COMMENT,
ISSUE_REPLY
}
private ChatExperiencePolicy() {
}
static String newChatInitialTitle() {
return "";
}
static String firstChatTitle(String content) {
String clean = content == null ? "" : content.trim();
if (clean.isEmpty()) return "";
StringBuilder title = new StringBuilder();
clean.codePoints().limit(15).forEach(title::appendCodePoint);
return title.toString();
}
static boolean requiresTitleBeforeEnteringChat() {
return false;
}
static int focusedComposerHeightPx(Route route, int usableHeightPx) {
int height = Math.max(0, usableHeightPx);
if (route == Route.ISSUE_COMMENT || route == Route.ISSUE_REPLY) {
int target = Math.round(height * ISSUE_FOCUSED_COMPOSER_RATIO);
return clamp(target, MIN_ISSUE_FOCUSED_COMPOSER_HEIGHT, MAX_ISSUE_FOCUSED_COMPOSER_HEIGHT);
}
int target = Math.round(height * CHAT_FOCUSED_COMPOSER_RATIO);
return clamp(target, MIN_CHAT_FOCUSED_COMPOSER_HEIGHT, MAX_CHAT_FOCUSED_COMPOSER_HEIGHT);
}
static int focusedComposerHeightPx(int usableHeightPx) {
return focusedComposerHeightPx(Route.CHAT, usableHeightPx);
}
static boolean isFocusedComposerRoute(Route route) {
return route == Route.CHAT || route == Route.ISSUE_COMMENT || route == Route.ISSUE_REPLY;
}
private static int clamp(int value, int min, int max) {
return Math.max(min, Math.min(max, value));
}
}
File diff suppressed because it is too large Load Diff
@@ -13,83 +13,44 @@ import java.util.regex.Pattern;
final class AgentMentionMarkdown {
static String markdown(String name, String agentId) {
return mentionMarkdown("agent", name, agentId);
String cleanName = name == null ? "" : name.trim()
.replace("[", "\\[")
.replace("]", "\\]");
if (cleanName.isEmpty()) cleanName = Models.shortId(agentId);
return "[@" + cleanName + "](mention://agent/" + agentId + ")";
}
static String memberMarkdown(String name, String memberId) {
return mentionMarkdown("member", name, memberId);
}
static String squadMarkdown(String name, String squadId) {
return mentionMarkdown("squad", name, squadId);
}
static String displayText(String name, String id) {
static String displayText(String name, String agentId) {
String cleanName = cleanName(name);
if (cleanName.isEmpty()) cleanName = Models.shortId(id);
if (cleanName.isEmpty()) cleanName = Models.shortId(agentId);
return "@" + cleanName;
}
static String markdownFromDraft(String draft, List<Models.Agent> agents) {
return markdownFromDraft(draft, agents, Collections.emptyList(), Collections.emptyList());
}
static String markdownFromDraft(String draft, List<Models.Agent> agents,
List<Models.Member> members, List<Models.Squad> squads) {
String content = draft == null ? "" : draft.trim();
if (content.isEmpty() || content.contains("mention://")) return content;
ArrayList<MentionReplacement> replacements = new ArrayList<>();
if (agents != null) {
for (Models.Agent agent : agents) {
replacements.add(new MentionReplacement(displayText(agent.name, agent.id), markdown(agent.name, agent.id)));
if (content.isEmpty() || content.contains("mention://agent/") || agents == null || agents.isEmpty()) {
return content;
}
}
if (members != null) {
for (Models.Member member : members) {
replacements.add(new MentionReplacement(displayText(member.displayName, member.id), memberMarkdown(member.displayName, member.id)));
if (member.userId != null && !member.userId.isEmpty() && !member.userId.equals(member.id)) {
replacements.add(new MentionReplacement(displayText(member.displayName, member.userId), memberMarkdown(member.displayName, member.userId)));
}
}
}
if (squads != null) {
for (Models.Squad squad : squads) {
replacements.add(new MentionReplacement(displayText(squad.name, squad.id), squadMarkdown(squad.name, squad.id)));
}
}
replacements.sort((left, right) -> Integer.compare(right.display.length(), left.display.length()));
ArrayList<Models.Agent> sortedAgents = new ArrayList<>(agents);
sortedAgents.sort((left, right) -> Integer.compare(
displayText(right.name, right.id).length(),
displayText(left.name, left.id).length()
));
String normalized = content;
for (MentionReplacement replacement : replacements) {
if (replacement.display.length() <= 1) continue;
normalized = normalized.replace(replacement.display, replacement.markdown);
for (Models.Agent agent : sortedAgents) {
String display = displayText(agent.name, agent.id);
if (display.length() <= 1) continue;
normalized = normalized.replace(display, markdown(agent.name, agent.id));
}
return normalized;
}
private static String mentionMarkdown(String type, String name, String id) {
String cleanName = cleanName(name)
.replace("[", "\\[")
.replace("]", "\\]");
if (cleanName.isEmpty()) cleanName = Models.shortId(id);
return "[@" + cleanName + "](mention://" + type + "/" + id + ")";
}
private static String cleanName(String name) {
if (name == null) return "";
String trimmed = name.trim();
return "null".equalsIgnoreCase(trimmed) ? "" : trimmed;
}
private static final class MentionReplacement {
final String display;
final String markdown;
MentionReplacement(String display, String markdown) {
this.display = display;
this.markdown = markdown;
}
}
private AgentMentionMarkdown() {
}
}
@@ -99,19 +60,13 @@ final class IssueCommentRichText {
static String mentionDisplayLabel(String mentionType, String mentionId, String fallbackLabel,
List<Models.Member> members, List<Models.Agent> agents,
List<Models.Squad> squads, Models.User currentUser) {
String resolved = resolveMentionName(mentionType, mentionId, members, agents, squads, currentUser);
Models.User currentUser) {
String resolved = resolveMentionName(mentionType, mentionId, members, agents, currentUser);
String label = resolved.isEmpty() ? cleanMentionFallback(fallbackLabel, mentionId) : resolved;
if (label.startsWith("@")) return label;
return "@" + label;
}
static String mentionDisplayLabel(String mentionType, String mentionId, String fallbackLabel,
List<Models.Member> members, List<Models.Agent> agents,
Models.User currentUser) {
return mentionDisplayLabel(mentionType, mentionId, fallbackLabel, members, agents, Collections.emptyList(), currentUser);
}
static int issueIdentifierEndAt(String value, int start) {
if (value == null || start < 0 || start >= value.length()) return -1;
Matcher matcher = ISSUE_IDENTIFIER.matcher(value);
@@ -150,7 +105,7 @@ final class IssueCommentRichText {
private static String resolveMentionName(String mentionType, String mentionId,
List<Models.Member> members, List<Models.Agent> agents,
List<Models.Squad> squads, Models.User currentUser) {
Models.User currentUser) {
String type = mentionType == null ? "" : mentionType.toLowerCase(Locale.US);
String id = mentionId == null ? "" : mentionId;
if ("agent".equals(type)) {
@@ -164,11 +119,6 @@ final class IssueCommentRichText {
if (id.equals(member.id) || id.equals(member.userId)) return clean(member.displayName);
}
}
if ("squad".equals(type)) {
for (Models.Squad squad : squads) {
if (id.equals(squad.id)) return clean(squad.name);
}
}
for (Models.Agent agent : agents) {
if (id.equals(agent.id)) return clean(agent.name);
}
@@ -176,9 +126,6 @@ final class IssueCommentRichText {
for (Models.Member member : members) {
if (id.equals(member.id) || id.equals(member.userId)) return clean(member.displayName);
}
for (Models.Squad squad : squads) {
if (id.equals(squad.id)) return clean(squad.name);
}
return "";
}
@@ -253,9 +200,7 @@ final class IssueCommentThreads {
ArrayList<Thread> threads = new ArrayList<>();
for (Models.Comment root : roots) {
ArrayList<Models.Comment> replies = repliesByRoot.getOrDefault(clean(root.id), new ArrayList<>());
replies.sort((left, right) -> descending
? compareCreatedAt(right, left)
: compareCreatedAt(left, right));
replies.sort(IssueCommentThreads::compareCreatedAt);
threads.add(new Thread(root, replies));
}
return threads;
@@ -1,28 +0,0 @@
package ai.multica.app;
import org.json.JSONObject;
final class IssuePayloads {
private IssuePayloads() {
}
static JSONObject createIssue(String title, String description, String workspaceId, String projectId,
String teamId, String status, String priority, Models.Assignee assignee,
String dueDate, String parentIssueId) throws Exception {
JSONObject body = new JSONObject();
body.put("title", title);
body.put("description", description == null || description.isEmpty() ? JSONObject.NULL : description);
body.put("workspace_id", workspaceId);
if (projectId != null) body.put("project_id", projectId);
if (teamId != null && !teamId.trim().isEmpty()) body.put("team_id", teamId.trim());
if (parentIssueId != null && !parentIssueId.isEmpty()) body.put("parent_issue_id", parentIssueId);
if (status != null) body.put("status", status);
if (priority != null) body.put("priority", priority);
if (dueDate != null && !dueDate.isEmpty()) body.put("due_date", dueDate); else body.put("due_date", JSONObject.NULL);
if (assignee != null && assignee.id != null) {
body.put("assignee_id", assignee.id);
body.put("assignee_type", assignee.type);
}
return body;
}
}
@@ -62,7 +62,6 @@ public final class MainActivity extends Activity {
private List<Models.Project> projectCache = new ArrayList<>();
private List<Models.Member> memberCache = new ArrayList<>();
private List<Models.Agent> agentCache = new ArrayList<>();
private List<Models.Squad> squadCache = new ArrayList<>();
private List<Models.Runtime> runtimeCache = new ArrayList<>();
private List<Models.Skill> skillCache = new ArrayList<>();
private List<Models.Autopilot> autopilotCache = new ArrayList<>();
@@ -88,20 +87,7 @@ public final class MainActivity extends Activity {
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
authStore = new AuthStore(this);
api = new ApiClient(authStore, new ApiClient.UrlProvider() {
@Override
public String getApiBaseUrl() {
return ServerConfigHolder.INSTANCE.get().getApiBaseUrl();
}
@Override
public String getWebBaseUrl() {
return ServerConfigHolder.INSTANCE.get().getWebBaseUrl();
}
@Override
public String getWsBaseUrl() {
return ServerConfigHolder.INSTANCE.get().getWsBaseUrl();
}
});
api = new ApiClient(authStore);
zh = authStore.isChinese();
demoMode = getIntent() != null && getIntent().getBooleanExtra("demo", false);
if (getIntent() != null && getIntent().getData() != null) handleDeepLink(getIntent().getData());
@@ -280,7 +266,6 @@ public final class MainActivity extends Activity {
projectCache = new ArrayList<>();
memberCache = new ArrayList<>();
agentCache = new ArrayList<>();
squadCache = new ArrayList<>();
runtimeCache = new ArrayList<>();
skillCache = new ArrayList<>();
autopilotCache = new ArrayList<>();
@@ -580,7 +565,6 @@ public final class MainActivity extends Activity {
projectCache = data.projects;
memberCache = data.members;
agentCache = data.agents;
try { squadCache = safeSquads(); } catch (Exception ignored) { squadCache = new ArrayList<>(); }
LinearLayout screen = vertical();
LinearLayout scrollBody = vertical();
scrollBody.setPadding(dp(14), dp(8), dp(14), dp(14));
@@ -1849,10 +1833,6 @@ public final class MainActivity extends Activity {
}
}
private List<Models.Squad> safeSquads() {
try { return api.squads(currentWorkspace.id); } catch (Exception e) { return new ArrayList<>(); }
}
private List<Models.Agent> safeAgents() {
if (currentWorkspace == null) return Models.emptyList();
try {
@@ -1874,7 +1854,7 @@ public final class MainActivity extends Activity {
private List<Models.Assignee> assignees(boolean includeEmpty) {
if (memberCache.isEmpty()) memberCache = safeMembers();
if (agentCache.isEmpty()) agentCache = safeAgents();
return Models.issueAssignees(includeEmpty, t("unassigned"), currentUser, memberCache, agentCache, squadCache);
return Models.issueAssignees(includeEmpty, t("unassigned"), currentUser, memberCache, agentCache);
}
private String assigneeName(String id, String type) {
@@ -1882,10 +1862,7 @@ public final class MainActivity extends Activity {
if (currentUser != null && currentUser.id.equals(id)) return currentUser.name;
for (Models.Member member : memberCache) if (member.id.equals(id)) return member.displayName;
for (Models.Agent agent : agentCache) if (agent.id.equals(id)) return agent.name;
String prefix;
if ("agent".equals(type)) prefix = t("agent");
else if ("squad".equals(type)) prefix = t("squad");
else prefix = t("member");
String prefix = "agent".equals(type) ? t("agent") : t("member");
return prefix + " " + Models.shortId(id);
}
@@ -2275,7 +2252,6 @@ public final class MainActivity extends Activity {
case "unassigned": return "未分配";
case "member": return "成员";
case "agent": return "Agent";
case "squad": return "小队";
case "archive": return "归档";
case "archived": return "已归档";
case "archiveAgent": return "归档 Agent";
@@ -2388,7 +2364,6 @@ public final class MainActivity extends Activity {
case "unassigned": return "Unassigned";
case "member": return "Member";
case "agent": return "Agent";
case "squad": return "Squad";
case "archive": return "Archive";
case "archived": return "Archived";
case "archiveAgent": return "Archive Agent";
+2 -61
View File
@@ -166,17 +166,13 @@ final class Models {
return subscriber.userType == null ? "" : subscriber.userType;
}
static String issueAssigneeDisplayName(Issue issue, List<Member> members, List<Agent> agents, List<Squad> squads, User currentUser) {
static String issueAssigneeDisplayName(Issue issue, List<Member> members, List<Agent> agents, User currentUser) {
if (issue == null || issue.assigneeId == null || issue.assigneeId.isEmpty()) return "";
String assigneeId = issue.assigneeId;
if ("agent".equals(issue.assigneeType)) {
for (Agent agent : agents) {
if (assigneeId.equals(agent.id)) return agent.name;
}
} else if ("squad".equals(issue.assigneeType)) {
for (Squad squad : squads) {
if (assigneeId.equals(squad.id)) return squad.name;
}
} else {
if (currentUser != null && assigneeId.equals(currentUser.id)) return currentUser.name;
for (Member member : members) {
@@ -189,16 +185,11 @@ final class Models {
for (Member member : members) {
if (assigneeId.equals(member.id) || assigneeId.equals(member.userId)) return member.displayName;
}
for (Squad squad : squads) {
if (assigneeId.equals(squad.id)) return squad.name;
}
String prefix;
if ("agent".equals(issue.assigneeType)) {
prefix = "Agent ";
} else if ("member".equals(issue.assigneeType)) {
prefix = "Member ";
} else if ("squad".equals(issue.assigneeType)) {
prefix = "Squad ";
} else {
prefix = "";
}
@@ -206,7 +197,7 @@ final class Models {
}
static List<Assignee> issueAssignees(boolean includeEmpty, String emptyLabel, User currentUser,
List<Member> members, List<Agent> agents, List<Squad> squads) {
List<Member> members, List<Agent> agents) {
List<Assignee> list = new ArrayList<>();
List<String> seenMemberIds = new ArrayList<>();
if (includeEmpty) list.add(new Assignee(null, null, emptyLabel));
@@ -223,35 +214,6 @@ final class Models {
if (agent.id == null || agent.id.isEmpty()) continue;
list.add(new Assignee(agent.id, "agent", agent.name));
}
for (Squad squad : squads) {
if (squad.id == null || squad.id.isEmpty()) continue;
list.add(new Assignee(squad.id, "squad", squad.name));
}
return list;
}
static List<Assignee> issueFormAssigneeOptions(String emptyLabel, User currentUser,
List<Member> members, List<Agent> agents, List<Squad> squads) {
List<Assignee> list = new ArrayList<>();
list.add(new Assignee(null, null, emptyLabel));
for (Squad squad : squads) {
if (squad.id == null || squad.id.isEmpty()) continue;
list.add(new Assignee(squad.id, "squad", squad.name));
}
for (Agent agent : agents) {
if (agent.id == null || agent.id.isEmpty()) continue;
list.add(new Assignee(agent.id, "agent", agent.name));
}
List<String> seenMemberIds = new ArrayList<>();
if (currentUser != null && currentUser.id != null && !currentUser.id.isEmpty()) {
list.add(new Assignee(currentUser.id, "member", currentUser.name));
seenMemberIds.add(currentUser.id);
}
for (Member member : members) {
if (member.id == null || member.id.isEmpty() || seenMemberIds.contains(member.id)) continue;
list.add(new Assignee(member.id, "member", member.displayName));
seenMemberIds.add(member.id);
}
return list;
}
@@ -794,27 +756,6 @@ final class Models {
}
}
static final class Squad {
final String id;
final String name;
final String description;
final String avatarUrl;
final String archivedAt;
final List<String> agentIds;
final List<String> memberIds;
Squad(JSONObject json) {
id = json.optString("id");
name = json.optString("name", shortId(id));
description = json.optString("description", "");
avatarUrl = cleanString(json, "avatar_url", cleanString(json, "avatarUrl", ""));
archivedAt = cleanString(json, "archived_at", "");
agentIds = stringList(json.optJSONArray("agent_ids"));
memberIds = stringList(json.optJSONArray("member_ids"));
}
}
static final class Runtime {
final String id;
final String workspaceId;
@@ -3,16 +3,9 @@ package ai.multica.app;
import android.app.Application;
public final class MulticaApplication extends Application {
private ServerConfigManager configManager;
@Override
public void onCreate() {
super.onCreate();
// Preload server configuration to memory
configManager = new ServerConfigManager(this);
configManager.preloadToMemory();
AppAnalytics.initializeIfAllowed(this);
}
}
@@ -1,20 +0,0 @@
package ai.multica.app
/**
* Global singleton to provide synchronous access to server configuration.
* Updated by ServerConfigManager on app startup and when config changes.
*
* TODO: Replace with CompositionLocal for better Compose integration.
* This is a temporary solution to avoid async DataStore reads on every API call.
*/
object ServerConfigHolder {
private var cached: ServerConfig = ServerConfig.getDefault()
@Synchronized
fun get(): ServerConfig = cached
@Synchronized
fun update(newConfig: ServerConfig) {
cached = newConfig
}
}
@@ -1,127 +0,0 @@
package ai.multica.app
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
private val Context.serverConfigDataStore: DataStore<Preferences> by preferencesDataStore(
name = "multica_server_config"
)
data class ServerConfig(
val apiBaseUrl: String,
val webBaseUrl: String,
val wsBaseUrl: String,
val isCustomServer: Boolean = false
) {
companion object {
fun getDefault(): ServerConfig = ServerConfig(
apiBaseUrl = "https://api.multica.ai",
webBaseUrl = "https://app.multica.ai",
wsBaseUrl = "wss://api.multica.ai",
isCustomServer = false
)
fun fromDomain(domain: String): ServerConfig {
val cleanDomain = domain.trim()
.removePrefix("http://")
.removePrefix("https://")
.removePrefix("ws://")
.removePrefix("wss://")
return ServerConfig(
apiBaseUrl = "https://api.$cleanDomain",
webBaseUrl = "https://app.$cleanDomain",
wsBaseUrl = "wss://api.$cleanDomain",
isCustomServer = true
)
}
}
}
class ServerConfigManager(private val context: Context) {
private val dataStore = context.serverConfigDataStore
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private object Keys {
val API_BASE_URL = stringPreferencesKey("api_base_url")
val WEB_BASE_URL = stringPreferencesKey("web_base_url")
val WS_BASE_URL = stringPreferencesKey("ws_base_url")
}
suspend fun getServerConfig(): ServerConfig {
val preferences = dataStore.data.first()
val apiBaseUrl = preferences[Keys.API_BASE_URL]
val webBaseUrl = preferences[Keys.WEB_BASE_URL]
val wsBaseUrl = preferences[Keys.WS_BASE_URL]
return if (apiBaseUrl != null && webBaseUrl != null && wsBaseUrl != null) {
ServerConfig(
apiBaseUrl = apiBaseUrl,
webBaseUrl = webBaseUrl,
wsBaseUrl = wsBaseUrl,
isCustomServer = true
)
} else {
ServerConfig.getDefault()
}
}
suspend fun saveCustomServer(
apiBaseUrl: String,
webBaseUrl: String,
wsBaseUrl: String
) {
validateUrls(apiBaseUrl, webBaseUrl, wsBaseUrl)
dataStore.edit { preferences ->
preferences[Keys.API_BASE_URL] = apiBaseUrl.trim()
preferences[Keys.WEB_BASE_URL] = webBaseUrl.trim()
preferences[Keys.WS_BASE_URL] = wsBaseUrl.trim()
}
}
suspend fun resetToDefault() {
dataStore.edit { preferences ->
preferences.remove(Keys.API_BASE_URL)
preferences.remove(Keys.WEB_BASE_URL)
preferences.remove(Keys.WS_BASE_URL)
}
}
fun preloadToMemory() {
scope.launch {
try {
val config = getServerConfig()
ServerConfigHolder.update(config)
} catch (e: Exception) {
// Fallback to default on error
ServerConfigHolder.update(ServerConfig.getDefault())
}
}
}
private fun validateUrls(apiBaseUrl: String, webBaseUrl: String, wsBaseUrl: String) {
require(apiBaseUrl.isNotBlank()) { "API base URL cannot be empty" }
require(webBaseUrl.isNotBlank()) { "Web base URL cannot be empty" }
require(wsBaseUrl.isNotBlank()) { "WebSocket base URL cannot be empty" }
require(apiBaseUrl.matches(Regex("^https?://.*"))) {
"API base URL must start with http:// or https://"
}
require(webBaseUrl.matches(Regex("^https?://.*"))) {
"Web base URL must start with http:// or https://"
}
require(wsBaseUrl.matches(Regex("^wss?://.*"))) {
"WebSocket base URL must start with ws:// or wss://"
}
}
}
@@ -61,7 +61,6 @@ import com.airbnb.lottie.compose.rememberLottieComposition
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.graphicsLayer
@@ -1021,8 +1020,6 @@ data class MulticaCupertinoActionSheetItem(
val title: String,
val subtitle: String? = null,
val icon: ImageVector? = null,
val badgeText: String? = null,
val badgeColor: Color = MulticaColors.Accent,
val contentDescription: String? = null,
val selected: Boolean = false,
val destructive: Boolean = false,
@@ -1058,10 +1055,6 @@ fun MulticaCupertinoActionSheet(
)
},
message = {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (!message.isNullOrBlank()) {
Text(
text = message,
@@ -1069,59 +1062,22 @@ fun MulticaCupertinoActionSheet(
color = MulticaColors.TextTertiary,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(8.dp))
}
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 420.dp),
) {
items(items.size) { index ->
val item = items[index]
MulticaCupertinoActionSheetItemContent(
item = item,
},
buttons = {
items.forEach { item ->
action(
onClick = {
if (!item.enabled) return@MulticaCupertinoActionSheetItemContent
performMulticaTapFeedback(haptic)
item.onClick()
onDismissRequest()
},
)
}
}
}
},
buttons = {
if (!cancelText.isNullOrBlank()) {
action(
onClick = {
performMulticaTapFeedback(haptic)
onDismissRequest()
},
style = AlertActionStyle.Cancel,
enabled = true,
style = if (item.destructive) AlertActionStyle.Destructive else AlertActionStyle.Default,
enabled = item.enabled,
) {
Text(
text = cancelText,
style = MaterialTheme.typography.bodyMedium.copy(fontSize = 15.sp, fontWeight = FontWeight.SemiBold),
color = MulticaColors.Accent,
)
}
}
},
)
}
@Composable
private fun MulticaCupertinoActionSheetItemContent(
item: MulticaCupertinoActionSheetItem,
onClick: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = item.enabled, onClick = onClick)
.padding(horizontal = 12.dp, vertical = 11.dp)
.semantics(mergeDescendants = true) {
contentDescription = "Multica Cupertino Action Sheet ${item.contentDescription ?: item.title}"
},
@@ -1136,24 +1092,6 @@ private fun MulticaCupertinoActionSheetItemContent(
modifier = Modifier.size(16.dp),
)
}
if (!item.badgeText.isNullOrBlank()) {
Text(
text = item.badgeText,
modifier = Modifier
.clip(RoundedCornerShape(999.dp))
.background(item.badgeColor.copy(alpha = 0.16f))
.border(1.dp, item.badgeColor.copy(alpha = 0.32f), RoundedCornerShape(999.dp))
.padding(horizontal = 7.dp, vertical = 3.dp),
style = MaterialTheme.typography.labelSmall.copy(
fontSize = 10.sp,
lineHeight = 12.sp,
fontWeight = FontWeight.SemiBold,
),
color = item.badgeColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = item.title,
@@ -1187,6 +1125,26 @@ private fun MulticaCupertinoActionSheetItemContent(
)
}
}
}
}
if (!cancelText.isNullOrBlank()) {
action(
onClick = {
performMulticaTapFeedback(haptic)
onDismissRequest()
},
style = AlertActionStyle.Cancel,
enabled = true,
) {
Text(
text = cancelText,
style = MaterialTheme.typography.bodyMedium.copy(fontSize = 15.sp, fontWeight = FontWeight.SemiBold),
color = MulticaColors.Accent,
)
}
}
},
)
}
@Composable
@@ -1654,7 +1612,6 @@ fun MulticaCupertinoTextField(
singleLine: Boolean = false,
minLines: Int = 1,
maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
onFocusedChange: ((Boolean) -> Unit)? = null,
) {
val fieldDescription = contentDescription ?: "Multica Cupertino Text Field $label"
val centerSingleLineContent = singleLine || minLines <= 1
@@ -1694,8 +1651,7 @@ fun MulticaCupertinoTextField(
onValueChange = onValueChange,
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.onFocusChanged { onFocusedChange?.invoke(it.isFocused) },
.wrapContentHeight(),
enabled = enabled,
singleLine = singleLine,
minLines = minLines,
@@ -1738,7 +1694,6 @@ fun MulticaTextField(
singleLine: Boolean = false,
minLines: Int = 1,
maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
onFocusedChange: ((Boolean) -> Unit)? = null,
) {
MulticaCupertinoTextField(
value = value,
@@ -1752,7 +1707,6 @@ fun MulticaTextField(
singleLine = singleLine,
minLines = minLines,
maxLines = maxLines,
onFocusedChange = onFocusedChange,
)
}
-21
View File
@@ -1,21 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Multi-Casual Android</title>
<link rel="canonical" href="https://park0er.github.io/multi-casual-android/">
<meta name="description" content="Main landing page for Multi-Casual Android, an independent Android companion app for Multica users.">
<style>body{margin:0;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;background:#fff;color:#111}a{color:#2563eb}main{max-width:960px;margin:0 auto;padding:48px 24px}h1{font-size:2rem}p{line-height:1.6;margin:12px 0}footer{margin-top:64px;color:#555}</style>
</head>
<body>
<main>
<h1>Multi-Casual Android</h1>
<p>Main site is published at <a href="https://multi-casual-android.pages.dev">https://multi-casual-android.pages.dev</a> and <a href="https://multi-casual-china-8temdtzs.zh-cn.edgeone.cool/">https://multi-casual-china-8temdtzs.zh-cn.edgeone.cool/</a>.</p>
<p>You can also install the signed APK directly from <a href="https://github.com/park0er/multi-casual-android/releases/download/v0.1.4/Multi-Casual-Android-v0.1.4.apk">GitHub Releases</a>.</p>
</main>
<footer>
<p>Site placeholder published from the public Android source repository.</p>
</footer>
</body>
</html>
-5
View File
@@ -1,5 +0,0 @@
node_modules/
dist/
.edgeone/
*.tsbuildinfo
public/downloads/*.apk
-78
View File
@@ -1,78 +0,0 @@
# site-internal — Multica Xiaomi internal landing page
A separate Vite + React landing page for the **Multica Xiaomi** internal
Android build. The public site at [`../site/`](../site/) targets the
Multi-Casual public release; this directory is a sibling project
published under the same GitHub Pages site at `/xiaomi/`.
## Visual scope
This page is a near-clone of the public site (`site/src/`) with three
intentional differences:
1. The "Relationship to Multica" section is removed.
2. The source link points to the private company GitLab project.
The signed APK download points to a separate artifact-only GitHub
release repository.
3. The iOS App button is left untouched and still points at the
public iOS repo — the same `github.com/park0er/multi-casual-ios-app`
source-of-truth is reused on purpose.
All screenshots, the promo video, the journey / workspace interactions,
the language toggle, and the layout are reused from `site/src/assets/`
so the visual stays in sync with the public site.
## Release metadata
`src/App.tsx` points the primary APK download button at the static
GitHub release asset:
```text
https://github.com/park0er/multica-xiaomi-apk/releases/download/v0.1.5-xiaomi/Multica-Xiaomi-v0.1.5.apk
```
The APK is copied from `app/build/outputs/apk/xiaomi/release/` into
`site-internal/public/downloads/` before deployment. Current metadata:
| Field | Value |
|---|---|
| `release.sha256` | `13e004472d33e66aa40aa7ebd58f75e22cfde8cce3a153729481f1be4d2d1414` |
| `release.gitlabUrl` | `https://git.n.xiaomi.com/zhaoxisheng/multica-xiaomi-android` |
| `release.releasesUrl` | `https://github.com/park0er/multica-xiaomi-apk/releases/download/v0.1.5-xiaomi/Multica-Xiaomi-v0.1.5.apk` |
Also confirm the `release.apiUrl` matches the staging or production
Xiaomi Multica endpoint you want this build to talk to
(`http://staging-multica.ad.xiaomi.srv` is the build.gradle default).
## Build
```bash
cd site-internal
npm install
npm run build
```
Output goes to `site-internal/dist/` and is gitignored.
## Deploy
The site is hosted as a subdirectory of the public GitHub Pages site:
```
https://park0er.github.io/multi-casual-android/xiaomi/
```
GitHub Actions builds the public site into the Pages root and this
internal Xiaomi site into `dist/xiaomi/` before deploying.
### Publish flow
1. Copy `site-internal/` into the public repo.
2. Commit and push the public repo.
3. GitHub Actions deploys both `site/` and `site-internal/`.
### EdgeOne status
Tencent EdgeOne hosting was attempted first but proved unreliable for
this internal distribution. The current source of truth for downloads is
GitHub Pages.
-14
View File
@@ -1,14 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Multica Xiaomi is the Xiaomi-flavor Android build of the Multica companion app, distributed internally via the company GitLab." />
<link rel="icon" href="./favicon.png" type="image/png" />
<title>Multica Xiaomi · Internal Build</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
-1001
View File
File diff suppressed because it is too large Load Diff
-22
View File
@@ -1,22 +0,0 @@
{
"name": "multica-xiaomi-internal-site",
"private": true,
"version": "0.1.0",
"scripts": {
"dev": "vite --host 127.0.0.1",
"build": "tsc -b && vite build",
"preview": "vite preview --host 127.0.0.1"
},
"dependencies": {
"@vitejs/plugin-react": "latest",
"motion": "latest",
"react": "latest",
"react-dom": "latest",
"typescript": "latest",
"vite": "latest"
},
"devDependencies": {
"@types/react": "^19.2.16",
"@types/react-dom": "^19.2.3"
}
}
-77
View File
@@ -1,77 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta http-equiv="refresh" content="0.8; url=https://park0er.github.io/multi-casual-android/xiaomi/" />
<title>跳转到 Multica Xiaomi</title>
<style>
:root {
color-scheme: light;
--ink: #101218;
--muted: #5b6472;
--blue: #1647ff;
--paper: #f6f8fc;
--card: #ffffff;
--line: rgba(16, 18, 24, 0.12);
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--paper);
color: var(--ink);
}
main {
width: min(520px, calc(100vw - 32px));
padding: 32px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--card);
box-shadow: 0 24px 70px rgba(16, 18, 24, 0.12);
}
h1 {
margin: 0 0 12px;
font-size: 26px;
line-height: 1.2;
letter-spacing: 0;
}
p {
margin: 0 0 24px;
color: var(--muted);
line-height: 1.7;
font-size: 15px;
}
a {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
padding: 0 18px;
border-radius: 999px;
background: var(--blue);
color: #fff;
text-decoration: none;
font-weight: 700;
}
code {
display: block;
margin-top: 18px;
overflow-wrap: anywhere;
color: var(--muted);
font-size: 12px;
}
</style>
</head>
<body>
<main>
<h1>正在跳转到 Multica Xiaomi</h1>
<p>这是小米内网入口,目标页面托管在 GitHub Pages。</p>
<a href="https://park0er.github.io/multi-casual-android/xiaomi/">立即打开</a>
<code>https://park0er.github.io/multi-casual-android/xiaomi/</code>
</main>
</body>
</html>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

-662
View File
@@ -1,662 +0,0 @@
import logoImg from "./assets/logo.png";
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { motion, AnimatePresence, useMotionValue, useTransform, useReducedMotion } from "motion/react";
import inboxImg from "./assets/screenshots/01-inbox.jpg";
import issuesListImg from "./assets/screenshots/02-issues-list.jpg";
import issuesBoardImg from "./assets/screenshots/03-issues-board.jpg";
import issueDetailImg from "./assets/screenshots/04-issue-detail.jpg";
import issueSubImg from "./assets/screenshots/05-issue-detail-subissues.jpg";
import commentImg from "./assets/screenshots/06-comment-composer.jpg";
import mentionImg from "./assets/screenshots/07-mention-picker.jpg";
import projectsImg from "./assets/screenshots/08-projects-list.jpg";
import projectDetailImg from "./assets/screenshots/09-project-detail.jpg";
import agentsImg from "./assets/screenshots/10-agents-list.jpg";
import runtimesImg from "./assets/screenshots/11-runtimes-list.jpg";
import skillsImg from "./assets/screenshots/12-skills-list.jpg";
import skillDetailImg from "./assets/screenshots/13-skill-detail.jpg";
import settingsImg from "./assets/screenshots/14-settings.jpg";
import promoVideo from "./assets/video/multica-android-promo-draft.mp4";
const release = {
packageName: "ai.multica.app.xiaomi",
sha256: "13e004472d33e66aa40aa7ebd58f75e22cfde8cce3a153729481f1be4d2d1414",
gitlabUrl: "https://git.n.xiaomi.com/zhaoxisheng/multica-xiaomi-android",
releasesUrl: "https://github.com/park0er/multica-xiaomi-apk/releases/download/v0.1.5-xiaomi/Multica-Xiaomi-v0.1.5.apk",
iosUrl: "https://github.com/park0er/multi-casual-ios-app",
multicaUrl: "https://multica.ai",
apiUrl: "http://staging-multica.ad.xiaomi.srv",
};
type Locale = "en" | "zh";
const copy: Record<Locale, {
nav: { brand: string; download: string; trust: string; language: string };
hero: { eyebrow: string; title: string; subtitle: string; download: string; gitlab: string; multica: string; ios: string };
// relationship section intentionally removed for the internal build
journey: { kicker: string; title: string; subtitle: string; cta: string; steps: Array<{ title: string; subtitle: string }> };
workspace: { kicker: string; title: string; subtitle: string; items: Array<{ title: string; subtitle: string }> };
video: { kicker: string; title: string; body: string };
download: { kicker: string; title: string; package: string; api: string; sha: string; latest: string; source: string; note: string };
trust: { kicker: string; items: string[] };
footer: { tagline: string; multica: string; gitlab: string; ios: string; download: string };
}> = {
en: {
nav: { brand: "Multica Xiaomi", download: "Download", trust: "Scope", language: "中文" },
hero: {
eyebrow: "Internal Xiaomi build · Company-only distribution",
title: "Multica Xiaomi for the Xiaomi workspace.",
subtitle: "An Android app independently built from scratch by Zhao Xisheng in Xiaomi's Commercial Platform Department. Multica has not provided an official Android app to date; this internal build gives Xiaomi users a native path today.",
download: "Download APK",
gitlab: "GitLab",
multica: "Visit Multica",
ios: "iOS App",
},
journey: {
kicker: "Interactive walkthrough",
title: "Try the flow.",
subtitle: "Click each step or scroll to see the app in action.",
cta: "Start the walkthrough",
steps: [
{ title: "Inbox notifications", subtitle: "Stay on top of activity — new issues, comments, and agent progress show up as they happen." },
{ title: "Browse issues", subtitle: "See all tasks in a clean list view, ready for triage. Filter, search, and tap into any issue." },
{ title: "Switch to board view", subtitle: "Tap the view toggle to switch to a kanban board that shows status flow at a glance." },
{ title: "Open issue detail", subtitle: "Tap an issue card to see the full detail: title, status, labels, assignee, and body text." },
{ title: "Explore context", subtitle: "Context guide points zoom into focus areas — title and status, body content, then subtasks and comments — and return to the full view between each." },
{ title: "Scroll for more", subtitle: "Keep scrolling to see subtasks, subscribers, and the comment thread — all the structured context stays on one page." },
{ title: "Draft a comment", subtitle: "Tap the comment box. The keyboard appears and a comment types out character by character." },
{ title: "Mention anyone", subtitle: "Type @ and a picker appears with people, agents, and squads. Choose who to notify." },
{ title: "Workspace overview", subtitle: "Switch between Projects, Agents, Runtimes, Skills, and Settings with workspace chips." },
],
},
workspace: {
kicker: "Workspace assets",
title: "Projects, agents, and everything in between.",
subtitle: "Switch between workspace views to see the full picture.",
items: [
{ title: "Projects", subtitle: "Browse projects with progress tracking" },
{ title: "Project detail", subtitle: "Task stats, status fields, and resources" },
{ title: "Agents", subtitle: "View agent status and recent activity" },
{ title: "Runtimes", subtitle: "Check runtime availability and versions" },
{ title: "Skills", subtitle: "Browse the workspace agent skill library" },
{ title: "Skill detail", subtitle: "Inspect skill structure and documentation" },
{ title: "Settings", subtitle: "Account, workspace, and preference controls" },
],
},
video: { kicker: "Product demo", title: "See it in 30 seconds.", body: "A fast walkthrough of inbox, issues, context, comments, mentions, and workspace views." },
download: { kicker: "Internal build", title: "Download the Xiaomi internal APK from GitHub Pages.", package: "Package", api: "Service API", sha: "APK SHA256", latest: "Download APK", source: "View source", note: "Internal distribution only — not for external release." },
trust: {
kicker: "Internal build scope",
items: [
"Designed and implemented from scratch by Zhao Xisheng of Xiaomi's Commercial Platform Department, with no upstream Android app to inherit from.",
"Multica has not provided an official Android app to date; this project is an independent Xiaomi internal Android client for Multica workflows.",
"Company-internal distribution only. APK download is served from a separate GitHub release artifact repository after EdgeOne proved unreliable.",
"Connects to the Xiaomi Multica service endpoint, with analytics routed through OneTrack and disabled for Umeng / PostHog.",
"Source is kept in the private company GitLab project; the signed APK is distributed from GitHub Pages.",
],
},
footer: { tagline: "Built from scratch by Zhao Xisheng for the Xiaomi workspace. APK served via GitHub Pages.", multica: "Multica website", gitlab: "GitLab", ios: "iOS App", download: "Download APK" },
},
zh: {
nav: { brand: "Multica Xiaomi", download: "下载", trust: "分发说明", language: "English" },
hero: {
eyebrow: "小米内部构建 · 仅限公司内部分发",
title: "面向小米工作区的 Multica Xiaomi 内部版。",
subtitle: "由小米商业平台部赵锡盛完全从头自研的 Android App。Multica 官方到目前为止还没有提供 Android App;这套内部构建先为小米用户补上原生移动端入口。",
download: "下载 APK",
gitlab: "GitLab",
multica: "访问 Multica",
ios: "iOS App",
},
journey: {
kicker: "交互式演示",
title: "试试这个流程。",
subtitle: "点击每个步骤,或滚动页面查看 app 实际操作。",
cta: "开始演示",
steps: [
{ title: "Inbox 通知", subtitle: "新 issues、评论和 agent 进展会实时出现在收件箱,不错过任何动态。" },
{ title: "浏览 Issues", subtitle: "在列表视图中查看所有任务,快速筛选和进入任意 issue。" },
{ title: "切换看板视图", subtitle: "点击视图切换按钮,看板视图让你一眼看到任务状态流。" },
{ title: "打开 Issue 详情", subtitle: "点击 issue 卡片查看完整详情:标题、状态、标签、负责人和正文。" },
{ title: "探索上下文", subtitle: "Context 引导点依次放大标题与状态、正文内容、子任务与评论,然后缩回全貌。" },
{ title: "滚动查看更多", subtitle: "继续下滑查看子任务、订阅者和评论线程 —— 结构化上下文都在同一页。" },
{ title: "草拟评论", subtitle: "点击评论框,键盘出现,评论逐字输入。" },
{ title: "@ 提及任何人", subtitle: "输入 @ 弹出选择器,可选人、agent 或小队。" },
{ title: "Workspace 总览", subtitle: "通过 workspace chips 切换查看 Projects、Agents、Runtimes、Skills 和 Settings。" },
],
},
workspace: {
kicker: "Workspace 资产",
title: "项目、agents,以及它们之间的一切。",
subtitle: "切换 workspace 视图,掌握全貌。",
items: [
{ title: "Projects", subtitle: "浏览项目和进度追踪" },
{ title: "项目详情", subtitle: "任务统计、状态字段和资源入口" },
{ title: "Agents", subtitle: "查看 agent 状态和最近活动" },
{ title: "Runtimes", subtitle: "检查运行时可用性和版本" },
{ title: "Skills", subtitle: "浏览 workspace agent 技能库" },
{ title: "技能详情", subtitle: "查看技能结构和文档" },
{ title: "Settings", subtitle: "账号、工作区和偏好控制" },
],
},
video: { kicker: "产品演示", title: "30 秒看完核心流程。", body: "快速演示 inbox 通知、issues 看板、上下文探索、评论回复、@ 提及和 workspace 视图切换。" },
download: { kicker: "内部构建", title: "从 GitHub Pages 下载小米内部 APK。", package: "Package", api: "Service API", sha: "APK SHA256", latest: "下载 APK", source: "查看源码", note: "仅供公司内部分发,不对外发布。" },
trust: {
kicker: "内部构建说明",
items: [
"由小米商业平台部赵锡盛完全从头设计和实现,不是基于 Multica 官方 Android 客户端改造。",
"Multica 官方到目前为止还没有提供 Android App;本项目是面向小米内部 Multica 工作流的独立 Android 客户端。",
"仅供公司内部分发;由于腾讯云 EdgeOne 访问不稳定,APK 下载改由单独的 GitHub Release 产物仓库托管提供。",
"连接小米 Multica 服务端点,统计走 OneTrackUmeng / PostHog 已关闭。",
"源码保存在私有公司 GitLab 项目中;签名 APK 从 GitHub Pages 分发。",
],
},
footer: { tagline: "商业平台部赵锡盛从头自研,APK 由 GitHub Pages 分发。", multica: "Multica 官网", gitlab: "GitLab", ios: "iOS App", download: "下载 APK" },
},
};
const JOURNEY_STEPS = 9;
const TYPING_TEXT = "Looks good. I'll ask the agent to check the failing case and post a follow-up.";
const MENTION_ITEMS = [
{ name: "codex", color: "#1647ff" },
{ name: "Opus", color: "#7c3aed" },
{ name: "RollieOC", color: "#059669" },
];
const WORKSPACE_SCREENSHOTS = [projectsImg, projectDetailImg, agentsImg, runtimesImg, skillsImg, skillDetailImg, settingsImg];
const fade = (reduced: boolean, delay = 0) => ({
initial: reduced ? {} : { opacity: 0, y: 18 },
whileInView: { opacity: 1, y: 0 },
viewport: { once: true, margin: "-60px" },
transition: { duration: 0.65, delay, ease: [0.25, 0.1, 0.25, 1] as const },
});
const reveal = (reduced: boolean) => ({
initial: reduced ? {} : { opacity: 0, y: 20 },
whileInView: { opacity: 1, y: 0 },
viewport: { once: true, margin: "-80px" },
transition: { duration: 0.7, ease: [0.25, 0.1, 0.25, 1] as const },
});
/* ── PhoneDemo ── */
function PhoneDemo({
src,
reduced,
zoomScale = 1,
zoomX = 0,
zoomY = 0,
children,
}: {
src: string;
reduced: boolean;
zoomScale?: number;
zoomX?: number;
zoomY?: number;
children?: React.ReactNode;
}) {
return (
<div className="phone-shell-compact">
<div className="speaker" />
<div className="phone-screen-compact">
<AnimatePresence mode="wait">
<motion.img
key={src}
src={src}
alt="App screenshot"
initial={reduced ? {} : { opacity: 0 }}
animate={{
opacity: 1,
scale: zoomScale,
x: zoomX,
y: zoomY,
}}
exit={reduced ? {} : { opacity: 0 }}
transition={{ duration: reduced ? 0.1 : 0.4, ease: [0.25, 0.1, 0.25, 1] as const }}
/>
</AnimatePresence>
{children}
</div>
</div>
);
}
/* ── Typewriter effect ── */
function TypewriterEffect({ active, text, reduced }: { active: boolean; text: string; reduced: boolean }) {
const charCount = useMotionValue(0);
const chars = useMemo(() => text.split(""), [text]);
const charRefs = useRef<(HTMLSpanElement | null)[]>([]);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Imperatively update char opacity from motion value (no hooks in loops)
useEffect(() => {
const unsubscribe = charCount.on("change", (v) => {
const count = Math.round(v);
charRefs.current.forEach((el, i) => {
if (el) el.style.opacity = i < count ? "1" : "0";
});
});
return unsubscribe;
}, [charCount]);
useEffect(() => {
if (intervalRef.current) clearInterval(intervalRef.current);
if (!active) { charCount.set(0); charRefs.current.forEach((el) => { if (el) el.style.opacity = "0"; }); return; }
if (reduced) {
charCount.set(chars.length);
charRefs.current.forEach((el, i) => { if (el) el.style.opacity = i < chars.length ? "1" : "0"; });
return;
}
let i = 0;
const interval = setInterval(() => {
i++;
charCount.set(i);
if (i >= chars.length) clearInterval(interval);
}, 42);
intervalRef.current = interval;
return () => clearInterval(interval);
}, [active, reduced, chars.length]);
return (
<motion.div
className="typing-line"
animate={active ? { opacity: 1 } : { opacity: 0 }}
transition={{ duration: 0.3 }}
>
{chars.map((char, i) => (
<span
key={i}
ref={(el) => { charRefs.current[i] = el; }}
style={{ opacity: 0 }}
>
{char === " " ? "\u00A0" : char}
</span>
))}
<motion.span
className="typing-cursor"
animate={active ? { opacity: [1, 0.18] } : { opacity: 0 }}
transition={active ? { duration: 0.64, repeat: Infinity, repeatType: "reverse" } : {}}
/>
</motion.div>
);
}
/* ── PhoneMockup (hero) ── */
function PhoneMockup({ reduced }: { reduced: boolean }) {
return (
<div className="hero-phone-stack">
<motion.img
src={inboxImg} alt="Inbox preview"
initial={reduced ? {} : { opacity: 0, x: 60, rotate: -14 }}
whileInView={{ opacity: 0.72, x: 0, rotate: -8 }}
viewport={{ once: true }}
transition={{ duration: 0.85, delay: 0.2, ease: [0.25, 0.1, 0.25, 1] as const }}
/>
<motion.img
src={issuesListImg} alt="Issues list preview"
initial={reduced ? {} : { opacity: 0, x: 80, rotate: 8 }}
whileInView={{ opacity: 1, x: 0, rotate: 4 }}
viewport={{ once: true }}
transition={{ duration: 0.85, delay: 0.3, ease: [0.25, 0.1, 0.25, 1] as const }}
/>
<motion.img
src={issueDetailImg} alt="Issue detail preview"
initial={reduced ? {} : { opacity: 0, x: 60, rotate: 14 }}
whileInView={{ opacity: 0.86, x: 0, rotate: 10 }}
viewport={{ once: true }}
transition={{ duration: 0.85, delay: 0.4, ease: [0.25, 0.1, 0.25, 1] as const }}
/>
</div>
);
}
/* ── Main App ── */
export default function App() {
const reduced = useReducedMotion() ?? false;
const [lang, setLang] = useState<Locale>("en");
const [step, setStep] = useState(0);
const [activeWs, setActiveWs] = useState(0);
const [hasInteracted, setHasInteracted] = useState(false);
const completedRef = useRef<Set<number>>(new Set());
const journeyRef = useRef<HTMLDivElement>(null);
const hasAutoPlayedRef = useRef(false);
const t = copy[lang];
// Auto-play journey on first scroll into view
useEffect(() => {
if (hasInteracted || hasAutoPlayedRef.current) return;
const el = journeyRef.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting && entry.intersectionRatio > 0.3 && !hasAutoPlayedRef.current) {
hasAutoPlayedRef.current = true;
let currentStep = 0;
const advance = () => {
currentStep++;
if (currentStep < JOURNEY_STEPS) {
setStep(currentStep);
setTimeout(advance, 2200);
}
};
setTimeout(advance, 2200);
}
},
{ threshold: 0.3 }
);
observer.observe(el);
return () => observer.disconnect();
}, [hasInteracted]);
const handleStepClick = useCallback((index: number) => {
setHasInteracted(true);
setStep(index);
}, []);
const handleWorkspaceClick = useCallback((index: number) => {
setActiveWs(index);
}, []);
// Phone screen source based on journey step
const phoneScreenSrc = useMemo(() => {
if (step <= 1) return step === 0 ? inboxImg : issuesListImg;
if (step === 2) return issuesBoardImg;
if (step <= 5) return step === 3 ? issueDetailImg : issueSubImg;
if (step === 6) return commentImg;
if (step === 7) return mentionImg;
return projectsImg;
}, [step]);
// Focus zoom transform for step 4 (context explore)
const focusZones = [
{ scale: 1.6, x: 0, y: -120 }, // title/status
{ scale: 1.45, x: 0, y: -40 }, // body
{ scale: 1.5, x: 0, y: 80 }, // subtasks/comments
];
const focusIdx = step === 4 ? 0 : -1; // simple: show first zone; cycle handled by auto-play
const zoom = step === 4 ? focusZones[focusIdx] || focusZones[0] : { scale: 1, x: 0, y: 0 };
return (
<main lang={lang === "zh" ? "zh-CN" : "en"}>
{/* Hero */}
<section className="hero">
<div className="hero-bg" />
<motion.nav className="topbar" {...fade(reduced, 0)}>
<div className="brand">
<img src={logoImg} alt="Multica Xiaomi" className="brand-mark" />
<span>{t.nav.brand}</span>
</div>
<div className="nav-links">
<a href="#download">{t.nav.download}</a>
<a href="#trust">{t.nav.trust}</a>
<button className="language-toggle" onClick={() => setLang((l) => (l === "en" ? "zh" : "en"))}>
{t.nav.language}
</button>
</div>
</motion.nav>
<div className="hero-grid">
<div>
<motion.p className="eyebrow" {...fade(reduced, 0.04)}>{t.hero.eyebrow}</motion.p>
<motion.h1 {...fade(reduced, 0.14)}>{t.hero.title}</motion.h1>
<motion.p className="hero-subtitle" {...fade(reduced, 0.24)}>{t.hero.subtitle}</motion.p>
<motion.div className="cta-row" {...fade(reduced, 0.34)}>
<motion.a href="#download" className="pill pill-android" whileHover={{ y: -2 }} whileTap={{ scale: 0.98 }}><span className="android-icon"></span>{t.hero.download}</motion.a>
<motion.a className="button secondary" href={release.gitlabUrl} whileHover={{ y: -2 }} whileTap={{ scale: 0.98 }}><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" style={{verticalAlign: "middle", marginRight: 6}}><path d="M22.65 14.39L12 22.13 1.35 14.39a.84.84 0 0 1-.3-.94l1.22-3.78 2.44-7.51A.42.42 0 0 1 4.82 2a.43.43 0 0 1 .58 0 .42.42 0 0 1 .11.18l2.44 7.49h8.1l2.44-7.51A.42.42 0 0 1 18.6 2a.43.43 0 0 1 .58 0 .42.42 0 0 1 .11.18l2.44 7.51L23 13.45a.84.84 0 0 1-.35.94z"/></svg>{t.hero.gitlab}</motion.a>
<motion.a className="button ghost" href={release.multicaUrl} whileHover={{ y: -2 }} whileTap={{ scale: 0.98 }}>{t.hero.multica}</motion.a>
<motion.a href={release.iosUrl} className="pill pill-ios" whileHover={{ y: -2 }} whileTap={{ scale: 0.98 }}><span className="apple-icon"></span>{t.hero.ios}</motion.a>
</motion.div>
</div>
<PhoneMockup reduced={reduced} />
</div>
</section>
{/* (Relationship section removed for the internal build.) */}
{/* Interactive Product Journey */}
<section ref={journeyRef} className="journey section-pad">
<motion.div className="journey-header" {...reveal(reduced)}>
<div className="section-kicker">{t.journey.kicker}</div>
<h2>{t.journey.title}</h2>
<p>{t.journey.subtitle}</p>
</motion.div>
<div className="journey-layout">
<div className="journey-steps">
{t.journey.steps.map((s, i) => (
<motion.div
key={i}
className={`journey-step ${step === i ? "journey-step-active" : ""} ${i < step ? "journey-step-completed" : ""}`}
onClick={() => handleStepClick(i)}
initial={reduced ? {} : { opacity: 0, x: -16 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: i * 0.06 }}
>
<div className="journey-step-number">{i < step ? "✓" : i + 1}</div>
<div className="journey-step-content">
<h3>{s.title}</h3>
<p>{s.subtitle}</p>
</div>
</motion.div>
))}
</div>
<div className="journey-phone-area">
<div className="journey-phone-container">
<PhoneDemo
src={phoneScreenSrc}
reduced={reduced}
zoomScale={step === 4 ? zoom.scale : 1}
zoomX={step === 4 ? zoom.x : 0}
zoomY={step === 4 ? zoom.y : 0}
>
{/* Hand + tap ripple for interactive steps */}
{step >= 1 && step <= 3 && (
<>
<motion.div
className="journey-hand"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
style={{ top: step === 1 ? "16%" : step === 2 ? "8%" : "35%", right: step === 2 ? "18%" : "22%" }}
>👆</motion.div>
<motion.div
className="journey-tap-ripple"
animate={{ scale: [1, 1.3, 1], opacity: [0.8, 0.3, 0.8] }}
transition={{ duration: 1.2, repeat: Infinity }}
style={{ top: step === 1 ? "18%" : step === 2 ? "10%" : "37%", right: step === 2 ? "14%" : "18%" }}
/>
</>
)}
{/* Typing effect for step 6 */}
{step === 6 && <TypewriterEffect active text={TYPING_TEXT} reduced={reduced} />}
{/* Mention picker overlay for step 7 */}
<AnimatePresence>
{step === 7 && (
<motion.div
className="mention-overlay"
initial={{ y: 60, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 60, opacity: 0 }}
transition={{ duration: 0.35, ease: [0.25, 0.1, 0.25, 1] as const }}
>
<div className="mention-search">
<span>@</span> cod
</div>
<div className="mention-list">
{MENTION_ITEMS.map((m, i) => (
<motion.div
key={m.name}
className={`mention-item ${i === 0 ? "mention-item-active" : ""}`}
initial={{ opacity: 0, x: -12 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.15 + i * 0.1 }}
>
<div className="mention-avatar" style={{ background: m.color }}>{m.name[0].toUpperCase()}</div>
<div>
<div className="mention-name">{m.name}</div>
<div className="mention-role">{i === 0 ? "agent" : i === 1 ? "agent" : "squad"}</div>
</div>
</motion.div>
))}
</div>
</motion.div>
)}
</AnimatePresence>
{/* Workspace chips overlay for step 8 */}
{step === 8 && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
style={{ position: "absolute", bottom: 8, left: 8, right: 8, zIndex: 6, display: "flex", gap: 4, justifyContent: "center", flexWrap: "wrap" }}
>
{["Projects", "Agents", "Runtimes", "Skills", "Settings"].map((label, i) => (
<motion.div
key={label}
className="workspace-chip"
style={{ fontSize: 10, padding: "4px 8px", borderRadius: 999 }}
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 0.1 + i * 0.08 }}
>
{label}
</motion.div>
))}
</motion.div>
)}
</PhoneDemo>
</div>
</div>
</div>
</section>
{/* Workspace Assets Grid */}
<motion.section className="workspace-section section-pad" {...reveal(reduced)}>
<div className="workspace-header">
<div className="section-kicker">{t.workspace.kicker}</div>
<h2>{t.workspace.title}</h2>
<p>{t.workspace.subtitle}</p>
</div>
<div className="workspace-chips">
{t.workspace.items.map((item, i) => (
<button
key={i}
className={`workspace-chip ${activeWs === i ? "workspace-chip-active" : ""}`}
onClick={() => handleWorkspaceClick(i)}
>
{item.title}
</button>
))}
</div>
<div className="workspace-phone-wrap">
<AnimatePresence mode="wait">
<motion.div
key={activeWs}
initial={reduced ? {} : { opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
exit={reduced ? {} : { opacity: 0, scale: 0.96 }}
transition={{ duration: 0.35 }}
>
<PhoneDemo src={WORKSPACE_SCREENSHOTS[activeWs]} reduced={reduced} />
</motion.div>
</AnimatePresence>
</div>
<motion.div className="workspace-card-grid" {...reveal(reduced)}>
{t.workspace.items.map((item, i) => (
<motion.div
key={i}
className={`workspace-card ${activeWs === i ? "workspace-card-active" : ""}`}
onClick={() => handleWorkspaceClick(i)}
initial={reduced ? {} : { opacity: 0, y: 16 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: i * 0.06 }}
>
<img src={WORKSPACE_SCREENSHOTS[i]} alt={item.title} className="workspace-card-thumb" />
<div className="workspace-card-label">
<strong>{item.title}</strong>
<span>{item.subtitle}</span>
</div>
</motion.div>
))}
</motion.div>
</motion.section>
{/* Video Demo */}
<motion.section className="video-demo section-pad" {...reveal(reduced)}>
<div className="section-kicker">{t.video.kicker}</div>
<div className="video-copy">
<h2>{t.video.title}</h2>
<p>{t.video.body}</p>
</div>
<video className="promo-video" src={promoVideo} controls playsInline preload="metadata" />
</motion.section>
{/* Download */}
<section id="download" className="download section-pad">
<motion.div className="release-card" {...reveal(reduced)}>
<div className="section-kicker">{t.download.kicker}</div>
<h2>{t.download.title}</h2>
<div className="release-grid">
<div>
<span>{t.download.package}</span>
<code>{release.packageName}</code>
</div>
<div>
<span>{t.download.api}</span>
<code>{release.apiUrl}</code>
</div>
<div className="hash-cell">
<span>{t.download.sha}</span>
<code>{release.sha256}</code>
</div>
</div>
<pre><code>{`adb install -r Multica-Xiaomi-v0.1.5.apk`}</code></pre>
<div className="cta-row compact">
<a href={release.releasesUrl} className="pill pill-android"><span className="android-icon"></span>{t.download.latest}</a>
<a className="button secondary" href={release.gitlabUrl}>{t.download.source}</a>
</div>
<p className="star-hint">
{lang === "en"
? <>APK downloads are served by a separate GitHub release artifact repository; source lives on the company <a href={release.gitlabUrl} target="_blank" rel="noopener">GitLab</a>.</>
: <>APK GitHub Release <a href={release.gitlabUrl} target="_blank" rel="noopener">GitLab</a></>
}
</p>
</motion.div>
</section>
{/* Trust */}
<section id="trust" className="trust section-pad">
<div className="section-kicker">{t.trust.kicker}</div>
<div className="trust-grid">
{t.trust.items.map((item) => (
<motion.div key={item} className="trust-item" {...reveal(reduced)}>
<span className="check"></span>
<p>{item}</p>
</motion.div>
))}
</div>
</section>
{/* Footer */}
<footer className="footer section-pad">
<div>
<strong>Multica Xiaomi</strong>
<p>{t.footer.tagline}</p>
</div>
<div className="footer-links">
<a href={release.multicaUrl}>{t.footer.multica}</a>
<a href={release.gitlabUrl}>{t.footer.gitlab}</a>
<a href={release.releasesUrl} className="pill pill-android"><span className="android-icon"></span>{t.footer.download}</a>
<a href={release.iosUrl} className="pill pill-ios"><span className="apple-icon"></span>{t.footer.ios}</a>
</div>
</footer>
</main>
);
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

-10
View File
@@ -1,10 +0,0 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
-971
View File
@@ -1,971 +0,0 @@
:root {
--blue: #1647ff;
--blue-2: #0f33c8;
--yellow: #ffe45c;
--ink: #101218;
--muted: #687080;
--paper: #f6f8fc;
--card: #ffffff;
--line: rgba(16, 18, 24, 0.1);
--shadow: 0 24px 70px rgba(16, 18, 24, 0.14);
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
color: var(--ink);
background: var(--paper);
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
margin: 0;
min-width: 320px;
overflow-x: hidden;
background:
radial-gradient(circle at 82% 5%, rgba(255, 228, 92, 0.4), transparent 31%),
radial-gradient(circle at 10% 30%, rgba(22, 71, 255, 0.16), transparent 30%),
linear-gradient(180deg, #fbfcff 0%, #e8eeff 55%, #d4dff9 85%, #c4d4f5 100%);
}
body, button, input { font: inherit; }
button { cursor: pointer; }
a { color: inherit; text-decoration: none; }
.section-pad { padding: 80px clamp(22px, 5vw, 76px); }
.hero {
min-height: 100vh;
position: relative;
overflow: hidden;
padding-top: 26px;
padding-left: clamp(16px, 5vw, 76px);
padding-right: clamp(16px, 5vw, 76px);
}
.hero-bg {
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(16, 18, 24, 0.045) 1px, transparent 1px),
linear-gradient(90deg, rgba(16, 18, 24, 0.045) 1px, transparent 1px);
background-size: 56px 56px;
mask-image: linear-gradient(to bottom, black 0%, transparent 88%);
pointer-events: none;
}
.topbar {
width: min(1160px, 100%);
margin: 0 auto 70px;
position: relative;
z-index: 2;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 12px;
border: 1px solid var(--line);
border-radius: 999px;
background: rgba(255, 255, 255, 0.72);
backdrop-filter: blur(18px);
box-shadow: 0 12px 36px rgba(16, 18, 24, 0.06);
}
.brand, .nav-links { display: flex; align-items: center; gap: 10px; }
.brand { font-weight: 850; letter-spacing: -0.02em; }
.brand-mark {
width: 34px;
height: 34px;
border-radius: 10px;
object-fit: cover;
display: block;
}
.nav-links a {
color: var(--muted);
font-size: 14px;
font-weight: 700;
padding: 8px 10px;
}
.nav-links a:hover { color: var(--ink); }
.language-toggle {
color: var(--blue);
font-size: 14px;
font-weight: 850;
padding: 8px 12px;
border: 1px solid rgba(22, 71, 255, 0.18);
border-radius: 999px;
background: rgba(22, 71, 255, 0.07);
}
.language-toggle:hover { background: rgba(22, 71, 255, 0.12); }
.hero-grid {
width: min(1160px, 100%);
margin: 0 auto;
position: relative;
z-index: 1;
display: grid;
grid-template-columns: minmax(0, 1.06fr) minmax(320px, 0.72fr);
gap: clamp(28px, 7vw, 96px);
align-items: center;
}
.eyebrow, .section-kicker {
margin: 0 0 16px;
color: var(--blue);
font-size: 12px;
font-weight: 900;
letter-spacing: 0.15em;
text-transform: uppercase;
}
h1, h2, h3, p { margin-top: 0; }
h1 {
max-width: 780px;
margin-bottom: 24px;
font-size: clamp(54px, 8.2vw, 112px);
line-height: 0.9;
letter-spacing: -0.075em;
}
h2 {
font-size: clamp(36px, 5vw, 72px);
line-height: 0.94;
letter-spacing: -0.065em;
margin-bottom: 22px;
}
main[lang="zh-CN"] h1 {
max-width: 700px;
font-size: clamp(42px, 6.8vw, 86px);
line-height: 0.98;
letter-spacing: -0.07em;
}
main[lang="zh-CN"] h2 {
font-size: clamp(34px, 4.6vw, 62px);
line-height: 1;
}
h3 {
font-size: 24px;
line-height: 1.08;
letter-spacing: -0.035em;
}
.hero-subtitle, .relationship p, .demo p, .journey-desc {
max-width: 650px;
color: #343b4a;
font-size: clamp(18px, 2vw, 23px);
line-height: 1.48;
}
.cta-row { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 32px; }
.cta-row.compact { margin-top: 22px; }
.button {
font-size: 15px;
display: inline-flex;
min-height: 48px;
align-items: center;
justify-content: center;
border-radius: 999px;
padding: 0 20px;
font-weight: 850;
border: 1px solid transparent;
transition: color 0.18s ease, background 0.18s ease, border-color 0.18s ease;
}
.button.primary { color: white; background: var(--blue); box-shadow: 0 14px 34px rgba(22, 71, 255, 0.32); }
.button.secondary { background: white; border-color: var(--line); }
.button.ghost { color: var(--muted); background: rgba(255, 255, 255, 0.54); border-color: var(--line); }
/* Phone mockup */
.phone-wrap {
width: min(390px, 82vw);
margin: 0 auto;
position: relative;
transform-origin: center;
}
.hero-phone-stack {
min-height: 720px;
position: relative;
perspective: 1200px;
}
.hero-phone-stack img {
position: absolute;
width: min(310px, 54vw);
height: 676px;
border-radius: 46px;
border: 12px solid #11141d;
background: white;
object-fit: cover;
object-position: top center;
box-shadow: var(--shadow);
}
.hero-phone-stack img:nth-child(1) { left: 2%; top: 48px; transform: rotate(-8deg) translateZ(-20px); }
.hero-phone-stack img:nth-child(2) { left: 28%; top: 0; transform: rotate(4deg) translateZ(40px); }
.hero-phone-stack img:nth-child(3) { right: 0; top: 132px; transform: rotate(10deg) translateZ(10px); }
.phone-shell {
position: relative;
padding: 14px;
border-radius: 54px;
background: #11141d;
box-shadow: var(--shadow), inset 0 0 0 2px rgba(255,255,255,0.1);
}
.phone-shell-compact {
position: relative;
padding: 12px;
border-radius: 48px;
background: #11141d;
box-shadow: 0 20px 56px rgba(16, 18, 24, 0.22);
}
.speaker {
position: absolute;
top: 18px;
left: 50%;
width: 72px;
height: 8px;
transform: translateX(-50%);
border-radius: 999px;
background: rgba(255,255,255,0.18);
z-index: 2;
}
.phone-screen {
position: relative;
overflow: hidden;
border-radius: 40px;
background: white;
aspect-ratio: 9 / 19.7;
}
.phone-screen-compact {
position: relative;
overflow: hidden;
border-radius: 36px;
background: white;
aspect-ratio: 9 / 19.7;
}
.phone-screen img,
.phone-screen-compact img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: top center;
display: block;
}
.phone-screen-shot {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
object-position: top center;
}
.tap-ring {
position: absolute;
right: -8px;
bottom: 158px;
width: 76px;
height: 76px;
border-radius: 999px;
border: 2px solid var(--blue);
background: rgba(255, 228, 92, 0.7);
box-shadow: 0 0 0 16px rgba(255, 228, 92, 0.22);
}
/* Shared content sections */
.relationship, .features, .demo, .video-demo, .download, .trust, .footer {
width: min(1160px, calc(100% - 44px));
margin: 0 auto;
padding-left: 0;
padding-right: 0;
}
.two-col, .demo-grid {
display: grid;
grid-template-columns: minmax(0, 0.85fr) minmax(320px, 1fr);
gap: clamp(24px, 5vw, 76px);
align-items: center;
}
/* Relationship section */
.relationship-card {
border: 1px solid var(--line);
border-radius: 30px;
background: rgba(255,255,255,0.76);
box-shadow: 0 18px 48px rgba(16,18,24,0.08);
padding: clamp(28px, 5vw, 48px);
}
.relationship-card p {
max-width: 800px;
margin: 0 0 16px;
font-size: clamp(16px, 1.8vw, 20px);
line-height: 1.65;
color: #343b4a;
}
.relationship-card p:last-child { margin-bottom: 0; }
.relationship-card a {
color: var(--blue);
text-decoration: underline;
text-decoration-color: rgba(22, 71, 255, 0.25);
}
.relationship-card a:hover { text-decoration-color: var(--blue); }
/* Interactive product journey */
.journey {
width: min(1160px, calc(100% - 44px));
margin: 0 auto;
padding: 40px 0;
}
.journey-header {
text-align: center;
margin-bottom: 48px;
}
.journey-header h2 {
max-width: 800px;
margin: 0 auto 16px;
}
.journey-header p {
max-width: 600px;
margin: 0 auto;
color: var(--muted);
font-size: 16px;
}
.journey-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 440px);
gap: clamp(24px, 5vw, 64px);
align-items: start;
}
.journey-steps {
display: flex;
flex-direction: column;
gap: 16px;
}
.journey-step {
display: flex;
gap: 18px;
padding: 20px 24px;
border-radius: 20px;
border: 1px solid var(--line);
background: rgba(255,255,255,0.68);
cursor: pointer;
transition: background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
}
.journey-step:hover {
background: rgba(255,255,255,0.92);
border-color: rgba(22, 71, 255, 0.18);
}
.journey-step-active {
background: white;
border-color: var(--blue);
box-shadow: 0 12px 36px rgba(22, 71, 255, 0.12);
}
.journey-step-completed {
opacity: 0.65;
cursor: default;
}
.journey-step-number {
width: 32px;
height: 32px;
border-radius: 999px;
background: rgba(22, 71, 255, 0.08);
color: var(--blue);
font-size: 14px;
font-weight: 900;
display: grid;
place-items: center;
flex: none;
margin-top: 2px;
}
.journey-step-active .journey-step-number {
background: var(--blue);
color: white;
}
.journey-step-completed .journey-step-number {
background: var(--blue);
color: white;
}
.journey-step-content h3 {
font-size: 18px;
margin-bottom: 6px;
letter-spacing: -0.02em;
}
.journey-step-content p {
font-size: 18px;
line-height: 1.55;
color: var(--muted);
margin: 0;
}
.journey-phone-area {
position: sticky;
top: 40px;
}
.journey-phone-container {
position: relative;
display: flex;
justify-content: center;
}
/* Journey phone overlays */
.journey-overlay {
position: absolute;
inset: 0;
z-index: 6;
border-radius: 36px;
overflow: hidden;
}
.journey-hand {
position: absolute;
z-index: 8;
font-size: 28px;
filter: drop-shadow(0 4px 8px rgba(0,0,0,0.2));
pointer-events: none;
}
.journey-tap-ripple {
position: absolute;
z-index: 7;
width: 52px;
height: 52px;
border-radius: 999px;
border: 2px solid var(--blue);
background: rgba(255, 228, 92, 0.55);
box-shadow: 0 0 0 12px rgba(255, 228, 92, 0.18);
pointer-events: none;
}
/* Focus zoom */
.focus-zoom {
position: absolute;
z-index: 5;
border-radius: 20px;
border: 3px solid var(--blue);
background: rgba(22, 71, 255, 0.06);
pointer-events: none;
}
/* Workspace assets grid */
.workspace-section {
width: min(1160px, calc(100% - 44px));
margin: 0 auto;
}
.workspace-header {
text-align: center;
margin-bottom: 48px;
}
.workspace-header h2 {
max-width: 700px;
margin: 0 auto 16px;
}
.workspace-header p {
max-width: 560px;
margin: 0 auto;
color: var(--muted);
font-size: 16px;
}
.workspace-chips {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 36px;
}
.workspace-chip {
padding: 10px 18px;
border-radius: 999px;
font-size: 14px;
font-weight: 850;
border: 1px solid var(--line);
background: rgba(255,255,255,0.72);
color: var(--muted);
transition: background 0.18s, color 0.18s, border-color 0.18s, box-shadow 0.18s;
}
.workspace-chip:hover {
background: white;
color: var(--ink);
}
.workspace-chip-active {
background: var(--blue);
color: white;
border-color: var(--blue);
box-shadow: 0 10px 24px rgba(22, 71, 255, 0.28);
}
.workspace-phone-wrap {
display: flex;
justify-content: center;
margin-bottom: 48px;
}
.workspace-phone-wrap .phone-shell-compact {
width: min(260px, 60vw);
}
/* Mention picker overlay */
.mention-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
z-index: 10;
background: rgba(255,255,255,0.97);
backdrop-filter: blur(12px);
border-top: 1px solid var(--line);
border-radius: 24px 24px 36px 36px;
padding: 16px 14px;
box-shadow: 0 -12px 36px rgba(16, 18, 24, 0.1);
}
.mention-search {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-radius: 14px;
background: #f4f6fa;
margin-bottom: 14px;
font-size: 14px;
color: var(--muted);
}
.mention-search span {
color: var(--blue);
font-weight: 800;
}
.mention-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.mention-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-radius: 14px;
transition: background 0.12s;
}
.mention-item-active {
background: rgba(22, 71, 255, 0.07);
}
.mention-avatar {
width: 32px;
height: 32px;
border-radius: 999px;
display: grid;
place-items: center;
font-size: 13px;
font-weight: 900;
color: white;
flex: none;
}
.mention-name {
font-size: 14px;
font-weight: 800;
}
.mention-role {
font-size: 12px;
color: var(--muted);
}
.mention-highlight {
position: absolute;
z-index: 5;
border-radius: 16px;
border: 2px solid var(--blue);
background: rgba(22, 71, 255, 0.06);
pointer-events: none;
}
/* Typing demo */
.typing-line {
position: absolute;
z-index: 6;
display: flex;
align-items: center;
gap: 2px;
font-size: 18px;
font-weight: 700;
color: var(--ink);
letter-spacing: -0.01em;
line-height: 1;
white-space: pre;
pointer-events: none;
}
.typing-cursor {
display: inline-block;
width: 2px;
height: 18px;
background: var(--blue);
border-radius: 999px;
margin-left: 1px;
}
/* Features grid (legacy, kept for reference) */
.feature-grid, .trust-grid, .release-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 18px;
}
.feature-card, .trust-item, .release-card {
border: 1px solid var(--line);
border-radius: 30px;
background: rgba(255,255,255,0.76);
box-shadow: 0 18px 48px rgba(16,18,24,0.08);
}
.feature-card { padding: 28px; min-height: 250px; }
.feature-card span { color: var(--blue); font-weight: 950; font-size: 13px; letter-spacing: 0.12em; }
.feature-card p, .trust-item p { color: var(--muted); line-height: 1.65; }
.demo { overflow: hidden; }
.screenshot-stack { min-height: 560px; position: relative; overflow: hidden; border-radius: 42px; }
.shot {
position: absolute;
width: min(310px, 52vw);
height: 620px;
border-radius: 34px;
border: 10px solid #11141d;
box-shadow: var(--shadow);
background: white;
object-fit: cover;
object-position: top center;
}
.shot.back { right: 210px; top: 40px; transform: rotate(-7deg); opacity: 0.78; }
.shot.front { right: 12px; top: 0; transform: rotate(5deg); }
.video-demo {
padding-top: 20px;
}
.video-copy {
display: grid;
grid-template-columns: minmax(0, 0.95fr) minmax(320px, 1fr);
gap: clamp(24px, 5vw, 76px);
align-items: end;
margin-bottom: 28px;
}
.video-copy p {
color: #343b4a;
font-size: clamp(17px, 1.7vw, 21px);
line-height: 1.55;
}
.promo-video, .video-placeholder {
display: block;
width: 100%;
aspect-ratio: 16 / 9;
border: 1px solid var(--line);
border-radius: 34px;
background: #0e1320;
box-shadow: var(--shadow);
}
.promo-video { object-fit: cover; z-index: 1; position: relative; }
.video-placeholder {
display: grid;
place-items: center;
color: white;
background:
radial-gradient(circle at 70% 24%, rgba(255, 228, 92, 0.22), transparent 34%),
linear-gradient(135deg, #0e1320, #172453);
}
.video-placeholder span {
width: 86px;
height: 86px;
display: grid;
place-items: center;
margin-bottom: 18px;
border-radius: 999px;
color: var(--blue);
background: var(--yellow);
font-size: 44px;
font-weight: 950;
box-shadow: 0 18px 42px rgba(255, 228, 92, 0.24);
}
.video-placeholder strong {
display: block;
color: white;
font-size: clamp(22px, 3vw, 42px);
letter-spacing: -0.04em;
}
.download { padding-top: 40px; }
.release-card { padding: clamp(28px, 5vw, 54px); }
.release-grid { margin: 30px 0 22px; }
.release-grid div {
min-width: 0;
padding: 18px;
border-radius: 20px;
background: #f7f9ff;
border: 1px solid rgba(22, 71, 255, 0.13);
}
.release-grid span { display: block; color: var(--muted); font-size: 12px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; margin-bottom: 8px; }
.release-grid code, pre code { font-family: "SFMono-Regular", ui-monospace, Menlo, Consolas, "PingFang SC", monospace; }
.release-grid code { display: block; overflow-wrap: anywhere; color: var(--ink); font-size: 14px; }
.hash-cell { grid-column: span 3; }
pre {
overflow: auto;
margin: 0;
padding: 18px 20px;
border-radius: 20px;
color: #eef4ff;
background: #0e1320;
}
.trust-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.trust-item { display: flex; gap: 14px; padding: 22px; align-items: flex-start; }
.check {
width: 30px;
height: 30px;
display: grid;
place-items: center;
flex: none;
color: white;
border-radius: 999px;
background: var(--blue);
font-weight: 900;
}
.footer {
display: flex;
justify-content: space-between;
gap: 24px;
align-items: flex-start;
color: var(--muted);
border-top: 1px solid var(--line);
}
.footer strong { color: var(--ink); }
.footer-links { display: flex; gap: 18px; flex-wrap: wrap; font-weight: 800; color: var(--ink); }
/* Responsive */
@media (max-width: 900px) {
.topbar { border-radius: 24px; align-items: flex-start; flex-direction: column; }
.nav-links { flex-wrap: wrap; }
.hero-grid, .two-col, .demo-grid, .video-copy, .feature-grid, .trust-grid, .release-grid { grid-template-columns: 1fr; }
.hero { padding-left: 16px; padding-right: 16px; padding-top: 20px; min-height: auto; }
.hero-grid { width: 100%; }
.hero-grid > div:first-child { padding-right: 0; }
.hero-phone-stack { min-height: 400px; overflow: visible; }
.hero-phone-stack img { height: 480px; width: min(240px, 42vw); border-radius: 36px; border-width: 10px; }
.hero-phone-stack img:nth-child(1) { left: -5%; top: 36px; transform: rotate(-8deg) translateZ(-20px); }
.hero-phone-stack img:nth-child(2) { left: 22%; top: 0; transform: rotate(4deg) translateZ(40px); }
.hero-phone-stack img:nth-child(3) { right: -5%; top: 100px; transform: rotate(10deg) translateZ(10px); }
.eyebrow { font-size: 11px; letter-spacing: 0.1em; }
h1 { font-size: clamp(32px, 8vw, 52px) !important; }
.hero-subtitle { font-size: 15px; }
.journey-layout { grid-template-columns: 1fr; }
.journey-phone-area { position: relative; top: 0; order: -1; }
.journey-phone-container { justify-content: center; }
.section-pad { padding: 48px 16px; }
.hash-cell { grid-column: auto; }
.screenshot-stack { min-height: 680px; }
.shot { width: min(300px, 74vw); }
.shot.back { right: auto; left: 4%; }
.shot.front { right: 4%; top: 160px; }
.footer { flex-direction: column; }
.workspace-chips { justify-content: flex-start; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
}
}
/* Workspace card grid */
.workspace-card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 14px;
margin-top: 8px;
}
.workspace-card {
border: 1px solid var(--line);
border-radius: 18px;
background: rgba(255, 255, 255, 0.72);
overflow: hidden;
cursor: pointer;
transition: border-color 0.18s, box-shadow 0.18s, background 0.18s;
}
.workspace-card:hover {
background: white;
border-color: rgba(22, 71, 255, 0.18);
}
.workspace-card-active {
border-color: var(--blue);
box-shadow: 0 10px 28px rgba(22, 71, 255, 0.12);
background: white;
}
.workspace-card-thumb {
width: 100%;
aspect-ratio: 9 / 19.7;
object-fit: cover;
object-position: top center;
display: block;
border-bottom: 1px solid var(--line);
}
.workspace-card-label {
padding: 10px 12px;
}
.workspace-card-label strong {
display: block;
font-size: 13px;
font-weight: 850;
letter-spacing: -0.01em;
margin-bottom: 2px;
}
.workspace-card-label span {
display: block;
font-size: 11px;
color: var(--muted);
line-height: 1.4;
}
@media (max-width: 900px) {
.workspace-card-grid {
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
}
}
.star-hint {
margin-top: 20px;
font-size: 18px;
color: var(--muted);
text-align: center;
font-weight: 600;
}
.star-hint a {
text-decoration: underline;
text-underline-offset: 3px;
}
/* ── Footer pills ── */
.pill {
display: inline-flex; align-items: center; gap: 7px;
padding: 10px 22px; border-radius: 999px;
font-weight: 850; font-size: 15px; letter-spacing: -0.01em;
text-decoration: none; transition: all 0.22s;
}
.pill-android {
background: var(--blue); color: #fff;
box-shadow: 0 12px 30px rgba(22,71,255,0.30);
}
.pill-android:hover { background: var(--blue-2); transform: translateY(-1px); }
.pill-android .android-icon {
width: 15px; height: 15px; display: inline-block; background: #fff;
-webkit-mask: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTYgMThjMCAuNTUuNDUgMSAxIDFoMXYzLjVjMCAuODMuNjcgMS41IDEuNSAxLjVzMS41LS42NyAxLjUtMS41VjE5aDJ2My41YzAgLjgzLjY3IDEuNSAxLjUgMS41czEuNS0uNjcgMS41LTEuNVYxOWgxYy41NSAwIDEtLjQ1IDEtMVY4SDZ2MTB6TTMuNSA4QzIuNjcgOCAyIDguNjcgMiA5LjV2N2MwIC44My42NyAxLjUgMS41IDEuNVM1IDE3LjMzIDUgMTYuNXYtN0M1IDguNjcgNC4zMyA4IDMuNSA4em0xNyAwYy0uODMgMC0xLjUuNjctMS41IDEuNXY3YzAgLjgzLjY3IDEuNSAxLjUgMS41czEuNS0uNjcgMS41LTEuNXYtN2MwLS44My0uNjctMS41LTEuNS0xLjV6bS00Ljk3LTUuODRsMS4zLTEuM2MuMi0uMi4yLS41MSAwLS43MS0uMi0uMi0uNTEtLjItLjcxIDBsLTEuNDggMS40OEE1Ljg0IDUuODQgMCAwMDEyIDFjLS45NiAwLTEuODYuMjMtMi42Ni42M0w3Ljg1LjE1Yy0uMi0uMi0uNTEtLjItLjcxIDAtLjIuMi0uMi41MSAwIC43MWwxLjMxIDEuMzFBNS45ODMgNS45ODMgMCAwMDYgN2gxMmMwLTIuMTItMS4xLTMuOTgtMi40Ny00Ljg0ek0xMCA1SDlWNGgxdjF6bTUgMGgtMVY0aDF2MXoiLz48L3N2Zz4=) center/contain no-repeat;
mask: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTYgMThjMCAuNTUuNDUgMSAxIDFoMXYzLjVjMCAuODMuNjcgMS41IDEuNSAxLjVzMS41LS42NyAxLjUtMS41VjE5aDJ2My41YzAgLjgzLjY3IDEuNSAxLjUgMS41czEuNS0uNjcgMS41LTEuNVYxOWgxYy41NSAwIDEtLjQ1IDEtMVY4SDZ2MTB6TTMuNSA4QzIuNjcgOCAyIDguNjcgMiA5LjV2N2MwIC44My42NyAxLjUgMS41IDEuNVM1IDE3LjMzIDUgMTYuNXYtN0M1IDguNjcgNC4zMyA4IDMuNSA4em0xNyAwYy0uODMgMC0xLjUuNjctMS41IDEuNXY3YzAgLjgzLjY3IDEuNSAxLjUgMS41czEuNS0uNjcgMS41LTEuNXYtN2MwLS44My0uNjctMS41LTEuNS0xLjV6bS00Ljk3LTUuODRsMS4zLTEuM2MuMi0uMi4yLS41MSAwLS43MS0uMi0uMi0uNTEtLjItLjcxIDBsLTEuNDggMS40OEE1Ljg0IDUuODQgMCAwMDEyIDFjLS45NiAwLTEuODYuMjMtMi42Ni42M0w3Ljg1LjE1Yy0uMi0uMi0uNTEtLjItLjcxIDAtLjIuMi0uMi41MSAwIC43MWwxLjMxIDEuMzFBNS45ODMgNS45ODMgMCAwMDYgN2gxMmMwLTIuMTItMS4xLTMuOTgtMi40Ny00Ljg0ek0xMCA1SDlWNGgxdjF6bTUgMGgtMVY0aDF2MXoiLz48L3N2Zz4=) center/contain no-repeat;
}
.pill-ios {
position: relative; overflow: hidden;
background: rgba(255,255,255,0.22);
backdrop-filter: blur(20px) saturate(1.8);
-webkit-backdrop-filter: blur(20px) saturate(1.8);
color: #1a1a2e; font-weight: 850;
border: 1px solid rgba(255,255,255,0.55);
box-shadow: 0 4px 20px rgba(0,0,0,0.08),
inset 0 1px 0 rgba(255,255,255,0.9),
inset 0 -1px 0 rgba(0,0,0,0.04);
}
.pill-ios:hover {
background: rgba(255,255,255,0.38);
transform: translateY(-1px);
box-shadow: 0 8px 28px rgba(0,0,0,0.12),
inset 0 1px 0 rgba(255,255,255,1),
inset 0 -1px 0 rgba(0,0,0,0.04);
}
.pill-ios::before {
content: "";
position: absolute; top: 0; left: -40%; width: 180%; height: 50%;
background: linear-gradient(180deg, rgba(255,255,255,0.7) 0%, rgba(255,255,255,0) 100%);
border-radius: 999px; pointer-events: none;
}
.pill-ios::after {
content: "";
position: absolute; bottom: -2px; left: 10%; width: 80%; height: 6px;
background: linear-gradient(90deg, #ff6b9d, #c44dff, #6b9dff, #45d4a8);
filter: blur(5px); opacity: 0.4; border-radius: 999px;
pointer-events: none; z-index: -1;
}
.pill-ios .apple-icon {
width: 14px; height: 14px; display: inline-block; background: #1a1a2e;
-webkit-mask: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTE4LjcxIDE5LjVjLS44MyAxLjI0LTEuNzEgMi40NS0zLjA1IDIuNDctMS4zNC4wMy0xLjc3LS43OS0zLjI5LS43OS0xLjUzIDAtMiAuNzctMy4yNy44Mi0xLjMxLjA1LTIuMy0xLjMyLTMuMTQtMi41M0M0LjI1IDE3IDIuOTQgMTIuNDUgNC43IDkuMzljLjg3LTEuNTIgMi40My0yLjQ4IDQuMTItMi41MSAxLjI4LS4wMiAyLjUuODcgMy4yOS44Ny43OCAwIDIuMjYtMS4wNyAzLjgxLS45MS42NS4wMyAyLjQ3LjI2IDMuNjQgMS45OC0uMDkuMDYtMi4xNyAxLjI4LTIuMTUgMy44MS4wMyAzLjAyIDIuNjUgNC4wMyAyLjY4IDQuMDQtLjAzLjA3LS40MiAxLjQ0LTEuMzggMi44M00xMyAzLjVjLjczLS44MyAxLjk0LTEuNDYgMi45NC0xLjUuMTMgMS4xNy0uMzQgMi4zNS0xLjA0IDMuMTktLjY5Ljg1LTEuODMgMS41MS0yLjk1IDEuNDItLjE1LTEuMTUuNDEtMi4zNSAxLjA1LTMuMTF6Ii8+PC9zdmc+) center/contain no-repeat;
mask: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTE4LjcxIDE5LjVjLS44MyAxLjI0LTEuNzEgMi40NS0zLjA1IDIuNDctMS4zNC4wMy0xLjc3LS43OS0zLjI5LS43OS0xLjUzIDAtMiAuNzctMy4yNy44Mi0xLjMxLjA1LTIuMy0xLjMyLTMuMTQtMi41M0M0LjI1IDE3IDIuOTQgMTIuNDUgNC43IDkuMzljLjg3LTEuNTIgMi40My0yLjQ4IDQuMTItMi41MSAxLjI4LS4wMiAyLjUuODcgMy4yOS44Ny43OCAwIDIuMjYtMS4wNyAzLjgxLS45MS42NS4wMyAyLjQ3LjI2IDMuNjQgMS45OC0uMDkuMDYtMi4xNyAxLjI4LTIuMTUgMy44MS4wMyAzLjAyIDIuNjUgNC4wMyAyLjY4IDQuMDQtLjAzLjA3LS40MiAxLjQ0LTEuMzggMi44M00xMyAzLjVjLjczLS44MyAxLjk0LTEuNDYgMi45NC0xLjUuMTMgMS4xNy0uMzQgMi4zNS0xLjA0IDMuMTktLjY5Ljg1LTEuODMgMS41MS0yLjk1IDEuNDItLjE1LTEuMTUuNDEtMi4zNSAxLjA1LTMuMTF6Ii8+PC9zdmc+) center/contain no-repeat;
}
@media (max-width: 600px) {
.pill { padding: 8px 16px; font-size: 14px; }
}
-1
View File
@@ -1 +0,0 @@
/// <reference types="vite/client" />
-21
View File
@@ -1,21 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"],
"references": []
}
-13
View File
@@ -1,13 +0,0 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig(() => ({
plugins: [react()],
base: process.env.VITE_BASE || '/',
build: {
outDir: 'dist',
},
server: {
host: '127.0.0.1',
},
}));
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Multi-Casual Android is an independent Android companion app for Multica users." />
<link rel="icon" href="./favicon.png" type="image/png" />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='18' fill='%231647ff'/%3E%3Ctext x='32' y='41' text-anchor='middle' font-family='Arial,sans-serif' font-size='30' font-weight='800' fill='white'%3EM%3C/text%3E%3C/svg%3E" />
<title>Multi-Casual Android</title>
</head>
<body>
+3 -34
View File
@@ -18,9 +18,9 @@
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
"license": "MIT",
"optional": true,
"dependencies": {
@@ -264,37 +264,6 @@
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

+230 -550
View File
@@ -1,629 +1,318 @@
import logoImg from "./assets/logo.png";
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { motion, AnimatePresence, useMotionValue, useTransform, useReducedMotion } from "motion/react";
import inboxImg from "./assets/screenshots/01-inbox.jpg";
import issuesListImg from "./assets/screenshots/02-issues-list.jpg";
import issuesBoardImg from "./assets/screenshots/03-issues-board.jpg";
import issueDetailImg from "./assets/screenshots/04-issue-detail.jpg";
import issueSubImg from "./assets/screenshots/05-issue-detail-subissues.jpg";
import commentImg from "./assets/screenshots/06-comment-composer.jpg";
import mentionImg from "./assets/screenshots/07-mention-picker.jpg";
import projectsImg from "./assets/screenshots/08-projects-list.jpg";
import projectDetailImg from "./assets/screenshots/09-project-detail.jpg";
import agentsImg from "./assets/screenshots/10-agents-list.jpg";
import runtimesImg from "./assets/screenshots/11-runtimes-list.jpg";
import skillsImg from "./assets/screenshots/12-skills-list.jpg";
import skillDetailImg from "./assets/screenshots/13-skill-detail.jpg";
import settingsImg from "./assets/screenshots/14-settings.jpg";
import promoVideo from "./assets/video/multica-android-promo-draft.mp4";
import { useState } from "react";
import { motion, useReducedMotion, type MotionProps } from "motion/react";
import issueDetail from "./assets/screenshots/02-issue-detail.png";
import commentFocused from "./assets/screenshots/03-comment-focused.png";
import promoVideo from "./assets/video/multica-android-promo-funk-final.mp4";
const release = {
packageName: "ai.multicasual.app",
sha256: "3f50276216d866c0e405e1e99f38f2f2c1c3d5b4a4e79dc092495c7c6a3ebe49",
sha256: "f724e2e18dcf429e4cf8ed8782507136175732ffda28a7fc89a4a14b5b603b57",
githubUrl: "https://github.com/park0er/multi-casual-android",
releasesUrl: "https://github.com/park0er/multi-casual-android/releases/download/v0.1.5/Multi-Casual-Android-v0.1.5.apk",
iosUrl: "https://github.com/park0er/multi-casual-ios-app",
releasesUrl: "https://github.com/park0er/multi-casual-android/releases/latest",
multicaUrl: "https://multica.ai",
serviceUrl: "https://app.multica.ai",
apiUrl: "https://api.multica.ai",
};
type Locale = "en" | "zh";
const copy: Record<Locale, {
nav: { brand: string; download: string; trust: string; language: string };
hero: { eyebrow: string; title: string; subtitle: string; download: string; github: string; multica: string; ios: string };
relationship: { kicker: string; title: string; body1: string; body2: string };
journey: { kicker: string; title: string; subtitle: string; cta: string; steps: Array<{ title: string; subtitle: string }> };
workspace: { kicker: string; title: string; subtitle: string; items: Array<{ title: string; subtitle: string }> };
video: { kicker: string; title: string; body: string };
download: { kicker: string; title: string; package: string; api: string; sha: string; latest: string; source: string; star: string };
trust: { kicker: string; items: string[] };
footer: { tagline: string; multica: string; github: string; ios: string; download: string };
}> = {
const copy = {
en: {
nav: { brand: "Multi-Casual Android", download: "Download", trust: "Trust", language: "中文" },
nav: {
brand: "Multi-Casual Android",
download: "Download",
trust: "Trust",
relationship: "Relationship",
language: "中文",
},
hero: {
eyebrow: "Independent companion app · Built for Multica workflows",
title: "Keep your Multica workspace moving from Android.",
subtitle: "Check inbox updates, browse issues, read full context, reply in threads, and keep agent workspace assets close — an independent Android companion app for Multica users.",
title: "Review issues, reply faster, stay in flow on Android.",
subtitle:
"An independent Android companion app for people who already use Multica and want the core workspace loop on a phone.",
download: "Download APK",
github: "GitHub",
github: "View GitHub",
multica: "Visit Multica",
ios: "iOS App",
typing: "Looks good — verified on Android.",
},
relationship: {
kicker: "Relationship to Multica",
title: "Respectful, explicit, and independent.",
body1: "Multi-Casual Android is an independent companion app for Multica users. It connects to Multica service APIs for compatibility, but it is not maintained, endorsed, or published by the Multica project maintainers.",
body2: "This project was originally explored as a contribution path with the Multica community. The Multica maintainers later indicated that their own app direction would be led upstream, so this Android client is maintained independently, following that boundary while staying respectful of the original project.",
body:
"Multi-Casual Android is an independent companion app for Multica users. It connects to Multica service APIs for compatibility, but it is not maintained, endorsed, or published by the Multica project maintainers, and it should not be presented as a Multica Android app from the upstream project.",
},
featuresKicker: "What it covers",
features: [
{
title: "Issue workflows",
body: "Browse inbox items, open issue details, review comments, and keep task context close while away from desktop.",
},
{
title: "Mobile replies",
body: "Tap into the comment flow, draft a response, and continue the same discussion you would normally handle on the web.",
},
{
title: "Cloud-service compatible",
body: "The Android app is designed as a companion to the Multica backend service. It does not bundle a server, and it does not add runtime backend switching for normal users.",
},
journey: {
kicker: "Interactive walkthrough",
title: "Try the flow.",
subtitle: "Click each step or scroll to see the app in action.",
cta: "Start the walkthrough",
steps: [
{ title: "Inbox notifications", subtitle: "Stay on top of activity — new issues, comments, and agent progress show up as they happen." },
{ title: "Browse issues", subtitle: "See all tasks in a clean list view, ready for triage. Filter, search, and tap into any issue." },
{ title: "Switch to board view", subtitle: "Tap the view toggle to switch to a kanban board that shows status flow at a glance." },
{ title: "Open issue detail", subtitle: "Tap an issue card to see the full detail: title, status, labels, assignee, and body text." },
{ title: "Explore context", subtitle: "Context guide points zoom into focus areas — title and status, body content, then subtasks and comments — and return to the full view between each." },
{ title: "Scroll for more", subtitle: "Keep scrolling to see subtasks, subscribers, and the comment thread — all the structured context stays on one page." },
{ title: "Draft a comment", subtitle: "Tap the comment box. The keyboard appears and a comment types out character by character." },
{ title: "Mention anyone", subtitle: "Type @ and a picker appears with people, agents, and squads. Choose who to notify." },
{ title: "Workspace overview", subtitle: "Switch between Projects, Agents, Runtimes, Skills, and Settings with workspace chips." },
],
demo: {
kicker: "Real Android flow",
title: "Open an issue. Read context. Draft a comment.",
body:
"The demo material uses emulator screenshots from a real project. Interactive moments are animated as safe overlays, so no public test comment needs to be submitted.",
},
workspace: {
kicker: "Workspace assets",
title: "Projects, agents, and everything in between.",
subtitle: "Switch between workspace views to see the full picture.",
items: [
{ title: "Projects", subtitle: "Browse projects with progress tracking" },
{ title: "Project detail", subtitle: "Task stats, status fields, and resources" },
{ title: "Agents", subtitle: "View agent status and recent activity" },
{ title: "Runtimes", subtitle: "Check runtime availability and versions" },
{ title: "Skills", subtitle: "Browse the workspace agent skill library" },
{ title: "Skill detail", subtitle: "Inspect skill structure and documentation" },
{ title: "Settings", subtitle: "Account, workspace, and preference controls" },
video: {
kicker: "Promo video",
title: "A quick walkthrough of the Android issue flow.",
body:
"The walkthrough shows a real emulator project, issue context, a simulated typewriter-style comment draft, and a light funk-style background track.",
},
download: {
kicker: "GitHub APK",
title: "Install from GitHub Releases.",
package: "Package",
api: "Service API",
sha: "APK SHA256",
latest: "Latest release",
source: "Source code",
},
trustKicker: "Trust and privacy",
trustItems: [
"Independent companion app; no claim of Multica project ownership or maintainer endorsement.",
"Published as its own Android app for Multica users, while keeping attribution and product boundaries explicit.",
"Analytics requires user consent and is limited to an app_open event in the public Android build.",
"No chat content, file content, email address, tokens, or precise location are intentionally reported.",
],
footer: {
tagline: "Independent Android companion app for Multica users. GitHub-distributed APK.",
multica: "Multica website",
github: "GitHub",
download: "Download APK",
},
video: { kicker: "Product demo", title: "See it in 30 seconds.", body: "A fast walkthrough of inbox, issues, context, comments, mentions, and workspace views." },
download: { kicker: "GitHub APK", title: "Install from GitHub Releases.", package: "Package", api: "Service API", sha: "APK SHA256", latest: "Download APK", source: "Source code", star: "Like the app? Give us a star on GitHub ⭐ — it means a lot to the maintainers." },
trust: {
kicker: "Trust and privacy",
items: [
"Community-maintained independent project; not affiliated with or endorsed by Multica maintainers.",
"Published as its own Android app for Multica users, with explicit attribution and product boundaries.",
"Open source under GPLv3 license — inspect, fork, or contribute.",
"No chat content, file content, email address, tokens, or precise location are collected.",
],
},
footer: { tagline: "Independent Android companion app for Multica users. GitHub-distributed APK.", multica: "Multica website", github: "GitHub", ios: "iOS App", download: "Download APK" },
},
zh: {
nav: { brand: "Multi-Casual Android", download: "下载", trust: "信任说明", language: "English" },
nav: {
brand: "Multi-Casual Android",
download: "下载",
trust: "信任说明",
relationship: "项目关系",
language: "English",
},
hero: {
eyebrow: "独立 companion app · 面向 Multica 工作流",
title: "在 Android 上跟进你的 Multica 工作现场。",
subtitle: "查看通知、浏览 issues、阅读完整上下文、回复评论、@ 对方 —— 面向 Multica 用户的独立 Android companion app。",
title: "在 Android 上处理 Issue。",
subtitle: "查看任务上下文、进入评论区、草拟回复,把核心 workspace 闭环带到手机上。",
download: "下载 APK",
github: "GitHub",
github: "查看 GitHub",
multica: "访问 Multica",
ios: "iOS App",
typing: "已在 Android 上验证,看起来没问题。",
},
relationship: {
kicker: "与 Multica 的关系",
title: "礼貌、清楚、保持独立。",
body1: "Multi-Casual Android 是面向 Multica 用户的独立 companion app。它为了兼容现有工作流而连接 Multica 服务 API,但并不由 Multica 项目维护者维护、背书或发布。",
body2: "这个项目最初是作为向 Multica 社区贡献的路径来探索的。由于 Multica maintainers 后续计划推进他们自己主导的 app 体系,因此这套 Android 代码体系没有直接并入上游。我们尊重 maintainers 的边界建议,将 Multi-Casual Android 作为独立 app 继续运营和维护。",
body:
"Multi-Casual Android 是面向 Multica 用户的独立 companion app。它为了兼容现有工作流而连接 Multica 服务 API,但并不由 Multica 项目维护者维护、背书或发布,也不应该被表述为上游项目推出的 Multica Android App。",
},
featuresKicker: "覆盖能力",
features: [
{
title: "Issue 工作流",
body: "查看 Inbox 条目、打开 Issue 详情、阅读评论,在离开桌面时也能保留任务上下文。",
},
{
title: "手机端回复",
body: "进入评论输入流程,草拟回复,继续原本会在 Web 端处理的同一条讨论。",
},
{
title: "兼容云服务流程",
body: "Android App 是 Multica 后端服务的 companion;不内置服务端,也不为普通用户提供运行时后端切换。",
},
journey: {
kicker: "交互式演示",
title: "试试这个流程。",
subtitle: "点击每个步骤,或滚动页面查看 app 实际操作。",
cta: "开始演示",
steps: [
{ title: "Inbox 通知", subtitle: "新 issues、评论和 agent 进展会实时出现在收件箱,不错过任何动态。" },
{ title: "浏览 Issues", subtitle: "在列表视图中查看所有任务,快速筛选和进入任意 issue。" },
{ title: "切换看板视图", subtitle: "点击视图切换按钮,看板视图让你一眼看到任务状态流。" },
{ title: "打开 Issue 详情", subtitle: "点击 issue 卡片查看完整详情:标题、状态、标签、负责人和正文。" },
{ title: "探索上下文", subtitle: "Context 引导点依次放大标题与状态、正文内容、子任务与评论,然后缩回全貌。" },
{ title: "滚动查看更多", subtitle: "继续下滑查看子任务、订阅者和评论线程 —— 结构化上下文都在同一页。" },
{ title: "草拟评论", subtitle: "点击评论框,键盘出现,评论逐字输入。" },
{ title: "@ 提及任何人", subtitle: "输入 @ 弹出选择器,可选人、agent 或小队。" },
{ title: "Workspace 总览", subtitle: "通过 workspace chips 切换查看 Projects、Agents、Runtimes、Skills 和 Settings。" },
],
demo: {
kicker: "真实 Android 流程",
title: "打开 Issue,阅读上下文,然后草拟评论。",
body: "演示素材来自真实项目里的模拟器截图;交互部分用安全的叠加动画表现,不需要真的往公开项目里提交测试评论。",
},
workspace: {
kicker: "Workspace 资产",
title: "项目、agents,以及它们之间的一切。",
subtitle: "切换 workspace 视图,掌握全貌。",
items: [
{ title: "Projects", subtitle: "浏览项目和进度追踪" },
{ title: "项目详情", subtitle: "任务统计、状态字段和资源入口" },
{ title: "Agents", subtitle: "查看 agent 状态和最近活动" },
{ title: "Runtimes", subtitle: "检查运行时可用性和版本" },
{ title: "Skills", subtitle: "浏览 workspace agent 技能库" },
{ title: "技能详情", subtitle: "查看技能结构和文档" },
{ title: "Settings", subtitle: "账号、工作区和偏好控制" },
video: {
kicker: "宣传视频",
title: "快速看一遍 Android 端 Issue 流程。",
body:
"这段 walkthrough 使用真实模拟器项目截图,展示 Issue 上下文、逐字输入感的评论草稿,以及一条轻快的 funk style 背景乐。",
},
download: {
kicker: "GitHub APK",
title: "从 GitHub Releases 安装。",
package: "包名",
api: "服务 API",
sha: "APK SHA256",
latest: "最新 Release",
source: "源码仓库",
},
trustKicker: "信任与隐私",
trustItems: [
"这是独立 companion app,不声明拥有 Multica 项目,也不暗示维护者背书。",
"作为独立 Android App 发布给 Multica 用户,同时明确署名和产品边界。",
"公开 Android 版本里的 analytics 需要用户同意,并且仅限 app_open 事件。",
"不会有意上报聊天内容、文件内容、邮箱、token 或精确位置。",
],
footer: {
tagline: "面向 Multica 用户的独立 Android companion app,通过 GitHub 分发 APK。",
multica: "Multica 网站",
github: "GitHub",
download: "下载 APK",
},
video: { kicker: "产品演示", title: "30 秒看完核心流程。", body: "快速演示 inbox 通知、issues 看板、上下文探索、评论回复、@ 提及和 workspace 视图切换。" },
download: { kicker: "GitHub APK", title: "从 GitHub Releases 安装。", package: "Package", api: "Service API", sha: "APK SHA256", latest: "下载APK包", source: "源代码", star: "喜欢我们的 app?请在 GitHub 上给项目点个 Star ⭐,这是对我们作者最大的支持。" },
trust: {
kicker: "信任与隐私",
items: [
"社区维护的独立项目;与 Multica maintainers 无隶属关系,无背书。",
"作为面向 Multica 用户的独立 Android app 发布,明确标注产品边界。",
"GPLv3 开源许可证 —— 可以查看、fork 或贡献。",
"不收集聊天内容、文件内容、邮箱、tokens 或精确位置信息。",
],
},
footer: { tagline: "面向 Multica 用户的独立 Android companion app。GitHub 分发 APK。", multica: "Multica 官网", github: "GitHub", ios: "iOS App", download: "下载 APK" },
},
};
} satisfies Record<Locale, unknown>;
const JOURNEY_STEPS = 9;
const TYPING_TEXT = "Looks good. I'll ask the agent to check the failing case and post a follow-up.";
const MENTION_ITEMS = [
{ name: "codex", color: "#1647ff" },
{ name: "Opus", color: "#7c3aed" },
{ name: "RollieOC", color: "#059669" },
];
const WORKSPACE_SCREENSHOTS = [projectsImg, projectDetailImg, agentsImg, runtimesImg, skillsImg, skillDetailImg, settingsImg];
const fade = (reduced: boolean, delay = 0) => ({
initial: reduced ? {} : { opacity: 0, y: 18 },
whileInView: { opacity: 1, y: 0 },
viewport: { once: true, margin: "-60px" },
transition: { duration: 0.65, delay, ease: [0.25, 0.1, 0.25, 1] as const },
});
const reveal = (reduced: boolean) => ({
initial: reduced ? {} : { opacity: 0, y: 20 },
whileInView: { opacity: 1, y: 0 },
viewport: { once: true, margin: "-80px" },
transition: { duration: 0.7, ease: [0.25, 0.1, 0.25, 1] as const },
});
/* ── PhoneDemo ── */
function PhoneDemo({
src,
reduced,
zoomScale = 1,
zoomX = 0,
zoomY = 0,
children,
}: {
src: string;
reduced: boolean;
zoomScale?: number;
zoomX?: number;
zoomY?: number;
children?: React.ReactNode;
}) {
return (
<div className="phone-shell-compact">
<div className="speaker" />
<div className="phone-screen-compact">
<AnimatePresence mode="wait">
<motion.img
key={src}
src={src}
alt="App screenshot"
initial={reduced ? {} : { opacity: 0 }}
animate={{
opacity: 1,
scale: zoomScale,
x: zoomX,
y: zoomY,
}}
exit={reduced ? {} : { opacity: 0 }}
transition={{ duration: reduced ? 0.1 : 0.4, ease: [0.25, 0.1, 0.25, 1] as const }}
/>
</AnimatePresence>
{children}
</div>
</div>
);
function fade(reducedMotion: boolean | null, delay = 0): MotionProps {
if (reducedMotion) {
return {};
}
return {
initial: { opacity: 0, y: 24 },
animate: { opacity: 1, y: 0 },
transition: { duration: 0.7, delay, ease: "easeOut" },
};
}
/* ── Typewriter effect ── */
function TypewriterEffect({ active, text, reduced }: { active: boolean; text: string; reduced: boolean }) {
const charCount = useMotionValue(0);
const chars = useMemo(() => text.split(""), [text]);
const charRefs = useRef<(HTMLSpanElement | null)[]>([]);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Imperatively update char opacity from motion value (no hooks in loops)
useEffect(() => {
const unsubscribe = charCount.on("change", (v) => {
const count = Math.round(v);
charRefs.current.forEach((el, i) => {
if (el) el.style.opacity = i < count ? "1" : "0";
});
});
return unsubscribe;
}, [charCount]);
useEffect(() => {
if (intervalRef.current) clearInterval(intervalRef.current);
if (!active) { charCount.set(0); charRefs.current.forEach((el) => { if (el) el.style.opacity = "0"; }); return; }
if (reduced) {
charCount.set(chars.length);
charRefs.current.forEach((el, i) => { if (el) el.style.opacity = i < chars.length ? "1" : "0"; });
return;
function reveal(reducedMotion: boolean | null): MotionProps {
if (reducedMotion) {
return {};
}
let i = 0;
const interval = setInterval(() => {
i++;
charCount.set(i);
if (i >= chars.length) clearInterval(interval);
}, 42);
intervalRef.current = interval;
return () => clearInterval(interval);
}, [active, reduced, chars.length]);
return {
initial: { opacity: 0, y: 28 },
whileInView: { opacity: 1, y: 0 },
viewport: { once: true, margin: "-80px" },
transition: { duration: 0.7, ease: "easeOut" },
};
}
function PhoneMockup({ typing }: { typing: string }) {
const reducedMotion = useReducedMotion();
return (
<motion.div
className="typing-line"
animate={active ? { opacity: 1 } : { opacity: 0 }}
transition={{ duration: 0.3 }}
className="phone-wrap"
animate={reducedMotion ? {} : { y: [0, -10, 0], rotate: [-1.5, -0.6, -1.5] }}
transition={reducedMotion ? {} : { duration: 7, repeat: Infinity, ease: "easeInOut" }}
>
{chars.map((char, i) => (
<span
key={i}
ref={(el) => { charRefs.current[i] = el; }}
style={{ opacity: 0 }}
<div className="phone-shell">
<div className="speaker" />
<div className="phone-screen">
<div className="phone-screen-shot" aria-label="Multi-Casual Android issue detail screen" />
<motion.div
className="typing-card"
initial={reducedMotion ? false : { opacity: 0, y: 14, scale: 0.98 }}
animate={reducedMotion ? { opacity: 1 } : { opacity: 1, y: 0, scale: 1 }}
transition={{ delay: 1.05, duration: 0.55 }}
>
{char === " " ? "\u00A0" : char}
</span>
))}
<motion.span
className="typing-cursor"
animate={active ? { opacity: [1, 0.18] } : { opacity: 0 }}
transition={active ? { duration: 0.64, repeat: Infinity, repeatType: "reverse" } : {}}
/>
<span className="typing-dot" />
{typing}
</motion.div>
</div>
</div>
<div className="tap-ring" aria-hidden="true" />
</motion.div>
);
}
/* ── PhoneMockup (hero) ── */
function PhoneMockup({ reduced }: { reduced: boolean }) {
return (
<div className="hero-phone-stack">
<motion.img
src={inboxImg} alt="Inbox preview"
initial={reduced ? {} : { opacity: 0, x: 60, rotate: -14 }}
whileInView={{ opacity: 0.72, x: 0, rotate: -8 }}
viewport={{ once: true }}
transition={{ duration: 0.85, delay: 0.2, ease: [0.25, 0.1, 0.25, 1] as const }}
/>
<motion.img
src={issuesListImg} alt="Issues list preview"
initial={reduced ? {} : { opacity: 0, x: 80, rotate: 8 }}
whileInView={{ opacity: 1, x: 0, rotate: 4 }}
viewport={{ once: true }}
transition={{ duration: 0.85, delay: 0.3, ease: [0.25, 0.1, 0.25, 1] as const }}
/>
<motion.img
src={issueDetailImg} alt="Issue detail preview"
initial={reduced ? {} : { opacity: 0, x: 60, rotate: 14 }}
whileInView={{ opacity: 0.86, x: 0, rotate: 10 }}
viewport={{ once: true }}
transition={{ duration: 0.85, delay: 0.4, ease: [0.25, 0.1, 0.25, 1] as const }}
/>
</div>
);
}
/* ── Main App ── */
export default function App() {
const reduced = useReducedMotion() ?? false;
const [lang, setLang] = useState<Locale>("en");
const [step, setStep] = useState(0);
const [activeWs, setActiveWs] = useState(0);
const [hasInteracted, setHasInteracted] = useState(false);
const completedRef = useRef<Set<number>>(new Set());
const journeyRef = useRef<HTMLDivElement>(null);
const hasAutoPlayedRef = useRef(false);
const t = copy[lang];
// Auto-play journey on first scroll into view
useEffect(() => {
if (hasInteracted || hasAutoPlayedRef.current) return;
const el = journeyRef.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting && entry.intersectionRatio > 0.3 && !hasAutoPlayedRef.current) {
hasAutoPlayedRef.current = true;
let currentStep = 0;
const advance = () => {
currentStep++;
if (currentStep < JOURNEY_STEPS) {
setStep(currentStep);
setTimeout(advance, 2200);
}
};
setTimeout(advance, 2200);
}
},
{ threshold: 0.3 }
);
observer.observe(el);
return () => observer.disconnect();
}, [hasInteracted]);
const handleStepClick = useCallback((index: number) => {
setHasInteracted(true);
setStep(index);
}, []);
const handleWorkspaceClick = useCallback((index: number) => {
setActiveWs(index);
}, []);
// Phone screen source based on journey step
const phoneScreenSrc = useMemo(() => {
if (step <= 1) return step === 0 ? inboxImg : issuesListImg;
if (step === 2) return issuesBoardImg;
if (step <= 5) return step === 3 ? issueDetailImg : issueSubImg;
if (step === 6) return commentImg;
if (step === 7) return mentionImg;
return projectsImg;
}, [step]);
// Focus zoom transform for step 4 (context explore)
const focusZones = [
{ scale: 1.6, x: 0, y: -120 }, // title/status
{ scale: 1.45, x: 0, y: -40 }, // body
{ scale: 1.5, x: 0, y: 80 }, // subtasks/comments
];
const focusIdx = step === 4 ? 0 : -1; // simple: show first zone; cycle handled by auto-play
const zoom = step === 4 ? focusZones[focusIdx] || focusZones[0] : { scale: 1, x: 0, y: 0 };
const reducedMotion = useReducedMotion();
const [locale, setLocale] = useState<Locale>("en");
const t = copy[locale];
return (
<main lang={lang === "zh" ? "zh-CN" : "en"}>
{/* Hero */}
<section className="hero">
<main lang={locale === "zh" ? "zh-CN" : "en"}>
<section className="hero section-pad">
<div className="hero-bg" />
<motion.nav className="topbar" {...fade(reduced, 0)}>
<div className="brand">
<img src={logoImg} alt="Multi-Casual" className="brand-mark" />
<nav className="topbar" aria-label="Primary navigation">
<a className="brand" href="#top" aria-label="Multi-Casual Android home">
<span className="brand-mark">M</span>
<span>{t.nav.brand}</span>
</div>
</a>
<div className="nav-links">
<a href="#download">{t.nav.download}</a>
<a href="#trust">{t.nav.trust}</a>
<button className="language-toggle" onClick={() => setLang((l) => (l === "en" ? "zh" : "en"))}>
<a href="#relationship">{t.nav.relationship}</a>
<button className="language-toggle" type="button" onClick={() => setLocale(locale === "en" ? "zh" : "en")}>
{t.nav.language}
</button>
</div>
</motion.nav>
<div className="hero-grid">
<div>
<motion.p className="eyebrow" {...fade(reduced, 0.04)}>{t.hero.eyebrow}</motion.p>
<motion.h1 {...fade(reduced, 0.14)}>{t.hero.title}</motion.h1>
<motion.p className="hero-subtitle" {...fade(reduced, 0.24)}>{t.hero.subtitle}</motion.p>
<motion.div className="cta-row" {...fade(reduced, 0.34)}>
<motion.a href="#download" className="pill pill-android" whileHover={{ y: -2 }} whileTap={{ scale: 0.98 }}><span className="android-icon"></span>{t.hero.download}</motion.a>
<motion.a className="button secondary" href={release.githubUrl} whileHover={{ y: -2 }} whileTap={{ scale: 0.98 }}><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" style={{verticalAlign: "middle", marginRight: 6}}><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>{t.hero.github}</motion.a>
<motion.a className="button ghost" href={release.multicaUrl} whileHover={{ y: -2 }} whileTap={{ scale: 0.98 }}>{t.hero.multica}</motion.a>
<motion.a href={release.iosUrl} className="pill pill-ios" whileHover={{ y: -2 }} whileTap={{ scale: 0.98 }}><span className="apple-icon"></span>{t.hero.ios}</motion.a>
</nav>
<div id="top" className="hero-grid">
<div className="hero-copy">
<motion.p className="eyebrow" {...fade(reducedMotion, 0.05)}>
{t.hero.eyebrow}
</motion.p>
<motion.h1 {...fade(reducedMotion, 0.14)}>{t.hero.title}</motion.h1>
<motion.p className="hero-subtitle" {...fade(reducedMotion, 0.24)}>
{t.hero.subtitle}
</motion.p>
<motion.div className="cta-row" {...fade(reducedMotion, 0.34)}>
<motion.a className="button primary" href={release.releasesUrl} whileHover={{ y: -2 }} whileTap={{ scale: 0.98 }}>
{t.hero.download}
</motion.a>
<motion.a className="button secondary" href={release.githubUrl} whileHover={{ y: -2 }} whileTap={{ scale: 0.98 }}>
{t.hero.github}
</motion.a>
<motion.a className="button ghost" href={release.multicaUrl} whileHover={{ y: -2 }} whileTap={{ scale: 0.98 }}>
{t.hero.multica}
</motion.a>
</motion.div>
</div>
<PhoneMockup reduced={reduced} />
<motion.div {...fade(reducedMotion, 0.18)}>
<PhoneMockup typing={t.hero.typing} />
</motion.div>
</div>
</section>
{/* Relationship */}
<motion.section id="relationship" className="relationship section-pad" {...reveal(reduced)}>
<motion.section id="relationship" className="relationship section-pad" {...reveal(reducedMotion)}>
<div className="section-kicker">{t.relationship.kicker}</div>
<div className="relationship-card">
<div className="two-col">
<h2>{t.relationship.title}</h2>
<p>{t.relationship.body1}</p>
<p>{t.relationship.body2}</p>
<p>
<a href="https://github.com/multica-ai/multica/issues/2285#issuecomment-4414513596" target="_blank" rel="noopener noreferrer">
View the discussion on GitHub
</a>
</p>
<p>{t.relationship.body}</p>
</div>
</motion.section>
{/* Interactive Product Journey */}
<section ref={journeyRef} className="journey section-pad">
<motion.div className="journey-header" {...reveal(reduced)}>
<div className="section-kicker">{t.journey.kicker}</div>
<h2>{t.journey.title}</h2>
<p>{t.journey.subtitle}</p>
</motion.div>
<div className="journey-layout">
<div className="journey-steps">
{t.journey.steps.map((s, i) => (
<motion.div
key={i}
className={`journey-step ${step === i ? "journey-step-active" : ""} ${i < step ? "journey-step-completed" : ""}`}
onClick={() => handleStepClick(i)}
initial={reduced ? {} : { opacity: 0, x: -16 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: i * 0.06 }}
>
<div className="journey-step-number">{i < step ? "✓" : i + 1}</div>
<div className="journey-step-content">
<h3>{s.title}</h3>
<p>{s.subtitle}</p>
</div>
</motion.div>
<section className="features section-pad">
<div className="section-kicker">{t.featuresKicker}</div>
<div className="feature-grid">
{t.features.map((feature, index) => (
<motion.article key={feature.title} className="feature-card" {...reveal(reducedMotion)} transition={{ duration: 0.7, delay: index * 0.08 }}>
<span>{String(index + 1).padStart(2, "0")}</span>
<h3>{feature.title}</h3>
<p>{feature.body}</p>
</motion.article>
))}
</div>
<div className="journey-phone-area">
<div className="journey-phone-container">
<PhoneDemo
src={phoneScreenSrc}
reduced={reduced}
zoomScale={step === 4 ? zoom.scale : 1}
zoomX={step === 4 ? zoom.x : 0}
zoomY={step === 4 ? zoom.y : 0}
>
{/* Hand + tap ripple for interactive steps */}
{step >= 1 && step <= 3 && (
<>
<motion.div
className="journey-hand"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
style={{ top: step === 1 ? "16%" : step === 2 ? "8%" : "35%", right: step === 2 ? "18%" : "22%" }}
>👆</motion.div>
<motion.div
className="journey-tap-ripple"
animate={{ scale: [1, 1.3, 1], opacity: [0.8, 0.3, 0.8] }}
transition={{ duration: 1.2, repeat: Infinity }}
style={{ top: step === 1 ? "18%" : step === 2 ? "10%" : "37%", right: step === 2 ? "14%" : "18%" }}
/>
</>
)}
{/* Typing effect for step 6 */}
{step === 6 && <TypewriterEffect active text={TYPING_TEXT} reduced={reduced} />}
{/* Mention picker overlay for step 7 */}
<AnimatePresence>
{step === 7 && (
<motion.div
className="mention-overlay"
initial={{ y: 60, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 60, opacity: 0 }}
transition={{ duration: 0.35, ease: [0.25, 0.1, 0.25, 1] as const }}
>
<div className="mention-search">
<span>@</span> cod
</div>
<div className="mention-list">
{MENTION_ITEMS.map((m, i) => (
<motion.div
key={m.name}
className={`mention-item ${i === 0 ? "mention-item-active" : ""}`}
initial={{ opacity: 0, x: -12 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.15 + i * 0.1 }}
>
<div className="mention-avatar" style={{ background: m.color }}>{m.name[0].toUpperCase()}</div>
<div>
<div className="mention-name">{m.name}</div>
<div className="mention-role">{i === 0 ? "agent" : i === 1 ? "agent" : "squad"}</div>
</div>
</motion.div>
))}
</div>
</motion.div>
)}
</AnimatePresence>
{/* Workspace chips overlay for step 8 */}
{step === 8 && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
style={{ position: "absolute", bottom: 8, left: 8, right: 8, zIndex: 6, display: "flex", gap: 4, justifyContent: "center", flexWrap: "wrap" }}
>
{["Projects", "Agents", "Runtimes", "Skills", "Settings"].map((label, i) => (
<motion.div
key={label}
className="workspace-chip"
style={{ fontSize: 10, padding: "4px 8px", borderRadius: 999 }}
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 0.1 + i * 0.08 }}
>
{label}
</motion.div>
))}
</motion.div>
)}
</PhoneDemo>
</div>
</div>
</div>
</section>
{/* Workspace Assets Grid */}
<motion.section className="workspace-section section-pad" {...reveal(reduced)}>
<div className="workspace-header">
<div className="section-kicker">{t.workspace.kicker}</div>
<h2>{t.workspace.title}</h2>
<p>{t.workspace.subtitle}</p>
<motion.section className="demo section-pad" {...reveal(reducedMotion)}>
<div className="section-kicker">{t.demo.kicker}</div>
<div className="demo-grid">
<div>
<h2>{t.demo.title}</h2>
<p>{t.demo.body}</p>
</div>
<div className="workspace-chips">
{t.workspace.items.map((item, i) => (
<button
key={i}
className={`workspace-chip ${activeWs === i ? "workspace-chip-active" : ""}`}
onClick={() => handleWorkspaceClick(i)}
>
{item.title}
</button>
))}
<div className="screenshot-stack" aria-label="Issue comment flow screenshots">
<img className="shot back" src={issueDetail} alt="Issue detail screenshot" />
<img className="shot front" src={commentFocused} alt="Focused issue comment input screenshot" />
</div>
<div className="workspace-phone-wrap">
<AnimatePresence mode="wait">
<motion.div
key={activeWs}
initial={reduced ? {} : { opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
exit={reduced ? {} : { opacity: 0, scale: 0.96 }}
transition={{ duration: 0.35 }}
>
<PhoneDemo src={WORKSPACE_SCREENSHOTS[activeWs]} reduced={reduced} />
</motion.div>
</AnimatePresence>
</div>
<motion.div className="workspace-card-grid" {...reveal(reduced)}>
{t.workspace.items.map((item, i) => (
<motion.div
key={i}
className={`workspace-card ${activeWs === i ? "workspace-card-active" : ""}`}
onClick={() => handleWorkspaceClick(i)}
initial={reduced ? {} : { opacity: 0, y: 16 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: i * 0.06 }}
>
<img src={WORKSPACE_SCREENSHOTS[i]} alt={item.title} className="workspace-card-thumb" />
<div className="workspace-card-label">
<strong>{item.title}</strong>
<span>{item.subtitle}</span>
</div>
</motion.div>
))}
</motion.div>
</motion.section>
{/* Video Demo */}
<motion.section className="video-demo section-pad" {...reveal(reduced)}>
<motion.section className="video-demo section-pad" {...reveal(reducedMotion)}>
<div className="section-kicker">{t.video.kicker}</div>
<div className="video-copy">
<h2>{t.video.title}</h2>
<p>{t.video.body}</p>
</div>
<video className="promo-video" src={promoVideo} controls playsInline preload="metadata" />
<video className="promo-video" src={promoVideo} controls playsInline preload="metadata" poster={commentFocused} />
</motion.section>
{/* Download */}
<section id="download" className="download section-pad">
<motion.div className="release-card" {...reveal(reduced)}>
<motion.div className="release-card" {...reveal(reducedMotion)}>
<div className="section-kicker">{t.download.kicker}</div>
<h2>{t.download.title}</h2>
<div className="release-grid">
@@ -640,26 +329,19 @@ export default function App() {
<code>{release.sha256}</code>
</div>
</div>
<pre><code>{`adb install -r Multi-Casual-Android-v0.1.5.apk`}</code></pre>
<pre><code>{`adb install -r Multi-Casual-Android.apk`}</code></pre>
<div className="cta-row compact">
<a href={release.releasesUrl} className="pill pill-android"><span className="android-icon"></span>{t.download.latest}</a>
<a className="button primary" href={release.releasesUrl}>{t.download.latest}</a>
<a className="button secondary" href={release.githubUrl}>{t.download.source}</a>
</div>
<p className="star-hint">
{lang === "en"
? <>Like the app? Give us a star on <a href={release.githubUrl} target="_blank" rel="noopener">GitHub</a> it means a lot to the maintainers.</>
: <> app <a href={release.githubUrl} target="_blank" rel="noopener">GitHub</a> Star </>
}
</p>
</motion.div>
</section>
{/* Trust */}
<section id="trust" className="trust section-pad">
<div className="section-kicker">{t.trust.kicker}</div>
<div className="section-kicker">{t.trustKicker}</div>
<div className="trust-grid">
{t.trust.items.map((item) => (
<motion.div key={item} className="trust-item" {...reveal(reduced)}>
{t.trustItems.map((item) => (
<motion.div key={item} className="trust-item" {...reveal(reducedMotion)}>
<span className="check"></span>
<p>{item}</p>
</motion.div>
@@ -667,7 +349,6 @@ export default function App() {
</div>
</section>
{/* Footer */}
<footer className="footer section-pad">
<div>
<strong>Multi-Casual Android</strong>
@@ -676,8 +357,7 @@ export default function App() {
<div className="footer-links">
<a href={release.multicaUrl}>{t.footer.multica}</a>
<a href={release.githubUrl}>{t.footer.github}</a>
<a href={release.releasesUrl} className="pill pill-android"><span className="android-icon"></span>{t.footer.download}</a>
<a href={release.iosUrl} className="pill pill-ios"><span className="apple-icon"></span>{t.footer.ios}</a>
<a href={release.releasesUrl}>{t.footer.download}</a>
</div>
</footer>
</main>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 397 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
+41 -565
View File
@@ -20,11 +20,10 @@ html { scroll-behavior: smooth; }
body {
margin: 0;
min-width: 320px;
overflow-x: hidden;
background:
radial-gradient(circle at 82% 5%, rgba(255, 228, 92, 0.4), transparent 31%),
radial-gradient(circle at 10% 30%, rgba(22, 71, 255, 0.16), transparent 30%),
linear-gradient(180deg, #fbfcff 0%, #e8eeff 55%, #d4dff9 85%, #c4d4f5 100%);
radial-gradient(circle at 80% 5%, rgba(255, 228, 92, 0.32), transparent 30%),
radial-gradient(circle at 15% 25%, rgba(22, 71, 255, 0.14), transparent 28%),
var(--paper);
}
body, button, input { font: inherit; }
@@ -40,8 +39,6 @@ a { color: inherit; text-decoration: none; }
position: relative;
overflow: hidden;
padding-top: 26px;
padding-left: clamp(16px, 5vw, 76px);
padding-right: clamp(16px, 5vw, 76px);
}
.hero-bg {
@@ -79,9 +76,12 @@ a { color: inherit; text-decoration: none; }
.brand-mark {
width: 34px;
height: 34px;
border-radius: 10px;
object-fit: cover;
display: block;
border-radius: 12px;
display: grid;
place-items: center;
color: white;
background: var(--blue);
box-shadow: 0 8px 18px rgba(22, 71, 255, 0.32);
}
.nav-links a {
@@ -160,7 +160,7 @@ h3 {
letter-spacing: -0.035em;
}
.hero-subtitle, .relationship p, .demo p, .journey-desc {
.hero-subtitle, .relationship p, .demo p {
max-width: 650px;
color: #343b4a;
font-size: clamp(18px, 2vw, 23px);
@@ -171,7 +171,6 @@ h3 {
.cta-row.compact { margin-top: 22px; }
.button {
font-size: 15px;
display: inline-flex;
min-height: 48px;
align-items: center;
@@ -187,7 +186,6 @@ h3 {
.button.secondary { background: white; border-color: var(--line); }
.button.ghost { color: var(--muted); background: rgba(255, 255, 255, 0.54); border-color: var(--line); }
/* Phone mockup */
.phone-wrap {
width: min(390px, 82vw);
margin: 0 auto;
@@ -195,28 +193,6 @@ h3 {
transform-origin: center;
}
.hero-phone-stack {
min-height: 720px;
position: relative;
perspective: 1200px;
}
.hero-phone-stack img {
position: absolute;
width: min(310px, 54vw);
height: 676px;
border-radius: 46px;
border: 12px solid #11141d;
background: white;
object-fit: cover;
object-position: top center;
box-shadow: var(--shadow);
}
.hero-phone-stack img:nth-child(1) { left: 2%; top: 48px; transform: rotate(-8deg) translateZ(-20px); }
.hero-phone-stack img:nth-child(2) { left: 28%; top: 0; transform: rotate(4deg) translateZ(40px); }
.hero-phone-stack img:nth-child(3) { right: 0; top: 132px; transform: rotate(10deg) translateZ(10px); }
.phone-shell {
position: relative;
padding: 14px;
@@ -225,14 +201,6 @@ h3 {
box-shadow: var(--shadow), inset 0 0 0 2px rgba(255,255,255,0.1);
}
.phone-shell-compact {
position: relative;
padding: 12px;
border-radius: 48px;
background: #11141d;
box-shadow: 0 20px 56px rgba(16, 18, 24, 0.22);
}
.speaker {
position: absolute;
top: 18px;
@@ -253,33 +221,43 @@ h3 {
aspect-ratio: 9 / 19.7;
}
.phone-screen-compact {
position: relative;
overflow: hidden;
border-radius: 36px;
background: white;
aspect-ratio: 9 / 19.7;
}
.phone-screen img,
.phone-screen-compact img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: top center;
display: block;
}
.phone-screen img { width: 100%; height: 100%; object-fit: cover; object-position: top center; display: block; }
.phone-screen-shot {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
object-position: top center;
background-image: url("./assets/screenshots/02-issue-detail.png");
background-size: cover;
background-position: top center;
background-repeat: no-repeat;
}
.typing-card {
position: absolute;
left: 22px;
right: 22px;
bottom: 22px;
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
border-radius: 20px;
color: #142142;
background: rgba(255,255,255,0.92);
border: 1px solid rgba(22, 71, 255, 0.22);
box-shadow: 0 20px 38px rgba(16, 18, 24, 0.18);
font-size: 13px;
font-weight: 800;
}
.typing-dot {
width: 10px;
height: 10px;
flex: none;
border-radius: 999px;
background: var(--yellow);
box-shadow: 0 0 0 5px rgba(255, 228, 92, 0.26);
}
.tap-ring {
position: absolute;
@@ -293,7 +271,6 @@ h3 {
box-shadow: 0 0 0 16px rgba(255, 228, 92, 0.22);
}
/* Shared content sections */
.relationship, .features, .demo, .video-demo, .download, .trust, .footer {
width: min(1160px, calc(100% - 44px));
margin: 0 auto;
@@ -308,356 +285,6 @@ h3 {
align-items: center;
}
/* Relationship section */
.relationship-card {
border: 1px solid var(--line);
border-radius: 30px;
background: rgba(255,255,255,0.76);
box-shadow: 0 18px 48px rgba(16,18,24,0.08);
padding: clamp(28px, 5vw, 48px);
}
.relationship-card p {
max-width: 800px;
margin: 0 0 16px;
font-size: clamp(16px, 1.8vw, 20px);
line-height: 1.65;
color: #343b4a;
}
.relationship-card p:last-child { margin-bottom: 0; }
.relationship-card a {
color: var(--blue);
text-decoration: underline;
text-decoration-color: rgba(22, 71, 255, 0.25);
}
.relationship-card a:hover { text-decoration-color: var(--blue); }
/* Interactive product journey */
.journey {
width: min(1160px, calc(100% - 44px));
margin: 0 auto;
padding: 40px 0;
}
.journey-header {
text-align: center;
margin-bottom: 48px;
}
.journey-header h2 {
max-width: 800px;
margin: 0 auto 16px;
}
.journey-header p {
max-width: 600px;
margin: 0 auto;
color: var(--muted);
font-size: 16px;
}
.journey-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 440px);
gap: clamp(24px, 5vw, 64px);
align-items: start;
}
.journey-steps {
display: flex;
flex-direction: column;
gap: 16px;
}
.journey-step {
display: flex;
gap: 18px;
padding: 20px 24px;
border-radius: 20px;
border: 1px solid var(--line);
background: rgba(255,255,255,0.68);
cursor: pointer;
transition: background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
}
.journey-step:hover {
background: rgba(255,255,255,0.92);
border-color: rgba(22, 71, 255, 0.18);
}
.journey-step-active {
background: white;
border-color: var(--blue);
box-shadow: 0 12px 36px rgba(22, 71, 255, 0.12);
}
.journey-step-completed {
opacity: 0.65;
cursor: default;
}
.journey-step-number {
width: 32px;
height: 32px;
border-radius: 999px;
background: rgba(22, 71, 255, 0.08);
color: var(--blue);
font-size: 14px;
font-weight: 900;
display: grid;
place-items: center;
flex: none;
margin-top: 2px;
}
.journey-step-active .journey-step-number {
background: var(--blue);
color: white;
}
.journey-step-completed .journey-step-number {
background: var(--blue);
color: white;
}
.journey-step-content h3 {
font-size: 18px;
margin-bottom: 6px;
letter-spacing: -0.02em;
}
.journey-step-content p {
font-size: 18px;
line-height: 1.55;
color: var(--muted);
margin: 0;
}
.journey-phone-area {
position: sticky;
top: 40px;
}
.journey-phone-container {
position: relative;
display: flex;
justify-content: center;
}
/* Journey phone overlays */
.journey-overlay {
position: absolute;
inset: 0;
z-index: 6;
border-radius: 36px;
overflow: hidden;
}
.journey-hand {
position: absolute;
z-index: 8;
font-size: 28px;
filter: drop-shadow(0 4px 8px rgba(0,0,0,0.2));
pointer-events: none;
}
.journey-tap-ripple {
position: absolute;
z-index: 7;
width: 52px;
height: 52px;
border-radius: 999px;
border: 2px solid var(--blue);
background: rgba(255, 228, 92, 0.55);
box-shadow: 0 0 0 12px rgba(255, 228, 92, 0.18);
pointer-events: none;
}
/* Focus zoom */
.focus-zoom {
position: absolute;
z-index: 5;
border-radius: 20px;
border: 3px solid var(--blue);
background: rgba(22, 71, 255, 0.06);
pointer-events: none;
}
/* Workspace assets grid */
.workspace-section {
width: min(1160px, calc(100% - 44px));
margin: 0 auto;
}
.workspace-header {
text-align: center;
margin-bottom: 48px;
}
.workspace-header h2 {
max-width: 700px;
margin: 0 auto 16px;
}
.workspace-header p {
max-width: 560px;
margin: 0 auto;
color: var(--muted);
font-size: 16px;
}
.workspace-chips {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 36px;
}
.workspace-chip {
padding: 10px 18px;
border-radius: 999px;
font-size: 14px;
font-weight: 850;
border: 1px solid var(--line);
background: rgba(255,255,255,0.72);
color: var(--muted);
transition: background 0.18s, color 0.18s, border-color 0.18s, box-shadow 0.18s;
}
.workspace-chip:hover {
background: white;
color: var(--ink);
}
.workspace-chip-active {
background: var(--blue);
color: white;
border-color: var(--blue);
box-shadow: 0 10px 24px rgba(22, 71, 255, 0.28);
}
.workspace-phone-wrap {
display: flex;
justify-content: center;
margin-bottom: 48px;
}
.workspace-phone-wrap .phone-shell-compact {
width: min(260px, 60vw);
}
/* Mention picker overlay */
.mention-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
z-index: 10;
background: rgba(255,255,255,0.97);
backdrop-filter: blur(12px);
border-top: 1px solid var(--line);
border-radius: 24px 24px 36px 36px;
padding: 16px 14px;
box-shadow: 0 -12px 36px rgba(16, 18, 24, 0.1);
}
.mention-search {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-radius: 14px;
background: #f4f6fa;
margin-bottom: 14px;
font-size: 14px;
color: var(--muted);
}
.mention-search span {
color: var(--blue);
font-weight: 800;
}
.mention-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.mention-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-radius: 14px;
transition: background 0.12s;
}
.mention-item-active {
background: rgba(22, 71, 255, 0.07);
}
.mention-avatar {
width: 32px;
height: 32px;
border-radius: 999px;
display: grid;
place-items: center;
font-size: 13px;
font-weight: 900;
color: white;
flex: none;
}
.mention-name {
font-size: 14px;
font-weight: 800;
}
.mention-role {
font-size: 12px;
color: var(--muted);
}
.mention-highlight {
position: absolute;
z-index: 5;
border-radius: 16px;
border: 2px solid var(--blue);
background: rgba(22, 71, 255, 0.06);
pointer-events: none;
}
/* Typing demo */
.typing-line {
position: absolute;
z-index: 6;
display: flex;
align-items: center;
gap: 2px;
font-size: 18px;
font-weight: 700;
color: var(--ink);
letter-spacing: -0.01em;
line-height: 1;
white-space: pre;
pointer-events: none;
}
.typing-cursor {
display: inline-block;
width: 2px;
height: 18px;
background: var(--blue);
border-radius: 999px;
margin-left: 1px;
}
/* Features grid (legacy, kept for reference) */
.feature-grid, .trust-grid, .release-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -719,7 +346,7 @@ h3 {
box-shadow: var(--shadow);
}
.promo-video { object-fit: cover; z-index: 1; position: relative; }
.promo-video { object-fit: cover; }
.video-placeholder {
display: grid;
@@ -799,33 +426,16 @@ pre {
.footer strong { color: var(--ink); }
.footer-links { display: flex; gap: 18px; flex-wrap: wrap; font-weight: 800; color: var(--ink); }
/* Responsive */
@media (max-width: 900px) {
.topbar { border-radius: 24px; align-items: flex-start; flex-direction: column; }
.nav-links { flex-wrap: wrap; }
.hero-grid, .two-col, .demo-grid, .video-copy, .feature-grid, .trust-grid, .release-grid { grid-template-columns: 1fr; }
.hero { padding-left: 16px; padding-right: 16px; padding-top: 20px; min-height: auto; }
.hero-grid { width: 100%; }
.hero-grid > div:first-child { padding-right: 0; }
.hero-phone-stack { min-height: 400px; overflow: visible; }
.hero-phone-stack img { height: 480px; width: min(240px, 42vw); border-radius: 36px; border-width: 10px; }
.hero-phone-stack img:nth-child(1) { left: -5%; top: 36px; transform: rotate(-8deg) translateZ(-20px); }
.hero-phone-stack img:nth-child(2) { left: 22%; top: 0; transform: rotate(4deg) translateZ(40px); }
.hero-phone-stack img:nth-child(3) { right: -5%; top: 100px; transform: rotate(10deg) translateZ(10px); }
.eyebrow { font-size: 11px; letter-spacing: 0.1em; }
h1 { font-size: clamp(32px, 8vw, 52px) !important; }
.hero-subtitle { font-size: 15px; }
.journey-layout { grid-template-columns: 1fr; }
.journey-phone-area { position: relative; top: 0; order: -1; }
.journey-phone-container { justify-content: center; }
.section-pad { padding: 48px 16px; }
.hash-cell { grid-column: auto; }
.screenshot-stack { min-height: 680px; }
.shot { width: min(300px, 74vw); }
.shot.back { right: auto; left: 4%; }
.shot.front { right: 4%; top: 160px; }
.footer { flex-direction: column; }
.workspace-chips { justify-content: flex-start; }
}
@media (prefers-reduced-motion: reduce) {
@@ -835,137 +445,3 @@ pre {
scroll-behavior: auto !important;
}
}
/* Workspace card grid */
.workspace-card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 14px;
margin-top: 8px;
}
.workspace-card {
border: 1px solid var(--line);
border-radius: 18px;
background: rgba(255, 255, 255, 0.72);
overflow: hidden;
cursor: pointer;
transition: border-color 0.18s, box-shadow 0.18s, background 0.18s;
}
.workspace-card:hover {
background: white;
border-color: rgba(22, 71, 255, 0.18);
}
.workspace-card-active {
border-color: var(--blue);
box-shadow: 0 10px 28px rgba(22, 71, 255, 0.12);
background: white;
}
.workspace-card-thumb {
width: 100%;
aspect-ratio: 9 / 19.7;
object-fit: cover;
object-position: top center;
display: block;
border-bottom: 1px solid var(--line);
}
.workspace-card-label {
padding: 10px 12px;
}
.workspace-card-label strong {
display: block;
font-size: 13px;
font-weight: 850;
letter-spacing: -0.01em;
margin-bottom: 2px;
}
.workspace-card-label span {
display: block;
font-size: 11px;
color: var(--muted);
line-height: 1.4;
}
@media (max-width: 900px) {
.workspace-card-grid {
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
}
}
.star-hint {
margin-top: 20px;
font-size: 18px;
color: var(--muted);
text-align: center;
font-weight: 600;
}
.star-hint a {
text-decoration: underline;
text-underline-offset: 3px;
}
/* ── Footer pills ── */
.pill {
display: inline-flex; align-items: center; gap: 7px;
padding: 10px 22px; border-radius: 999px;
font-weight: 850; font-size: 15px; letter-spacing: -0.01em;
text-decoration: none; transition: all 0.22s;
}
.pill-android {
background: var(--blue); color: #fff;
box-shadow: 0 12px 30px rgba(22,71,255,0.30);
}
.pill-android:hover { background: var(--blue-2); transform: translateY(-1px); }
.pill-android .android-icon {
width: 15px; height: 15px; display: inline-block; background: #fff;
-webkit-mask: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTYgMThjMCAuNTUuNDUgMSAxIDFoMXYzLjVjMCAuODMuNjcgMS41IDEuNSAxLjVzMS41LS42NyAxLjUtMS41VjE5aDJ2My41YzAgLjgzLjY3IDEuNSAxLjUgMS41czEuNS0uNjcgMS41LTEuNVYxOWgxYy41NSAwIDEtLjQ1IDEtMVY4SDZ2MTB6TTMuNSA4QzIuNjcgOCAyIDguNjcgMiA5LjV2N2MwIC44My42NyAxLjUgMS41IDEuNVM1IDE3LjMzIDUgMTYuNXYtN0M1IDguNjcgNC4zMyA4IDMuNSA4em0xNyAwYy0uODMgMC0xLjUuNjctMS41IDEuNXY3YzAgLjgzLjY3IDEuNSAxLjUgMS41czEuNS0uNjcgMS41LTEuNXYtN2MwLS44My0uNjctMS41LTEuNS0xLjV6bS00Ljk3LTUuODRsMS4zLTEuM2MuMi0uMi4yLS41MSAwLS43MS0uMi0uMi0uNTEtLjItLjcxIDBsLTEuNDggMS40OEE1Ljg0IDUuODQgMCAwMDEyIDFjLS45NiAwLTEuODYuMjMtMi42Ni42M0w3Ljg1LjE1Yy0uMi0uMi0uNTEtLjItLjcxIDAtLjIuMi0uMi41MSAwIC43MWwxLjMxIDEuMzFBNS45ODMgNS45ODMgMCAwMDYgN2gxMmMwLTIuMTItMS4xLTMuOTgtMi40Ny00Ljg0ek0xMCA1SDlWNGgxdjF6bTUgMGgtMVY0aDF2MXoiLz48L3N2Zz4=) center/contain no-repeat;
mask: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTYgMThjMCAuNTUuNDUgMSAxIDFoMXYzLjVjMCAuODMuNjcgMS41IDEuNSAxLjVzMS41LS42NyAxLjUtMS41VjE5aDJ2My41YzAgLjgzLjY3IDEuNSAxLjUgMS41czEuNS0uNjcgMS41LTEuNVYxOWgxYy41NSAwIDEtLjQ1IDEtMVY4SDZ2MTB6TTMuNSA4QzIuNjcgOCAyIDguNjcgMiA5LjV2N2MwIC44My42NyAxLjUgMS41IDEuNVM1IDE3LjMzIDUgMTYuNXYtN0M1IDguNjcgNC4zMyA4IDMuNSA4em0xNyAwYy0uODMgMC0xLjUuNjctMS41IDEuNXY3YzAgLjgzLjY3IDEuNSAxLjUgMS41czEuNS0uNjcgMS41LTEuNXYtN2MwLS44My0uNjctMS41LTEuNS0xLjV6bS00Ljk3LTUuODRsMS4zLTEuM2MuMi0uMi4yLS41MSAwLS43MS0uMi0uMi0uNTEtLjItLjcxIDBsLTEuNDggMS40OEE1Ljg0IDUuODQgMCAwMDEyIDFjLS45NiAwLTEuODYuMjMtMi42Ni42M0w3Ljg1LjE1Yy0uMi0uMi0uNTEtLjItLjcxIDAtLjIuMi0uMi41MSAwIC43MWwxLjMxIDEuMzFBNS45ODMgNS45ODMgMCAwMDYgN2gxMmMwLTIuMTItMS4xLTMuOTgtMi40Ny00Ljg0ek0xMCA1SDlWNGgxdjF6bTUgMGgtMVY0aDF2MXoiLz48L3N2Zz4=) center/contain no-repeat;
}
.pill-ios {
position: relative; overflow: hidden;
background: rgba(255,255,255,0.22);
backdrop-filter: blur(20px) saturate(1.8);
-webkit-backdrop-filter: blur(20px) saturate(1.8);
color: #1a1a2e; font-weight: 850;
border: 1px solid rgba(255,255,255,0.55);
box-shadow: 0 4px 20px rgba(0,0,0,0.08),
inset 0 1px 0 rgba(255,255,255,0.9),
inset 0 -1px 0 rgba(0,0,0,0.04);
}
.pill-ios:hover {
background: rgba(255,255,255,0.38);
transform: translateY(-1px);
box-shadow: 0 8px 28px rgba(0,0,0,0.12),
inset 0 1px 0 rgba(255,255,255,1),
inset 0 -1px 0 rgba(0,0,0,0.04);
}
.pill-ios::before {
content: "";
position: absolute; top: 0; left: -40%; width: 180%; height: 50%;
background: linear-gradient(180deg, rgba(255,255,255,0.7) 0%, rgba(255,255,255,0) 100%);
border-radius: 999px; pointer-events: none;
}
.pill-ios::after {
content: "";
position: absolute; bottom: -2px; left: 10%; width: 80%; height: 6px;
background: linear-gradient(90deg, #ff6b9d, #c44dff, #6b9dff, #45d4a8);
filter: blur(5px); opacity: 0.4; border-radius: 999px;
pointer-events: none; z-index: -1;
}
.pill-ios .apple-icon {
width: 14px; height: 14px; display: inline-block; background: #1a1a2e;
-webkit-mask: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTE4LjcxIDE5LjVjLS44MyAxLjI0LTEuNzEgMi40NS0zLjA1IDIuNDctMS4zNC4wMy0xLjc3LS43OS0zLjI5LS43OS0xLjUzIDAtMiAuNzctMy4yNy44Mi0xLjMxLjA1LTIuMy0xLjMyLTMuMTQtMi41M0M0LjI1IDE3IDIuOTQgMTIuNDUgNC43IDkuMzljLjg3LTEuNTIgMi40My0yLjQ4IDQuMTItMi41MSAxLjI4LS4wMiAyLjUuODcgMy4yOS44Ny43OCAwIDIuMjYtMS4wNyAzLjgxLS45MS42NS4wMyAyLjQ3LjI2IDMuNjQgMS45OC0uMDkuMDYtMi4xNyAxLjI4LTIuMTUgMy44MS4wMyAzLjAyIDIuNjUgNC4wMyAyLjY4IDQuMDQtLjAzLjA3LS40MiAxLjQ0LTEuMzggMi44M00xMyAzLjVjLjczLS44MyAxLjk0LTEuNDYgMi45NC0xLjUuMTMgMS4xNy0uMzQgMi4zNS0xLjA0IDMuMTktLjY5Ljg1LTEuODMgMS41MS0yLjk1IDEuNDItLjE1LTEuMTUuNDEtMi4zNSAxLjA1LTMuMTF6Ii8+PC9zdmc+) center/contain no-repeat;
mask: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTE4LjcxIDE5LjVjLS44MyAxLjI0LTEuNzEgMi40NS0zLjA1IDIuNDctMS4zNC4wMy0xLjc3LS43OS0zLjI5LS43OS0xLjUzIDAtMiAuNzctMy4yNy44Mi0xLjMxLjA1LTIuMy0xLjMyLTMuMTQtMi41M0M0LjI1IDE3IDIuOTQgMTIuNDUgNC43IDkuMzljLjg3LTEuNTIgMi40My0yLjQ4IDQuMTItMi41MSAxLjI4LS4wMiAyLjUuODcgMy4yOS44Ny43OCAwIDIuMjYtMS4wNyAzLjgxLS45MS42NS4wMyAyLjQ3LjI2IDMuNjQgMS45OC0uMDkuMDYtMi4xNyAxLjI4LTIuMTUgMy44MS4wMyAzLjAyIDIuNjUgNC4wMyAyLjY4IDQuMDQtLjAzLjA3LS40MiAxLjQ0LTEuMzggMi44M00xMyAzLjVjLjczLS44MyAxLjk0LTEuNDYgMi45NC0xLjUuMTMgMS4xNy0uMzQgMi4zNS0xLjA0IDMuMTktLjY5Ljg1LTEuODMgMS41MS0yLjk1IDEuNDItLjE1LTEuMTUuNDEtMi4zNSAxLjA1LTMuMTF6Ii8+PC9zdmc+) center/contain no-repeat;
}
@media (max-width: 600px) {
.pill { padding: 8px 16px; font-size: 14px; }
}
-13
View File
@@ -1,13 +0,0 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig(() => ({
plugins: [react()],
base: process.env.VITE_BASE || '/',
build: {
outDir: 'dist',
},
server: {
host: '127.0.0.1',
},
}));