From 6591eccf318465e22607492a0e136a01d47f7e31 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8B=92=E5=B8=83=E6=9C=97-=E8=A9=B9=E5=A7=86=E6=96=AF?=
<2986253039@qq.com>
Date: Fri, 18 Sep 2026 10:50:19 +0800
Subject: [PATCH] feat(mobile): split phone shells into iOS, Android, and
HarmonyOS (#334 #335 #336)
Detect HarmonyOS/OpenHarmony/ArkWeb first, then iOS, then Android. Each OS gets its own layout.
---
CHANGELOG.md | 2 +
apps/mobile-web/android/app.css | 72 ++++++
apps/mobile-web/android/app.mjs | 1 +
apps/mobile-web/android/index.html | 63 ++++++
apps/mobile-web/app.css | 213 ------------------
apps/mobile-web/harmony/app.css | 63 ++++++
apps/mobile-web/harmony/app.mjs | 1 +
apps/mobile-web/harmony/index.html | 63 ++++++
apps/mobile-web/index.html | 87 -------
apps/mobile-web/ios/app.css | 73 ++++++
apps/mobile-web/ios/app.mjs | 1 +
apps/mobile-web/ios/index.html | 64 ++++++
apps/mobile-web/{app.mjs => shared/shell.mjs} | 107 ++++++++-
apps/mobile-web/surface.mjs | 45 +++-
docs/design/mobile-shell.md | 34 +--
scripts/test/mobile-web.test.mjs | 38 ++--
16 files changed, 568 insertions(+), 359 deletions(-)
create mode 100644 apps/mobile-web/android/app.css
create mode 100644 apps/mobile-web/android/app.mjs
create mode 100644 apps/mobile-web/android/index.html
delete mode 100644 apps/mobile-web/app.css
create mode 100644 apps/mobile-web/harmony/app.css
create mode 100644 apps/mobile-web/harmony/app.mjs
create mode 100644 apps/mobile-web/harmony/index.html
delete mode 100644 apps/mobile-web/index.html
create mode 100644 apps/mobile-web/ios/app.css
create mode 100644 apps/mobile-web/ios/app.mjs
create mode 100644 apps/mobile-web/ios/index.html
rename apps/mobile-web/{app.mjs => shared/shell.mjs} (64%)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 51bf4b07..88a0fa50 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,8 @@
### Added
+- #334 #335 #336:手机壳按 iOS / Android / HarmonyOS 拆开,UA 分流;可用 `?platform=` 强制切换。
+
- #329:手机打开 RoleWeave 时进入原生手机壳(组织 / 指令 / 桌面 / 设置),而不是缩过的桌面工作台。组织页只读预览 `examples/oss-maintainer`;回合仍在已打开的电脑桌面执行。
- #328 R2:新增 `semantic-runtime.v1alpha1` 合同切片(BusinessObjectRef / EvidenceRef / DecisionRecord / ActionProposal / ExecutionReceipt)与 Ontology Runtime 术语表;github-ops 示例给出只读分析与可写 squash-merge 轨迹;纯函数测试覆盖「未批准不可执行、非法过期时间 fail-closed、幂等重试绑定目标版本、目标版本失效、运行前状态不可直接失败、indeterminate 不能变成 succeeded」。不是 live GitHub 执行,也不是 Sales Workbench 核心词汇。设计说明见 `docs/design/ontology-runtime-r2.md`。
diff --git a/apps/mobile-web/android/app.css b/apps/mobile-web/android/app.css
new file mode 100644
index 00000000..71dd88d4
--- /dev/null
+++ b/apps/mobile-web/android/app.css
@@ -0,0 +1,72 @@
+:root {
+ color-scheme: dark;
+ --md-bg: #131316;
+ --md-surface: #1b1b1f;
+ --md-ink: #e6e1e5;
+ --md-muted: #cac4d0;
+ --md-line: #49454f;
+ --md-accent: #7aa2ff;
+ --md-on-accent: #001b3f;
+ font-family: Roboto, "Noto Sans SC", system-ui, sans-serif;
+}
+* { box-sizing: border-box; }
+html, body { margin: 0; min-height: 100%; background: var(--md-bg); color: var(--md-ink); }
+body {
+ min-height: 100dvh;
+ padding: 0 0 calc(80px + env(safe-area-inset-bottom));
+ overscroll-behavior: contain;
+ touch-action: manipulation;
+}
+.md-bar {
+ padding: max(12px, env(safe-area-inset-top)) 16px 16px;
+ background: var(--md-surface);
+ box-shadow: 0 1px 3px rgb(0 0 0 / 40%);
+}
+.md-bar__over { margin: 0; font-size: 12px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--md-accent); }
+.md-bar h1 { margin: 6px 0 0; font-size: 22px; font-weight: 500; }
+.md-body, .host-status, #command-status { margin: 16px; color: var(--md-muted); line-height: 1.5; }
+.panel[hidden] { display: none; }
+.md-list { list-style: none; margin: 0; padding: 0; }
+.md-list .role, .md-settings li {
+ width: 100%; min-height: 72px; padding: 16px;
+ border: 0; border-bottom: 1px solid var(--md-line);
+ background: var(--md-surface); color: inherit; font: inherit; text-align: left;
+ display: grid; gap: 4px;
+}
+.md-list .role:active { background: #2b2930; }
+.md-sheet { margin: 16px; padding: 16px; background: var(--md-surface); border-radius: 12px; display: grid; gap: 8px; }
+.md-stack { display: grid; gap: 12px; margin: 16px; }
+label { font-size: 12px; color: var(--md-muted); }
+input, textarea {
+ width: 100%; padding: 16px; border-radius: 4px 4px 0 0; border: 0; border-bottom: 2px solid var(--md-accent);
+ background: #2b2930; color: inherit; font: inherit;
+}
+input { letter-spacing: 0.35em; font-size: 22px; text-align: center; }
+.md-fab {
+ min-height: 48px; display: flex; align-items: center; justify-content: center;
+ border: 0; border-radius: 16px; background: var(--md-accent); color: var(--md-on-accent);
+ font: inherit; font-weight: 600; text-decoration: none;
+}
+.md-text {
+ min-height: 48px; display: flex; align-items: center; justify-content: center;
+ border: 0; background: transparent; color: var(--md-accent); font: inherit; text-decoration: none;
+}
+.md-nav {
+ position: fixed; left: 0; right: 0; bottom: 0;
+ display: grid; grid-template-columns: repeat(4, 1fr);
+ height: calc(80px + env(safe-area-inset-bottom));
+ padding-bottom: env(safe-area-inset-bottom);
+ background: var(--md-surface); border-top: 1px solid var(--md-line);
+}
+.tab {
+ min-height: 48px; border: 0; background: transparent; color: var(--md-muted);
+ font-size: 12px; font-weight: 500;
+}
+.tab.is-active { color: var(--md-accent); background: #2b2930; }
+.error { color: #f2b8b5; }
+.meta { display: flex; gap: 8px; flex-wrap: wrap; }
+.chip { font-size: 12px; color: var(--md-muted); }
+.detail pre, #command-summary { white-space: pre-wrap; color: var(--md-muted); font: 13px/1.45 ui-monospace, monospace; }
+button:focus-visible, a:focus-visible, input:focus-visible, textarea:focus-visible {
+ outline: 2px solid var(--md-accent); outline-offset: 2px;
+}
diff --git a/apps/mobile-web/android/app.mjs b/apps/mobile-web/android/app.mjs
new file mode 100644
index 00000000..04023123
--- /dev/null
+++ b/apps/mobile-web/android/app.mjs
@@ -0,0 +1 @@
+import "/shared/shell.mjs";
diff --git a/apps/mobile-web/android/index.html b/apps/mobile-web/android/index.html
new file mode 100644
index 00000000..c79384ab
--- /dev/null
+++ b/apps/mobile-web/android/index.html
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+ RoleWeave
+
+
+
+
+ RoleWeave · Android
+ 工作区
+
+
+
+
+ 发给电脑上已打开的 RoleWeave。员工在电脑上跑。
+ 正在查看电脑是否在线…
+
+
+
+ 尚未连接。
+
+
+
+ 完整工作台在电脑上。需要时再打开远程桌面。
+ 打开远程桌面
+
+
+
+ - 平台Android
+ - 组织数据尚未同步
+ - 这台手机未连接
+ - 回合电脑宿主执行
+
+
+ 打开桌面版
+
+
+
+
+
+
diff --git a/apps/mobile-web/app.css b/apps/mobile-web/app.css
deleted file mode 100644
index 75b931fa..00000000
--- a/apps/mobile-web/app.css
+++ /dev/null
@@ -1,213 +0,0 @@
-:root {
- color-scheme: dark;
- --bg: #12141b;
- --bg-elev: #1b1d26;
- --ink: #f3f4f8;
- --muted: #b0b5c4;
- --line: #323644;
- --accent: #7aa2ff;
- --accent-ink: #101116;
- --danger: #f0a8a8;
- --radius: 16px;
- --pad: 16px;
- --tab: 64px;
- font-family: Inter, "PingFang SC", "Noto Sans SC", system-ui, sans-serif;
-}
-
-* { box-sizing: border-box; }
-html, body { margin: 0; min-height: 100%; background: var(--bg); color: var(--ink); }
-body {
- min-height: 100dvh;
- padding:
- max(12px, env(safe-area-inset-top))
- max(var(--pad), env(safe-area-inset-right))
- calc(var(--tab) + env(safe-area-inset-bottom))
- max(var(--pad), env(safe-area-inset-left));
- overscroll-behavior: contain;
- touch-action: manipulation;
-}
-
-.skip {
- position: absolute;
- left: -999px;
- top: 8px;
-}
-.skip:focus {
- left: 12px;
- z-index: 20;
- padding: 8px 12px;
- background: var(--accent);
- color: var(--accent-ink);
- border-radius: 8px;
-}
-
-.top { margin-bottom: 18px; }
-.brand { display: flex; gap: 12px; align-items: center; }
-.mark { width: 36px; height: 36px; flex: none; }
-.eyebrow {
- margin: 0;
- color: var(--accent);
- font-size: 11px;
- letter-spacing: 0.08em;
- text-transform: uppercase;
-}
-h1 {
- margin: 2px 0 0;
- font-size: 22px;
- line-height: 1.25;
- letter-spacing: -0.02em;
-}
-.lead, #command-status { color: var(--muted); line-height: 1.5; font-size: 15px; }
-
-.panel[hidden] { display: none; }
-.panel.is-active { display: block; }
-
-.roles {
- list-style: none;
- margin: 16px 0 0;
- padding: 0;
- display: grid;
- gap: 10px;
-}
-.role {
- width: 100%;
- text-align: left;
- min-height: 72px;
- padding: 14px 16px;
- border: 1px solid var(--line);
- border-radius: var(--radius);
- background: var(--bg-elev);
- color: inherit;
- font: inherit;
- display: grid;
- gap: 4px;
-}
-.role:focus-visible, .button:focus-visible, .tab:focus-visible, input:focus-visible, textarea:focus-visible, button:focus-visible {
- outline: 2px solid var(--accent);
- outline-offset: 2px;
-}
-.role strong { font-size: 16px; }
-.role span { color: var(--muted); font-size: 13px; }
-.role:active { transform: scale(0.99); }
-
-.detail {
- margin-top: 14px;
- padding: 16px;
- border: 1px solid var(--line);
- border-radius: var(--radius);
- background: var(--bg-elev);
- display: grid;
- gap: 10px;
-}
-.detail pre {
- white-space: pre-wrap;
- margin: 0;
- color: var(--muted);
- font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
-}
-.meta { display: flex; gap: 8px; flex-wrap: wrap; }
-.chip {
- border: 1px solid var(--line);
- border-radius: 999px;
- padding: 4px 10px;
- font-size: 12px;
- color: var(--muted);
-}
-
-.stack { display: grid; gap: 10px; margin: 20px 0; }
-label { font-size: 14px; }
-input, textarea, button, .button {
- width: 100%;
- font: inherit;
- color: var(--ink);
- border: 1px solid var(--line);
- border-radius: 12px;
- background: var(--bg-elev);
-}
-input, textarea { padding: 14px; }
-input { letter-spacing: 0.35em; font-size: 22px; text-align: center; }
-button, .button {
- min-height: 48px;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- background: var(--accent);
- color: var(--accent-ink);
- border: 0;
- font-weight: 650;
- text-decoration: none;
- cursor: pointer;
-}
-button:disabled { opacity: 0.55; cursor: not-allowed; }
-.button.ghost {
- background: transparent;
- color: var(--ink);
- border: 1px solid var(--line);
-}
-#command-summary {
- white-space: pre-wrap;
- background: var(--bg-elev);
- border: 1px solid var(--line);
- border-radius: 12px;
- padding: 14px;
- line-height: 1.45;
-}
-
-.settings {
- list-style: none;
- margin: 0 0 16px;
- padding: 0;
- border: 1px solid var(--line);
- border-radius: var(--radius);
- overflow: hidden;
-}
-.settings li {
- display: flex;
- justify-content: space-between;
- gap: 12px;
- padding: 14px 16px;
- min-height: 48px;
- border-bottom: 1px solid var(--line);
-}
-.settings li:last-child { border-bottom: 0; }
-.settings span { color: var(--muted); }
-.settings strong { font-weight: 600; text-align: right; }
-
-.tabs {
- position: fixed;
- left: 0;
- right: 0;
- bottom: 0;
- display: grid;
- grid-template-columns: repeat(4, 1fr);
- gap: 4px;
- padding: 6px 8px calc(6px + env(safe-area-inset-bottom));
- background: rgb(18 20 27 / 94%);
- border-top: 1px solid var(--line);
- backdrop-filter: blur(12px);
-}
-.tab {
- min-height: 48px;
- border: 0;
- background: transparent;
- color: var(--muted);
- display: grid;
- justify-items: center;
- gap: 2px;
- font-size: 11px;
- font-weight: 600;
- width: auto;
- border-radius: 12px;
-}
-.tab svg { width: 22px; height: 22px; fill: currentColor; }
-.tab.is-active { color: var(--ink); background: #262936; }
-
-.error { color: var(--danger); }
-
-@media (prefers-reduced-motion: reduce) {
- .role:active { transform: none; }
-}
-
-@media (min-width: 720px) {
- main, .top { max-width: 42rem; margin-left: auto; margin-right: auto; }
-}
diff --git a/apps/mobile-web/harmony/app.css b/apps/mobile-web/harmony/app.css
new file mode 100644
index 00000000..be35030a
--- /dev/null
+++ b/apps/mobile-web/harmony/app.css
@@ -0,0 +1,63 @@
+:root {
+ color-scheme: dark;
+ --hw-bg: #16141a;
+ --hw-card: #221f28;
+ --hw-ink: #f4f0f7;
+ --hw-muted: #b7b0c2;
+ --hw-line: #3a3544;
+ --hw-accent: #c9a7ff;
+ --hw-on-accent: #2b1658;
+ font-family: "HarmonyOS Sans", "Noto Sans SC", system-ui, sans-serif;
+}
+* { box-sizing: border-box; }
+html, body { margin: 0; min-height: 100%; background: var(--hw-bg); color: var(--hw-ink); }
+body {
+ min-height: 100dvh;
+ padding: max(16px, env(safe-area-inset-top)) 16px calc(72px + env(safe-area-inset-bottom));
+ overscroll-behavior: contain;
+ touch-action: manipulation;
+}
+.hw-hero { margin-bottom: 18px; }
+.hw-kicker { margin: 0; color: var(--hw-accent); font-size: 12px; letter-spacing: 0.12em; }
+.hw-hero h1 { margin: 8px 0 0; font-size: 28px; font-weight: 650; }
+.hw-copy, .host-status, #command-status { color: var(--hw-muted); line-height: 1.55; }
+.panel[hidden] { display: none; }
+.hw-cards { list-style: none; margin: 12px 0 0; padding: 0; display: grid; gap: 12px; }
+.hw-cards .role, .hw-settings li {
+ width: 100%; min-height: 88px; padding: 18px;
+ border: 1px solid var(--hw-line); border-radius: 20px;
+ background: var(--hw-card); color: inherit; font: inherit; text-align: left;
+ display: grid; gap: 6px;
+}
+.hw-settings li { display: flex; justify-content: space-between; align-items: center; min-height: 56px; }
+.hw-panel { margin-top: 14px; padding: 18px; border-radius: 20px; background: var(--hw-card); display: grid; gap: 10px; }
+.hw-stack { display: grid; gap: 12px; margin: 18px 0; }
+label { font-size: 13px; color: var(--hw-muted); }
+input, textarea {
+ width: 100%; padding: 14px; border-radius: 16px; border: 1px solid var(--hw-line);
+ background: #2a2631; color: inherit; font: inherit;
+}
+input { letter-spacing: 0.32em; font-size: 22px; text-align: center; }
+.hw-primary, .hw-ghost {
+ min-height: 48px; display: flex; align-items: center; justify-content: center;
+ border-radius: 24px; font: inherit; font-weight: 650; text-decoration: none;
+}
+.hw-primary { border: 0; background: var(--hw-accent); color: var(--hw-on-accent); }
+.hw-ghost { border: 1px solid var(--hw-line); background: transparent; color: var(--hw-ink); }
+.hw-dock {
+ position: fixed; left: 12px; right: 12px; bottom: calc(8px + env(safe-area-inset-bottom));
+ display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px;
+ padding: 8px; border-radius: 28px; background: var(--hw-card); border: 1px solid var(--hw-line);
+}
+.tab {
+ min-height: 48px; border: 0; border-radius: 20px; background: transparent; color: var(--hw-muted);
+ font-size: 12px; font-weight: 650;
+}
+.tab.is-active { color: var(--hw-on-accent); background: var(--hw-accent); }
+.error { color: #ffb4ab; }
+.meta { display: flex; gap: 8px; flex-wrap: wrap; }
+.chip { font-size: 12px; color: var(--hw-muted); }
+.detail pre, #command-summary { white-space: pre-wrap; color: var(--hw-muted); font: 13px/1.45 ui-monospace, monospace; }
+button:focus-visible, a:focus-visible, input:focus-visible, textarea:focus-visible {
+ outline: 2px solid var(--hw-accent); outline-offset: 2px;
+}
diff --git a/apps/mobile-web/harmony/app.mjs b/apps/mobile-web/harmony/app.mjs
new file mode 100644
index 00000000..04023123
--- /dev/null
+++ b/apps/mobile-web/harmony/app.mjs
@@ -0,0 +1 @@
+import "/shared/shell.mjs";
diff --git a/apps/mobile-web/harmony/index.html b/apps/mobile-web/harmony/index.html
new file mode 100644
index 00000000..5cadd36d
--- /dev/null
+++ b/apps/mobile-web/harmony/index.html
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+ RoleWeave 鸿蒙
+
+
+
+
+
+
+
+ 发给电脑上已打开的 RoleWeave。员工在电脑上跑。
+ 正在查看电脑是否在线…
+
+
+
+ 尚未连接。
+
+
+
+ 完整工作台在电脑上。需要时再打开远程桌面。
+ 打开远程桌面
+
+
+
+ - 平台HarmonyOS
+ - 组织数据尚未同步
+ - 这台手机未连接
+ - 回合电脑宿主执行
+
+
+ 打开桌面版
+
+
+
+
+
+
diff --git a/apps/mobile-web/index.html b/apps/mobile-web/index.html
deleted file mode 100644
index f5724569..00000000
--- a/apps/mobile-web/index.html
+++ /dev/null
@@ -1,87 +0,0 @@
-
-
-
-
-
-
-
-
- RoleWeave
-
-
-
- 跳到内容
-
-
-
-
-
- 招聘、改组织、跑回合仍在电脑上的 RoleWeave 桌面应用里完成。手机壳只负责看组织和发一条指令。
-
-
-
- -
- 入口
- 手机壳优先
-
- -
- 组织数据
- 示例工作区只读预览
-
- -
- 回合
- 在已打开的电脑桌面执行
-
-
-
-
-
-
-
-
diff --git a/apps/mobile-web/ios/app.css b/apps/mobile-web/ios/app.css
new file mode 100644
index 00000000..651b1a78
--- /dev/null
+++ b/apps/mobile-web/ios/app.css
@@ -0,0 +1,73 @@
+:root {
+ color-scheme: dark;
+ --ios-bg: #000;
+ --ios-group: #1c1c1e;
+ --ios-ink: #f5f5f7;
+ --ios-muted: #8e8e93;
+ --ios-line: #38383a;
+ --ios-accent: #0a84ff;
+ font-family: -apple-system, "SF Pro Text", "PingFang SC", system-ui, sans-serif;
+}
+* { box-sizing: border-box; }
+html, body { margin: 0; min-height: 100%; background: var(--ios-bg); color: var(--ios-ink); }
+body {
+ min-height: 100dvh;
+ padding: max(8px, env(safe-area-inset-top)) 16px calc(56px + env(safe-area-inset-bottom));
+ overscroll-behavior: contain;
+ touch-action: manipulation;
+}
+.ios-nav { margin: 8px 4px 18px; }
+.ios-nav__product { margin: 0; color: var(--ios-muted); font-size: 13px; }
+.ios-nav h1 { margin: 4px 0 0; font-size: 34px; letter-spacing: -0.04em; }
+.ios-footnote, .host-status, #command-status { color: var(--ios-muted); font-size: 13px; line-height: 1.4; }
+.panel[hidden] { display: none; }
+.ios-group {
+ list-style: none; margin: 12px 0; padding: 0;
+ background: var(--ios-group); border-radius: 12px; overflow: hidden;
+}
+.ios-group .role, .ios-settings li {
+ width: 100%; min-height: 48px; padding: 12px 16px;
+ border: 0; border-bottom: 0.5px solid var(--ios-line);
+ background: transparent; color: inherit; font: inherit; text-align: left;
+ display: flex; justify-content: space-between; gap: 12px; align-items: center;
+}
+.ios-group li:last-child .role, .ios-settings li:last-child { border-bottom: 0; }
+.ios-group .role { display: grid; justify-items: start; }
+.ios-group .role strong { font-size: 17px; }
+.ios-group .role span { color: var(--ios-muted); font-size: 13px; }
+.ios-card {
+ margin-top: 12px; padding: 16px; background: var(--ios-group); border-radius: 12px;
+ display: grid; gap: 8px;
+}
+.ios-stack { display: grid; gap: 10px; margin: 16px 0; }
+label { font-size: 13px; color: var(--ios-muted); }
+input, textarea {
+ width: 100%; padding: 12px 14px; border-radius: 12px; border: 0;
+ background: var(--ios-group); color: inherit; font: inherit;
+}
+input { letter-spacing: 0.3em; font-size: 22px; text-align: center; }
+.ios-btn {
+ min-height: 48px; display: flex; align-items: center; justify-content: center;
+ border: 0; border-radius: 12px; background: var(--ios-accent); color: #fff;
+ font: inherit; font-weight: 600; text-decoration: none;
+}
+.ios-btn--plain { background: var(--ios-group); color: var(--ios-accent); }
+.ios-tabbar {
+ position: fixed; left: 0; right: 0; bottom: 0;
+ display: grid; grid-template-columns: repeat(4, 1fr);
+ min-height: 49px; padding-bottom: env(safe-area-inset-bottom);
+ background: rgb(28 28 30 / 92%); border-top: 0.5px solid var(--ios-line);
+ backdrop-filter: blur(16px);
+}
+.tab {
+ min-height: 44px; border: 0; background: transparent; color: var(--ios-muted);
+ font-size: 10px; font-weight: 600;
+}
+.tab.is-active { color: var(--ios-accent); }
+.error { color: #ff453a; }
+.chip { color: var(--ios-muted); font-size: 12px; }
+.meta { display: flex; gap: 8px; flex-wrap: wrap; }
+.detail pre, #command-summary { white-space: pre-wrap; color: var(--ios-muted); font: 13px/1.45 ui-monospace, Menlo, monospace; }
+button:focus-visible, a:focus-visible, input:focus-visible, textarea:focus-visible {
+ outline: 2px solid var(--ios-accent); outline-offset: 2px;
+}
diff --git a/apps/mobile-web/ios/app.mjs b/apps/mobile-web/ios/app.mjs
new file mode 100644
index 00000000..04023123
--- /dev/null
+++ b/apps/mobile-web/ios/app.mjs
@@ -0,0 +1 @@
+import "/shared/shell.mjs";
diff --git a/apps/mobile-web/ios/index.html b/apps/mobile-web/ios/index.html
new file mode 100644
index 00000000..743b6e5b
--- /dev/null
+++ b/apps/mobile-web/ios/index.html
@@ -0,0 +1,64 @@
+
+
+
+
+
+
+
+ 组织 · RoleWeave
+
+
+
+
+
+
+
+
+
+
+ - 平台iOS
+ - 组织数据尚未同步
+ - 这台手机未连接
+ - 回合电脑宿主执行
+
+
+ 打开桌面版
+
+
+
+
+
+
diff --git a/apps/mobile-web/app.mjs b/apps/mobile-web/shared/shell.mjs
similarity index 64%
rename from apps/mobile-web/app.mjs
rename to apps/mobile-web/shared/shell.mjs
index 5407f7bd..4b9b6b0c 100644
--- a/apps/mobile-web/app.mjs
+++ b/apps/mobile-web/shared/shell.mjs
@@ -3,6 +3,8 @@ const orgLead = document.getElementById("org-lead");
const roleList = document.getElementById("role-list");
const roleDetail = document.getElementById("role-detail");
const pairForm = document.getElementById("pair-form");
+const claimForm = document.getElementById("claim-form");
+const hostStatus = document.getElementById("host-status");
const commandForm = document.getElementById("command-form");
const commandStatus = document.getElementById("command-status");
const commandSummary = document.getElementById("command-summary");
@@ -55,9 +57,34 @@ function escapeHtml(value) {
.replaceAll('"', """);
}
+function describeSource(snapshot) {
+ if (snapshot.source === "live") return "电脑当前工作区";
+ if (snapshot.source === "closed") return "电脑未打开工作区";
+ return "示例预览,电脑打开工作区后会换成当前组织";
+}
+
+function setDeviceState(connected) {
+ const state = document.getElementById("device-state");
+ const revoke = document.getElementById("revoke");
+ if (state) state.textContent = connected ? "已连接" : "未连接";
+ if (revoke) revoke.hidden = !connected;
+}
+
function renderRoles(snapshot) {
+ const sourceEl = document.getElementById("org-source");
+ if (sourceEl) sourceEl.textContent = describeSource(snapshot);
+ if (snapshot.source === "closed") {
+ workspaceName.textContent = "未打开工作区";
+ orgLead.textContent = "在电脑上打开一个工作区后,这里会列出当前员工。";
+ orgLead.classList.remove("error");
+ roleList.replaceChildren();
+ roleDetail.hidden = true;
+ return;
+ }
workspaceName.textContent = snapshot.name;
- orgLead.textContent = snapshot.description || "当前是示例组织的只读预览。选一个岗位,看说明书和预算。";
+ orgLead.textContent = snapshot.source === "live"
+ ? (snapshot.description || "这是电脑上正在打开的工作区。")
+ : (snapshot.description || "当前是示例组织的只读预览。选一个岗位,看说明书和预算。");
const names = new Map(snapshot.roles.map((role) => [role.id, role.name]));
roleList.replaceChildren();
for (const role of snapshot.roles) {
@@ -120,7 +147,9 @@ function connect() {
}
if (message.type === "phone.accepted") {
pairForm.hidden = true;
+ claimForm.hidden = true;
commandForm.hidden = false;
+ setDeviceState(true);
setCommandStatus("已连上电脑,可以发指令。");
return;
}
@@ -142,7 +171,9 @@ function connect() {
localStorage.removeItem(storageKey);
deviceToken = null;
pairForm.hidden = false;
+ claimForm.hidden = false;
commandForm.hidden = true;
+ setDeviceState(false);
}
setCommandStatus(message.message ?? "出错了");
}
@@ -152,6 +183,41 @@ function connect() {
});
}
+async function acceptGrant(body) {
+ if (!body?.deviceToken) return false;
+ deviceToken = body.deviceToken;
+ localStorage.setItem(storageKey, deviceToken);
+ connect();
+ return true;
+}
+
+async function refreshHostStatus() {
+ try {
+ const response = await fetch("/phone-link/v1/status", { headers: { accept: "application/json" } });
+ const body = await response.json();
+ if (body.hostOnline) {
+ hostStatus.textContent = "电脑在线,可以连接。";
+ hostStatus.classList.remove("error");
+ } else {
+ hostStatus.textContent = "电脑还没连上。先打开 RoleWeave 桌面。";
+ hostStatus.classList.add("error");
+ }
+ } catch {
+ hostStatus.textContent = "暂时读不到电脑状态。";
+ hostStatus.classList.add("error");
+ }
+}
+
+claimForm.addEventListener("submit", async (event) => {
+ event.preventDefault();
+ setCommandStatus("正在连接电脑…");
+ const response = await fetch("/phone-link/v1/claim", { method: "POST" });
+ const body = await response.json();
+ if (!response.ok || !(await acceptGrant(body))) {
+ setCommandStatus(body.message ?? "连接失败");
+ }
+});
+
pairForm.addEventListener("submit", async (event) => {
event.preventDefault();
const code = document.getElementById("code").value.trim();
@@ -162,13 +228,9 @@ pairForm.addEventListener("submit", async (event) => {
body: JSON.stringify({ code }),
});
const body = await response.json();
- if (!response.ok || !body.deviceToken) {
+ if (!response.ok || !(await acceptGrant(body))) {
setCommandStatus(body.message ?? "配对失败");
- return;
}
- deviceToken = body.deviceToken;
- localStorage.setItem(storageKey, deviceToken);
- connect();
});
commandForm.addEventListener("submit", (event) => {
@@ -180,14 +242,43 @@ commandForm.addEventListener("submit", (event) => {
}
showSummary("");
const commandId = crypto.randomUUID();
- socket.send(JSON.stringify({ v: 1, type: "command.submit", commandId, text }));
+ socket.send(JSON.stringify({
+ v: 1,
+ type: "command.submit",
+ commandId,
+ text,
+ ...(selectedRoleId ? { positionId: selectedRoleId } : {}),
+ }));
setCommandStatus("已发出,等电脑受理…");
});
-document.querySelector(".tabs").addEventListener("click", (event) => {
+document.querySelector("nav[aria-label='主要功能']")?.addEventListener("click", (event) => {
const tab = event.target.closest("[data-tab]");
if (tab) selectTab(tab.dataset.tab);
});
+document.getElementById("revoke")?.addEventListener("click", async () => {
+ if (!deviceToken) return;
+ const response = await fetch("/phone-link/v1/revoke", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ deviceToken }),
+ });
+ localStorage.removeItem(storageKey);
+ deviceToken = null;
+ socket?.close();
+ pairForm.hidden = false;
+ claimForm.hidden = false;
+ commandForm.hidden = true;
+ setDeviceState(false);
+ setCommandStatus(response.ok ? "已断开这台手机。" : "已在本机退出,电脑侧可能仍需确认。");
+});
+
if (deviceToken) connect();
+else setDeviceState(false);
void loadWorkspace();
+void refreshHostStatus();
+setInterval(() => {
+ void refreshHostStatus();
+ void loadWorkspace();
+}, 4000);
diff --git a/apps/mobile-web/surface.mjs b/apps/mobile-web/surface.mjs
index 566eafdc..cbc3bdfd 100644
--- a/apps/mobile-web/surface.mjs
+++ b/apps/mobile-web/surface.mjs
@@ -3,15 +3,33 @@ import path from "node:path";
const MOBILE_UA = /Android|iPhone|iPod|iPad|Mobile|ASteamApp|webOS|BlackBerry|IEMobile|Opera Mini/i;
const SKILL_EXCERPT_CHARS = 480;
+const PHONE_PLATFORMS = new Set(["ios", "android", "harmony"]);
export function isMobileUserAgent(userAgent) {
return typeof userAgent === "string" && MOBILE_UA.test(userAgent);
}
+export function detectPlatform(userAgent) {
+ const ua = typeof userAgent === "string" ? userAgent : "";
+ if (/OpenHarmony|ArkWeb|HarmonyOS/i.test(ua)) return "harmony";
+ if (/iPhone|iPad|iPod/i.test(ua)) return "ios";
+ if (/Android/i.test(ua)) return "android";
+ if (isMobileUserAgent(ua)) return "android";
+ return "desktop";
+}
+
+export function choosePlatform({ userAgent, searchParams }) {
+ const params = searchParams instanceof URLSearchParams ? searchParams : new URLSearchParams();
+ const requestedPlatform = params.get("platform");
+ if (requestedPlatform === "desktop" || PHONE_PLATFORMS.has(requestedPlatform)) return requestedPlatform;
+ if (params.get("surface") === "desktop") return "desktop";
+ const detected = detectPlatform(userAgent);
+ if (params.get("surface") === "mobile" && detected === "desktop") return "android";
+ return detected;
+}
+
export function chooseSurface({ userAgent, searchParams }) {
- const requested = searchParams instanceof URLSearchParams ? searchParams.get("surface") : null;
- if (requested === "desktop" || requested === "mobile") return requested;
- return isMobileUserAgent(userAgent) ? "mobile" : "desktop";
+ return choosePlatform({ userAgent, searchParams }) === "desktop" ? "desktop" : "mobile";
}
function safeJoin(root, relative) {
@@ -20,14 +38,25 @@ function safeJoin(root, relative) {
return candidate;
}
+function phoneDir(webDir, platform) {
+ return path.join(webDir, PHONE_PLATFORMS.has(platform) ? platform : "android");
+}
+
export function resolvePublicAsset(urlPath, { webDir, userAgent, searchParams }) {
- const surface = chooseSurface({ userAgent, searchParams });
+ const platform = choosePlatform({ userAgent, searchParams });
+ const folder = platform === "desktop" ? "android" : platform;
if (urlPath === "/" || urlPath === "/mobile" || urlPath === "/mobile/") {
- if (urlPath === "/" && surface === "desktop") return null;
- return path.join(webDir, "index.html");
+ if (urlPath === "/" && platform === "desktop") return null;
+ return path.join(phoneDir(webDir, folder), "index.html");
}
- if (urlPath === "/app.css") return path.join(webDir, "app.css");
- if (urlPath === "/app.mjs") return path.join(webDir, "app.mjs");
+ const phoneAsset = /^\/(ios|android|harmony)\/(index\.html|app\.css|app\.mjs)$/.exec(urlPath);
+ if (phoneAsset) return path.join(webDir, phoneAsset[1], phoneAsset[2]);
+ if (urlPath === "/ios" || urlPath === "/ios/") return path.join(webDir, "ios", "index.html");
+ if (urlPath === "/android" || urlPath === "/android/") return path.join(webDir, "android", "index.html");
+ if (urlPath === "/harmony" || urlPath === "/harmony/") return path.join(webDir, "harmony", "index.html");
+ if (urlPath === "/shared/shell.mjs") return path.join(webDir, "shared", "shell.mjs");
+ if (urlPath === "/app.css") return path.join(phoneDir(webDir, folder), "app.css");
+ if (urlPath === "/app.mjs") return path.join(phoneDir(webDir, folder), "app.mjs");
return null;
}
diff --git a/docs/design/mobile-shell.md b/docs/design/mobile-shell.md
index 12aaede8..fcd3a845 100644
--- a/docs/design/mobile-shell.md
+++ b/docs/design/mobile-shell.md
@@ -1,32 +1,20 @@
-# RoleWeave mobile shell
+# RoleWeave mobile shells
Date: 2026-09-17
-Status: first slice
-
-## Problem
-
-Opening RoleWeave on a phone currently presents a desktop workspace. Targets are too small, typing is hostile, and the org chart plus conversation are not designed for a thumb.
+Status: split by OS
## Decision
-Phones get a native web shell. The desktop app remains where hiring, org edits, and turns actually run.
-
-Bottom navigation, four entries at most:
-
-1. Organization: read-only preview of the example workspace roles.
-2. Command: send one instruction to an already-running desktop host.
-3. Desktop: remind the user that the full workbench stays on the computer.
-4. Settings: data boundary (preview is read-only; turns execute on the desktop).
+Phone traffic is split into three shells. They share the workspace snapshot and command protocol, not the layout.
-## Constraints
+| Platform | Detection | Layout |
+| --- | --- | --- |
+| iOS | iPhone / iPad / iPod | Large title, inset grouped list, 49pt tab bar |
+| Android | Android UA without Harmony | Material top app bar, full-bleed list, 80dp nav |
+| HarmonyOS | HarmonyOS / OpenHarmony / ArkWeb first | Service cards, 20px radius, floating dock |
-- Touch targets at least 44px, including the tab bar, with safe-area insets.
-- Keep the existing RoleWeave palette (`#12141b` / `#7aa2ff`).
-- `/api/mobile/workspace` returns names, descriptions, budgets, and skill excerpts only. No host filesystem paths.
-- The control plane stays on loopback. The phone never receives the boot token.
+Force with `?platform=ios|android|harmony`. Desktop remains `?surface=desktop`.
-## Non-goals
+Harmony wins when a UA contains both Android and HarmonyOS.
-- Hiring, rewriting the org tree, or editing budgets on the phone.
-- Exposing the control-plane port on a LAN.
-- Rebuilding the Electron four-zone layout in this slice.
+Turns still run on the desktop host. These shells do not hire or edit the org tree.
diff --git a/scripts/test/mobile-web.test.mjs b/scripts/test/mobile-web.test.mjs
index 007c6eed..972d54d4 100644
--- a/scripts/test/mobile-web.test.mjs
+++ b/scripts/test/mobile-web.test.mjs
@@ -5,8 +5,8 @@ import path from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import {
- chooseSurface,
- isMobileUserAgent,
+ choosePlatform,
+ detectPlatform,
loadWorkspaceSnapshot,
resolvePublicAsset,
} from "../../apps/mobile-web/surface.mjs";
@@ -14,15 +14,17 @@ import {
const productDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const exampleDir = path.join(productDir, "examples", "oss-maintainer");
-test("classifies phone and ASteam app user agents as mobile", () => {
- assert.equal(isMobileUserAgent("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)"), true);
- assert.equal(isMobileUserAgent("Mozilla/5.0 ASteamApp/0.15"), true);
- assert.equal(isMobileUserAgent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"), false);
+test("splits phone platforms: iOS, Android, HarmonyOS", () => {
+ assert.equal(detectPlatform("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)"), "ios");
+ assert.equal(detectPlatform("Mozilla/5.0 (Linux; Android 14; Pixel 8) Mobile"), "android");
+ assert.equal(detectPlatform("Mozilla/5.0 (Linux; Android 12; HarmonyOS) Mobile"), "harmony");
+ assert.equal(detectPlatform("Mozilla/5.0 (Phone; OpenHarmony 5.0) ArkWeb/5.0.0.0"), "harmony");
+ assert.equal(detectPlatform("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"), "desktop");
});
-test("surface query overrides user agent", () => {
- assert.equal(chooseSurface({ userAgent: "iPhone", searchParams: new URLSearchParams("surface=desktop") }), "desktop");
- assert.equal(chooseSurface({ userAgent: "Macintosh", searchParams: new URLSearchParams("surface=mobile") }), "mobile");
+test("platform query overrides user agent", () => {
+ assert.equal(choosePlatform({ userAgent: "iPhone", searchParams: new URLSearchParams("platform=android") }), "android");
+ assert.equal(choosePlatform({ userAgent: "iPhone", searchParams: new URLSearchParams("surface=desktop") }), "desktop");
});
test("desktop surface falls through instead of serving the mobile shell", () => {
@@ -53,7 +55,7 @@ function unusedPort() {
});
}
-test("phone user agents receive the mobile shell", async (t) => {
+test("each phone UA receives its own shell", async (t) => {
const port = await unusedPort();
const child = spawn(process.execPath, [path.join(productDir, "scripts", "mobile-web.mjs")], {
cwd: productDir,
@@ -75,14 +77,10 @@ test("phone user agents receive the mobile shell", async (t) => {
reject(new Error(`exited ${code}`));
});
});
- const phone = await fetch(`http://127.0.0.1:${port}/`, {
- headers: { "user-agent": "Mozilla/5.0 (iPhone) Mobile" },
- });
- const html = await phone.text();
- assert.equal(phone.status, 200);
- assert.match(html, /data-surface="mobile"/);
- const workspace = await fetch(`http://127.0.0.1:${port}/api/mobile/workspace`);
- assert.equal(workspace.status, 200);
- const body = await workspace.json();
- assert.equal(body.roles.length, 4);
+ const ios = await (await fetch(`http://127.0.0.1:${port}/`, { headers: { "user-agent": "Mozilla/5.0 (iPhone) Mobile" } })).text();
+ const android = await (await fetch(`http://127.0.0.1:${port}/`, { headers: { "user-agent": "Mozilla/5.0 (Linux; Android 14) Mobile" } })).text();
+ const harmony = await (await fetch(`http://127.0.0.1:${port}/`, { headers: { "user-agent": "Mozilla/5.0 (Phone; OpenHarmony 5.0) ArkWeb/5.0.0.0" } })).text();
+ assert.match(ios, /data-platform="ios"/);
+ assert.match(android, /data-platform="android"/);
+ assert.match(harmony, /data-platform="harmony"/);
});