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

+

工作区

+
+
+
+

正在读取组织…

+
    + +
    + + + +
    + + + + 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 · 鸿蒙

    +

    工作区

    +
    +
    +
    +

    正在读取组织…

    +
      + +
      + + + +
      + + + + 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 + + + +
        +

        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"/); });