Checkpoint public Android release state

This commit is contained in:
Xisheng-Zhao
2026-06-07 03:38:36 +08:00
parent d9faa6c0c6
commit ff09efe3a1
22 changed files with 2427 additions and 273 deletions
+2
View File
@@ -7,3 +7,5 @@ local.properties
*.iml
.DS_Store
*.hprof
.recovery-backups/
.superpowers/
+28 -2
View File
@@ -30,6 +30,17 @@ def umengPublicAppKey = configValue("multicaUmengPublicAppKey", "MULTICA_UMENG_P
def postHogPublicApiKey = configValue("multicaPostHogPublicApiKey", "MULTICA_POSTHOG_PUBLIC_API_KEY", "")
def postHogPublicHost = configValue("multicaPostHogPublicHost", "MULTICA_POSTHOG_PUBLIC_HOST", "https://us.i.posthog.com")
def analyticsRoute = configValue("multicaAnalyticsRoute", "MULTICA_ANALYTICS_ROUTE", "auto")
def releaseStoreFilePath = configValue("multicaReleaseStoreFile", "MULTICA_RELEASE_STORE_FILE", "")
def releaseStorePassword = configValue("multicaReleaseStorePassword", "MULTICA_RELEASE_STORE_PASSWORD", "")
def releaseKeyAlias = configValue("multicaReleaseKeyAlias", "MULTICA_RELEASE_KEY_ALIAS", "")
def releaseKeyPassword = configValue("multicaReleaseKeyPassword", "MULTICA_RELEASE_KEY_PASSWORD", "")
def hasReleaseSigning = !releaseStoreFilePath.isBlank()
&& !releaseStorePassword.isBlank()
&& !releaseKeyAlias.isBlank()
&& !releaseKeyPassword.isBlank()
def releaseStoreFile = releaseStoreFilePath.isBlank()
? null
: (new File(releaseStoreFilePath).isAbsolute() ? file(releaseStoreFilePath) : rootProject.file(releaseStoreFilePath))
android {
namespace "ai.multica.app"
@@ -40,8 +51,8 @@ android {
applicationId "ai.multicasual.app"
minSdk 24
targetSdk 36
versionCode 1
versionName "1.0"
versionCode 2
versionName "0.1.1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
@@ -60,6 +71,18 @@ android {
}
flavorDimensions "distribution"
signingConfigs {
release {
if (hasReleaseSigning) {
storeFile releaseStoreFile
storePassword releaseStorePassword
keyAlias releaseKeyAlias
keyPassword releaseKeyPassword
}
}
}
productFlavors {
create("public") {
dimension "distribution"
@@ -87,6 +110,9 @@ android {
buildTypes {
release {
if (hasReleaseSigning) {
signingConfig signingConfigs.release
}
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
}
}
+31 -12
View File
@@ -237,12 +237,12 @@ final class ApiClient {
}
List<Models.Issue> searchIssues(String workspaceId, String text, int limit) throws Exception {
JSONObject query = query(null,
"workspace_id", workspaceId,
"q", text,
"limit", limit,
"include_closed", "true");
JSONObject json = requestObject("GET", "/api/issues/search", query, null);
return searchIssues(workspaceId, null, text, limit);
}
List<Models.Issue> searchIssues(String workspaceId, String workspaceSlug, String text, int limit) throws Exception {
JSONObject json = requestObject("GET", "/api/issues/search",
searchQuery(text, limit), null, searchWorkspaceHeaders(workspaceId, workspaceSlug));
return parseArray(extractArray(json, "issues"), Models.Issue::new);
}
@@ -582,12 +582,12 @@ final class ApiClient {
}
List<Models.Project> searchProjects(String workspaceId, String text, int limit) throws Exception {
JSONObject query = query(null,
"workspace_id", workspaceId,
"q", text,
"limit", limit,
"include_closed", "true");
JSONObject json = requestObject("GET", "/api/projects/search", query, null);
return searchProjects(workspaceId, null, text, limit);
}
List<Models.Project> searchProjects(String workspaceId, String workspaceSlug, String text, int limit) throws Exception {
JSONObject json = requestObject("GET", "/api/projects/search",
searchQuery(text, limit), null, searchWorkspaceHeaders(workspaceId, workspaceSlug));
return parseArray(extractArray(json, "projects"), Models.Project::new);
}
@@ -1531,6 +1531,25 @@ final class ApiClient {
return obj;
}
static JSONObject searchQuery(String text, int limit) throws Exception {
return query(null,
"q", text,
"limit", limit,
"include_closed", "true");
}
static Map<String, String> searchWorkspaceHeaders(String workspaceId, String workspaceSlug) {
String slug = workspaceSlug == null ? "" : workspaceSlug.trim();
if (!slug.isEmpty()) {
return Collections.singletonMap("X-Workspace-Slug", slug);
}
String id = workspaceId == null ? "" : workspaceId.trim();
if (!id.isEmpty()) {
return Collections.singletonMap("X-Workspace-ID", id);
}
throw new IllegalArgumentException("workspace_id is required. Select a workspace first.");
}
private static boolean isBlankQueryValue(Object value) {
if (value == null || value == JSONObject.NULL) return true;
return String.valueOf(value).trim().isEmpty();
@@ -23,7 +23,7 @@ public final class AppAnalytics {
private AppAnalytics() {}
public static boolean shouldPromptForConsent(Context context) {
return !new AnalyticsConsentStore(context).hasDecision();
return !new AnalyticsConsentStore(context).isGranted();
}
public static void grantConsentAndInitialize(Context context) {
@@ -1,8 +1,10 @@
package ai.multica.app
import android.app.Activity
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import android.content.res.Configuration
import android.graphics.Color as AndroidColor
@@ -554,6 +556,19 @@ private fun ComposePilotApp(
var showAnalyticsConsent by remember { mutableStateOf(AppAnalytics.shouldPromptForConsent(context)) }
val zh = authStore.isChinese()
fun exitApp() {
var currentContext: Context? = context
while (currentContext is ContextWrapper) {
if (currentContext is Activity) {
currentContext.finishAffinity()
return
}
currentContext = currentContext.baseContext
}
}
BackHandler(enabled = showAnalyticsConsent) { exitApp() }
LaunchedEffect(refresh) {
appState = PilotState.Loading
if (authStore.token().isNullOrBlank()) {
@@ -620,17 +635,14 @@ private fun ComposePilotApp(
visible = showAnalyticsConsent,
title = if (zh) "允许基础使用统计?" else "Allow basic usage analytics?",
message = if (zh) {
"Multi-Casual 只会上报 app_open 启动事件,用于统计用户量和日活。中国大陆用户使用友盟SDK,海外用户使用 PostHog SDK;SDK 可能按各自规则处理设备信息、Android ID、IP 地址和网络状态。我们不会采集页面浏览、聊天内容、文件内容、邮箱、Token 或精确位置。你可以选择不同意,应用仍可继续使用。"
"Multi-Casual 只会上报 app_open 启动事件,用于统计用户量和日活。中国大陆用户使用友盟SDK,海外用户使用 PostHog SDK;SDK 可能按各自规则处理设备信息、Android ID、IP 地址和网络状态。我们不会采集页面浏览、聊天内容、文件内容、邮箱、Token 或精确位置。不同意将退出应用。"
} else {
"Multi-Casual only reports the app_open event to measure user count and daily active users. Mainland China uses the Umeng SDK, while global users use the PostHog SDK; each SDK may process device information, Android ID, IP address, and network state under its own policy. We do not collect page views, chat content, file content, email, tokens, or precise location. You can decline and still use the app."
"Multi-Casual only reports the app_open event to measure user count and daily active users. Mainland China uses the Umeng SDK, while global users use the PostHog SDK; each SDK may process device information, Android ID, IP address, and network state under its own policy. We do not collect page views, chat content, file content, email, tokens, or precise location. Declining exits the app."
},
cancelText = if (zh) "不同意" else "Decline",
confirmText = if (zh) "同意并继续" else "Agree",
cancelText = if (zh) "不同意并退出" else "Decline and Exit",
confirmText = if (zh) "同意并继续使用" else "Agree and Continue",
contentDescription = "Analytics Consent",
onDismissRequest = {
AppAnalytics.denyConsent(context)
showAnalyticsConsent = false
},
onDismissRequest = { exitApp() },
onConfirm = {
AppAnalytics.grantConsentAndInitialize(context)
showAnalyticsConsent = false
@@ -740,7 +752,7 @@ private fun PilotLoginScreen(
textAlign = TextAlign.Center,
)
Text(
text = if (zh) "输入邮箱获取验证码,和 Web/桌面端使用同一套官方登录服务。" else "Use your email code. This is the same official sign-in service as web and desktop.",
text = if (zh) "输入邮箱获取验证码,和 Web/桌面端使用同一套邮箱验证码登录服务。" else "Use your email code. This is the same email-code sign-in service as web and desktop.",
style = MaterialTheme.typography.bodyMedium.copy(fontSize = 15.sp, lineHeight = 21.sp),
color = MulticaColors.Muted,
textAlign = TextAlign.Center,
@@ -845,16 +857,6 @@ private fun PilotLoginScreen(
}
}
}
item {
MulticaPillButton(
text = if (zh) "用浏览器打开 Web 登录" else "Open Web Sign In",
onClick = {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("${BuildConfig.MULTICA_WEB_BASE_URL}/login")))
},
tone = MulticaButtonTone.Ghost,
contentDescription = "Login Open Web Sign In",
)
}
}
}
}
@@ -1755,6 +1757,7 @@ private fun ComposePilotShell(
PilotSearchPage(
api = api,
workspaceId = session.workspace.id,
workspaceSlug = session.workspace.slug,
zh = zh,
query = retainedSearchQuery,
onQueryChange = { retainedSearchQuery = it },
@@ -2003,6 +2006,7 @@ private fun PilotTabContent(
private fun PilotSearchPage(
api: ApiClient,
workspaceId: String,
workspaceSlug: String,
zh: Boolean,
query: String,
onQueryChange: (String) -> Unit,
@@ -2031,8 +2035,8 @@ private fun PilotSearchPage(
val searchResult = withContext(Dispatchers.IO) {
runCatching {
PilotSearchData(
issues = api.searchIssues(workspaceId, text, 20),
projects = api.searchProjects(workspaceId, text, 10),
issues = api.searchIssues(workspaceId, workspaceSlug, text, 20),
projects = api.searchProjects(workspaceId, workspaceSlug, text, 10),
agents = runCatching { searchLocalAgents(api.agents(workspaceId), text) }.getOrElse { emptyList() },
skills = runCatching { searchLocalSkills(api.skills(workspaceId), text) }.getOrElse { emptyList() },
)
@@ -9481,7 +9485,7 @@ private fun PilotIssueDetail(
issueReferenceMessage = null
scope.launch {
val result = withContext(Dispatchers.IO) {
runCatching { api.searchIssues(workspaceId, identifier, 10) }
runCatching { api.searchIssues(workspaceId, workspaceSlug, identifier, 10) }
}
result.onSuccess { issues ->
val match = issues.firstOrNull { it.identifier.equals(identifier, ignoreCase = true) }
@@ -9799,7 +9803,7 @@ private fun PilotIssueDetail(
}
fun sendNewComment() {
val content = newCommentDraft.trim()
val content = AgentMentionMarkdown.markdownFromDraft(newCommentDraft.trim(), data.agents)
if (sendingNewComment || content.isEmpty() && pendingCommentAttachments.isEmpty()) return
sendingNewComment = true
newCommentMessage = null
@@ -9820,8 +9824,10 @@ private fun PilotIssueDetail(
}
}
fun sendReply(parentId: String) {
val content = replyDraft.trim()
fun sendReply(thread: IssueCommentThreads.Thread) {
val parentId = thread.root.id
val explicitContent = AgentMentionMarkdown.markdownFromDraft(replyDraft.trim(), data.agents)
val content = IssueCommentThreads.replyContentForThread(thread, explicitContent, data.agents)
if (sendingReply || (content.isEmpty() && pendingReplyAttachments.isEmpty())) return
sendingReply = true
replyMessage = null
@@ -9872,6 +9878,10 @@ private fun PilotIssueDetail(
?.takeIf { it.isNotBlank() }
?: data.issue.projectId?.takeIf { it.isNotBlank() }?.let { Models.shortId(it) }
?: if (zh) "无项目" else "No project"
val commentThreads = IssueCommentThreads.build(data.comments, commentsDescending)
val replyingThread = replyingCommentId?.let { activeReplyId ->
commentThreads.firstOrNull { it.root.id == activeReplyId }
}
PilotListPage(
title = "${data.issue.identifier} · ${Models.statusLabel(data.issue.status, zh)}",
@@ -9915,18 +9925,44 @@ private fun PilotIssueDetail(
contentDescription = "Issue Comment Bottom Haze Composer $WEB_SSOT_COMMENT_INPUT"
},
) {
IssueCommentInputBar(
draft = newCommentDraft,
pendingAttachments = pendingCommentAttachments,
agents = data.agents,
uploadingAttachment = uploadingCommentAttachment,
sending = sendingNewComment,
zh = zh,
onDraftChange = { newCommentDraft = it },
onAttach = { if (!uploadingCommentAttachment) commentAttachmentLauncher.launch("*/*") },
onAttachImage = { if (!uploadingCommentAttachment) commentImageAttachmentLauncher.launch("image/*") },
onSend = { sendNewComment() },
)
if (replyingThread != null) {
IssueCommentReplyBox(
parentId = replyingThread.root.id,
draft = replyDraft,
pendingAttachments = pendingReplyAttachments,
agents = data.agents,
uploadingAttachment = uploadingReplyAttachment,
sending = sendingReply,
zh = zh,
bottomMode = true,
onDraftChange = { replyDraft = it },
onAttach = { if (!uploadingReplyAttachment) replyAttachmentLauncher.launch("*/*") },
onAttachImage = { if (!uploadingReplyAttachment) replyImageAttachmentLauncher.launch("image/*") },
onRemoveAttachment = { removeIndex ->
pendingReplyAttachments = pendingReplyAttachments.filterIndexed { index, _ -> index != removeIndex }
},
onCancel = {
replyingCommentId = null
replyDraft = ""
pendingReplyAttachments = emptyList()
replyMessage = null
},
onSend = { sendReply(replyingThread) },
)
} else {
IssueCommentInputBar(
draft = newCommentDraft,
pendingAttachments = pendingCommentAttachments,
agents = data.agents,
uploadingAttachment = uploadingCommentAttachment,
sending = sendingNewComment,
zh = zh,
onDraftChange = { newCommentDraft = it },
onAttach = { if (!uploadingCommentAttachment) commentAttachmentLauncher.launch("*/*") },
onAttachImage = { if (!uploadingCommentAttachment) commentImageAttachmentLauncher.launch("image/*") },
onSend = { sendNewComment() },
)
}
}
},
) {
@@ -10110,166 +10146,158 @@ private fun PilotIssueDetail(
)
}
}
val comments = if (commentsDescending) {
data.comments.sortedByDescending { it.createdAt }
} else {
data.comments.sortedBy { it.createdAt }
}
if (comments.isEmpty()) {
val threads = commentThreads
if (threads.isEmpty()) {
item { IssueDetailCommentsWebEmptyState(zh = zh) }
}
val baseVisibleCount = if (showExpandedCommentHistory) 8 else 2
val highlightedIndex = highlightCommentId?.let { targetId ->
comments.indexOfFirst { it.id == targetId || it.parentId == targetId }.takeIf { it >= 0 }
threads.indexOfFirst { thread ->
thread.root.id == targetId || thread.replies.any { it.id == targetId }
}.takeIf { it >= 0 }
}
val visibleCommentCount = maxOf(
val visibleThreadCount = maxOf(
baseVisibleCount,
highlightedIndex?.plus(1) ?: 0,
).coerceAtMost(comments.size)
val visibleComments = comments.take(visibleCommentCount)
items(visibleComments) { comment ->
val content = clean(comment.content)
val authorName = Models.commentAuthorDisplayName(comment, data.members, data.agents, currentUser)
val authorAvatarUrl = Models.commentAuthorAvatarUrl(comment, data.members, data.agents, currentUser)
IssueCommentCard(
comment = comment,
authorName = authorName,
authorAvatarUrl = authorAvatarUrl,
authorAgentStatus = commentAuthorAgentStatus(comment, data.agents),
members = data.members,
agents = data.agents,
currentUser = currentUser,
onIssueIdClick = { onOpenIssue(it) },
onIssueReferenceClick = { openIssueReference(it) },
expandedActions = expandedCommentActionsId == comment.id,
contentExpanded = comment.id in expandedCommentContentIds,
highlighted = comment.id == highlightCommentId,
zh = zh,
onToggleActions = {
expandedCommentActionsId = if (expandedCommentActionsId == comment.id) null else comment.id
},
onToggleContent = {
expandedCommentContentIds = if (comment.id in expandedCommentContentIds) {
expandedCommentContentIds - comment.id
} else {
expandedCommentContentIds + comment.id
).coerceAtMost(threads.size)
val visibleThreads = threads.take(visibleThreadCount)
items(visibleThreads) { thread ->
Column(
modifier = Modifier
.fillMaxWidth()
.semantics(mergeDescendants = false) {
contentDescription = "Issue Comment Thread ${thread.root.id}"
},
) {
(listOf(thread.root) + thread.replies).forEach { comment ->
val authorName = Models.commentAuthorDisplayName(comment, data.members, data.agents, currentUser)
val authorAvatarUrl = Models.commentAuthorAvatarUrl(comment, data.members, data.agents, currentUser)
val isThreadReply = comment.id != thread.root.id
Column(
modifier = Modifier
.fillMaxWidth()
.padding(start = if (isThreadReply) 28.dp else 0.dp),
) {
IssueCommentCard(
comment = comment,
authorName = authorName,
authorAvatarUrl = authorAvatarUrl,
authorAgentStatus = commentAuthorAgentStatus(comment, data.agents),
members = data.members,
agents = data.agents,
currentUser = currentUser,
onIssueIdClick = { onOpenIssue(it) },
onIssueReferenceClick = { openIssueReference(it) },
expandedActions = expandedCommentActionsId == comment.id,
contentExpanded = comment.id in expandedCommentContentIds,
highlighted = comment.id == highlightCommentId,
zh = zh,
onToggleActions = {
expandedCommentActionsId = if (expandedCommentActionsId == comment.id) null else comment.id
},
onToggleContent = {
expandedCommentContentIds = if (comment.id in expandedCommentContentIds) {
expandedCommentContentIds - comment.id
} else {
expandedCommentContentIds + comment.id
}
},
onReplyAction = {
replyingCommentId = if (replyingCommentId == thread.root.id) null else thread.root.id
replyDraft = ""
pendingReplyAttachments = emptyList()
editingCommentId = null
editingCommentContent = ""
pendingDeleteComment = null
replyMessage = null
},
onEditAction = {
editingCommentId = comment.id
editingCommentContent = comment.content
pendingDeleteComment = null
replyingCommentId = null
replyDraft = ""
},
onDeleteAction = {
pendingDeleteComment = comment
editingCommentId = null
editingCommentContent = ""
replyingCommentId = null
replyDraft = ""
},
)
if (comment.attachments.isNotEmpty()) {
CompactInfoPanel(
modifier = Modifier
.fillMaxWidth()
.padding(start = 38.dp, bottom = 8.dp),
) {
comment.attachments.forEachIndexed { index, attachment ->
CommentAttachmentWebDenseRow(
attachment = attachment,
zh = zh,
showDivider = index < comment.attachments.lastIndex,
onOpen = { openAttachment(attachment) },
)
}
}
}
if (editingCommentId == comment.id) {
MulticaTextField(
value = editingCommentContent,
onValueChange = { editingCommentContent = it },
label = if (zh) "编辑评论" else "Edit comment",
modifier = Modifier
.fillMaxWidth()
.semantics { contentDescription = "Issue Comment Edit Field ${comment.id}" },
minLines = 3,
maxLines = 8,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
MulticaPillButton(
text = if (zh) "取消" else "Cancel",
onClick = {
editingCommentId = null
editingCommentContent = ""
},
modifier = Modifier.weight(1f),
tone = MulticaButtonTone.Ghost,
)
MulticaPillButton(
text = if (zh) "保存评论" else "Save Comment",
onClick = {
val nextContent = editingCommentContent.trim()
if (nextContent.isEmpty()) return@MulticaPillButton
runCommentMutation { api.updateComment(workspaceId, comment.id, nextContent) }
},
modifier = Modifier.weight(1f),
tone = MulticaButtonTone.Primary,
)
}
}
if (pendingDeleteComment?.id == comment.id) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(start = 38.dp, bottom = 8.dp)
.semantics(mergeDescendants = true) {
contentDescription = "Issue Comment Delete Cupertino Alert Preview Row ${comment.id}"
},
)
}
}
},
onReplyAction = {
replyingCommentId = if (replyingCommentId == comment.id) null else comment.id
replyDraft = ""
pendingReplyAttachments = emptyList()
editingCommentId = null
editingCommentContent = ""
pendingDeleteComment = null
replyMessage = null
},
onEditAction = {
editingCommentId = comment.id
editingCommentContent = comment.content
pendingDeleteComment = null
replyingCommentId = null
replyDraft = ""
},
onDeleteAction = {
pendingDeleteComment = comment
editingCommentId = null
editingCommentContent = ""
replyingCommentId = null
replyDraft = ""
},
)
if (replyingCommentId == comment.id) {
IssueCommentReplyBox(
parentId = comment.id,
draft = replyDraft,
pendingAttachments = pendingReplyAttachments,
agents = data.agents,
uploadingAttachment = uploadingReplyAttachment,
sending = sendingReply,
zh = zh,
onDraftChange = { replyDraft = it },
onAttach = { if (!uploadingReplyAttachment) replyAttachmentLauncher.launch("*/*") },
onAttachImage = { if (!uploadingReplyAttachment) replyImageAttachmentLauncher.launch("image/*") },
onRemoveAttachment = { removeIndex ->
pendingReplyAttachments = pendingReplyAttachments.filterIndexed { index, _ -> index != removeIndex }
},
onCancel = {
replyingCommentId = null
replyDraft = ""
pendingReplyAttachments = emptyList()
replyMessage = null
},
onSend = { sendReply(comment.id) },
)
}
if (!replyMessage.isNullOrBlank() && replyingCommentId == comment.id) {
}
if (!replyMessage.isNullOrBlank() && replyingCommentId == thread.root.id) {
PilotInlineResultState(
message = replyMessage.orEmpty(),
isError = pilotInlineResultIsError(replyMessage.orEmpty()),
contentDescription = "Issue Detail Reply Result Web Banner",
)
}
if (comment.attachments.isNotEmpty()) {
CompactInfoPanel(
modifier = Modifier
.fillMaxWidth()
.padding(start = 38.dp, bottom = 8.dp),
) {
comment.attachments.forEachIndexed { index, attachment ->
CommentAttachmentWebDenseRow(
attachment = attachment,
zh = zh,
showDivider = index < comment.attachments.lastIndex,
onOpen = { openAttachment(attachment) },
)
}
}
}
if (editingCommentId == comment.id) {
MulticaTextField(
value = editingCommentContent,
onValueChange = { editingCommentContent = it },
label = if (zh) "编辑评论" else "Edit comment",
modifier = Modifier
.fillMaxWidth()
.semantics { contentDescription = "Issue Comment Edit Field ${comment.id}" },
minLines = 3,
maxLines = 8,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
MulticaPillButton(
text = if (zh) "取消" else "Cancel",
onClick = {
editingCommentId = null
editingCommentContent = ""
},
modifier = Modifier.weight(1f),
tone = MulticaButtonTone.Ghost,
)
MulticaPillButton(
text = if (zh) "保存评论" else "Save Comment",
onClick = {
val nextContent = editingCommentContent.trim()
if (nextContent.isEmpty()) return@MulticaPillButton
runCommentMutation { api.updateComment(workspaceId, comment.id, nextContent) }
},
modifier = Modifier.weight(1f),
tone = MulticaButtonTone.Primary,
)
}
}
if (pendingDeleteComment?.id == comment.id) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(start = 38.dp, bottom = 8.dp)
.semantics(mergeDescendants = true) {
contentDescription = "Issue Comment Delete Cupertino Alert Preview Row ${comment.id}"
},
)
}
if (!commentMessage.isNullOrBlank()) {
}
if (!commentMessage.isNullOrBlank()) {
item {
PilotInlineResultState(
message = commentMessage.orEmpty(),
isError = pilotInlineResultIsError(commentMessage.orEmpty()),
@@ -10277,34 +10305,34 @@ private fun PilotIssueDetail(
)
}
}
if (comments.size > visibleComments.size) {
if (threads.size > visibleThreads.size) {
item {
IssueDetailWebActionRow(
eyebrow = if (zh) "评论历史" else "Comment history",
title = if (zh) {
if (showExpandedCommentHistory) {
"已显示最新 ${visibleComments.size} ,点此收起"
"已显示最新 ${visibleThreads.size} 个帖子,点此收起"
} else {
"已优先显示最新 ${visibleComments.size} ,点此查看更多"
"已优先显示最新 ${visibleThreads.size} 个帖子,点此查看更多"
}
} else {
if (showExpandedCommentHistory) {
"Showing latest ${visibleComments.size}; tap to collapse"
"Showing latest ${visibleThreads.size} threads; tap to collapse"
} else {
"Showing latest ${visibleComments.size}; tap for more"
"Showing latest ${visibleThreads.size} threads; tap for more"
}
},
subtitle = if (zh) {
if (showExpandedCommentHistory) {
"还有 ${comments.size - visibleComments.size} 更早评论"
"还有 ${threads.size - visibleThreads.size} 更早帖子"
} else {
"还有 ${comments.size - visibleComments.size} 历史评论"
"还有 ${threads.size - visibleThreads.size} 历史帖子"
}
} else {
if (showExpandedCommentHistory) {
"${comments.size - visibleComments.size} older comments"
"${threads.size - visibleThreads.size} older threads"
} else {
"${comments.size - visibleComments.size} older comments"
"${threads.size - visibleThreads.size} older threads"
}
},
icon = if (showExpandedCommentHistory) Icons.Outlined.KeyboardArrowUp else Icons.Outlined.KeyboardArrowDown,
@@ -10716,6 +10744,7 @@ private fun IssueCommentReplyBox(
uploadingAttachment: Boolean,
sending: Boolean,
zh: Boolean,
bottomMode: Boolean = false,
onDraftChange: (String) -> Unit,
onAttach: () -> Unit,
onAttachImage: () -> Unit,
@@ -10728,6 +10757,7 @@ private fun IssueCommentReplyBox(
val focusRequester = remember(parentId) { FocusRequester() }
val keyboard = LocalSoftwareKeyboardController.current
val focusManager = LocalFocusManager.current
val replyActionModifier = Modifier.size(42.dp)
val mentionAgents = remember(agents, mentionQuery) {
val query = mentionQuery.trim().lowercase()
agents
@@ -10750,7 +10780,7 @@ private fun IssueCommentReplyBox(
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(start = 38.dp, bottom = 8.dp)
.padding(start = if (bottomMode) 0.dp else 38.dp, bottom = if (bottomMode) 0.dp else 8.dp)
.semantics(mergeDescendants = false) { contentDescription = "Issue Comment Web Reply Editor $parentId" },
shape = RoundedCornerShape(14.dp),
color = MulticaColors.Surface.copy(alpha = 0.82f),
@@ -10796,25 +10826,28 @@ private fun IssueCommentReplyBox(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
MulticaPillButton(
text = if (uploadingAttachment) "..." else if (zh) "图片" else "Image",
MulticaIconPillButton(
icon = Icons.Outlined.Image,
contentDescription = "Issue Comment Reply Attach Image",
onClick = onAttachImage,
tone = MulticaButtonTone.Secondary,
contentDescription = "Issue Comment Reply Attach Image",
enabled = !uploadingAttachment,
modifier = replyActionModifier,
)
MulticaPillButton(
text = "+",
onClick = onAttach,
tone = MulticaButtonTone.Ghost,
MulticaIconPillButton(
icon = Icons.Outlined.AttachFile,
contentDescription = "Issue Comment Reply Attach",
onClick = onAttach,
tone = MulticaButtonTone.Secondary,
enabled = !uploadingAttachment,
modifier = replyActionModifier,
)
MulticaPillButton(
text = "@",
MulticaIconPillButton(
icon = Icons.Outlined.AlternateEmail,
contentDescription = "Issue Comment Reply Agent Mention",
onClick = { mentionPickerOpen = !mentionPickerOpen },
tone = if (mentionPickerOpen) MulticaButtonTone.Primary else MulticaButtonTone.Secondary,
contentDescription = "Issue Comment Reply Agent Mention",
modifier = replyActionModifier,
)
MulticaPillButton(
text = if (zh) "取消" else "Cancel",
@@ -10831,7 +10864,7 @@ private fun IssueCommentReplyBox(
onClick = onSend,
tone = MulticaButtonTone.Primary,
enabled = !sending && (draft.trim().isNotEmpty() || pendingAttachments.isNotEmpty()),
modifier = Modifier.weight(1f),
modifier = Modifier.weight(1.18f).widthIn(min = 88.dp),
)
}
if (mentionPickerOpen) {
@@ -10867,7 +10900,7 @@ private fun IssueCommentReplyBox(
agent = agent,
contentDescription = "Issue Comment Reply Mention Agent Row ${agent.id}",
onClick = {
val insertion = AgentMentionMarkdown.markdown(agent.name, agent.id)
val insertion = AgentMentionMarkdown.displayText(agent.name, agent.id)
val separator = if (draft.isBlank() || draft.endsWith(" ") || draft.endsWith("\n")) "" else " "
onDraftChange(draft + separator + insertion + " ")
mentionPickerOpen = false
@@ -10935,7 +10968,7 @@ private fun IssueMentionAgentWebRow(
overflow = TextOverflow.Ellipsis,
)
Text(
text = AgentMentionMarkdown.markdown(agent.name, agent.id),
text = AgentMentionMarkdown.displayText(agent.name, agent.id),
style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp, lineHeight = 12.sp, fontFamily = FontFamily.Monospace),
color = MulticaColors.Muted,
maxLines = 1,
@@ -11110,7 +11143,7 @@ private fun IssueCommentInputBar(
agent = agent,
contentDescription = "Issue Comment Mention Agent Row ${agent.id}",
onClick = {
val insertion = AgentMentionMarkdown.markdown(agent.name, agent.id)
val insertion = AgentMentionMarkdown.displayText(agent.name, agent.id)
val separator = if (draft.isBlank() || draft.endsWith(" ") || draft.endsWith("\n")) "" else " "
onDraftChange(draft + separator + insertion + " ")
mentionPickerOpen = false
@@ -26866,10 +26899,33 @@ private fun agentTasksSummary(agent: Models.Agent, data: PilotAgentSettingsData,
private fun shortDate(value: String?): String {
val text = clean(value)
val timestampMillis = parseTimestampMillis(text)
if (timestampMillis != null) {
val formatter = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US)
formatter.timeZone = TimeZone.getDefault()
return formatter.format(Date(timestampMillis))
}
if (text.length >= 16) return text.substring(0, 16).replace('T', ' ')
return text
}
private fun parseTimestampMillis(value: String?): Long? {
val text = clean(value)
if (text.isBlank() || !text.contains('T')) return null
val patterns = listOf(
"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'",
"yyyy-MM-dd'T'HH:mm:ss'Z'",
"yyyy-MM-dd'T'HH:mm:ssXXX",
)
for (pattern in patterns) {
val formatter = SimpleDateFormat(pattern, Locale.US)
if (pattern.endsWith("'Z'")) formatter.timeZone = TimeZone.getTimeZone("UTC")
val parsed = runCatching { formatter.parse(text)?.time }.getOrNull()
if (parsed != null) return parsed
}
return null
}
private fun relativeDate(value: String?, zh: Boolean): String {
val millis = parseDueDateMillis(value) ?: return shortDate(value)
val deltaSeconds = ((System.currentTimeMillis() - millis) / 1000).coerceAtLeast(0)
@@ -20,6 +20,37 @@ final class AgentMentionMarkdown {
return "[@" + cleanName + "](mention://agent/" + agentId + ")";
}
static String displayText(String name, String agentId) {
String cleanName = cleanName(name);
if (cleanName.isEmpty()) cleanName = Models.shortId(agentId);
return "@" + cleanName;
}
static String markdownFromDraft(String draft, List<Models.Agent> agents) {
String content = draft == null ? "" : draft.trim();
if (content.isEmpty() || content.contains("mention://agent/") || agents == null || agents.isEmpty()) {
return content;
}
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 (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 cleanName(String name) {
if (name == null) return "";
String trimmed = name.trim();
return "null".equalsIgnoreCase(trimmed) ? "" : trimmed;
}
private AgentMentionMarkdown() {
}
}
@@ -132,6 +163,127 @@ final class IssueCommentRichText {
}
}
final class IssueCommentThreads {
static final class Thread {
final Models.Comment root;
final List<Models.Comment> replies;
Thread(Models.Comment root, List<Models.Comment> replies) {
this.root = root;
this.replies = Collections.unmodifiableList(replies);
}
}
static List<Thread> build(List<Models.Comment> comments, boolean descending) {
LinkedHashMap<String, Models.Comment> byId = new LinkedHashMap<>();
for (Models.Comment comment : comments) {
if (comment != null && !clean(comment.id).isEmpty()) byId.put(comment.id, comment);
}
LinkedHashMap<String, ArrayList<Models.Comment>> repliesByRoot = new LinkedHashMap<>();
ArrayList<Models.Comment> roots = new ArrayList<>();
for (Models.Comment comment : comments) {
if (comment == null) continue;
Models.Comment root = rootFor(comment, byId);
if (root == comment || !byId.containsKey(clean(root.id))) {
if (!roots.contains(comment)) roots.add(comment);
repliesByRoot.putIfAbsent(clean(comment.id), new ArrayList<>());
} else {
repliesByRoot.putIfAbsent(clean(root.id), new ArrayList<>());
repliesByRoot.get(clean(root.id)).add(comment);
}
}
roots.sort((left, right) -> descending
? compareCreatedAt(right, left)
: compareCreatedAt(left, right));
ArrayList<Thread> threads = new ArrayList<>();
for (Models.Comment root : roots) {
ArrayList<Models.Comment> replies = repliesByRoot.getOrDefault(clean(root.id), new ArrayList<>());
replies.sort(IssueCommentThreads::compareCreatedAt);
threads.add(new Thread(root, replies));
}
return threads;
}
static List<String> replyIds(Thread thread) {
ArrayList<String> ids = new ArrayList<>();
if (thread == null) return ids;
for (Models.Comment reply : thread.replies) ids.add(reply.id);
return ids;
}
static String replyContentForThread(Thread thread, String draft, List<Models.Agent> agents) {
String content = clean(draft);
if (content.contains("mention://agent/")) return content;
String agentId = firstAgentParticipantId(thread);
if (agentId.isEmpty()) return content;
String mention = AgentMentionMarkdown.markdown(agentName(agentId, agents), agentId);
return content.isEmpty() ? mention : mention + "\n\n" + content;
}
private static Models.Comment rootFor(Models.Comment comment, Map<String, Models.Comment> byId) {
Models.Comment current = comment;
ArrayList<String> seen = new ArrayList<>();
while (current != null && !isRoot(current)) {
String parentId = clean(current.parentId);
if (seen.contains(parentId)) return comment;
seen.add(parentId);
Models.Comment parent = byId.get(parentId);
if (parent == null) return comment;
current = parent;
}
return current == null ? comment : current;
}
private static String firstAgentParticipantId(Thread thread) {
if (thread == null) return "";
String rootAgentId = agentParticipantId(thread.root);
if (!rootAgentId.isEmpty()) return rootAgentId;
for (Models.Comment reply : thread.replies) {
String agentId = agentParticipantId(reply);
if (!agentId.isEmpty()) return agentId;
}
return "";
}
private static String agentParticipantId(Models.Comment comment) {
if (comment == null || !"agent".equalsIgnoreCase(clean(comment.authorType))) return "";
return clean(comment.authorId);
}
private static String agentName(String agentId, List<Models.Agent> agents) {
if (agents != null) {
for (Models.Agent agent : agents) {
if (agentId.equals(agent.id)) return clean(agent.name);
}
}
return Models.shortId(agentId);
}
private static boolean isRoot(Models.Comment comment) {
String parentId = clean(comment.parentId);
return parentId.isEmpty() || "null".equalsIgnoreCase(parentId);
}
private static int compareCreatedAt(Models.Comment left, Models.Comment right) {
String leftCreatedAt = left == null ? "" : clean(left.createdAt);
String rightCreatedAt = right == null ? "" : clean(right.createdAt);
int compared = leftCreatedAt.compareTo(rightCreatedAt);
if (compared != 0) return compared;
return clean(left == null ? "" : left.id).compareTo(clean(right == null ? "" : right.id));
}
private static String clean(String value) {
if (value == null) return "";
String trimmed = value.trim();
return "null".equalsIgnoreCase(trimmed) ? "" : trimmed;
}
private IssueCommentThreads() {
}
}
final class InboxNotificationDeduper {
static List<Models.InboxItem> deduplicateByIssue(Collection<Models.InboxItem> items) {
Map<String, Models.InboxItem> newestByIssue = new LinkedHashMap<>();
@@ -151,7 +151,6 @@ public final class MainActivity extends Activity {
EditText email = input("you@example.com");
email.setInputType(InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS);
Button continueButton = button(t("continue"), BLUE, 0xFFFFFFFF);
Button google = button(t("google"), 0xFFE5E7EB, TEXT);
root.addView(spacer(30));
root.addView(mark);
@@ -161,8 +160,6 @@ public final class MainActivity extends Activity {
root.addView(email, matchWrap());
root.addView(spacer(12));
root.addView(continueButton, matchWrap());
root.addView(spacer(12));
root.addView(google, matchWrap());
continueButton.setOnClickListener(v -> {
String value = email.getText().toString().trim();
@@ -179,7 +176,6 @@ public final class MainActivity extends Activity {
toast(t("sendCodeFailed"));
});
});
google.setOnClickListener(v -> startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(BuildConfig.MULTICA_WEB_BASE_URL + "/login"))));
setContentView(root);
}
@@ -2193,7 +2189,6 @@ public final class MainActivity extends Activity {
case "signIn": return "登录 Multi-Casual";
case "signInHint": return "输入邮箱获取登录验证码";
case "continue": return "继续";
case "google": return "使用 Google 继续";
case "emailRequired": return "请输入邮箱";
case "sendCodeFailed": return "验证码发送失败";
case "otpTitle": return "输入验证码";
@@ -2306,7 +2301,6 @@ public final class MainActivity extends Activity {
case "signIn": return "Sign in to Multi-Casual";
case "signInHint": return "Enter your email to get a login code";
case "continue": return "Continue";
case "google": return "Continue with Google";
case "emailRequired": return "Email is required";
case "sendCodeFailed": return "Failed to send code";
case "otpTitle": return "Enter code";
@@ -34,6 +34,7 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowRight
import androidx.compose.material.icons.outlined.AssignmentInd
@@ -61,6 +62,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.hapticfeedback.HapticFeedback
@@ -1032,7 +1034,7 @@ fun MulticaCupertinoActionSheet(
title: String,
message: String? = null,
items: List<MulticaCupertinoActionSheetItem>,
cancelText: String,
cancelText: String? = null,
onDismissRequest: () -> Unit,
) {
if (!visible) return
@@ -1125,19 +1127,21 @@ fun MulticaCupertinoActionSheet(
}
}
}
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,
)
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,
)
}
}
},
)
@@ -1184,19 +1188,23 @@ fun MulticaCupertinoAlertDialog(
)
},
buttons = {
action(
onClick = {
performMulticaTapFeedback(haptic)
onDismissRequest()
},
style = AlertActionStyle.Cancel,
enabled = true,
) {
Text(
text = cancelText,
style = MaterialTheme.typography.bodyMedium.copy(fontSize = 15.sp, fontWeight = FontWeight.Medium),
color = MulticaColors.Accent,
)
if (!cancelText.isNullOrBlank()) {
action(
onClick = {
performMulticaTapFeedback(haptic)
onDismissRequest()
},
style = AlertActionStyle.Cancel,
enabled = true,
) {
Text(
text = cancelText,
style = MaterialTheme.typography.bodyMedium.copy(fontSize = 14.sp, fontWeight = FontWeight.Medium),
color = MulticaColors.Accent,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
action(
onClick = {
@@ -1208,8 +1216,10 @@ fun MulticaCupertinoAlertDialog(
) {
Text(
text = confirmText,
style = MaterialTheme.typography.bodyMedium.copy(fontSize = 15.sp, fontWeight = FontWeight.SemiBold),
style = MaterialTheme.typography.bodyMedium.copy(fontSize = 14.sp, fontWeight = FontWeight.SemiBold),
color = if (destructive) MulticaColors.Danger else MulticaColors.Accent,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
},
@@ -1604,6 +1614,7 @@ fun MulticaCupertinoTextField(
maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
) {
val fieldDescription = contentDescription ?: "Multica Cupertino Text Field $label"
val centerSingleLineContent = singleLine || minLines <= 1
val fieldModifier = Modifier
.fillMaxWidth()
.semantics { this.contentDescription = fieldDescription }
@@ -1620,22 +1631,52 @@ fun MulticaCupertinoTextField(
overflow = TextOverflow.Ellipsis,
)
}
CupertinoBorderedTextField(
value = value,
onValueChange = onValueChange,
modifier = fieldModifier.heightIn(min = if (singleLine) 44.dp else 48.dp),
enabled = enabled,
singleLine = singleLine,
minLines = minLines,
maxLines = maxLines,
placeholder = {
if (placeholder.isNotBlank()) {
Text(placeholder, color = MulticaColors.TextTertiary)
}
},
textStyle = MaterialTheme.typography.bodyLarge.copy(fontSize = 16.sp, lineHeight = 21.sp, color = MulticaColors.TextPrimary),
shape = RoundedCornerShape(10.dp),
val fieldShape = RoundedCornerShape(10.dp)
val fieldTextStyle = MaterialTheme.typography.bodyLarge.copy(
fontSize = 16.sp,
lineHeight = 21.sp,
color = if (enabled) MulticaColors.TextPrimary else MulticaColors.Muted,
)
Box(
modifier = fieldModifier
.heightIn(min = if (centerSingleLineContent) 44.dp else 48.dp)
.clip(fieldShape)
.background(if (enabled) MulticaColors.Surface else MulticaColors.Surface.copy(alpha = 0.56f))
.border(1.dp, MulticaColors.Border.copy(alpha = if (enabled) 0.72f else 0.42f), fieldShape)
.padding(horizontal = 12.dp, vertical = if (centerSingleLineContent) 0.dp else 10.dp),
contentAlignment = if (centerSingleLineContent) Alignment.CenterStart else Alignment.TopStart,
) {
BasicTextField(
value = value,
onValueChange = onValueChange,
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight(),
enabled = enabled,
singleLine = singleLine,
minLines = minLines,
maxLines = maxLines,
textStyle = fieldTextStyle,
cursorBrush = SolidColor(MulticaColors.Accent),
decorationBox = { innerTextField ->
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = if (centerSingleLineContent) Alignment.CenterStart else Alignment.TopStart,
) {
if (value.isEmpty() && placeholder.isNotBlank()) {
Text(
text = placeholder,
style = fieldTextStyle,
color = MulticaColors.TextTertiary,
maxLines = if (singleLine) 1 else maxLines,
overflow = TextOverflow.Ellipsis,
)
}
innerTextField()
}
},
)
}
}
}
@@ -2031,7 +2072,7 @@ fun MulticaErrorState(
style = MaterialTheme.typography.bodyMedium.copy(fontSize = 13.sp, lineHeight = 17.sp),
color = MulticaColors.Muted,
textAlign = TextAlign.Center,
maxLines = 2,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
+8 -2
View File
@@ -1,3 +1,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission
android:name="com.google.android.gms.permission.AD_ID"
tools:node="remove" />
<uses-permission
android:name="freemme.permission.msa"
tools:node="remove" />
</manifest>
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
output/
.playwright-cli/
*.tsbuildinfo
+14
View File
@@ -0,0 +1,14 @@
<!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="Multi-Casual Android is an independent Android companion app for Multica users." />
<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>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+975
View File
@@ -0,0 +1,975 @@
{
"name": "site",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"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"
}
},
"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/@napi-rs/wasm-runtime": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
"integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
"license": "MIT",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@oxc-project/types": {
"version": "0.133.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz",
"integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz",
"integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz",
"integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz",
"integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz",
"integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz",
"integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz",
"integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz",
"integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz",
"integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==",
"cpu": [
"ppc64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz",
"integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==",
"cpu": [
"s390x"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz",
"integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz",
"integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz",
"integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz",
"integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==",
"cpu": [
"wasm32"
],
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "1.10.0",
"@emnapi/runtime": "1.10.0",
"@napi-rs/wasm-runtime": "^1.1.4"
},
"engines": {
"node": "^20.19.0 || >=22.12.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",
"integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz",
"integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"license": "MIT"
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
"integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@types/react": {
"version": "19.2.16",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.16.tgz",
"integrity": "sha512-esJiCAnl0kfpNdE69f3So4WJUXy95dLZydX0KwK46riIHDzHM7O9Vtf9xCHW0PXIqvgqNrswl522kA/5yx+F4w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
},
"node_modules/@types/react-dom": {
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
}
},
"node_modules/@vitejs/plugin-react": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz",
"integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==",
"license": "MIT",
"dependencies": {
"@rolldown/pluginutils": "^1.0.0"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"peerDependencies": {
"@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
"babel-plugin-react-compiler": "^1.0.0",
"vite": "^8.0.0"
},
"peerDependenciesMeta": {
"@rolldown/plugin-babel": {
"optional": true
},
"babel-plugin-react-compiler": {
"optional": true
}
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/framer-motion": {
"version": "12.40.0",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.40.0.tgz",
"integrity": "sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.40.0",
"motion-utils": "^12.39.0",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
"license": "MPL-2.0",
"dependencies": {
"detect-libc": "^2.0.3"
},
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-android-arm64": "1.32.0",
"lightningcss-darwin-arm64": "1.32.0",
"lightningcss-darwin-x64": "1.32.0",
"lightningcss-freebsd-x64": "1.32.0",
"lightningcss-linux-arm-gnueabihf": "1.32.0",
"lightningcss-linux-arm64-gnu": "1.32.0",
"lightningcss-linux-arm64-musl": "1.32.0",
"lightningcss-linux-x64-gnu": "1.32.0",
"lightningcss-linux-x64-musl": "1.32.0",
"lightningcss-win32-arm64-msvc": "1.32.0",
"lightningcss-win32-x64-msvc": "1.32.0"
}
},
"node_modules/lightningcss-android-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
"cpu": [
"arm64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
"cpu": [
"arm64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
"cpu": [
"x64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-freebsd-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
"cpu": [
"x64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
"cpu": [
"arm"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
"cpu": [
"arm64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
"cpu": [
"arm64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
"cpu": [
"x64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
"cpu": [
"x64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
"cpu": [
"arm64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-x64-msvc": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
"cpu": [
"x64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/motion": {
"version": "12.40.0",
"resolved": "https://registry.npmjs.org/motion/-/motion-12.40.0.tgz",
"integrity": "sha512-yjrHUrBFW6kQvjJwRsoiPSAhC5tRwRqNGJWmiJ4CrGnbKp0V88AdzkhBmDoqIsIPfarOe0Uddd37Xq43/gIocA==",
"license": "MIT",
"dependencies": {
"framer-motion": "^12.40.0",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/motion-dom": {
"version": "12.40.0",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.40.0.tgz",
"integrity": "sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.39.0"
}
},
"node_modules/motion-utils": {
"version": "12.39.0",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz",
"integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==",
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/postcss": {
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.12",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/react": {
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
"integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
"integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^19.2.7"
}
},
"node_modules/rolldown": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz",
"integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==",
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.133.0",
"@rolldown/pluginutils": "^1.0.0"
},
"bin": {
"rolldown": "bin/cli.mjs"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.0.3",
"@rolldown/binding-darwin-arm64": "1.0.3",
"@rolldown/binding-darwin-x64": "1.0.3",
"@rolldown/binding-freebsd-x64": "1.0.3",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.3",
"@rolldown/binding-linux-arm64-gnu": "1.0.3",
"@rolldown/binding-linux-arm64-musl": "1.0.3",
"@rolldown/binding-linux-ppc64-gnu": "1.0.3",
"@rolldown/binding-linux-s390x-gnu": "1.0.3",
"@rolldown/binding-linux-x64-gnu": "1.0.3",
"@rolldown/binding-linux-x64-musl": "1.0.3",
"@rolldown/binding-openharmony-arm64": "1.0.3",
"@rolldown/binding-wasm32-wasi": "1.0.3",
"@rolldown/binding-win32-arm64-msvc": "1.0.3",
"@rolldown/binding-win32-x64-msvc": "1.0.3"
}
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.4"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/typescript": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/vite": {
"version": "8.0.16",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz",
"integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
"license": "MIT",
"peer": true,
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
"postcss": "^8.5.15",
"rolldown": "1.0.3",
"tinyglobby": "^0.2.17"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.1.18",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
"sass": "^1.70.0",
"sass-embedded": "^1.70.0",
"stylus": ">=0.54.8",
"sugarss": "^5.0.0",
"terser": "^5.16.0",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"@vitejs/devtools": {
"optional": true
},
"esbuild": {
"optional": true
},
"jiti": {
"optional": true
},
"less": {
"optional": true
},
"sass": {
"optional": true
},
"sass-embedded": {
"optional": true
},
"stylus": {
"optional": true
},
"sugarss": {
"optional": true
},
"terser": {
"optional": true
},
"tsx": {
"optional": true
},
"yaml": {
"optional": true
}
}
}
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"scripts": {
"dev": "vite --host 127.0.0.1",
"dev:62702": "vite --host 127.0.0.1 --port 62702",
"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"
}
}
+365
View File
@@ -0,0 +1,365 @@
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: "0b34ab0d164403aec655ca9e829b3fce6a2849f4a83dc1f631336ff725299c42",
githubUrl: "https://github.com/park0er/multi-casual-android",
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 = {
en: {
nav: {
brand: "Multi-Casual Android",
download: "Download",
trust: "Trust",
relationship: "Relationship",
language: "中文",
},
hero: {
eyebrow: "Independent companion app · Built for Multica workflows",
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: "View GitHub",
multica: "Visit Multica",
typing: "Looks good — verified on Android.",
},
relationship: {
kicker: "Relationship to Multica",
title: "Respectful, explicit, and independent.",
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.",
},
],
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.",
},
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",
},
},
zh: {
nav: {
brand: "Multi-Casual Android",
download: "下载",
trust: "信任说明",
relationship: "项目关系",
language: "English",
},
hero: {
eyebrow: "独立 companion app · 面向 Multica 工作流",
title: "在 Android 上处理 Issue。",
subtitle: "查看任务上下文、进入评论区、草拟回复,把核心 workspace 闭环带到手机上。",
download: "下载 APK",
github: "查看 GitHub",
multica: "访问 Multica",
typing: "已在 Android 上验证,看起来没问题。",
},
relationship: {
kicker: "与 Multica 的关系",
title: "礼貌、清楚、保持独立。",
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;不内置服务端,也不为普通用户提供运行时后端切换。",
},
],
demo: {
kicker: "真实 Android 流程",
title: "打开 Issue,阅读上下文,然后草拟评论。",
body: "演示素材来自真实项目里的模拟器截图;交互部分用安全的叠加动画表现,不需要真的往公开项目里提交测试评论。",
},
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",
},
},
} satisfies Record<Locale, unknown>;
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" },
};
}
function reveal(reducedMotion: boolean | null): MotionProps {
if (reducedMotion) {
return {};
}
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="phone-wrap"
animate={reducedMotion ? {} : { y: [0, -10, 0], rotate: [-1.5, -0.6, -1.5] }}
transition={reducedMotion ? {} : { duration: 7, repeat: Infinity, ease: "easeInOut" }}
>
<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 }}
>
<span className="typing-dot" />
{typing}
</motion.div>
</div>
</div>
<div className="tap-ring" aria-hidden="true" />
</motion.div>
);
}
export default function App() {
const reducedMotion = useReducedMotion();
const [locale, setLocale] = useState<Locale>("en");
const t = copy[locale];
return (
<main lang={locale === "zh" ? "zh-CN" : "en"}>
<section className="hero section-pad">
<div className="hero-bg" />
<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>
</a>
<div className="nav-links">
<a href="#download">{t.nav.download}</a>
<a href="#trust">{t.nav.trust}</a>
<a href="#relationship">{t.nav.relationship}</a>
<button className="language-toggle" type="button" onClick={() => setLocale(locale === "en" ? "zh" : "en")}>
{t.nav.language}
</button>
</div>
</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>
<motion.div {...fade(reducedMotion, 0.18)}>
<PhoneMockup typing={t.hero.typing} />
</motion.div>
</div>
</section>
<motion.section id="relationship" className="relationship section-pad" {...reveal(reducedMotion)}>
<div className="section-kicker">{t.relationship.kicker}</div>
<div className="two-col">
<h2>{t.relationship.title}</h2>
<p>{t.relationship.body}</p>
</div>
</motion.section>
<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>
</section>
<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="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>
</motion.section>
<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" poster={commentFocused} />
</motion.section>
<section id="download" className="download section-pad">
<motion.div className="release-card" {...reveal(reducedMotion)}>
<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 Multi-Casual-Android-v0.1.1.apk`}</code></pre>
<div className="cta-row compact">
<a className="button primary" href={release.releasesUrl}>{t.download.latest}</a>
<a className="button secondary" href={release.githubUrl}>{t.download.source}</a>
</div>
</motion.div>
</section>
<section id="trust" className="trust section-pad">
<div className="section-kicker">{t.trustKicker}</div>
<div className="trust-grid">
{t.trustItems.map((item) => (
<motion.div key={item} className="trust-item" {...reveal(reducedMotion)}>
<span className="check"></span>
<p>{item}</p>
</motion.div>
))}
</div>
</section>
<footer className="footer section-pad">
<div>
<strong>Multi-Casual Android</strong>
<p>{t.footer.tagline}</p>
</div>
<div className="footer-links">
<a href={release.multicaUrl}>{t.footer.multica}</a>
<a href={release.githubUrl}>{t.footer.github}</a>
<a href={release.releasesUrl}>{t.footer.download}</a>
</div>
</footer>
</main>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 397 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 KiB

+10
View File
@@ -0,0 +1,10 @@
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>,
);
+447
View File
@@ -0,0 +1,447 @@
: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;
background:
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; }
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;
}
.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: 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 {
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 {
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 {
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-wrap {
width: min(390px, 82vw);
margin: 0 auto;
position: relative;
transform-origin: center;
}
.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);
}
.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 img { width: 100%; height: 100%; object-fit: cover; object-position: top center; display: block; }
.phone-screen-shot {
position: absolute;
inset: 0;
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;
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);
}
.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;
}
.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; }
.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); }
@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; }
.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; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
}
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+21
View File
@@ -0,0 +1,21 @@
{
"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": []
}