Commit Graph

77 Commits

Author SHA1 Message Date
cbb1e1ee62 跨端: 修「深色下玻璃变灰纸板」+「主题选了不生效」(两个真 bug)
用户问「你的玻璃效果呢?」。我上手先取像素,结果是**两个一直存在的真 bug**,
不是观感偏好问题。两个都有实测数据。

## ① 深色下玻璃 alpha 没跟着翻 →「像深色卡片浮在灰纸板上」

### 实测

深色主题 + aurora 壁纸,同一行交替采样(`/tmp/g1.raw`,1008×2232):

    y=670   卡片缝隙 rgb(199,199,199)   卡片内 rgb(27,37,50)
    y=880   卡片缝隙 rgb(201,203,201)   卡片内 rgb(27,36,53)

缝隙是**浅灰 199**、比卡片还亮。而 199 恰好 = `0.78 × 255`
(壁纸层实测 x=4..24 为 rgb(0,1,3),近黑)—— 就是 `glassCardWall` 那层白纱。

### 根因:我把 WebUI 的一句话读漏了后半句

`Theme.glassCard` / `glassCardWall` 是**单一值、不分深浅**。我当初写的理由是
「基色恒为白,主题之间只差 alpha」——但 WebUI 原话是
「**基材恒为白**,主题之间**只差 alpha**」(`index.css:406`、`:1546`)。
我只抄了前半句,把「只差 alpha」误读成「alpha 也不用变」。

WebUI `.dark` 的实际取值:

    --glass-card-a:      0.06     ← 浅色 0.92
    --glass-card-wall-a: 0.04     ← 浅色 0.78

**深浅差 15 倍**:深色下白纱要几乎撤掉让深壁纸透上来,浅色下才用厚白纱盖亮壁纸。
我用同一个 0.92 配两套主题 ⇒ 深色下壁纸被糊成浅灰,**玻璃感与深色同时消失**。

### 修法

`Theme.glassCardFor(wall, dark?)` —— 与 `accentFor`/`textSubtleFor` 同一范式,
深浅从 `Theme.isDarkNow()`(`AppStorage` 那个发布键)读,不靠调用方自觉传。
新增 `glassCardDark #0FFFFFFF` / `glassCardWallDark #0AFFFFFF`。

改后同处实测:缝隙 rgb(3..16),壁纸透上来了;浅色档回归检查未变(244/239,厚白纱仍在)。

## ② 用户选的主题被无条件覆盖 →「选了深色但界面还是浅的」

### 实测

「我的」页选「深色」后:
· 按钮显示选中、服务端也记下 `theme:'dark'`(`GET /me/appearance` 确认);
· 但界面仍浅色,且文字浅色压浅底 —— 实测背景 `rgb(243,243,243)` /
  文字 `rgb(243,243,243)`,**对比度 ≈1.0:1,完全不可读**。

### 根因(两处叠加)

① `EntryAbility.onCreate` 有一句**无条件**的
   `setColorMode(COLOR_MODE_NOT_SET)`(= 跟随系统)—— 每次冷启都把用户的选择重置掉。
   它是目录迁移时抄进来的(`git log -S` 指向 `f9d757b chore: directory migration`),
   **没有注释说明为什么,也没有谁在用**。已删除(连带上游 import)。

② `MainPage.applyAppearance()` 算出了 `isDarkNow`、发布了 `AppStorage`,
   但**从来没调用过 `store.applyTheme(...)`** ⇒ 系统色彩模式根本没被设过。

### 修法

· 删掉 EntryAbility 那句无条件覆盖(附长注释说明为什么由页面负责);
· `applyAppearance()` 补上 `store.applyTheme(ctx, snap.theme)`。

★ **顺序**:`KEY_IS_DARK` 发布必须在 `applyTheme` **之前** ——
  因为 `glassCardFor()` 是从那个键读深浅的,反过来会让卡片用上一轮的深浅画一帧。
  `applyThemeNow()` 里同一处顺序也一并修正。

## 判据

`cross-client-theme` 的两条原本把 `Theme.glassCard`/`glassCardWall` 两个**字面量**钉死,
被我这次正确的改动撞红 —— 我没有放宽它们,而是改成钉**行为**:
· 卡片必须经 `glassCardFor(...)` 取色(入口存在);
· 四个令牌都要在 Theme 里存在;
· **深色档不得与浅色档同值**(同值 = 主题感知是假的)。

变异验证:① 深色档改回与浅色同值 → 3 条红;② 卡片改回不分深浅 → 5 条红;还原 → 21/21 绿。

## 验证状态

✓ 两个修复都在设备上取**像素**验证(不是看截图说"像了")
✓ `cross-client-theme` 21/21、全量套件 545 pass(3 红均为改动前既有:
  `harmony-admin` 两处 = 我上一轮 `SecuritySection` 拆分遗留、`build-stamp` = 产物戳过期)
✓ 编译通过、进程存活、无新 jscrash
2026-09-21 18:46:19 +08:00
e29f38b151 跨端: 页面转场 + 主题切换交叉淡出(用户:「加页面转场,因为是桌面应用了」+「深浅色切换也加动画」)
## ① 页面间转场(`pageTransition`)

用户要的是"因为它是桌面应用了"—— 桌面端的页面切换有转场是基本预期。

官方文档(`ts-page-transition-animation`)给了两条关键信息:
· 「当路由(**router**)进行切换时,可以通过在 `pageTransition` 函数中自定义
  页面入场和页面退场的转场动效」;
· 「为了实现更好的转场效果,**推荐使用 Navigation 组件和模态转场**」。

⇒ 所以**两套机制各归各位**,不混用:
  · 走 `router` 的独立页(管理页 / 邮件详情独立页 / 写信独立页)→ `pageTransition`;
  · 应用内 `Navigation` 跳转(邮件详情 / 写信,都走 `pushPath`)→ 已有
    `geometryTransition`(上一轮加的共享元素转场)。

数值照 WebUI `rise-in` 的关键帧(`index.css:1298-1307`)逐字对齐:
    from { opacity: 0; transform: translateY(4px) }
    to   { opacity: 1; transform: none }
⇒ 淡入 + 上浮 `Theme.riseInOffset`(4vp),时长 `Theme.durRise`(200)。

★ 退场**只淡出、不做位移**:两端都位移会看起来互相推挤。

## ② 主题切换的交叉淡出

这是**平台机制差异**,不是"懒得做":

· WebUI 切主题是**免费**的 —— CSS transition 挂在 `background-color` 上,
  主题换的是 CSS **变量**,于是每个元素各自补间(`index.css:1169` 给
  button/a/input/textarea/select 都挂了);
· ArkUI 的主题走 `app.setColorMode()`,而界面颜色是**系统资源**
  (`$r('sys.color.*')`)。`animateTo` 只能补间**数值属性** ——
  "把一个资源换成另一个资源"没有中间值 ⇒ 实测整屏同时跳变(闪一下)。

⇒ 自己造一个"旧屏淡出":
  ① 切主题**之前**用 `getComponentSnapshot().get(id)` 抓当前界面;
  ② 立刻切主题(底层已是新主题);
  ③ 把位图盖在最上层、`opacity` 1 → 0 补间 ⇒ "旧界面淡出、新界面透出"。

新增:
· `Motion.captureForThemeFade(ui, rootId)` —— 抓图,失败返回 `undefined`;
· `Theme.durThemeFade = 260`(整屏变化比单组件入场慢一点才不刺眼);
· `MainPage` 根 Stack 加 `.id(THEME_FADE_ROOT_ID)`(**常量**,两处拼错不报错、
  只表现为"动画静默不发生")+ `themeFadeImage`/`themeFadeOpacity` 两个状态
  + 最上层那层 `Image`;
· `toggleTheme` 拆成"带动画"与 `applyThemeNow`(**不含动画**)两条路,
  共用同一份状态变更 —— 抓图失败 / 系统关掉动画时直接切主题
  (**功能优先于观感**:不能因为动画失败而切不了主题)。

★ 两个必须做的收尾(都不是可选):
  · 动画结束后**清空 `themeFadeImage`** —— 留着是一张**整屏大小**的 PixelMap
    常驻内存,而且它盖在最上层会**吃掉所有触摸事件**(界面看着正常但点不动);
  · 那层加 `hitTestBehavior(HitTestMode.None)` —— 动画期间用户可能正好在点按钮。

★ 默认跟随系统:**本来就已经是**(`Appearance.theme` 初值 `'system'`,
  `AppearanceStore` 与 `Models` 两处都是),本轮未改,只是确认了。

## 验证状态(如实)

✓ 编译通过;设备上装包后进程存活、无新 jscrash
✗ **动画本体没有在设备上看到**:
  · 页面转场与主题淡出都是 200-260ms,而 `snapshot_display` 往返 1.5-3s
    ⇒ 探针比被测对象慢一个数量级(同 `harmony-morph-unverified-middleframes`);
  · 主题淡出还额外需要"已登录 + 找到主题开关",而我在清 preferences 之后
    登录流程没能用 `uitest uiInput` 走通(`inputText` 追加而非替换,
    把地址栏填成了 `https://exm/api/v1https://mail.jianfgit.`)。

⇒ 这两条的**结构与接线**有判据/编译保障,但"动起来好不好看"只能由真机确认。
   我不声称已验 —— 与既有的 morph 那条同一个诚实口径。
2026-09-21 17:36:40 +08:00
d857352e29 跨端: 闭合 harmony-permission-history —— 授权栏补上「已决策的历史」
这是 `docs/DEBTS.json` 里登记的一条,它的到期条件原文是
「做『授权栏与 WebUI 对齐』时」—— 就是现在这一轮。

## 原缺口

鸿蒙的 `PermissionTab` 只调 `GET /permission/pending`(服务端
`ListPendingPermissionsFor`,SQL 带 `WHERE pr.result IS NULL`)
⇒ **只拿得到待决的**,于是"这条会话批过哪些事"完全看不到;
而 WebUI 有(`PermissionList.tsx:182` 的「历史 {n}」)。

## 关键判断:**不照抄 WebUI 的 inbox 分组**

我先把 `PermissionTab.load` 整个改成读 inbox + `groupPermissions`,
**改到一半发现行不通**(编译报 `question`/`context`/`agent_name` 找不到):

· WebUI 从 inbox 分组,但它的 `PermissionRow` **只渲染**
  `subject`/`created_at`/`permission_result`/`permission_expires_at`
  (逐字段 grep 过,全文件没有 `question`/`options`/`context`);
· 而**待决**那一段我们要显示 `question`/`options`/`context`/`kind`
  —— 那四个字段在 `permission_requests` **表**里,
  inbox 回包(`models.Mail`)**没有它们**(模型逐条核过,只有 `permission_result`
  与 `permission_options`)。

⇒ 两条来源各有各的信息量,不是二选一:
  · **待决**继续走专用端点(信息更全、能直接决策);
  · **历史**走 inbox 补上。
  代价是每账号多一次请求 —— 这是**有意的取舍**,写在代码注释里。

(半成品已 `git checkout` 撤掉,没有把它留在提交里。
 撤掉的原因如实记在注释里,免得下一个人以为"照着 WebUI 改"就行。)

## 落地

· `model/MailGrouping.ts`:加 `groupPermissions` + `PermissionGroup` +
  `isPendingPermission`,逐条对齐 WebUI 的 `groupPermissions`,
  含它那**三步排序**(有待决的先来 → 待决多的更靠前 → 最新一封倒序)。
· `PermissionTab`:从 inbox 取 `mail_type=permission_request &&
  permission_result != ''` 的,分组后渲染「历史 n 条」(只读、不可操作)。
· 空态判据从 `requests.length === 0` 改成**两者都空**才显示 ——
  否则"有待决的历史"会被误报成"没有待决策的请求"。

## 判据自己抓到了我

`cross-client-logic.test.mjs` 的「缺口只减不增」在我补上 `groupPermissions`
之后立刻变红,并给出准确指引:

    减少(harmony 补上了功能)→ 请把 gaps 里对应的名字删掉

⇒ 已清空 `gaps`。**这条判据在这轮里三次发挥作用**:
  第一次报出这个缺口(09-20),第二次在我半成品时红了,
  第三次确认闭合。`pass=7 fail=0`。

`docs/DEBTS.json` 的 `count` 已改 0、`due` 记完成、`note` 写明修法与取舍。

## 设备验证(如实)

✓ 授权页正常渲染,进程存活(23343),无新 jscrash
✓ 空态文案正确(本机确实没有权限邮件)
✗ **"历史 n 条"真的显示出来**这条路径没能实测:
  本机没有已决策的权限请求(服务端实测 `permission_request` 0 封)。
  逻辑逐条对齐 WebUI、编译通过,但我不声称已看到它渲染。
2026-09-21 16:45:23 +08:00
1869924507 跨端: 接上聚合失败横幅(数据一直在收集,界面从来没读过 —— 安全网是断的)
逐页对齐 WebUI 时发现的**不是观感问题,是安全网断线**。

## 事实

`MailStore` 一直在收集"聚合模式下哪个账号取失败了":

    MailStore.ets:142  const failed: AccountError[] = [];
    MailStore.ets:208  failed.push(AccountError.of(acct.displayName, reason));
    MailStore.ets:226  snap.accountErrors = failed;
    (收件箱与发件箱两处 load 都写)

而**界面一次都没读过 `snap.accountErrors`**(`MainPage` 里 grep 为 0 处)。

⇒ 后果:某个账号拉不到邮件时,列表**静默少一整份**,界面看起来完全正常。
   用户会据此得出错误结论 —— "没人给我发信"。

WebUI 把这条写成了显式警告(`MailList.tsx:85-91` 原注释):

  「聚合时**某个账号取不到**必须说出来:静默丢掉它,列表会少一整份邮件,
    而界面看起来完全正常 —— 这正是『聚合』最容易骗人的失败方式」

## 改动

· `InboxTab` 加 `@State accountErrors`,在 `applyStoreSnapshot` 里接上 `snap.accountErrors`;
· 卡片下方渲染琥珀色横幅,文案照 WebUI:
  「有 {n} 个账号没取到:{账号}({原因});…」
  (`bg-amber-50 border-amber-200 text-amber-800` → `warnBg` / `warnFgDark` / 11 号字);
· 新增 `accountErrorText()` 做拼接(`「a(原因);b(原因)」`)。

★ 只接了**收件箱**那一处。发件箱 `SentTab` 有自己的 `applyStoreSnapshot`,
  本轮不动它 —— 它的聚合语义与收件箱不同(见 `SentTab.load`),
  要不要显示同一张横幅需要单独判断,不顺手加(顺手加是本仓反复出现的错法)。

## 设备验证(如实)

✓ 收件箱正常渲染(`9 组 · 9 封`),进程存活(22242),无新 jscrash
✓ 横幅**正确不出现** —— 当前所有账号都取得到(这是应该的行为)
✗ **横幅"出现"的那条路径没能实测到**:要触发它需要一个取失败的账号,
  而本机没有可用的失败账号构造。逻辑与 WebUI 逐条对齐、编译通过,
  但"真的会显示成那样"我不声称已验 —— 与"动画中间帧看不到"同样的诚实口径。
2026-09-21 16:32:49 +08:00
877fda5829 跨端: 联系人页标题补计数(WebUI ContactPanel.tsx:68 有、我们没有)
逐页对齐时逐项比对发现的漏项。

## 事实

WebUI `ContactPanel.tsx:63-68` 的头部是三段:

    <h2>{view === 'card' ? '工作列表' : '联系人'}</h2>
    <span className="ml-2 text-xs text-gray-400">{contacts.length}</span>   ← 这一段我们没有
    <div className="flex-1" />  + 视图切换按钮

## 改动

标题右侧补 `contacts.length`,字号/颜色照 WebUI:
`text-xs`(12) → `Theme.fontSmall`,`gray-400` → `Theme.textSubtleFor()`。

★ 用 `textSubtleFor()` 而非写死灰:深色下三级文字要提亮 ——
  本仓 2026-09-18 实测过「三级文字浅色也一样不够」(`110de79`)。
★ 取值与 WebUI 同一个来源(`contacts`),**两个视图共用同一份** ——
  所以计数不随列表/卡片视图切换而变(WebUI 也是这样)。

## 设备验证

点「联系人」后 `uitest dumpLayout`:

    '联系人'   y=189..256
    '9'       y=203..243      ← 同一行、右侧,正是 WebUI 的位置

(改前该位置为空。)
2026-09-21 16:26:20 +08:00
2f80e1102d 跨端: 写信 FAB → 写信页 共享元素转场 + morph 收成单一入口(判据两版错法都记了)
用户 2026-09-21:「webui 行为是按钮变成对应的写邮件页面或输入框吧,你做的啥?」
「都做啊」—— 两处 morph 现在都在了。

## ① 写信 FAB → 写信页(跨 NavDestination)

官方 FAQ `faqs-arkui-991` 给的正是"在 NavDestination 子页面里做共享元素转场"
的完整步骤,逐步照做:

  · 两端绑同一 id `compose-morph`(FAB / ComposeDestination 的 NavDestination);
  · **`pushPath` 放进 `animateTo` 闭包**(FAQ 步骤 3 原文就是这个形状);
  · `follow: false`(两端互斥出现,不是"始终在树上跟随")。

## ② 把 morph 收成**单一入口** `Motion.morph(ui, mutate)`

这一步不是为了少写代码,是为了**让判据能判**。过程值得记:

**第一版判据** —— 全仓 `any()`:
    /animateTo\(/.test(allHarmony) && /durMorph/.test(allHarmony) && …
变异实测(把 morph 那处的 `animateTo` 改名、把 `Theme.durMorph` 就地写 `220`)
**三条全绿** —— 因为全仓**别处**还有这些名字,"删掉这一处"永远命中不了。

**第二版判据** —— 逐站点取"文本邻域"看有没有 animateTo:
**全假红**。因为 `geometryTransition(id)` 绑在**组件树**上,而 `animateTo`
写在**另一个方法**里,文本邻域取不到隔壁的方法。

⇒ 结论不是"把判据写得更聪明",而是**把结构改成可判的**:
把"带 morph 的状态切换"收进 `Motion.morph(ui, mutate)` 一处
(`animateTo` + 时长 + 曲线都在里面),调用点只剩「我要改哪个状态」。
这与本仓既有解法同型(`PressEffectModifier` / `GlassCardModifier`:
把"每处都得记得写"收敛成"一处定义、处处引用")。

3 个调用点已全部改走它(`MainPage.openComposeWithMorph`、
`MailDetailPage.openReplyWithMorph` / `closeReplyWithMorph`)。

★ `Motion.morph` 必须收 `UIContext`:全局 `animateTo` **已废弃**
  (编译器告警 `'animateTo' has been deprecated`),而静态方法里拿不到
  `this.getUIContext()`(本仓纪律:静态方法里不用 `this`)。

## 判据:6 条,三条变异逐个验过

    通过  每个 id 恰好绑两处(一 in 一 out)         [变异:删一端 → 红 ✓]
    通过  morph 只有一个入口且内部有 animateTo        [变异:换成普通调用 → 红 ✓]
    通过  用 ui.animateTo 而非废弃的全局 animateTo
    通过  每个用 geometryTransition 的文件都走 helper  [变异:自己写 animateTo → 红 ✓]
    通过  页面里不再直接出现 Theme.durMorph
    通过  Theme.durMorph 存在且 = 220                [变异:改成 450 → 红 ✓]

★ 期间还抓到一个**判据自己的 bug**:我重写那一段时把 `themeSrc` 的定义
  一起删了 ⇒ 第 6 条抛 `ReferenceError`、**整条判据根本没跑**
  (而其余 9 条照常打印"通过",退出码 1 但没人看得到那条)。
  这与"守具有齿但不在位"同形:**判据崩了不会显示成失败**。
  已补回定义并重跑确认。

计数棘轮 4 → 10(显式编辑,理由写在 `run-all.mjs` 里)。

## 设备验证

✓ 点 FAB → 写信页到场、取消 → 回列表,进程存活(17827),无新 jscrash
  (`faultlogger` 里最新仍是 15:08 那条,即修复前的)
✗ 220ms 的**中间帧**仍看不到(`snapshot_display` 往返 1.5-3s 慢一个数量级)——
  与上一条提交同样的诚实交代:动画本体只能由用户在真机上看
2026-09-21 15:50:00 +08:00
20fc8a5800 跨端: 修三个真崩溃/失败 —— @BuilderParam 丢 this、发送后退错页、漏校验 body
用户 2026-09-21:「点击发送邮件直接闪退,点击授权也直接闪退,所有功能全部不可用」。
三个都是**真 bug**,逐个拿到证据后修的(不是猜的)。

## ① 点「授权」必崩:`@BuilderParam` 把 `this` 换掉了

崩溃日志(`jscrash-…-20260921150832132.log`)给出的栈:

    Reason: TypeError
    Error message: Cannot read property length of undefined
    at anonymous entry (MainPage.ets:1458:23)       ← this.requests.length
    at … Surface.ets:717:7                          ← AppHeader 里 this.trailing()

`MainPage.ets:1458` 是 `if (this.requests.length > 0)`,
而它住在 `PendingTrailing()` 这个 `@Builder` 里 —— **传给 `AppHeader` 的
`@BuilderParam` 之后,它执行时的 `this` 变成了 `AppHeader`**,
而 `AppHeader` 上当然没有 `requests` ⇒ `undefined.length` ⇒ 崩。

★ 这是 ArkUI 的老坑:`@BuilderParam` 是**按值传递一个函数**,
  调用方的 `this` 不会跟着过去。全仓**4 处**都踩了(`MainPage` 的
  `SentCountTrailing`/`PendingTrailing`、`AdminUsersPage`/`SettingsPage`
  的 `HeaderTrailing`)—— 它们各自读 `this.loaded`/`this.load()`。

修法:改成**尾随闭包**(`AppHeader({...}) { this.XxxTrailing() }`),
闭包捕获的是**定义处**的 `this`(本组件的),而不是 AppHeader 的。

★ 为什么判据没抓到:那 4 处此前都只是"静态源码里有这个 builder",
  而崩溃只发生在**运行时的 `this` 绑定**上 —— 形态判据看不见绑定。
  这一条只能靠设备实测(我这次是靠真机崩溃日志)。

## ② 发送成功后"闪退":其实是退错了页

`ComposePage.doSend()` 成功分支里是**无条件** `router.back()`。

而内嵌时(宽屏右栏 / 窄屏 `Navigation` 覆盖)写信只是 `MainPage` 的一个
**右栏状态** —— `router.back()` 退掉的是**整个 MainPage**,用户看到的就是
"发送之后 App 没了"(报成闪退)。

★ 同一个文件里,顶栏「取消」键(上面几十行)**早就写对了**:

    if (this.embedded) { this.onBack(); return; }
    this.getUIContext().getRouter().back();

我加 `doSend` 时没照着抄。`MailDetailView.goBack()` 也是这个正确形状 ——
**只有 `doSend` 是那个异类**。已改成与取消键同一判据。

## ③ 发送真的失败:校验漏了 `body`,且没 trim

日志里 `→ POST …/mail/send` 发出去了,但服务端 400。
直接打服务端复现:

    curl -d '{"to":"pi@root.new","subject":"t","body":""}'
    → {"error":"Missing to, subject, or body"}

而 WebUI 的 `canSend`(`ComposePage.tsx:132-138`)是**四个条件**:
    to.trim() !== '' && subject.trim() !== '' && body.trim() !== '' && …
鸿蒙这边只校验了 `to` 与 `subject` —— **漏了 `body`**。

⇒ 用户在"正文本来就是可选的"观感下不填正文,请求照样发出去、被拒。

同时补 `trim()`:WebUI 发的是 `to.trim()` / `subject.trim()`,
而 `pi@root.new ` 与 `pi@root.new` 在服务端是**两条不同地址**。

## 设备验证(改前 → 改后)

· 点「授权」:崩(进程消失,新增 jscrash) → **进程存活,页面正常渲染,
                                待决策徽标 "4" 正确显示**(证明 `this.requests` 绑定对了)
· 发送邮件:POST 发出但服务端 400,且"闪退" → **回到收件箱,
                              发件箱里 `realtest` 已落库**(服务端实测 9 封)

## 另修:候选补全的菜单按 WebUI 补齐四件

用户:「收件人填充能力完全不可用,根本没有与 webui 对齐」。
实测后确认功能是通的(`pi` → `pi@` → 路径 → 会话 → 完整地址,
三段链逐段验过),但**行内渲染漏了 WebUI 的四个要素**(`AddressInput.tsx:186-231`):

  ① 别名 `font-mono`(地址类文本全仓等宽)
  ② 标题在**第二行**(原来挤在右边同一行)
  ③ `source` 三态视觉:`platform` 蓝胶囊 / `new` 灰字 / `mail` 无标
  ④ `unread > 0` 红徽标(服务端 `SessionCandidate.Unread`,带 `omitempty`)

`AddressSuggestion` 顺带补 `unread` 字段并守住 `omitempty`
(缺键时裸 cast 是 `undefined`,不是类里的 `= 0` —— 与 `title` 同一个坑)。

★ 另外修掉一个我自己写错的参数:`suggestAddress` 原来把 `'?name=…'` 传给
  `ApiClient.get(path, query)`,而**问号是那个方法自己加的**
  ⇒ 会拼成 `??name=`。约定:`query` 只放 `k=v`,不含问号。
2026-09-21 15:21:47 +08:00
125aec1191 跨端: 2in1 键盘可达(Ctrl+N 写信 / ↑↓ 换补全 / Enter 选中)+ 三处判据被实测改判
用户 2026-09-21:「接下来做一下 2in1 上的快捷键,比如快捷键打开发信页面」
「上下键切换发信目标」「回车展开输入框等」

## ① 打开发信页:Ctrl+N,用官方 `keyboardShortcut`

先查了官方文档再动手,避开两条静默失败的坑:

  · `keyboardShortcut`(组件快捷键事件,API 10+)「**无论组件是否获焦** ——
    只要窗口获焦,快捷键就会响应」。而 `onKeyEvent` 要求**组件先获焦**,
    邮件列表里焦点落在哪是不确定的(点一下就换)⇒ 用它做全局快捷键会时灵时不灵。
  · 「多个不同组件设置相同组合键 ⇒ 只响应节点树**深度最浅**的那个」。
    ⇒ 再给别处的"写信"按钮补一个 Ctrl+N 不是"多一个入口",而是**让后来那处永久失效**。
    判据因此钉"全仓只许绑一处"。

绑定位置在 `MainPage` 的**根 Stack**(窗口组件树的根)—— 绑在 FAB 上不行,
它在 `if` 分支里、窄屏/宽屏位置也不同,会随分支挂卸。

键位 `Ctrl+N` 避开了官方列出的五个**禁止绑定**组合(Alt+F4/Alt+Tab/Ctrl+Shift+Esc…)。
判据直接解析调用参数校验这三点。

## ② 根够不着 `openCompose()` ⇒ 照 `PushService` 的"两半"形状

`openCompose()` 住在**条件挂载**的 `CommPage` 上(`if (currentIndex === 0)`),
根组件拿不到它。新建 `common/ComposeIntent.ets`:

  · **格子**(`pending`)—— 用户此刻在别的页、`CommPage` 还没实例化;
  · **回调**(`listener`)—— 用户此刻就在通信页、页面早挂载完了。

缺任何一半都是**按键静默失效**:只有格子 ⇒ `aboutToAppear` 不重跑;
只有回调 ⇒ 没有监听者。这形状与 `api/PushService.ets` 处理"点通知跳转"时
踩的是同一个坑,那边注释里已写过解法 —— 这次是照着抄,不是重新踩。

## ③ 收件人三段式补全(↑↓ 切换、Enter/Tab 选中、Esc 收起)

WebUI 的逻辑原先**散在组件闭包里**(`parseParts` 没 export、`apply`/`onKeyDown`
直接改 React state)⇒ 判据根本 import 不到。先把它抽成一对纯逻辑:
`client/electron/src/lib/addressSuggest.ts` ↔ `model/AddressSuggest.ts`,
**抽的时候行为一字不改**(抽出来顺手"改进"会让判据比新行为、线上跑旧行为,两边都错)。
`AddressInput.tsx` 改接这份 lib。

`cross-client-logic` 新增 `AddressSuggest` 一对,22 个用例两边逐例比。
其中 `nextActiveIndex(0,3,-1)` 是**负下标陷阱**:JS 的 `%` 对负数返回负数
(`-1 % 5 === -1`),而负下标在数组访问里**不报错**(返回 `undefined`),
只表现为"按上键后没有任何一项高亮"。直接写 `(i-1) % n` 就会这样静默坏掉。

候选走**内联渲染**而不是 `bindPopup`/`bindMenu`:那两者各有焦点体系,
会先吃掉按键 ⇒ "↑↓ 换候选"落不到 `onToKey` 上。

## ④ 另修一个真 bug:`AddressSuggestion` 字段名整套写错

模型声明 `value`/`kind`,而服务端(`contacts.go:206`)给的是 `alias`/`title`/`source`
—— **从来没返回过** `value`/`kind`。按本仓纪律「声明了服务端从不返回的字段 ⇒ 删掉声明」改正。

顺带守住 `title` 的 `omitempty`:缺键时裸 cast 给 `undefined`(**不是**类里那个 `= ''`),
直接读会 `Cannot read property of undefined` —— 本仓在 `MailDetail.normalize()` 上
踩过同一形状(整页白屏)。判据钉住那句 `typeof … === 'string'` 的守。

## ⑤ 三处判据被**实测**改判(不是我挑一边,是拿数字定的)

### a. 卡片不该有模糊 —— 反转原断言
原判据断言「玻璃卡要走参数化 `backgroundEffect`」。那是**记录旧实现的副作用**
(重言式)。逐字读 WebUI 的 CSS:全仓 `backdrop-filter` **只有两处**
(`.glass-control` 8px/1.1、`.narrow-nav` 18px/1.5),而 `.glass-card`(`index.css:1629`)
**完全没有** —— 它的玻璃感是 `rgb(255 255 255 / 0.92)` 这个 alpha。
留着旧断言更坏:下次谁把卡片改成正确的"白 + alpha"会被判红,然后去**把模糊加回来**。

### b. 我试了"把模糊移到导航条",被实测否掉
推断「真归属是底栏」,于是把底栏改成 `backgroundEffect({radius:18, saturation:1.5})`。
实测(模拟器窄屏 1008×2232,底栏中心列 x=504):

    y      backgroundEffect        backgroundBlurStyle
    1960   rgb(191,199,209)        rgb(234,235,239)      ← 差 -36 亮度
    2060   rgb(206,211,219)        rgb(234,235,239)      ← 差 -24

⇒ `backgroundEffect` **只给模糊、不给底色**,壁纸原样透上来,底栏暗了 24~36 级。
WebUI 的 `.narrow-nav` 是**两条声明**组合的(`background-color` + `backdrop-filter`),
我只搬了后者。系统材质**同时含色调 + 模糊 + 深浅两套** ⇒ 在这里它才是正解
(§7.12 把它判成"有意差异"是对的,我的"改进"是退步)。
判据改成**反向钉住** `backgroundEffect`,并把这段实测数字留在 Theme.ets 里。

### c. 页签条判据记录的是被否掉的"胶囊"
原判据钉 `TAB_BAR_RADIUS`/`TAB_BAR_SIDE`/`TAB_BAR_TOP` —— 那正是用户否掉的形状
(「你又在内部套了一个胶囊」)。CDP 读 WebUI 的实测几何:

    .comm-pane   x=80 w=320 radius=14px overflow=hidden   ← 窗格,裁圆的是它
    tabstrip     x=80 w=320 radius=0px                    ← 条自己无圆角

设备实测(`uitest dumpLayout`):页签条 `[28,140][980,267]`、窗格 `[28,140][980,1957]`
⇒ 左右边缘逐像素相同。判据改为钉"与窗格齐平 + 只有左上角圆角"这两条**不变式**,
`TAB_BAR_RADIUS`/`TAB_BAR_SIDE` 一并**删除**(留着就是孤儿,会邀请人把胶囊拼回来)。

## ⑥ 顺带修两条判据自己的正则
`{6}` 看不见 8 位色 ⇒ 把 `glassCard`/`glassCardWall` 报成"清册过期",
**病因报错了**。改成 `{6}(?:[0-9A-Fa-f]{2})?`(不能写 `{6,8}`,那会连 7 位也放进来)。
半透明禁令改为**枚举白名单**(不是放宽:遮罩那个真实约束原样保留,
`overlay` 写成 8 位单色照样红)。

新增判据 `harmony-2in1.test.mjs` 12 条;`files=34 checks=543 pass=540 fail=2`(收尾前)。
2026-09-21 13:29:24 +08:00
f83c23362a 跨端: 修卡片投影/页签条圆角/日视图/周视图 — 五处对 WebUI 的误读
用户逐条指出后,用 CDP 读 WebUI 的 computed style 与祖先链,发现五处都是我读错/抄错。

## ① 列表底部那条"不知道什么玩意的阴影"(用户原话)

根因:我把 `instance.shadow(Theme.glassShadow)` 挂在 **每张卡片** 上。
WebUI 全仓 `box-shadow` 只有三处 —— `.app-shell > *`(窗格)、
`html[data-bg='on'] .app-shell > *`、`.narrow-nav`(底部导航条)。
**卡片 `.glass-card` 一次都没有**(整个规则块只有 radius/border/bg/transition)。

一屏十几张卡各投一次 ⇒ 叠加成灰雾。设备实测(x=600,卡片底边 y≈1847):
    有投影 y=1846→231、y=1852→230(向下衰减的暗带)
    去掉后 y=1846→255、y=1852→251(消失)
⇒ 投影移到 `PaneModifier`(窗格层),与 WebUI 一致。

★ 判定实验还排除了一个嫌疑:`List` 默认 `edgeEffect(EdgeEffect.Spring)`
  (官方文档「支持弹簧效果和**阴影效果**」)。设 `EdgeEffect.None` 后暗带照样在
  ⇒ 不是它。**弹簧是滚到边缘的正常反馈,不该为遮一个 bug 把它关掉。**

## ② 页签条右上角"那个圆角"(用户原话)

两道弧叠在一起。WebUI 实测(CDP 读祖先链):
    .comm-pane   x=80 w=320 radius=14px overflow=hidden   ← 窗格
      tabstrip   x=80 w=320 radius=0px                    ← 页签条
**两者横向完全齐平**,页签条自己直角、靠窗格裁圆 ⇒ 只有一道弧。
我们内缩 16vp + 自己带角 ⇒ 两道弧,中间一弯月牙。
⇒ 改成与窗格齐平、只留左上角圆角。

★ 期间我犯过两个错,都记在注释里:先把半径设成 `HEADER_HEIGHT/2`=22(条高也是
  44,那就是一个完整胶囊),后来自作主张把整条改成"通栏+下边框+无玻璃"——
  那是重做而不是用户要的小改,已还原。

## ③ 日视图与周视图一样(用户原话)

`DayTimeline()` 我写好了却**从未调用**。日视图一直走 `rows()`(周视图那套七列)。
⇒ 按 scale 分流:日视图 → 24 小时时间轴(对齐 WebUI `DayGrid` 的 `HOURS` 逐行、
  当前小时淡蓝底);周/日格子改用 `WEEK_CELL_HEIGHT`(240) 并画日程标题条
  (对齐 `EventChip`:`HH:mm` + 标题,停用的加删除线 + 灰底)。

## ④ 邮件正文垂直居中(用户原话)

官方 FAQ `faqs-arkui-725` 原文:内容比 `Scroll` 矮时「**默认居中排布**」,
要 `.align(Alignment.Top)` 才是顶部排布。
实测内容块在 `Scroll` 里偏移 731px = (1944−481)/2 —— 正好居中。
⇒ 4 处 Scroll/List 补 `.align(Alignment.Top)`(详情页 y 从 1018 → 287)。

## ⑤ 窄屏圆角/留白全丢(用户:「你的邮件的圆角呢?日历的圆角呢?」)

`borderRadius(this.isWide ? glassRadius : 0)` —— 窄屏恒 0;
`left/right: isWide ? paneGap : 0` —— 把 WebUI「窄屏**底边**留白为 0」
错推广成「四边都为 0」。
WebUI 两条 shell 规则**都给圆角**,差的只是底边留白。
⇒ 圆角两端都开;左右留白两端都给;只有底边分宽窄。

## 连带修的两个真问题

· **底部导航避让**(用户:「为什么不避让底部导航栏」):根是 `NavBar` 与内容
  是 `Stack` 里的**并列兄弟**(互相重叠),所以每个滚动容器都得手写
  `contentEndOffset` —— 我漏了 8 处。照 WebUI 改成 `Column{内容, NavBar}`
  (WebUI 实测 `scroller.bottom=861` / `nav.top=871`,内容停在导航上方)。
· **不透明的那一张卡**(用户:「有一个完全不透明的邮箱项」):`MailRow` 手写
  `Theme.surface`(系统卡片色 α=1),而 `GroupHeader` 用的是 `GlassCardModifier`。
  ⇒ 改用同一件基础件 + 新增 `TintModifier` 合成状态色(`.attributeModifier`
  是单一插槽,色必须参与合成)。实测 `(255,255,255)` → `(195,218,204)`。

判据随之反转 3 条(`harmony-widescreen` 两处把"窄屏 0"改成"两端都要"、
`cross-client-theme` 抓到我新写的 `Theme.border` 当 `fontColor`)。
2026-09-21 12:04:52 +08:00
5e4a1b616c 跨端: B 的交付物(跨端纯逻辑一致性判据)+ 照 skill 回扫修掉两处隐形债
用户:「你为什么不加载鸿蒙开发相关skill?」—— 说得对。那份
`arkts-grammar-standards` 写着 "REQUIRED before writing the first .ets file of a
session",而我这轮一直在写 `.ets`。补加载后照它的规则表**逐条回扫**,
当场抓出两处此前没人管的违规。

══ ① 用户要做的 B:`cross-client-logic.test.mjs`(新,6 条判据)

背景:两套纯逻辑各写一份且已分叉(replyTarget 214/170 行、mailGroups 178/459、
appearance 187/324、calendar 208/446)。当天已**踩到**两处分叉
(`participantAddress` 的 `||`、`ThreadPage` 字段全错)。

做法:**同一张用例表喂给两边,逐条比结果**(`--experimental-strip-types`
直接在 node 里跑两侧源码 —— 两边的 model 层都是纯逻辑、无 SDK 依赖)。
不选"生成一份共享源码":harmony 不能 import 工程外文件,
且两边类型系统不同(ArkTS 禁解构/any/对象字面量要具名类型),
生成器要维护"两边都能过"的子集,是另一个大工程。

★ **首轮运行就报出两处真分叉,都不是我踩到才发现**:
  ① `formatAddress('dsh', undefined, undefined)`:electron 返回 `"dsh"`,
     harmony **抛** `Cannot read properties of undefined`。
     —— 又是 `omitempty` 那个坑(**第三次**),这次是判据先报的。
  ② `monthGrid`:electron **固定 6 行**(`grid-rows-6`),harmony **4~6 行**
     ⇒ 翻月时网格高度跳动。WebUI 的注释明写要避免这个("行数变化会让整个
     网格高度跳动,翻月时页面内容上下弹")。
  ③ 顺着 ② 又发现:WebUI 邻月格子**填真实日期并置灰、可点**
     (`CalendarView.tsx:545-556`),harmony 留**空白格**。

★ 判据自身的两次错,都留了档(判据的 bug 与代码的 bug 一样危险):
  · 第一版把 `args[0]` 当单个参数传,字符串被当可迭代对象展开 ⇒
    `formatAddress('d','s','h')` —— **判据自己造出假分叉**。
  · 第一版 `weekStart` 传 0(周日),而两端实际都是 1(周一)⇒ 又一处假分叉。
    差一点就去"修"一个不存在的问题。
  · `monthGrid` 的投影第一版按 `inMonth ? [y,m,day] : null`,
    把"邻月填不填真日期"这个**真分叉**抹平了 —— 投影只该换表示,不该替我看不看。

★ 三类"不同"要分清(写进文件头):**命名不同**(投影归一,不是分叉)、
  **签名不同**(ArkTS 没 Date 重载习惯;语义必须一样)、**行为不同**(是分叉,以 electron 为准)。

══ ② skill 回扫抓出的两处隐形债(编译器只告警、判据也不管)

· **正则字面量**(`arkts-no-regexp-literals`):`MailDetailPage.ets:615` 的
  `/^\d+$/`(从 2026-09-19 活到今天)。
· **废弃的全局 `router`**:`api/Logout.ets:76` 的 `router.replaceUrl(...)`。
  它是个独立函数(没有 `this`)⇒ 拿不到 `UIContext`,改成由调用方传
  (两个调用点都持有 `getUIContext()`,零成本)。

★ 这两条为什么能活这么久:**编译器对它们只告警、不挡构建**,
  全仓也**没有判据**管 ⇒ 规则事实上不存在。已补两条判据,都做了变异验证。

══ ③ 顺带修正一条**恒真的同义反复**断言

`harmony-calendar` 里 "today 不在本月:不许标在别的月" 那条:
它是在"邻月格子是空 `DayCell`(`iso` 为空串)"时写的 ⇒ `c.iso === today`
**永远不可能**匹配 ⇒ `count === 0` 恒真,**看起来守着一条规则,其实什么都没守**。
改成真不变量:**"被标为今天的那一格,iso 必须就是 today;至多一格"**,
并反向核对"2026-10-01 确实出现在 9 月网格里"(否则那段是空转)。
实测 WebUI `CalendarView.tsx:547` 是逐格 `isSameDay` ⇒ **它会标**,
所以原来那条"不许标"本身就窄了一半。

══ ④ 登记两处盘点发现(**没有**顺手改,因为需要人决定)

· `harmony-dead-pages`:`InboxPage.ets`(238 行) 不可达(不在页面表、无人导航),
  `SessionsPage.ets`(170 行) 唯一引用来自 InboxPage ⇒ 一起不可达。
  没删是因为 `HARMONY-ALIGN-PLAN.md:214` 把它当变异测试靶子用过 ——
  删掉会永久丢代码,是否只是"早期留存"我判断不了。
· `harmony-permission-history`:WebUI 授权栏显示**待决 + 已决策历史**两段
  (拿 inbox 自己分组,`PermissionList.tsx:27/174/182`);鸿蒙调专用端点
  `/permission/pending`(SQL `WHERE pr.result IS NULL`)⇒ **只拿得到待决的**。
  已在 `cross-client-logic` 的 gaps 里如实登记,判据会盯着"不要再少"。

══ 判据状态

`files=33 ran=33 checks=530 pass=530 fail=0 skip=0 red=0 broken=0 unreported=0`;
`baseline=7/7✓`(底本第 9 次重算,已按规矩先 `git diff --quiet HEAD` 取证 + 记录理由)。
`verdict=red` 残余仍是 5 条静态判据的**设备到期提示**(既有机制)。

══ 环境

模拟器昨天起卡死(hdc 能连、shell 超时、CPU 150%、跑了 34 小时),
导致设备判据各跑 836 秒后失败 —— 看起来像"套件卡死"。用户批准后杀掉重启
(`Emulator -start HATriple -noWindow`,`devecocli` 那套因 x11 起不来),
现在**75~150 秒**跑完整套。
2026-09-20 22:35:35 +08:00
25e7d8f3bf 跨端: 补附件区(两端一直都有这个功能,我上次误判成"死代码")+ 修两个真 bug
══ ① 更正我 2026-09-19 的一个**错误结论**(已写进 docs/DEBTS.json 留档)

那天我审计后写下:`GET /me/mail/inbox` 的回包**既没有 `attachments` 也没有
`has_attachments`** ⇒ `MailList.tsx:261` 的 `mail.attachments?.length ?? 0`
恒为 0、WebUI 那个 📎 是**死代码**。并据此在鸿蒙侧**有意不抄**这个标记。

**这个结论是错的**,错在取证方法:我**只看了一封没有附件的邮件**,
看到 key 不在,就断言服务端从不返回它。事实:
· 服务端 `GetInbox`(`mail.go:586`)**明确调了 `fillAttachments`**,注释还写着
  理由:「Agent 靠收件箱列表得知有哪些附件可下载,否则它不知道该调 attachment_id」
· `Mail.Attachments` 的 tag 带 **`omitempty`** ⇒ **没有附件的邮件根本不输出这个 key**

实测 `limit=200`(96 封):带 `attachments` 的 **2 封**,正是真有附件那两封。

★ 教训:**`omitempty` 字段的"缺失"不等于"服务端不返回"**。
  判「某字段有没有」必须拿**确实有值的那条**去验,而不是拿一条恰好为空的数据。
  这与同一天那个白屏崩溃(`session_workspace` 缺失 → `undefined` → 抛)
  是**同一个坑的两面** —— 那天是"缺失 → 客户端崩",今天是"缺失 → 我误判成不返回"。

══ ② 补附件区(鸿蒙原来完全没有)

· `model/Attachment.ts`(新)—— `formatSize` / `attachmentLabel`,纯逻辑无 SDK 依赖,
  逐字对齐 WebUI `api/client.ts:390`(三档 + 保留一位小数)。
· `MailApi.downloadAttachment` —— 走 `getBytes`(不是 `get<T>`:后者假定 JSON,
  取二进制会炸;壁纸当初踩过)。
· `IcsFile.saveBinaryFile` —— 与既有 `saveIcsText` 同一套流程,只是写 `ArrayBuffer`。
· `MailDetailPage` 正文之后渲染附件清单(回形针 + 文件名 + 大小 + 下载),
  位置/形态对齐 WebUI `Attachments.tsx`(无附件时**整块不渲染**)。
· 列表行的 📎 + 数字(`attach_count`,与 `cc_count` 同形状派生)。

══ ③ 顺带撞出并修掉两个**真 bug**

**bug A(差一点就是 94/96 必崩)**:我第一版写 `mail.attachments.length` ——
而 `Attachments` 带 `omitempty`,96 封里只有 2 封有这个 key ⇒ 其余 94 封是
`undefined` ⇒ `.length` 抛。**与当天早些时候那个白屏崩溃是同一个坑,
我刚修过、还在 `Models.ets` 里写了一大段注释,然后加新字段时照踩。**
⇒ 说明"记住别这么写"不管用,要在每个真正读的地方把 `?? []` 写出来。

**bug B(潜在白屏)**:`PermissionTab` 读 `req.session_alias` ——
而服务端 `PermissionRequest` struct **根本没有这个字段**(`models.go:239-255`),
`ListPendingPermissionsFor` 的 SELECT 也没查它,WebUI 的类型里同样没有。
它是我照"授权卡总得显示会话名"的直觉加出来的。⇒ 恒 `undefined`,
一旦有待办就抛。**一直没暴露只因为当前待办数一直是 0**(实测 `{"requests":[]}`)。
⇒ 改成服务端确实有的 `agent_name`,并**删掉那个字段声明**:
让误用变成**编译错**,而不是运行时白屏。

同样是 `body_preview`(`omitempty`,值是 `Body` 的截断)——
空正文 ⇒ 空串 ⇒ 服务端省略 key ⇒ `undefined.length` 抛。那批 96 封恰好都有正文,
所以"看起来没问题"——那正是这个坑的形态。已加 `?? ''`。

══ ④ 新判据:`omitempty` 字段的读法(形状,不是实例)

从 `server/internal/models` **算出**"只以 omitempty 形式出现过"的字段名
(不在判据里手抄名单),再扫鸿蒙侧对它们的裸成员调用。
★ 关键:**不能按字段名一刀切** —— 我第一版就是这么写的,报了 6 处、4 处误报:
  `session_alias` 在服务端有**两个**声明(`Mail` 上带 omitempty、`repo.Contact` 上不带),
  鸿蒙那 4 处读的全是 `Contact` ⇒ 恒有值、不是 bug。
  ⇒ 只扫"从未不带 omitempty 出现过"的名字,那 4 处自动排除。
已逐个核实 4 处豁免(每条都写了取证理由,不是"看着像就放过")。
变异验证:把 `?? []` 去掉 → 判据转红,且**正是**报 `MailStore.ets: mail.attach_count = mail.attachments.length`。

══ ⑤ 数据路径已实测(模拟器)

临时把 `INBOX_PAGE_SIZE` 提到 200(因为有附件那两封在下标 51/52,
默认 limit=50 **根本取不到** —— 这也解释了为什么之前一直没发现),
加临时 hilog 后拿到:
    AttProbe: mail=531a1629-… attach=1
    AttProbe: mail=b68cbbe8-… attach=1
正好是那两封。验完已撤掉探针、`INBOX_PAGE_SIZE` 恢复 50。

══ ⚠️ 本轮**未能**完成设备端视觉验收

模拟器已卡死(`hdc` 能连上但 `shell` 超时;进程 152% CPU、已跑 32 小时),
导致套件里的设备判据各跑 836 秒后失败("要能拉起应用")。
主机可用内存只剩 ~3.7GB。附件区的**渲染**(清单外观、下载落盘)
尚未在设备上看过 —— 待模拟器恢复后补。
2026-09-20 21:14:20 +08:00
f1db99ef41 跨端: 发件箱接上 MailStore(A 的剩余)+ 修 3 处把原始 ISO 印到界面上的时间
══ ① A 的剩余:发件箱走了 store(顺带撞出 store 里的一个真 bug)

`SentTab.load()` 原来把收件箱那 107 行**抄了一遍**(注释里还写着"同收件箱"
—— 抄的时候就知道是重复)。现在改成 3 行调用 `MailStore.loadSent`。

★ 接上之后**立刻炸出一个真 bug**:`MailStore.loadSent` 里写的是
      snap.groups = [];
  而发件箱的列表**就是按会话分组渲染的**(`ForEach(this.groups, …)`)
  ⇒ 接上 store 之后发件箱会**一片空白**,而"接口有返回、loaded > 0"
  会让症状看起来像"数据没到"。

  为什么会写成空数组:它是照收件箱那半抄的,而收件箱的 `groups` 来自
  `splitByPermission` **筛完之后**的 `inboxMails`(授权邮件要挑出去单独成栏)。
  抄的时候只看到"要赋值",没注意发件箱没有那一步筛选 ⇒ 把 `[]` 抄了过来。
  发件箱也**不能**照搬那个筛选:那是为"授权待办栏"服务的,发件箱不显示那栏。

★ 这段经历本身值得记:**死代码不会自己暴露错误**。
  `loadSent` 写完到今天我接上它之前,那行 `groups = []` 一直没人执行过。
  "写完就搁着"和"接上一个调用点"是两件事 —— 后者才算验过。

══ ② 3 处把原始 ISO 直接印到界面上(看到屏幕才发现的)

发件箱那张卡的时间格显示的是
      2026-09-19T02:55:33.10099Z
(23 个字符,把「致 homeagent」那行挤到换行)。

WebUI **三种卡片全部格式化**、一个没漏:`MailList.tsx:151/262`、
`PermissionList.tsx:143/251`,全是 `toLocaleString('zh-CN', {month,day,hour,minute})`
⇒ `MM/DD HH:mm`。鸿蒙的 `compactMailTime` 就是那个实现,收件箱一直在用 ——
所以这不是"要不要格式化"的分歧,是**三处漏调**(收件箱 / 发件箱 / 授权栏)。

══ ③ 补判据(形状而不是实例)

新判据:`Text(<x>.created_at)` 这个**形状**不许出现(`Text` 只负责画,
不做格式化)。为什么枚举形状而不是列举调用点:漏的三处分布在**三个不同 struct**,
按名字枚举一定会再漏第四个。
自检 + 变异验证都做了(把 SentRow 那处改回裸字段 → 判据转红;还原 → 绿)。

设备验证:发件箱正常渲染(会话组 + 条目,非空白),
时间显示 `09/19 10:55` 而非 ISO。
2026-09-20 18:43:35 +08:00
99a7bbccf7 跨端: 联系人栏宽度随视图模式变(卡片 400 / 列表 320,原来写死 320)
WebUI `ContactPanel.tsx:59-62` 原文:
    // 卡片要放两行摘要 + 预算条,320px 会挤;列表视图保持紧凑
    view === 'card' ? 'lg:w-[400px]' : 'lg:w-[320px]'

而鸿蒙写死 `navBarWidth(320)` —— 两个视图一样宽。
卡片视图比列表视图多一行(`N 封 · 时间`)**再加一条预算胶囊**,
320 装不下;WebUI 早就为此单独放宽了,我们没跟上。

★ 顺带一个很容易漏的点:范围也要一起抬(`navBarWidthRange([280, 400])`)。
  只改 `navBarWidth(400)` 而 range 还是 `[280, 360]`,值会被**静默夹回 360** ——
  "改了宽度但没变",且没有任何报错。这类"值被另一处覆盖"的坑
  与 `.attributeModifier` 单一插槽(后一个挤掉前一个)是同一族。

设备验证(模拟器,密度 2.875,实测可点卡片右边界):
· 列表视图:`ListItem [265,326,1117,…]` → 右边界 1117 ⇒ **320vp**
· 卡片视图:`ListItem [265,326,1347,…]` → 右边界 1347 ⇒ **400vp**
  同时标题从「联系人」变「工作列表」(与 WebUI 的 `view === 'card' ? '工作列表' : '联系人'` 一致)。
2026-09-20 13:56:52 +08:00
6f7592d1c1 跨端: 按压反馈内联进基础玻璃卡 —— 18 个站点从 1 个变全部(基础组件自带动画)
用户问过两次:
  ·「各个组件带响应点击、滑动的动画了吗」
  ·「基础组件包含动画」

之前按压反馈是**单独一个 modifier**,要靠调用点自己叠:
    CompositeModifier.of([GlassCardModifier.of(x), PressFeedbackModifier.of()])
实测全仓 18 个玻璃卡站点里**只有 1 个**记得叠。

★ 这不是"写的人不小心"—— **两个东西要一起用时,就该是一个东西**。
  把按压并进基础卡之后,18 个站点**自动全都有**按压反馈,
  且**不需要在每个调用点改一个字**。这才是"基础组件包含动画"的形状。

实现:
· `GlassCardModifier` 加 `pressable`(**默认 true**),在自己的
  `applyNormalAttribute` 末尾内联调用反馈 —— 不走 CompositeModifier,
  那是给"调用点自己有好几个 modifier 要叠"用的,这里是基础组件内部要知道的事。
· `PressFeedbackModifier.attach(instance, color?)` 抽成静态方法,
  两边共用同一段 `onTouch` 逻辑(`instance` 本来就是 `CommonAttribute`,
  类型一致,不需要包一层)。
· 顺带把原先唯一"叠对了"的那处(`MainPage` 会话组头卡)简化掉 ——
  它现在是全场唯一的例外写法,反而容易让人以为"要叠才生效"。

★ 为什么默认 **true** 而不是"想按才开":WebUI 那一侧是**全局**的 ——
  `index.css:1169` 对 `button, a, input, textarea, select, [role='button']`
  统一给了 `transition`。"可点就有点击反馈"在两端都该是默认。
  静态信息卡可以传 false —— 但不会有人漏,因为不可点的卡本来就没有按压语义,
  而**漏掉真正可点的卡**才是原来那个问题(18 分之 17 漏)。

设备验证(模拟器):在组头卡上长按,hilog 抓到
    PressFB: touch type=0   ← Down
    PressFB: touch type=1   ← Up
成对到达。验证用的临时 hilog 已撤(不是留在代码里)。

★ 记一个**没验成**的取证方法,免得下次再花时间:
  想靠"按住时截图看底色变了"来取证,试了三轮都不行 ——
  `snapshot_display` 的往返延迟(~250ms + 传输)比一次按压的窗口长,
  抓到的永远是松开后的画面。**延时不敏感的取证是 hilog**:
  回调有没有触发是个**事件事实**,不需要抢时间窗。
2026-09-20 13:53:40 +08:00
6085159694 跨端: 5 处 Unicode 符号当图标换成 AmIcon(并把判据扩到"排版符号"这一类)
起因:做邮件详情时顺手把 `Text('‹')` 换成 `AmIcon('chevronLeft')`,
想着"本仓不是有这条判据吗,怎么没抓到" —— 去看了判据,发现它**只扫 emoji 那一段**。

判据(`harmony-arkts.test.mjs:168`)扫的是 U+2600–27BF / U+2B00–2BFF / U+FE0F,
而漏网的 5 处全在这三段**之外**:
    MainPage.ets      Text('‹')   返回键(会话视图)
    MainPage.ets      Text('›')   会话别名前的小箭头
    SettingsPage.ets  Text('›')   「管理」的进入下一级指示
    ComposePage.ets   Text(' ▾')  账号下拉指示
    MailDetailPage.ets Text('‹')  返回键

★ 它们与 emoji 那类**问题不同、但同样是"看着像图标其实不是"**:
  · 字形宽窄由**字体**决定,与旁边 20vp 的 `AmIcon` 对不齐;
  · 而且**WebUI 用的根本不是字符** —— `icons.tsx:135` 是
    `ChevronRightIcon`(SVG)、`AccountSwitcher.tsx:84` 是它**转 90°**。
    所以这同时是"两端不一致",不只是"字形不好看"。

修法:5 处一律换 `AmIcon`,并按 WebUI 的**同一形状**给:
· 两个返回键 → `chevronLeft` 20vp、可点区 `HEADER_BACK_HIT`(与 `AppHeader` 同值)
· 会话别名 / 「管理」→ `chevronRight`
· 账号下拉 → `chevronRight` + `rotate(open ? 90 : 0)`(照 `AccountSwitcher` 那句)

★ 判据扩了一类(`GLYPH_ISH`:U+2039/203A/00AB/00BB/25A0–25CF/25B2/25BC/25C0/25B6),
  并保持原有那条边界不被破坏:**U+2190–21FF 基本箭头仍不扫** ——
  `→`/`←` 在正文里是标点,扫进来会误伤大量正常文案(原注释里已经踩过这个坑)。
  这次的符号是"单个字符整体",所以 `Text('a → b')` 这类多字符本来也不匹配。

★ 扩完之后判据**当场又抓到一处我自己没注意的**(`SettingsPage.ets` 的 `›`)——
  这正是"把判据从'枚举实例'扩到'枚举类'立刻多抓一个"的现场证据,
  也说明原来那三条区间是照"已经出现过的实例"框的,不是照"这类东西的共同形状"框的。
2026-09-20 13:33:13 +08:00
1df8245a6d 跨端: 写邮件改在宽屏右栏打开(原来盖住全屏、把列表栏也带走)
用户:「写邮件 webui 的宽屏样式不是在右侧打开吗」—— 是,这是**结构性不符**。

WebUI `App.tsx:179` 宽屏下写信只是把 `main` 那一格换掉:
    const main = composing ? <ComposePage /> : viewMode === 'account' ? ... : ...
侧栏与列表栏都还在。而鸿蒙无条件 `router.pushUrl('pages/ComposePage')` ——
一个 `@Entry` 全屏页,左侧列表整片消失。

修法照**既有先例** `MailDetailView` / `MailDetailPage`(同一个问题上次已经解过):
· `ComposePage` 拆成 `ComposeView`(真内容)+ `@Entry ComposePage`(只负责窗口避让)
· 新增 `ComposeDestination`(`NavDestination` 壳),与 `MailDetailDestination`
  **完全同构** —— 宽屏由 `mode(Auto)` 自动并排在右栏,窄屏自动 push 覆盖全屏。
  **两条路径同一套代码,不自己判断宽窄**(这正是 `Navigation` 该干的事)。
· 走本页自己的 `Navigation` 栈(`COMPOSE_ROUTE`)而不是布尔 `@State showCompose`:
  路由让"返回"自动正确(系统返回键 / 手势 / 头部按钮弹同一个栈);
  布尔状态要自己接三条返回路径 —— 那正是 2026-09-17 那批「返回直接回登录页」的来源。
· `InboxTab` 里那个自己的 `openCompose` 也一起改(它抄了同一个 `pushUrl`)——
  收件箱内按筛选账号写信、收件箱外用活跃账号写信,两条入口现在共用
  `CommPage.openComposeWith(accountId)`。

★ 两个必须守住的细节(都是上一次踩过的坑,这里重复了一遍):
· 避让留给 **`@Entry` 包装层**,`ComposeView` 里 `embedded` 时取 0 ——
  同一个 View 被内嵌复用,而 `MainPage` 已经加过避让,加在里面就是**让两次**。
· 「取消」内嵌时必须弹**自己的**栈(`onBack`),不能 `router.back()` ——
  那会退掉整个 `MainPage`(写信只是它的一个右栏状态,不是一个页面)。
  `MailDetailView.goBack()` 里是同一条判断。
· 内嵌时参数走 `@Prop` 初值,**不读** `getRouter().getParams()` ——
  内嵌没走 router,那里拿到的是**上一次 push 的残留**,
  会把上一封信的收件人带进来(比空更坏)。

设备验证(模拟器 3184×2232 宽屏):点悬浮加号 → 写信在**右栏**打开,
侧栏与列表栏都在;点「取消」→ 弹回右栏空态,列表与选中态不受影响。
2026-09-20 12:57:55 +08:00
44e277e0b6 跨端: 补「当前选中邮件」高亮 —— 鸿蒙原来完全没有这个概念
用户:「你自己看看跟 webui 相比,观感真的差很多」。逐项比对后找到的**行为缺口**
(不是配色问题,是少了一个状态)。

WebUI 一直有:`MailList.tsx:104/116/229` 把 `currentMail?.mail_id` 传进卡片,
卡片据此上 `active ? 'bg-blue-50 border-blue-200'`。
鸿蒙**一个都没有** —— 点开一封邮件后,左侧列表那一行和旁边几行长得一模一样:
你不知道自己正在读哪一封、读完了该往哪回。

修法:
· `CommPage` 拥有 `@State currentMailId`,`openMail()` 里记下,以 `@Prop` 下发给
  `InboxTab`/`SentTab`。**单点写、多点读** —— 两个 tab 各存一份必然会分叉
  (收件箱和发件箱都能触发同一个动作)。
· 存 `mail_id` 而非索引/组键:SSE 会让列表重排,索引会错位。
· 三级优先 **选中 > 未读 > 普通**。★ 这里有个必须成对的理由:
  选中与未读底色**相同**(都是 `accentSoft`),只靠底色的话
  "选中一封未读邮件"看不出任何变化 ⇒ 选中必须额外加那圈边。
  这正是 WebUI 两个 token 并存的原因,不是随手加的边框。
· 新增 `Theme.accentEdge/accentEdgeDark/accentEdgeFor()`(= tailwind blue-200
  及其深色值)——我第一版只搬了底、忘了边,选中态淡到几乎看不见。

★ 顺带记一个工具坑:`devecocli ui tap` 在这台模拟器上**点了不生效**
(截图前后一样、布局树无变化),而 `hdc shell "uitest uiInput click X Y"`
有效。以后点不动就先换这条,别以为是代码没生效 —— 我为此白跑了两轮。
2026-09-20 12:24:17 +08:00
d10c641e41 跨端: 账号徽标对齐 WebUI(中性灰胶囊 + 80px 截断,原来是被染成品牌蓝)
用户:「你自己看看跟 webui 相比,观感真的差很多」。逐字段对比卡片后找到这一处。

WebUI(`MailList.tsx:303-310`):
    className="text-[10px] leading-4 px-1.5 rounded-full
               bg-gray-100 text-gray-600 shrink-0 max-w-[80px] truncate"

我们原来:
    .fontColor(Theme.accentFor())
    .backgroundColor(Theme.accentSoftFor(this.isDarkNow)).borderRadius(4)
    (没有 maxWidth)

**三处都不一样**:
· 底色/字色:品牌浅蓝 + 品牌蓝 vs **中性灰**。徽标表达的是"这条来自哪个账号",
  是**中性的元信息**,不该与主操作抢注意力 —— WebUI 选 gray-100 正是这个意思。
  我们把品牌色用在元信息上,等于把每一行的副标题都标成了"主操作"。
· 形状:圆角方(4) vs 全圆胶囊(rounded-full)
· 截断:原来没有 `maxWidth` —— 账号名一长就把主题挤没了(WebUI 有 `max-w-[80px]`,
  而多账号正是这个徽标存在的场景,名字长是常态)。

措辞上保持"用户看到什么"这个层次:这条判的是**渲染结果**,
不是"有没有这个字段"(两端字段集本来就是一致的)。
2026-09-20 12:12:08 +08:00
a97b83e221 跨端: 宽屏右栏空态(splitPlaceholder)—— WebUI 有引导,我们原来一片空白
用户:「你自己看看跟 webui 相比,观感真的差很多」。同 1107vp 视口并排后,
差异确实明显,其中**最扎眼**的一条:宽屏两栏并排时,右栏是**一大片空白**。

WebUI 有引导(`MailView.tsx:121-129`):
    信封图标 + 「选择一封邮件查看,或点击左侧「新建」写邮件」
我们什么都没渲染。

★ 第一版放错位置(记下来,这个坑很隐蔽)
  我把它写进 `NavDestination` 的 `else` 分支 —— 而 **`NavDestination` 只在
  push 之后才挂载**,栈空时它根本不存在 ⇒ 那个 else **一次都不会显示**
  (实测:加上去之后右栏仍然全空)。
  正确入口是 `splitPlaceholder(ComponentContent)`:系统给"右栏默认页"的专用 API
  (`navigation.d.ts`,API 20+,我们是 23),由 `Navigation` 在栈空时自己渲染。

★ 三个 ArkTS 约束(都撞了才过)
  ① `splitPlaceholder` 要的 `wrapBuilder` 只接受**全局** `@Builder` 函数 ——
     写成 struct 成员方法会报 "The wrapBuilder's parameter should be '@Builder' function"。
     内容本身是静态的(不需要 `this`),所以全局正好。
  ② `wrapBuilder` 是**全局声明**(`common.d.ts:27448`),**不在** `@kit.ArkUI` 里 ——
     从 kit 引会报 "has no exported member"。
  ③ `ComponentContent` 要从 `@kit.ArkUI` 引。

文案**逐字对齐 WebUI**(同一句话,不自己改写)—— 与本仓既有纪律一致
(`harmony-logic` 里「空态主句与 WebUI 逐字一致」那条是同源要求)。

设备验证:`选择一封邮件查看,或点击左侧「新建」写邮件` 已在右栏渲染(布局树可见)。
2026-09-20 12:07:32 +08:00
e988b6f24d 跨端: 去掉宽屏内容列与两栏的 .clip(圆角保留、不裁切)+ 更正两处我自己的误判
★ 改了什么
  `MainPage.ets` 三处 `.clip(...)` 去掉,**圆角保留**:
  · 宽屏内容列的 `.clip(this.isWide)` —— 这处**本来就在**(不是今天加的),
    它是内容列的根,`Navigation` 的 `List` 在它里面
  · 今天新加的两处(左栏 navBar 根、右栏 NavDestination)

★ 依据是 WebUI 自己写在**同一个规则块**里的教训(`index.css:968`):
      .app-shell > * {
        border-radius: var(--radius-card);
        // ★ 这里**不能**写 overflow: hidden(2026-09-14 用户:
        //   「通信页面完全无法上下滑动」)。
        // 面板自己就是滚动容器,而这条规则的特异性比 Tailwind 的
        // .overflow-y-auto 高 ⇒ 滚动被静默干掉:实测当时**一个可滚动
        // 容器都不存在**(scrollerCount=0),内容是直接被裁掉的。
        // 圆角仍然生效(border-radius 不影响滚动);角落的方角残影
        // 改用 background-clip 处理……比不能滚动好得多。
  我照"两栏各自圆角"改的时候,把 `overflow: hidden` 一起搬了过来 ——
  而那条警告**就在同一个块里**,正是为了防这一手。

★ 更正我自己两处**错误结论**(都写进注释,免得下次重犯)
  ① 「你一改了之后列表没法滚动了」——**不成立**。实测该账户只有 6 组
     (内容 1434px),而列表容器 1750px,**内容比容器矮,本来就不需要滚动**。
     用会溢出的页面验证(「我的」页):fling 之后页面从「背景」滚到「管理」,
     **滚动是好的**。我把"内容没溢出"误读成了"滚动坏了"。
  ② 「列表栏宽一倍」——不成立。两端**绝对宽度相同**(列表 320vp、侧栏 60vp),
     百分比差异只是视口宽度不同(1280 vs 1107vp)。我漏了密度换算(2.875)。

★ 同视口并排的结论(1107vp × 2 端,逐项量过)
  侧栏 60vp、列表栏 320vp、卡片 300×78vp —— **几何两端一致**。
  真正的差异在**内容**,不在尺寸(下一步按这条去做)。
2026-09-20 11:58:16 +08:00
97037c0424 跨端: A 步 —— harmony 补 store 层(MailStore),搬走 InboxTab 107 行重复实现
用户裁定:「A+B 都做」「electron 是唯一真实源泉」。本条做 A 的第一步。

★ 量的结果(决定为什么要做)
  · harmony 48 文件 / 18,444 行,**无 store 层**,223 个 @State + 47 @Prop 散在各页
  · electron 58 文件 / 13,801 行,**9 个 store**(mailStore/sessionStore/contactStore/
    accountStore/appearanceSync/uiStore/themeStore/authStore/backgroundStore)
  · `MainPage.ets` 3,289 行,其中 4 个 load* 方法合计 250 行在重复
    "遍历账号 → 逐账号拉 → 合并 → 派生 cc_count → 分流 → 分组 → 算未读 → 出提示"
  · 同一套多账号遍历在 `InboxTab`(107 行) / `SentTab`(40 行) / `ContactsTab` 各写一遍

★ 做了什么
  新增 `common/MailStore.ets`(318 行,照 `AppearanceStore` 的单例形状):
  · `loadInbox(ctx, accountFilter)` —— 全仓**唯一**的收件箱取数实现
  · `loadSent(ctx, accountFilter)` —— 与收件箱共用同一套聚合形状
  · `dropSession(sessionId)` —— 归档后就地剔除(与 WebUI `mailStore.dropSession` 同动作)
  · `clear()` —— 退出/换账号时清空(否则下一账号会看到上一人的邮件,哪怕只有一帧)
  · `MailSnapshot` / `AccountError` / `INBOX_PAGE_SIZE`(从页面搬进来)

  它只做"取数 + 组装 + 错误归类"——**纯逻辑仍留在 `model/MailGrouping.ts`**,
  这样 `harmony-logic.test.mjs` 不必起设备/网络就能跑(本仓既有纪律)。

  `MainPage.InboxTab` 的 `loadData()` 从 107 行变成 3 行调用 + 两个辅助
  (`syncAccountFilter` / `applyStoreSnapshot`)。

★ 途中撞到的四个 ArkTS 约束(都已修,记下来免得重犯)
  ① `INBOX_PAGE_SIZE` 在页面里已有一份 ⇒ 导入与本地声明冲突(`arkts-unique-names`)
  ② `MailApi.sent()` **不收参数**(与 `inbox(status, limit)` 不同),照着 WebUI 写会错
  ③ `SentResponse` 定义在 `model/Models.ets`,**不在** `api/MailApi.ets` 里再导出
  ④ store 持有接口类型 `MailLike[]`,页面字段是 `MailSummary[]` ⇒
     `MailSummary implements MailLike` 成立,但**赋值要显式向下转型**

★ 为什么页面还保留 `@State` 镜像而不直接读 store 字段
  ArkUI 的 `@State` 只观察**它持有的那个值**;store 是普通类实例,改内部字段
  不触发刷新(本仓 `appearance-defaults` 判据钉过同类问题)。所以照既有约定:
  store 每次改完自增 `revision`,调用方把快照接进 `@State`。

设备验证:收件箱正常渲染(6 组 · 50 封、25 未读),与改动前逐项一致。
2026-09-20 11:28:48 +08:00
ed1ae02441 跨端: 宽屏两栏各自圆角(原来只有外壳有,内部两半是直角)
用户:「同时邮件展示左侧没有圆角」。

WebUI 宽屏的真结构是**三个独立圆角面板并排**(App.tsx:262):
    <div className="app-shell">        ← padding/gap = 10px
      <Sidebar/>  {list}  {main}        ← 每个子元素各自 border-radius:14px
    </div>
(index.css:968 的 .app-shell > * 统一给 border-radius,第 1070 行在壁纸态再加
 overflow:hidden + 阴影)。所以列表栏与详情栏**各有自己的四个圆角**,
中间的缝里透出壁纸。

我们这边是一个 Navigation,由系统 Split 成 navBar + content 两半,
圆角只加在**外壳**上 ⇒ 内部这两半是直角。实测(模拟器 3184×2232):
详情栏白区左缘在 y=145 与 y=2195 都是 x=1152 —— **完全垂直,没有圆角**。

修法:给两栏**各自**补圆角 + 裁切(与 WebUI 的 .app-shell > * 逐条对应),
左栏在 navBar 内容根、右栏在 NavDestination 上。
修后实测白区左缘:y=145→x=1178、y=165→1156、y=185→1152(圆弧正确),
下角同理(y=2190→1165、y=2196→1172)。

★ 顺带给 MailDetailDestination 补 bgActive 参数(圆角要在壁纸开启时才有 ——
  与 WebUI 一致:非壁纸态 .app-shell > * 也圆角,但那时底色是实心的,
  圆角看不出来;壁纸态才需要圆角把缝让出来)。
2026-09-20 11:14:05 +08:00
6ca0113fd2 跨端: 统一顶栏组件 + 按压反馈(真跑通)+ 滑动判定从"时长门"改"速度门"
用户四条意见,逐条都是真问题,且**两条是我写完没生效**:
  ① 「所有的顶栏都应该应用玻璃圆框效果」② 「抽象一个统一的顶栏组件出来吧」
  ③ 「你不觉得发件箱太大了吗」④ 「各个组件带响应点击、滑动等的动画了吗」
  ⑤ 「还有转场动画呢?」⑥ 「还有其他行为都要一一对齐,例如邮件展示页面」

★ ① ② 统一顶栏 `AppHeader`
  改造前**六处各写各的**:`AdminUsersPage`(40×40 返回键+16 号标题)、
  `SettingsPage`(20 号标题+40×40「+」+**实心 Theme.surface**)、
  `MailDetailPage`(36×36 + 14 号标题,行高只有 14px 被裁过)、
  `MainPage.SentTab`/`PermissionTab`(通栏、无圆角)、`CommTabBar`。
  尺寸(36/40、14/16/20)、底色(实心白/透明/无)、返回键(有/无)三类都不一致;
  且四处用 `Text('‹')` 当返回键 —— **用字符当图标**(本仓明令禁止,字形随字体变、
  基线对不齐),而 `ICON_PATHS` 里**早就有** `chevronLeft`。
  ⇒ 抽成 `AppHeader`(返回键 + 标题 + `@BuilderParam` 右侧动作区),
    几何复用底部条常量,材质走 `Theme.navMaterial`。已接入 5 处。
  ★ `topInsetPx` 由调用方传:状态栏避让是**窗口级**事实(属页面),
    组件自己读会变成"每层各加一次"(平板侧栏 83+83 双计就是这么来的)。

★ ③ 发件箱"太大"—— 我的理由错了
  我第一版让 `HEADER_HEIGHT = NAV_BAR_HEIGHT`(56),理由是"顶栏与底栏同在一根
  竖轴上,高度不同会一眼看出来"。**那个理由不成立**:
  底栏是**两行**(图标+文字标签)⇒ 需要 56;顶栏只有**一行标题** ⇒ 56 里一半是空白。
  实测 `Row [277,315,1105,476]` = 161px = 56vp,标题那行只占 54px。
  "两根轴上的条要一样高"是把**对齐**理解成了**等高**。真正要对齐的是**左右留白与圆角**。
  ⇒ `HEADER_HEIGHT = 44`(= 可点区下限,返回键装得下)。

★ ④ 按压反馈 —— 两次失败才跑通,两个坑都记进注释
  · **坑一**:`.attributeModifier()` **一个组件只能挂一个**,链两个是**后者覆盖前者**。
    会话组头卡同时挂了玻璃与按压反馈 ⇒ 只生效一个,**编译器不报错、运行不提示**。
    WebUI 是 CSS,类名天然叠加;ArkUI 是单一插槽,"叠加"必须显式做
    ⇒ 新增 `CompositeModifier`。
  · **坑二**:`applyPressedAttribute` **只对自带按压状态机的组件**(`Button`)回调。
    实测:挂 `Button` 上 → hilog 有输出;挂只有 `.onClick` 的 `Row`/`Column` 上
    → **一次都不回调**(按下与常态逐像素相同 `239,244,255`)。而全仓 117 处
    `onClick` 的主体正是纯 `Row`/`Column` ⇒ 那条路对本仓没用。
    改用 `onTouch` + `animateTo`。
  · 底色用**系统点击效果色** `ohos_id_color_click_effect`(不是我自己挑的):
    第一版用 `Theme.surfaceMuted`,实测只差 `3/3/3`,肉眼看不出 ——
    因为"一块面的颜色"与"按下时叠的提示色"在系统色板里是**两个不同语义**。
  设备验证:`onTouch type=0`(Down) → `type=1`(Up) 成对到达,像素确有位移。

★ ④ 滑动判定:从「时长门」改成「速度门」(**设计错误,不是调参**)
  旧门 `SWIPE_MAX_DURATION_MS = 700`("整个手势超 700ms 就拒")。
  而**时长 = 距离 ÷ 速度** —— 它把两个量混成一个 ⇒ 同样的手速下
  **滑得越远越容易被拒**,屏幕越大越严重(平板同一手势像素更多)。
  设备实测(3184×2232,位移恒 542.6vp):
      600px/s→5909ms | 1200→3161ms | 1800→2006ms | 2500→1429ms | 5000→616ms | 15000→123ms
  ⇒ 旧门意味着**只有 ≥5000px/s 才过**,而那一档重复测试也只有 2/4 成功
    —— 用户报的"滑不动/时灵时不灵"就是这个。
  改 `SWIPE_MIN_SPEED = 0.25`(vp/ms,取自上表两档中间)后实测:
      1200px/s → 0/3(正确拒绝:那是拖动)    2500px/s → **0/4 → 3/3**
      5000px/s → **2/4 → 3/3**
  两条判据跟着改形态(**语义不变**:都是"够快才翻页"):
  · `cross-client-gesture` 断言"算的是 dx/ms"——只断言"有个门"会漏掉这次的错
    (旧的时长门**存在**、常量**有名字**、数值**是正数**,三条全过,而真机拒掉正常滑动);
  · `harmony-logic` 的行为判据加了两条**回归钉**:
    `542.6vp/1429ms`(实测的正常甩动)必须过;
    `1200vp/3000ms`(同速度、更远)也必须过 —— 旧门正是在这里误拒。

★ ⑤ 转场动画:查清了,"不做页面级"是**决定**而不是漏做
  WebUI `index.css:1230` 写明:页面级淡入会让**已在那儿的框架**也一起暗一下
  ("观感还是闪"),且用户 2026-09-14 **亲口否掉过**"整屏一起淡",
  所以那一档整体删掉,只保留**局部**动效。鸿蒙侧当前是:
  · `pushUrl` 推页 → **系统自带的滑动转场**(连拍 8 帧验证:第 3 帧能看到
    写信页正在覆盖发件箱,是真实的横向滑入,不是硬切);
  · 局部入场 → `paneRiseIn` / `menuIn` / `calendarSlide`(已接 12 处)。
  所以"加 pageTransition"反而会与用户当时的否决冲突 —— 这一条**不做**,
  理由记在这里,免得下次又当成遗漏。

判据:files=32 ran=32 checks=519 pass=519 fail=0 red=0;baseline 7/7✓。
2026-09-20 10:51:28 +08:00
c0ab3f57b2 跨端: 顶部页签条改悬浮玻璃 + 深色压暗下限(修 8 处深色可读性)+ 手势判据自搭现场
用户:「顶部的收件箱发件箱和授权为什么不做成悬浮玻璃?」
问得对,而且它当时是**整页唯一一块实心白**。

★ 顶部页签条 → 悬浮玻璃
  `CommTabBar` 原来写的是 `backgroundColor(Theme.surface)`(系统卡片色=实体)。
  上一轮把列表容器、卡片、底部导航条都玻璃化了,**唯独漏了这条** ——
  它既不是卡片也不是容器,是"条状 chrome",逐处修时最容易漏的那一类。
  ⇒ 现在与底部浮动条**同一族**:圆角 + 左右留白 + `Theme.navMaterial`。
  ★ 几何常量复用底部条的(`TAB_BAR_RADIUS = NAV_BAR_RADIUS`、
    `TAB_BAR_SIDE = NAV_BAR_SIDE`),不各写一份 —— 两个数一旦分叉,
    "同一条轴线上的两种条"就不齐了。为什么选悬浮而不是 WebUI 的"通栏无底色"
    (WebUI 页签条自己**没有**底色,靠父面板的玻璃)也记在那个常量的注释里。
  新增判据:页签条必须与导航条同族(不许实体面 / 要引 TAB_BAR_* 常量),
  变异验证过(改回 `Theme.surface` 即红)。

★ 深色压暗下限 —— 本轮最重要的真 bug
  玻璃让壁纸**真的透进卡片**之后,"浅壁纸 + 深色文字令牌"这个组合会在
  **卡片内部**发生。设备实测(深色、aurora、压暗 37):
      「角色」ink rgb(183,195,211) on bg rgb(68,95,128) = 2.57:1
      「创建时间」2.27:1、「状态」2.68:1、「压暗」2.19:1(共 8 段 <3:1)
  而**同一套代码在浅色下全部达标** ⇒ 差别不在"选错了色",在"底没暗下来"。
  WebUI 早就撞过并写下了必然值(`index.css:442`):
      .dark { --bg-dim-min: 92%; }     /* 浅色下是 0% */
      background-color: rgb(var(--bg-scrim) / max(var(--bg-dim), var(--bg-dim-min)));
  我们只有 `scrimOpacity(bgDim)`、**没有下限**。补上 `DARK_DIM_MIN = 0.92`
  (浅色下用户设的值照旧生效,不忽略设置)。
  复扫:低对比 8 段 → 通信 0 / 日历 0 / 联系 1 / 我的 1
  (剩下两条经手核是扫描器的假阳性:近黑底上把抗锯齿暗像素当成了"墨")。

★ 手势判据"没有自己搭现场"(套件红、单独绿)
  `cross-client-gesture` 只保证"在月档"、**不保证在哪一月**,而它算的是
  相对最初那一月的 delta ⇒ 前面某个判据(或一次失败的手势)把日历翻到 10 月后,
  期望值就差一格。修法:先点「今天」归位到本月(该按钮语义就是"回本月")。
  验证:从 9 月起步、从 10 月起步,两种脏状态都通过。
  ★ 顺带确认手势本体是好的:`--speed 1500` 时 1000px 要走 670ms,
    紧贴 `SWIPE_MAX_DURATION_MS=700` 而被丢弃;`--speed 3000` 两个方向都翻。

★ 判据基建:修掉一个**静默失效**的 API 名(沿用上轮的教训修法)
  `Surface.ets` 里两处块注释内嵌了 `/* ... */` —— 会把块注释**提前闭合**,
  于是后面的代码跑到注释外面(报"Use let instead of var"/"Invalid character")。
  本仓已有这条纪律("当 `code()` 因注释里的 `/*` 吞掉 import 时,改注释文本"),
  这次又踩了两处(`Surface.ets` 与 `Appearance.ts`)。

判据:files=32 ran=32 checks=519 pass=519 fail=0 red=0;baseline 7/7✓。
2026-09-20 09:01:46 +08:00
eb4e413c67 跨端: 玻璃走基础组件(透明度/磨砂/投影)+ 判据去芜存菁(修 4 个判据自身缺陷)
用户两条:「玻璃不透明/不够磨砂炫酷,要更基础组件化」+「判据去掉无意义的、保留有用的」。

★ 玻璃的透明度与质感(逐层调出来的,不是拍的)
  · 透明度:`BlurStyle` 是固定档、没有 saturate 旋钮 ⇒ 观感偏灰。
    改用参数化 `backgroundEffect({ radius, saturation })`,字段与 CSS `backdrop-filter`
    一一对应,于是可以**照抄 WebUI 的数**:`blur(18px) saturate(1.5)`
    (WebUI `.narrow-nav`,那里最"玻璃"的一档)。**saturate 才是玻璃感的主要来源**。
  · 质感:只模糊+饱和仍然"像一块平的白方块" —— 玻璃板之所以像板靠的是**边缘**。
    WebUI 的完整配方是 `box-shadow: 0 8px 28px rgb(0 0 0 / 0.16)` + 发丝描边。
    只有模糊没有投影 ⇒ 一片雾;加上投影 ⇒ 一块板。**这是"不够炫酷"的主因**
    (我先前两个方向都调了模糊与饱和,方向就偏了)。
  · 投影用**系统预置档** `ShadowStyle.OUTER_FLOATING_SM`("浮起的小面板"),
    不是手写 `{radius, color, offsetY}` —— 手写色要么是 `#AARRGGBB`、
    要么是 `rgba()`,两者都被判据明令禁止,而那条禁令**是对的**:
    手写阴影色在深色下看不见(深底叠深影 = 无变化),WebUI 在 `.dark` 里
    要把同一处从 0.16 加重到 0.55 —— 那正是"替系统猜深浅"。

★ 基础组件(用户:「定义一个基础玻璃容器给各个组件引用」)
  · `GlassCardModifier` / `PaneModifier`(`AttributeModifier<CommonAttribute>`):
    **一处定义、处处引用**。页面里的玻璃从 15 处内联三连收敛成
    `.attributeModifier(...of(this.bgActive))`。
    ★ 为什么不是 `@Styles`:全局 `@Styles` 读不到 `this`(而玻璃要知道 `bgActive`);
      成员级 `@Styles` 能读 `this` 但**每个 struct 各一份**,MainPage 有 7 个 struct
      ⇒ 只是把"每处抄 3 行"换成"每 struct 抄 1 份",没解决问题。
  · `Motion.dur()`:接系统「减弱动态效果」(`isAnimationReduceEnabledSync()`,API 23)。
    WebUI 有 `prefers-reduced-motion` 硬约束,鸿蒙这边**改造前一次都没调过**。

★ 途中撞出并修掉的**真 bug**(都是我自己的)
  · 把 `@Styles glassCard()` 里也替换成 `attributeModifier` ⇒
    「我的」页 ProfileSection/AppearanceSection/PushSection **整块不渲染**
    (设备实测:Scroll 直接跳到「客户端连接密钥」)。删掉那个死 `@Styles` 即恢复。
  · `Theme.shadowColor/hairline` 用了 `rgba()` + `#AARRGGBB` ⇒ 违反本仓两条既有禁令
    ("鸿蒙侧不写 CSS 颜色函数"、"半透明属于系统材质/职责")。改用系统阴影档。

★ 判据去芜存菁:修了 4 个**判据自身的缺陷**(都是它声称在管、实际漏判/误判)
  · 材质来源只扫 `backgroundBlurStyle` —— 玻璃改成 `backgroundEffect` 后**整类漏判**。
    实测:往 Modifier 里塞 `backgroundEffect({radius:99,saturation:9.9})` 照样全绿。
    ⇒ 补上这一类,并加"参数必须引 Theme 令牌"(否则只是换个旋钮写死)。
  · 参数抽取用 `[^)]*` —— 对 `this.materialOf()` 与 `{...}` 对象字面量都提前截断,
    **误伤合法写法**(本次被它误红两次)。改成配对括号 + 去外层花括号。
  · 嵌套判定**没比文件**:拿 A 文件的字符偏移去比 B 文件 ⇒ 判出
    "MainPage 内含 SettingsPage 的玻璃"这种物理上不可能的红。
    假红比漏报更坏 —— 会让人去改本来对的代码(本次差一点)。
  · `harmony-appearance` 数的是**内联字面量**("三元出现 ≥5 次"),
    收敛成组件后写法变了、行为没变却变红 ⇒ 改成判不变式
    (让位只有一条路径 + 每个窗格都收到开关)。
  · 顺带:设备判据找「背景」时只在首屏可见节点里找(而 dumpLayout 只给可见节点),
    且我的第一版只朝一个方向滑(越滑越远)⇒ 改成"先回顶、再逐屏向下扫"。

判据:files=32 ran=32 checks=518 pass=518 fail=0 red=0;baseline 7/7✓(第 7 次,逐个取证)。
2026-09-20 08:16:10 +08:00
5103e0aee3 跨端: 抽出基础面/玻璃组件(Motion + Surface)+ 三处硬弹的弹层补过渡
用户两条要求,各自都指向"决策被复制到各处"这个病根:
  ① 「应该定义一个基础玻璃容器给各个组件引用」
  ② 「很多页面还是不够精致……封装为基础组件供所有页面使用。基础组件包含动画」

★ 玻璃不透明的真根因(前几轮都没找到,这次是像素证据定的)
  实测模拟器 3184×2232、壁纸 aurora + 压暗 37:
      Navigation [229,140,3156,2204]   ← 255,255,255(整块内容区)
      y=2210(Navigation 之外那条缝)    ← 226,225,235(壁纸清楚可见)
  那条缝是决定性的:**壁纸层本身是好的**,白是因为上面盖了不透明的壳。
  链路上一共三层,逐层修:
    · `Navigation` 外壳:不设背景 ⇒ 系统默认不透明白,把里面已透明的窗格整片盖住;
    · navBar 内容层(列表栏根容器):同样没有背景;
    · **卡片**:铺的是 `Theme.surface`(实体系统色)。
  修后:缝隙 203,213,228 / 卡片 191,204,220 —— 壁纸透得出来。

★ 新增基础组件(common/)
  · `Surface.ets`  —— `GlassPane`(正文面)/ `GlassCard`(嵌套面)/ `PageHeader` / `Pressable`
  · `Motion.ets`   —— `Motion.dur()`:接系统「减弱动态效果」
    (`accessibility.isAnimationReduceEnabledSync()`,API 23 正好够用)。
    WebUI 侧有 `prefers-reduced-motion` 硬约束(index.css:1266),
    鸿蒙这边**改造前一次都没调过** —— 系统里关了动画,我们照样动。这是无障碍义务。
  · `Theme.cardMaterial` —— 卡片材质令牌。不是拍脑袋选的:
    `COMPONENT_THIN` 实测卡片 252,254,254(吃掉 94% 壁纸,就是用户看到的"白卡");
    `BACKGROUND_THIN` → 185,190,201,壁纸透得出来且文字对比度仍够。

★ 判定形状按"玻璃从例外变成默认"重写(判据 C 条)
  原来是"允许出现玻璃的位置"白名单,逐处登记。那个形状在"玻璃是少数例外"时成立,
  但用户要的是**全部玻璃化** ⇒ 白名单退化成"把每处抄一遍",且挡不住新写的裸枚举。
  改成**规则**:材质档次只能来自 `Theme.navMaterial` / `Theme.cardMaterial`
  (或 `BlurStyle.NONE`),页面里出现裸 `BlurStyle.XXX` 即红;
  辅助方法(`this.materialOf()`)体内也查,否则等于开了后门。两条变异都验证咬得住。

★ 判据 C 条自身的两个 bug(都被本次触发)
  · 嵌套判定**没比文件**:`c.at`/`o.at` 是各文件自己的字符下标,直接比大小
    于是判出"MainPage 内含 SettingsPage 的玻璃"这种物理上不可能的红。
    假红比漏报更坏 —— 会让人去改本来对的代码(本次差一点)。
  · 参数抽取用 `[^)]*`:`this.materialOf()` 里含一层 `()`,在内层括号处截断,
    把合法写法读成 `this.materialOf(` 判红。改成配对括号抽取。

★ 出现/消失动画:用户点名的三处硬弹
  这些挂在 `if (cond)` 上,条件一变整块出现/消失,原来一帧过渡都没有 ——
  而代码上"看着像挂了动画"(外层页面有 transition),所以最容易漏。
  · `MainPage` 账号选择器下拉 → `menuIn()`(从上往下落的浮层档)
  · `AdminUsersPage` 新建用户表单 → `paneRiseIn()`(就地展开档)
    ★ 过渡必须挂在**调用点的 Column**:`@Builder` 返回 void,链不上修饰符。
      第一版写进了 `CreateForm()` 内部,是新判据抓出来的。
  · `SettingsPage` 新增账号弹层 → `paneRiseIn()`(与回复/转发弹层同一挂法)
  新增判据「出现/消失的那类元素真的挂了过渡」:**枚举所有条件挂载的浮层/展开块**
  (不是数数 —— 数数挡不住"新增第四处又漏了"),变异验证过。

★ 顺手修的两处真嵌套玻璃
  `SettingsPage` 的账号列表与密钥列表都是"卡里面的一段列表",两层都铺材质 =
  WebUI 用 `.bg-white .bg-white { backdrop-filter: none }` 明确禁止的嵌套
  (alpha 相乘 0.82×0.82=0.97 把壁纸吃光)。去掉内层材质,玻璃只留一层。

★ 判据放宽一处(不是放水)
  `harmony-contacts` 那句写死 `pushUrl`,判的是"tryRestore 有没有跳转",
  与用哪个原语无关。修 LoginPage 返回栈时改成 `replaceUrl` 后它误报了;
  改成 `(pushUrl|replaceUrl)`,原语该用哪个由 `harmony-nav` 那条专门判。

判据:files=32 ran=32 checks=518 pass=518 fail=0 red=0;
baseline 7/7✓(第 6 次重算,两个文件逐个 `git diff --quiet HEAD` 取证为有意编辑)。
2026-09-20 07:37:16 +08:00
c1465e09ab 跨端: 平板三处真 bug(返回回登录页 / 避让重复叠加 / 联系人卡被裁 10vp)
用户报的「在主页返回为什么会直接回到登陆页」是**原语用错**:
LoginPage 用 pushUrl 进 MainPage,路由栈成 [LoginPage, MainPage],
返回自然弹回登录页。而 Logout.ets 早就是 replaceUrl 并写了理由
(「退出后不该还能'返回到已登出的页'」)—— 同一个不变式、相反方向,
只改了一半。三处 pushUrl → replaceUrl,设备实测:返回直接退出 app
(前台变 com.huawei.hmos.browser),不再回登录页。

另两处平板(HUAWEI MatePad Pro, 2800x1840, ratio 1.52 ⇒ 宽屏):
· 内容列 `this.isWide ? Theme.surface : (bgActive ? 透明 : surface)`
  —— 宽屏分支把 bgActive 丢掉了 ⇒ 平板上壁纸永远被挡。
· `top: this.isWide ? paneGap : statusBar` —— 宽屏分支把状态栏避让丢掉
  ⇒ 页签字压在系统时钟下。
· 侧栏自己也加了一次 topInset,而父 Row 的 padding 已经含状态栏
  ⇒ 83+83=166px 空白(用户:「避让有点用力过猛」)。
· 联系人列表项硬写 .height(85)+clip,内容实际要 95vp ⇒ 写信/归档行被裁一半。

判据 harmony-nav 新增「登录/退出必须用同一个原语」并做变异验证
(改回 pushUrl 即红);注册数 18→19,全绿 19/19。

★ 途中发现的记账缺口:设备判据连续失败时走 noteBusySkip 计数,
  .tmp/harmony-busy-skips.json 累到 10 后拒绝再当'礼貌跳过'。
  清除账本 + 让 app 真在前台后立刻 19/19 —— 说明**不是代码问题**,
  是账本把'设备忙'当成了证据。这一点记进 DEBTS。
2026-09-19 23:38:35 +08:00
1da4e15a7b 跨端: 补齐「我的」页深色可读性(4 页 219 段全 ≥3:1)+ 判据自己漏报的那一类
上一条把通信/日历/联系扫干净了,但**「我的」页没扫到**
(扫描脚本按文案点不到它 —— 那是侧栏**底部的头像按钮**,不是导航项)。
补上后立刻又抓出问题,并把判据自身的**漏报形状**一并修了。

## 一、「我的」页两处真 bug

1. **主题分段按钮 `SettingsPage.ets:834`**:
   `.fontColor(... ? Theme.surface : Theme.textPrimary)` —— `surface` 是
   **会翻转的面色**,压在 `Theme.accent` 蓝底上,深色下变成近黑。
   设备读数:`深色 2.93:1  ink rgb(32,34,36) bg rgb(34,96,228)`。
2. **权限档徽标 `MailDetailPage.ets:391`**:同一个错法(第 13 处)。
3. **账号名 `SettingsPage.ets:1179`**:三元里的裸 `Theme.accent`
   压在 `accentSoftFor()` 底上 ⇒ `jianf 2.83:1`。
   `AdminUsersPage.ets:386` 同形状(角色徽标)。

## 二、判据自己的漏报形状(比 bug 本身更值得记)

静态防线(`C|会翻转的 Resource 不得当前景色`)**在 bug 存在时是绿的**。
原因是它的提取正则:

    /\.fontColor\(Theme\.([A-Za-z0-9_]+)\)/      ← 要求令牌是**唯一实参**

于是**三元里的令牌全被漏掉**:

    .fontColor(this.appearanceTheme === t ? Theme.surface : Theme.textPrimary)

改成"在 `fontColor(` 之后的整段实参里找所有 `Theme.X`"之后,
它**立刻报出上面第 1、2 两处**(此前一直绿)。

★ **判据漏报的常见形状是它自己的正则太窄,而不是被测代码太隐蔽。**
  这条写成注释留在判据里了。

## 三、我自己犯的批量替换错误(已加判据钉住)

把裸 `Theme.accent` 换成 `accentFor()` 时,**误把 6 处 `backgroundColor` 也换了**。
`accentFor()` 深色给浅蓝 `#80AFF9`,而搭档前景是白色 `accentFg`
⇒ 白字压浅蓝 ≈ **1.4:1**,主按钮文字会彻底看不见。

- 6 处已逐处还原(复核:`grep -c "backgroundColor(Theme.accentFor())"` = 0)。
- 新增判据 **`C2`**:`accentFor / dangerFor / approveFor / warnFgFor / textSubtleFor`
  **只能用于前景**,`backgroundColor(Theme.XFor(...))` 直接判红。
  (`accentSoftFor` 是例外 —— 它本来就是"面"。)
  ★ 修法不是"下次小心点":批量替换一定会再犯,**一行判据把它变成不可能**。

## 四、设备复扫(修后)

```
[通信]      扫 42 段,低对比 0
[日历]      扫 85 段,低对比 0
[联系人]    扫 51 段,低对比 0
[我的]      扫 41 段,低对比 0      ← 新增
```
**219 段文字全部 ≥3:1**(本轮全部针对深色)。

## 五、底本

`baseline.sha` 第 5 次重算。重算前**专门复核**了"那 6 处误改有没有残留"
(不只看 `git diff` 非空就放行):`backgroundColor(accentFor)` 计数为 0、
`git diff HEAD~1` 里 backgroundColor 只有 calendar 那一处(有意改动)。

`run-all.mjs` → `checks=515 pass=515 fail=0 skip=0 red=0 broken=0 unreported=0`。

**未验**:浅色主题下的对比度没扫(本轮全部针对深色)。
2026-09-19 20:43:59 +08:00
4ecddfc2b6 跨端: 语义色/三级文字在深色下没提亮(设备扫出 12 处)+ 判据取样法修到第三版
## 一、判据基建先修对,否则是在听噪声

深色可读性扫描的**取样法迭代了三版**,每版都因为**假红**才改的
(记在这里,因为"判据自己错"比"界面错"更费时间):

1. **取中心一个像素** ⇒ 落在笔画之间、读到的是底色 ⇒ 一片 ratio=1.00 假红。
2. **取全局最暗/最亮** ⇒ 会采到**两个不同的东西**:细字的抗锯齿中间色。
   实例:segmented「日」报 ink `rgb(32,34,35)` / bg `rgb(98,100,101)` 2.69:1 ——
   而截图里它要么蓝底白字、要么深底浅字,两种都远高于 3:1。
   **反复核对才确认是判据的错,不是界面的错。**
3. **步长采样 + 众数** ⇒ 仍然假红:周表头「一」报 2.03:1,
   而逐像素的真实墨是 `rgb(166,167,167)`(**6.6:1**)——
   稀疏网格**整个跳过了笔画**。这不是调参能修好的(字越小越糟)。
4. **逐像素 + 众数=底 + 离底最远者=墨**(最终版)。
   理由:一个文字框里**面积最大的一定是底**,而"离底色最远"就是笔画最实那部分。
   ⚠️ 逐像素是**负担得起的**(整屏已解码在内存,一个框最多 9 万像素)——
   **不要再为了省开销退回抽样**,前两版都因此假红。

## 二、真 bug 三批(都是"深色下没提亮")

### ① 语义色前景(12 处 → 修完设备复扫 0)
WebUI 的语义色也是**双通道**,`.dark` 段把 `red/green/amber-700` 换成浅色
(`248/118/249 → 164/219/195` 那组)。鸿蒙只有一个深色值:
- 「归档」按钮 `Theme.danger` #B91C1C 压深面 ⇒ **2.47:1**(联系人页 7 处)
- 「今天」/「日」等 ⇒ **2.38–2.77:1**

加 `dangerDark/approveDark/warnFgDark`(逐字对齐 WebUI `.dark`)
+ `dangerFor()/approveFor()/warnFgFor()` 入口,22 处接入。

### ② 日历工具条整块配色抄错(不是"深色档选错")
对照 WebUI `CalendarView.tsx:313-331`:

| 元素 | WebUI | 鸿蒙(改前) |
|---|---|---|
| 「今天」 | `border-gray-300` + `text-gray-700`(**中性**) | `accentSoft` 底 + `accentStrong` 字 |
| 月/周/日 未选中 | `bg-white` + `text-gray-700` | `accentSoft` 底 |

鸿蒙把**次要的中性按钮**画成了**品牌色按钮** —— 视觉重量跟主操作一样,
三个档看起来"都像选中"。已按 WebUI 改成中性。

### ③ 三级文字在深色下 2.82:1(**71 处**,全局最大的一个)
WebUI 的 `.dark` 段注释逐字写着这个坑:

> 「`gray-400/500`(次要文字)→ **提亮**。深底上的浅色 gray-400
>  只有约 2:1 对比度,远低于 WCAG AA 的 4.5:1 —— **看得见但读不动**。」

鸿蒙用的系统三级色 `ohos_id_color_text_tertiary` 深色下是 `rgb(102,103,105)`
压深面 **2.82–3.05:1**,而它被用在 10–11px 的**小字**上
(时间戳、农历、空态、辅助说明)。

**不换掉系统令牌**("系统拥有的维度"纪律照旧),加 `textSubtleFor()`:
深色下改用二级色(实测 `rgb(166,167,167)` ≈ 7.15:1,
且与 WebUI 的 `gray-500` 同档位)。71 处接入。

**设备复扫(修后)**:通信/日历/联系三页 **176 段文字全部 ≥3:1**。

## 三、欠账与底本

- `baseline.sha` 第 4 次重算。仍**先逐个取证**:
  `git diff --quiet HEAD -- <f> && echo STALE || echo INTENTIONAL`
  ⇒ 三个全是 `INTENTIONAL`(我改的),不是变异残留。
  把这条取证命令也写进文件,下次照抄。
- `dangerDark/approveDark/warnFgDark` 已登记进 `SELF_OWNED_COLORS`
  + `Theme.ets` 登记表(各带理由与 WebUI 出处)。

`run-all.mjs` → `checks=514 pass=514 fail=0 skip=0 red=0 broken=0 unreported=0`。

**未验**:①「我的」页(底部头像按钮,扫描脚本没找到入口)没扫;
② 浅色下的对比度没扫(本轮全部针对深色)。
2026-09-19 20:27:09 +08:00
13b557a742 跨端: 品牌蓝深色下没提亮(24 处字/图标看不见)+ 补深色可读性设备判据
## 一、真 bug:WebUI 深色下把品牌蓝**提亮**了,这边没有

WebUI 的强调色是**双通道**(`tailwind.config.js` 的 `backgroundColor`
/`textColor` 覆盖 + `index.css` 两段定义):

| 通道 | 用途 | 浅色 | 深色 |
|---|---|---|---|
| `--s-blue-600` | **实心按钮底** | `37 99 235` | `37 99 235`(**同值**)|
| `--c-blue-600` | 内容/交互的**蓝字与图标** | `37 99 235` | **`128 175 249`** |

`index.css:353` 写了理由:主按钮底跟着变「会让主按钮在深色页面上
失去『这是主操作』的视觉重量」;而蓝字必须提亮,否则深底上读不动。

鸿蒙只有一个 `Theme.accent = '#2563EB'` ⇒ 24 处字/图标在深色下
对比度 **2.61:1**(设备实测:管理页返回箭头 `‹`),低于 WCAG 图形下限 3:1。

**取证方式**:在跑着的 WebUI 上用 CDP 读**计算样式**(不是读 CSS 源)——
浅色 `37 99 235` / 深色 `128 175 249`,实测确认。

## 二、修法:加前景专用的深色档 + 唯一入口

- `Theme.accentDark = '#80AFF9'`(= WebUI `.dark --c-blue-600`)
- `Theme.accentFor(dark?)` 作为**前景**唯一入口
- **当背景的 20 处保持 `Theme.accent` 不动**(跟 WebUI 的 `--s-*` 一致)

24 处 `.fontColor/.iconColor(Theme.accent)` → `Theme.accentFor()`。

## 三、顺手修掉「转述一层就会漏」这个结构问题

`accentSoftFor(isDark)` 原本的约定是"页面算好深浅色传进来"。
给 `accentFor` 做准备时一数:**8 处**直接用了 `Theme.accentSoft`(没走入口)
—— 约定**已经漏了**,而漏掉的症状正是上一轮那个"深色下白底卡片刺眼"。

于是把两个 `For()` 的参数都改成**可选**:`AppStorage` 是 ArkTS 全局键值存储,
静态类可以直接读(原来"拿不到 Context"的理由不成立)。
⇒ `Theme.isDarkNow()` 成为唯一判断点,调用方不必再各自转述。

## 四、设备判据:深色可读性**扫一屏**

原来只有悬浮球那一条(单个点)。这个 bug 类一天撞到**两批**(12 处 + 24 处),
逐处写判据追不上 ⇒ 改成把当前页所有小段文字都量一遍对比度。

三个实现要点(第一版全踩了,都写进注释):
- **不能只取中心一个像素**:中心多半落在笔画之间 ⇒ 读到的是底色,
  报出一片 ratio=1.00 的假红。改成**框内网格扫描取极值**(最亮=底/最暗=墨)。
- **整屏解码一次**:每点 spawn 一次 ffmpeg 太慢 ⇒ 新增
  `readPixels()`(157ms 解整屏,比逐点快三个数量级)。
- **只判"有真实墨迹"的框**(`hi.L - lo.L >= 0.02`),否则跳过而不是判红。

实测:修前 1 处低对比(2.61:1),修后 **40 段文字全部 ≥3:1**。

## 五、判据自身的三个修正

- `accentSoftFor(dark:)` 的签名断言跟着放宽成 `dark?`,并**补上 `accentFor` 的**。
- **孤儿令牌判据从"一跳"改成"走整条链"**:`KEY_IS_DARK` ← `isDarkNow()`
  ← `accentFor()` ← 24 处页面。只查一跳时它假红 ——
  ★ **"有没有人用"是可达性问题,不是邻接问题**;加中间层(抽 `For()` 入口)
  恰恰是我们鼓励的写法,而旧判据会因此假红。
- 手写色登记表补 `accentDark` 一行理由。

## 六、`baseline.sha` 重算(先核过不是残留)

三个文件哈希对不上。逐个 `git diff --quiet HEAD -- <f>` 取证:
- `AdminUsersPage.ets` / `SettingsPage.ets` —— 本次**有意编辑**;
- `api/AppearanceApi.ets` —— **与 HEAD 逐字节相同** ⇒ 底本取完后被**合法改过**
  (提交 `f811c98`),属 `stale` 不是 `residue`。

按该文件自己那条纪律(「重算必须是一次有记录的动作」)在文件里记了理由。

`run-all.mjs` → `checks=514 pass=514 fail=0 skip=0 red=0 broken=0 unreported=0`。
2026-09-19 20:05:33 +08:00
21132647bc 跨端: 12 处「深色下字看不见」的真 bug + 判据基建补上「看像素」这一层
## 一、判据基建:本目录终于能**看像素**了

此前只能靠 `dumpLayout` —— 那是**结构化描述**,报的是"组件声明了什么",
不是"屏幕上画成什么样"。两者会分叉,而观感类结论只能在像素上得出来。

新增 `lib/harmony-device.mjs`:`screenshot()` / `pixelAt()` /
`hexToRgb()` / `closeColor()`(用 ffmpeg 转 1×1 原始 RGB,不引依赖)。

**它当场证明了它的价值**:`cross-client-theme` 新增的设备判据
用真实像素抓到下面这个 bug —— 静态判据全绿时它藏得好好的。

## 二、真 bug:**12 处**把 `Theme.surface` 当前景色用

`Theme.surface` 是 `sys.color.ohos_id_color_list_card_bg` ——
一个**跟随系统主题翻转**的 Resource:浅色近白、**深色近黑**。

- 浅色下当白字用**碰巧对**(白字压蓝底)
- **深色下字变成黑的**,压在品牌蓝 / danger 红 / warn 琥珀上**几乎看不见**

设备现场:写邮件悬浮球是品牌蓝 `#2563EB`,截图里那个铅笔图标**几乎是隐形的**;
读圆心像素得到 `rgb(32,34,36)`。往左偏 50px 读到底色才见 `rgb(36,99,235)`。

`Theme.accentFg`(`#FFFFFF`)的注释原话就是「品牌底上的文字」—— 为这个场景存在,
却**一处都没用**。

修:12 处 `fontColor/iconColor(Theme.surface)` → `Theme.accentFg`
(`MainPage` 10 + `InboxPage` 1 + `SessionsPage` 1)。改完全仓 0 处残留。
另在 `Theme.ets` 给 `surface` / `accentFg` 都补上"能当什么、不能当什么"的注释。

## 三、判据(两条,都做了变异验证)

1. **设备条**(`cross-client-theme`):读悬浮球像素 ——
   ① 品牌色**真的画成** `#2563EB`(声明 ≠ 渲染);
   ② 球上图标与底色 **WCAG 对比度 ≥3:1**(压在上面的东西得看得见)。
   把 `.accentFg` 改回 `.surface` ⇒ **判红**;还原 ⇒ 绿。
2. **静态防线**(同文件):全局 grep「`fontColor/iconColor(Theme.surface)`」一处不许有。
   设备条只能看一处,而这个错法有 12 处 —— 静态防线管住整类。

## 四、判据自身踩的三个坑(都写进注释了)

- **采样点撞上图标**:第一版取球心,读到 `rgb(32,34,36)`,差点当成"品牌色没渲染"。
  截图一看球是蓝的,深色那点是**铅笔图标**。⇒ 往中心左偏 30% 球宽。
- **假设错了 FAB 的位置**:按"屏幕右下角"找(`x1 > 屏宽*0.6`),
  实测 `[942,1997]`(`x1=942` vs 阈值 1910)⇒ 永远找不到、**静默跳过**。
  原因是列表窗格是**左栏**,球在"左栏的右下角"。⇒ 形状只用站得住的那部分(下半部)。
- **设备判据要自己搭现场**:不加自导航时它**永远跳过**(前面的判据把前台留在管理页),
  而那看起来像"功能没了"。加自导航后立刻开始工作并抓到 bug。

## 五、欠账

- `harmony-maildetail-missing-three` → **count 0(结算)**:三块都做完了
  (转发 `b7c5d8b` / 改名建议 `c2f35d1`+`e79a86a` / 往返预算 `ac62daf`)。
  如实记着**未验**的那点:预算条的**点击**没在设备上走通
  (模拟器顶部 155px 是系统手势区,折叠头部恰在其中)。
- `static-criteria` 5:`cross-client-theme` **升级了一半**,仍留在名单里 ——
  `.ets` 那半只有悬浮球这一处上了设备,其余令牌仍是静态对齐。
- `debt-visibility` 登记 `cross-client-theme` 1 处边界声明(带出处)。

`run-all.mjs` → `checks=513 pass=513 fail=0 skip=0 red=0 broken=0 unreported=0`;
Go 侧 `./internal/repo/...` 通过。
2026-09-19 19:29:49 +08:00
fc295893cb 跨端: 深色模式下的品牌浅底不跟随 —— 12 处「选中/未读」底色在深色页上刺眼
## 真 bug(设备实测)

深色主题下,「我的」页**选中**的那张账号卡片仍是接近纯白的浅蓝
(实测像素 `(255,255,255)` 级别的浅底压在 `(32,34,36)` 的深色页上)。

根因:`Theme.accentSoft = '#EFF6FF'` 是**写死的浅色**,而它被当作
「选中态背景」用在 12 处(未读邮件行、选中账号、分段选中、登录页模式切换…)。

**为什么只有 WebUI 没这个问题**:它有 CSS 变量的**反转发**机制 ——
`index.css:113` 的 `--c-blue-50: 239 246 255` 在 `.dark` 段(`:475`)被换成
`28 37 54`(深蓝黑)。ArkTS 的 `static readonly` **一个常量一个值**,
没有那层机制 ⇒ 静态常量必须自己提供两个取值。

## 修法

1. `Theme.accentSoftDark = '#1C2536'`(对齐 WebUI `.dark --c-blue-50` 的 `28 37 54`)
2. `Theme.accentSoftFor(dark)` 作为**唯一入口** —— 不在页面里各自
   `isDark ? a : b`:那样每处都会各写一遍,迟早漏一处
   (WebUI 那条"由 test/theme.test.mjs 逐档断言"就是为防这个)
3. **深浅色从哪来**:只有 `MainPage` 算得出(它读 `resourceManager` 的
   `colorMode`)。所以走 `AppStorage` 单向发布(与徽标、windowInsets 同一套):
   MainPage 算 → 写 `agentmail.appearance.isDark` → 各窗格 `@StorageProp` 读。
   6 个文件、12 处,全部改用 `accentSoftFor(this.isDarkNow)`。

**设备实测**:深色下「我的」页账号卡片与收件箱未读行都变成深蓝底,
像素 `(32,34,36)` 与页面底一致(不再刺眼)。

## 判据(这条是新加的,形状值得记)

`cross-client-theme` 新增:**品牌浅底必须有深色变体**。断三件事:
1. 深色变体存在,且**取值从 WebUI 的 `.dark` 段反推**(不是随手挑一个深色);
2. 有按主题选值的**入口**(防"页面各自写三元");
3. **用到它的地方真的走那个入口** —— 扫描所有 `backgroundColor(… accentSoft …)`
   并排除 `accentSoftFor`,把漏改的位置**逐行报出来**。

第 3 条在我改到一半时**当场列出了剩下 9 处**(`CalendarPage:1172`、
`ComposePage:255`、`LoginPage:327`…)—— 这就是它该有的样子:
不是"断言存在某个常量",而是"断言没有一处漏改"。

★ 写这条判据时踩了自己一次:JS 模板串里嵌了反引号包围的标识符
(`` `accentSoft` ``),直接 SyntaxError。改用字符串拼接。

## 判据

`run-all.mjs` → `checks=506 pass=506 fail=0 skip=0 red=0 broken=0 unreported=0`。
`hvigorw assembleHap` 成功;前端重建。

**未验**:日历/登录页在深色下的观感(只逐处改了底色,没逐页截图)。
2026-09-19 16:02:26 +08:00
f655453424 跨端: 审计补强 —— 邮件行的「抄送 N」+ 断点差异登记 + 两处"看起来有其实没有"的澄清
继续「全面对齐 WebUI 和鸿蒙」。这一轮做的是**逐页对照审计**(子代理通道被
session daemon 的端口占用堵死,改为自己逐处读源码对照)。

## 一、补上邮件行的「抄送 N」(真缺失)

WebUI `MailList.tsx:350` 行上有「抄送 N」,鸿蒙**完全没有**。而数据一直在:
`cc_list` 服务端确实返回(实测回包字段列表里有),只是鸿蒙的 `MailLike`
接口漏了这个字段 ⇒ 一封抄送给多个人的邮件在列表里看不出任何区别。

修法:接口加 `cc_count`,`MailSummary` 加 `cc_list` + `cc_count`,
收件箱/发件箱两处填充派生值,行上按 WebUI 的位置渲染。
**设备实测**(造了一封带 2 个抄送的真邮件):行上出现「抄送 2」,
位置与 WebUI 一致(主题行下方、灰字)。

★ 接口用**数字**而不是 getter:`MailSummary implements MailLike`,
而 ArkTS 的 interface 里不能声明 getter(编译报 "incorrectly implements interface")。

## 二、有意**不抄**附件标记(发现 WebUI 那段是死代码)

WebUI 行上还有一个 📎 + 数量的标记(`MailList.tsx:351-355`)。但核对服务端
**实测回包**:`GET /me/mail/inbox` 既没有 `attachments` 也没有 `has_attachments`
⇒ `mail.attachments?.length ?? 0` **恒为 0**,那个标记在 WebUI 上**从不出现**。

所以鸿蒙这一轮**有意不抄它** —— 照抄一个不工作的东西,只会多一处
"看起来有、永远不亮"的代码。要做这个功能得先让服务端在列表回包带上附件计数
(一次 JOIN 的事),那是独立的一件事,已登记进 `docs/DEBTS.json`
(`mail-list-attachment-count`)。

★ 这一条与鸿蒙 `MailSummary.has_attachments` 那个字段一起处理掉了:
它还留在那里会误导人(服务端永不返回它 ⇒ 恒 false)。

## 三、两端宽屏断点不同 —— 登记 + 判据(此前**无任何记录**)

审计点名要核实的这条确认成立:

    WebUI: `NARROW_QUERY = '(max-width: 1023px)'`
           含义 = 「三栏(60 导航 + 320 列表 + ≥520 详情 ≈ 900px,再加余量)放不下就退化单栏」
    鸿蒙:  `isWide = width >= 768`
           含义 = 「要不要显示**侧栏**」(鸿蒙内容区是一个窗格,没有并排三栏)

**含义不同,所以数值不同本身不算错** —— 这与手势阈值同一条口径
(语义各自成立时,数值不必强求一致)。但**用户可见的后果**是:768–1023 宽
(常见竖屏平板、窄窗口)下 WebUI 是单栏+底栏、鸿蒙是侧栏+内容,
同一宽度在两端长得不一样。

处理:① 登记进 `docs/DEBTS.json`(`wide-breakpoint-divergence`,
带三个待定选项);② 加判据 —— 它**不**要求两端取值相同,而是要求
「取值可读 + 含义写清 + 差异被登记」三件事同时成立。

★ 写这条判据时又踩了同一坑:用 `code()` 读注释 ⇒ 永远红。
`criteria-hygiene` 判据的头顶就写着"判代码用 code、判理由用 prose"。

## 四、判据

`run-all.mjs` → `checks=505 pass=505 fail=0 skip=0 red=0 broken=0 unreported=0`
(cross-client-theme 15→16)。`hvigorw assembleHap` 成功。

**未验**:附件标记(有意不做);抄送行在**深色**下的对比度未单独验。
2026-09-19 15:13:51 +08:00
f811c9887a 跨端: 三个真 bug(外观保存 400 / 改档位界面不动 / 内容列溢出屏幕)+ 设备判据
这一轮从「全面对齐 WebUI 和鸿蒙」开始,先做设备层判据升级,结果**判据一上线就连撞三个真 bug**
—— 它们全都是静态判据照不到的形状:**数据对、界面不动**。

## 一、外观保存从来就没成功过(PUT 400)

`payloadFromLocal` 复用了 `AppearanceResponse` 当请求体,而那个类型是 **GET 的响应**:
带着 `has_image` / `image_bytes` / `saved`。服务端的 `Decode()` 是
`DisallowUnknownFields()`(严格,**有意为之**)⇒ **每一次保存都被拒收(400)**。

症状极隐蔽:本地 `@State` 立刻变 ⇒ 肉眼看着像成功了;只有看 hilog 的 HTTP 状态码
才发现 400。修法是加 `AppearancePayload`(**恰好**服务端 `models.Appearance` 的五个字段)。

★ 这是"两端共用同一个类型"的代价:请求与响应本来就不该同形。
★ 服务端严格是**对的** —— 它帮我们抓到了这个错误。修客户端,不是放宽服务端。

## 二、改了档位,页面背景一点不变(两层原因)

**第一层**:`SettingsPage` 存进 store 了,但 `MainPage` 的 `bgPlan` 只在启动时算一次,
之后没人动 ⇒ 发布一个 `AppStorage` revision(计数器,不是布尔 —— 布尔 true→true
不发变化通知),`MainPage` 用 `@StorageProp + @Watch` 接住。

**第二层(更隐蔽)**:接上之后**还是不动**。因为 `bgPlan` 是 `@State BackgroundPlan`,
而 **ArkTS 的 `@State` 观察不到类内部字段**的变化 —— 渲染读的正是
`this.bgPlan.kind` / `.layers`。hilog 一对证据同一次启动相差 100ms:

    Appearance: sync: bgKind=preset … hasImg=true    ← 数据是对的
    Wallpaper: kind=none layers=0 active=false       ← 渲染读到的还是旧值

修法:加 `@State bgContentRev: number`,每次算完 plan 就 +1,**并在 Builder 的
条件表达式里消费它**(ArkUI 按"这个 Builder 读了哪些 @State"决定是否重渲染;
只加计数器而渲染不读,等于没加 —— 判据同时断这两半)。

★ 同一个坑本仓出现过(`AppearanceStore` 的注释里写着这句),这次换了地方发作。

## 三、「我的」页右端内容被顶出屏幕(追了很久的 `56.000000` 之谜)

真相有**两层,两层都值得记**:

1. `dumpLayout` 里 `Slider` 节点的 `text='56.000000'` 是**无障碍文本**
   —— 屏幕上根本没这串字(截图可证)。**dump 的 text ≠ 看得见的字**。
2. 真正的问题是那个**看得见的** `Text('56%')` 落在 `x=3250`,而屏宽 3184
   ⇒ **它在屏幕外**。用户只看得到滑杆、看不到数值。

根因:`MainPage` 里"侧栏 + 内容列"是 `Row` 并排,内容列写 `.width('100%')`
—— 在 Row 里 `100%` 是**父容器全宽**,与侧栏的 60vp **相加** ⇒ 必然溢出。
实测内容列 `[229,28][3357,2204]`,右边缘超出屏幕整整 173px(= 60vp)。
修法:改 `.layoutWeight(1)`(吃剩余空间)。修完实测 `[229,28][3156,2204]`,
`Text('56%')` 落在 `[3049,1803]` —— 屏内。

★ 为什么值得一条设备判据:**同一处错误在不同 pane 上表现不同**
(日历页自己算宽度就没露出来),很容易被当成"某一页的样式问题"去调。

## 四、设备判据基建(这一轮加的能力)

- `lib/harmony-device.mjs` 新增 `launchOurApp` / `ourAppInFront` / `tapText` / `swipe`。
  `swipe` 里 clamp velocity 并写明那个坑:`uitest` 的 velocity 越界**不报错**,
  只回一句 "out of range, the default value will be used",静默换成默认 600。
- 三条新设备判据(`harmony-appearance`):壁纸档位真的切换 / 窗格内容不得超出屏幕。
- 修了一个**元问题**:设备判据在套件里**恒跳过**(要求"现场已经摆好"),
  只有手动摆好才通过 ⇒ 那等于没有判据。现在它们**自己搭现场**
  (拉起应用 → 导到目标页 → 操作 → 复位)。`cross-client-gesture` 与
  `harmony-appearance` 都改成了这样,套件里 `skip=0`。
- 途中撞出的两个判据自身缺陷(都写了注释):
  · `root0` 用**切页前**的快照 ⇒ 套件里红、单独跑绿(通过与否取决于跑之前那一屏)
  · 侧栏项筛选没排除**品牌标** ⇒ 想点「日历」却点到「通信」

## 验证

`run-all.mjs` → `files=32 ran=32 checks=503 pass=503 fail=0 skip=0
red=0 broken=0 unreported=0`(含设备判据:gesture 9、appearance 27)。
`hvigorw assembleHap` 成功;前端重建 + 重打包(`build-stamp` 7/7、`packaging` 5/5)。

设备实测(HATriple 3184×2232):
· `PUT /me/appearance` 从 **400 → 200**(服务端访问日志),
  库里 `jianf` 的记录从空变成 `bg_kind=preset / bg_dim=56 / bg_blur=3`。
· 壁纸真的透出来了:预设档缝隙 `#E0E2E4`、不设档 `#FFFFFF`(像素级对比)。
· 「我的」页 `56%` / `3px` 正常显示在屏内。

**未验**:壁纸在真机上的观感(渐变是否好看、压暗 56% 是否合适);
这一轮只验了"数据通了、界面响应了、内容没被裁掉"。
2026-09-19 15:03:40 +08:00
28e3da76d4 跨端: 左右滑动翻页(P6 第 3 步)+ 还 gesture-semantics 债 + 修跑不起来的判据基建
★ 这一轮从用户一句「滑动手势呢?」开始。查下去发现它不是"顺手加个手势",
  而是 `docs/DEBTS.json` 里挂着的一笔债 —— `gesture-semantics` 的原话是:

    「P6 第 3 步:鸿蒙侧出现滑动手势代码时**立即建**判据
      (此前建 = 只有一端存在的假判据)」

  也就是**先有手势、再钉语义**。WebUI 2026-09-14 就有滑动翻页(用户当时
  亲口提的),鸿蒙一直没有 ⇒ 之前建判据会是空真(∀x∈∅)。

## 一、手势本体(两端语义逐项对齐,数值各自定)

按 `HARMONY-ALIGN-PLAN.md:118-128` 显式选的 **(b) 口径**:
「手势的物理量本来就不该强求同值……该对齐的是**语义层**」。

· 判定逻辑放**纯逻辑层** `model/Calendar.ts` 的 `judgeSwipe`(可被 node 直跑,
  写在 .ets 里就跑不了判据,语义没法被单测钉住)。四道门:位移 / 纵向优先 /
  快滑窗口 / 方向。
· 阈值**不引用** WebUI 的 40 / 1.5 / 600(那是把巧合当契约),
  各自定为 56vp / 1.4× / 700ms,并在注释里写出取值依据。
· 接线在 `CalendarPage.ets`:`PanGesture({direction: Horizontal})` +
  `onActionStart`(记时 —— `GestureEvent` **没有时间戳字段**,我查了 SDK
  的 gesture.d.ts 确认)+ `onActionEnd`(读 offsetX/offsetY)。
· ★ 挂在**网格列**上而不是整页:右栏(日程/编辑器)里有输入框与可滚内容,
  整页挂会让「在表单里横划一下」变成翻月。
· ★ 翻页复用 `shiftRange()`(与 ‹ › 按钮**同一个来源**)—— WebUI 的注释
  专门交代过:各写一套的话,阈值、边界、三档行为迟早分叉。
  手势回调里**不准**直接改 year/month(锚点是唯一真相,年月只能由
  `shiftRange → syncYearMonthFrom` 派生)。

**有意差异(记录在案,不是漏做)**:WebUI 在周/日档会额外检查「触点是否落在
可横向滚动的区域里」,是则让给滚动条(用户 2026-09-14 报过这个冲突)。
鸿蒙周档是「一行 7 格按 layoutWeight 等分」、**不横滚** ⇒ 该条件不适用。
哪天加了横滚必须同时补上它。

## 二、判据(8 条语义契约 + 行为层)

新增 `cross-client-gesture.test.mjs`:两端都真有手势 / 方向映射逐项相同 /
纵向优先 / 快滑窗口 / 复用同一翻页函数 / 无边界回弹 / 有意差异被记录 / 自检。
**只比语义、不比数值**,并反向断言鸿蒙的阈值常量不得直接取 WebUI 的那三个数。

`harmony-logic.test.mjs` 加行为判据:真跑 `judgeSwipe`,把四道门各自验一遍
(只钉字符串的话,一个 return 写漏了照样全绿)。

## 三、顺手修掉的三处**判据基建**缺陷(不修就没法验证上面这些)

1. `run-all.mjs` 只认 `# pass N`,而 node v24 打的是 `ℹ pass N`
   ⇒ **21 个文件被记成"没自报条数"**、套件在 HEAD 就恒红(memory 里记过这条,
   修法也记过,今天终于落进代码:4 个正则加 `(?:#|ℹ)`)。修完 `unreported` 27 → 0。
   代价是暴露出一批此前被"没自报"掩盖的真实问题(下面 4~6 条)。
2. `harmony-nav` 的设备判据 `navItemsOf`:宽屏过滤条件从「左边缘靠左 1/6」
   改成「**整个盒子在侧栏轨道内**」。旧条件把**日历网格的格子**
   (实测 `[229,511][366,794]`)也当成导航项,一屏数出 11~12 个。
   这是设备实测抓出来的 —— 我第一版还以为是"底部簇混进来了",
   打印真实数据才发现是隔壁页面的格子。
3. `harmony-logic` 两处:从 `NAV_ITEMS` / `NAV_SIDEBAR_ITEMS` **各自的数组**里取
   label。原先把全文件 `label:` 一网打尽 ⇒ 得到 7 个(4+3 混在一起),
   任何一边改对了它都会红。

## 四、被判据拦住后的正经修法(每条都按判据自己给的方向改,不改判据迁就代码)

· `cross-client-theme` A2 拦住我:新增的 `navActiveBg`/`navBrandFg`/`badgePlain`
  未登记;又拦住我:`sseColorOf` 里四个裸色值。→ 抽成 `Theme.sseConnected` 等
  四个令牌(取值对齐 WebUI 的 Tailwind 类)+ 登记 + 在 Theme.ets 的表里写理由。
· 同一条判据的"死令牌"检出:`Theme.durBase` 声明了却从没人读。
  **查 WebUI 才发现壁纸淡入是真有的动效**(`index.css:742-752` 的
  `.app-backdrop` 从 opacity:0 → 1,180ms)⇒ 补上而不是删令牌
  (删掉等于把差异抹平、还说成"清理")。reset 在 `animateTo` **外**,
  与 `calPaneIn` 同一条纪律。
· 玻璃登记:`NavItemBuilder` → `SidebarItem`(重构后按最近的 @Builder 命名),
  登记同步跟上。
· `harmony-admin` 的退出判据:退出逻辑抽成 `api/Logout.ets` 的 `performLogout()`
  (两个入口——「我的」页与侧栏底簇——必须做同一件事,尤其"先注销推送 token"
  那一步)。判据相应改成**追到实际执行处**(两半都断:按钮调了 + 函数真清了全部),
  并写明"别再退回直接匹配 SETTINGS_PAGE 的写法"(那会随重构假红,
  下一个人只会去改判据而不看行为)。
· `harmony-widescreen` 的连接点色值:色值搬进 Theme 后,判据改成断
  「令牌定义对了 + 侧栏真的用了它」两半(只断任一半都有假绿形态)。
· `align-refs`:`CalendarView.tsx` 变了,按判据要求**读一遍差异**再更新登记
  (差异只有农历小字的灰阶档位 gray-300→gray-400/500,**骨架未变**)。
· `criteria-hygiene`:`harmony-contacts` 自造了一个 `code()`、`harmony-widescreen`
  裸用 `readFileSync` ⇒ 都改用 `lib/read.mjs` 的共享入口。
  途中撞出一个**判据自己的 bug**:`code()` 的块注释正则
  `/\*[\s\S]*?\*\//` 会把注释里出现的 `/*`(如 `/」**` 这种中文夹星号)
  当成块注释起点,一路吃到几十行后的 `*/`,把中间的 import 全吞掉 ——
  于是 hygiene 判据假红"没 import"。改掉那处写法后正常。

## 五、验证

判据面:`run-all.mjs` → `files=32 ran=32 checks=497 pass=497 fail=0
skip=0 red=0 broken=0 unreported=0`。
其中新/改判据:gesture 8、nav 18、widescreen 7、logic 30、admin 27、
cross-client-theme 15、criteria-hygiene 6。
构建:`hvigorw assembleHap` 成功;前端 `npm run build` + 重新打 AppImage/deb
(`build-stamp` 7/7、`packaging` 5/5)。

**设备实测(HATriple 三折叠 3184×2232,hdc 连 127.0.0.1:5555)**:
· 农历在格子里真的显示(1=二十 / 7=廿六 / 19=**初九** / 11=八月),与 WebUI 一致;
  这条同时验证了**服务端农历路由已部署**(之前线上是 404)。
· 日历左右两栏:编辑器出现在**右栏**、左栏月份仍可见(单栏模式下编辑器会整页盖掉它)。
· 侧栏 3 项 + 底部一簇;徽标回到图标右上角。

**仍未验(如实标注)**:滑动翻页的**手感**(阈值 56vp/1.4×/700ms 是否合适)
只能真人滑过才知道;我只验了判定逻辑与接线形态。动画同理 ——
机制已验证(`animateTo` 驱动 + reset 在窗口外),但"看起来顺不顺"未做取样验证。

docs:`DEBTS.json` 销掉 `gesture-semantics`(并记结算说明)、
`HARMONY-ALIGN-PLAN.md` P6 从「✅(滑动翻页除外)」改为 ✅。
2026-09-19 14:01:21 +08:00
36ef15a8f2 跨端: 顶栏不再自己铺白条 + 日历改左右两栏 + 常驻窗格动画真的会播(用户三处实测指出)
用户三条反馈,逐条对应:

① 「底栏数字为什么显示在图标下面?」
   WebUI 的徽标是 `absolute top-1 right-[22%]`(脱离文档流、浮在图标右上角),
   我写成了 `Column` 的第三个子节点 ⇒ 参与竖向布局、掉到文字下面。
   改用 `Stack({ alignContent: Alignment.TopEnd })` 锚在**图标**上。
   (顺带撞了 skill 里明写的坑:Stack 没有 `.justifyContent()`。)

② 「一个横着过去的白条,我真的服了」/「期望:融进背景」
   WebUI 的顶栏**自身没有底色** —— 只有 `border-b border-gray-200`
   (`ContactPanel.tsx:64`、`CommTabs.tsx:40`、`CalendarView.tsx:295`),
   底色由所在面板给;壁纸开启时那层面板是玻璃色(`index.css:876`)。
   鸿蒙三个窗格顶栏写死了 `Theme.surface`(实心白)⇒ 无论壁纸开没开,
   顶上都是一条不通明白带。改成与**页面底**同一口径
   (`bgActive ? Transparent : surface`)+ 补下边框。

③ 「日历页面和webui布局完全不同」
   WebUI 是**左右两栏**(`CalendarView.tsx:452-457`):左 `flex-1` 网格、
   右 `400px` 常驻面板(日程 / 编辑器 / 小时网格三态互斥)。
   鸿蒙原来是**单栏竖堆**。重搭为两栏,`paneWide` 由 `.onAreaChange`
   量本页**自己的**宽度(不是屏幕宽度 —— 宽屏下这一页已被侧栏占掉一截);
   编辑器改占右栏位置(不再整页盖掉正在看的那个月)。

④ 「最严重的动画问题你一点也不该改」
   日历是**常驻挂载**(`visibility` 控制,因为它里面 today 要随时间重算、
   也要保住"正在看哪个月"),而 `.transition()` 只在**挂载/卸载**时触发
   (SDK 原话 \"when it **appears and disappears**\")⇒ 挂在它上面的
   `.transition(paneRiseIn())` **一帧也不会播**,切过去是硬弹。
   WebUI 踩过同一个坑并把错法写进了 `index.css:1305-1320`
   (「只挂了类,却没让触发窗口出现 ⇒ 类挂着、动画永远不播」),
   它的解法是 `html.view-switch` 重放窗口。ArkUI 对应物是
   `animateTo` + 显式 `calPaneIn` 属性(`opacity` + `translate`)。
   ★ reset 必须在 `animateTo` **外**:写进回调里会与同帧的 1 相抵,
     渲染层只看得见最终值 ⇒ 动画退化成一个瞬移。

顺带修:
· 宽屏侧栏 = 3 项(通信/日历/**联系**)+ 底部一簇(头像/主题/退出),
  与底栏的四项(含「我的」)**不是同一份清单** —— WebUI 的 Sidebar 与
  NarrowNav 本就不同(`Sidebar.tsx:26-44` vs `NarrowNav.tsx:37-40`+143)。
  新增 `NAV_SIDEBAR_ITEMS` / `ME_PANE_INDEX` / `SIDEBAR_ITEM_*`。
· 退出登录抽成 `api/Logout.ets` 的 `performLogout()`:侧栏底簇与「我的」页
  两个入口必须做同一件事(尤其"先注销推送 token"那一步),复制一份就会不一致。
· 徽标 `'plain'` 档底色:WebUI 是石板灰 `--c-chrome-600`(#475569),
  我写成与未读共用红色 ⇒ 「联系」的徽标看起来像"有未读"。
· 主题快捷开关(侧栏底簇):对齐 `ThemeToggleButton` —— 从 `system` 翻转时
  落到**当前生效值的反面**(不是回 system;那可能毫无变化、让按钮看起来坏了)。
· 「浓度」→「压暗」+ 数值带单位(原先屏上印 `56.000000`;WebUI 是 `suffix="%"`)。

判据(8 个套件全绿:logic 28 / nav 18 / widescreen 7 / window 9 /
arkts 5 / contacts 5 / calendar 30 / system-api 5):
· `harmony-widescreen` ② 重写:**回读 `Sidebar.tsx` 数 `short:` 的个数**
  要求鸿蒙同数,并断言底部一簇三键真的被调用(`this.onToggleTheme()` ——
  第一版写成 `/onToggleTheme/`,变异测试当场证明它不咬:属性**声明**还在,
  正则照样匹上)。新增 ⑧:三档色调各自底色,期望值从 `--c-chrome-600` 读出。
· `harmony-nav` 动画条重写:改判**机制真的存在且被驱动**
  (旧断言 `.visibility(...).transition(...)` 锁的正是那个 bug ——
  判据引自己写的注释当依据,就会把错误锁死)。新增 reset-在-animateTo-外
  这条断言(否则动画退化成瞬移)。
· `harmony-nav` 设备条:`navItemsOf` 的宽屏过滤从"左边缘靠左 1/6"
  改成"**整个盒子在侧栏轨道内**" —— 旧条件把日历网格的格子
  (实测 `[229,511][366,794]`)也当成导航项,一屏数出 11~12 个。
  新增 `navRailItemsOf`:导航轨贴顶、底部簇在屏底,按位置切一刀。
· `harmony-logic` 两处:从 `NAV_ITEMS` / `NAV_SIDEBAR_ITEMS`
  **各自的数组**里取 label —— 原先把全文件 `label:` 一网打尽,
  得到 7 个(4+3 混在一起),任何一边改对了它都会红。

设备实测(HATriple 三折叠,3184×2232):侧栏 3 项 + 底簇 / 底栏徽标回到
图标右上角 / 日历左右两栏与 WebUI 并排同构。

server/go.mod:补 2da38bb 漏提交的 lunar-go 依赖。
2026-09-19 12:30:05 +08:00
d8277a5977 跨端: 导航项徽标两侧补齐(我上次"撤回"错了 —— WebUI 是有的)
上一笔我凭"两侧导航都没有徽标"把刚写好的徽标**撤回**了。那是错的:
`client/electron/src/components/Sidebar.tsx:88-95,111-125` 明确有——

```
const badge = isComm ? unread + pendingPerms
                      : modes.includes('contacts') ? contacts.length : 0;
const badgeTone = isComm && pendingPerms > 0 ? 'perm' : isComm ? 'unread' : 'plain';
```

并且是 `absolute top-0.5 right-1` 压在导航项右上角,`>99` 显示 `99+`。
我当时只看了底栏 `NavItem`(那里确实没有),就把结论推到了"两侧都没有"。

## 补的东西

- 新增**纯逻辑** `model/NavItems.ts` 的 `navBadgeCount` / `navBadgeTone` / `navBadgeText`,
  逐条对齐 WebUI 的口径:
  · **通信** = 未读 + 待决策(两类"要动手"合起来);
  · **联系人** = 联系人数;日历/「我的」= 0(没有徽标);
  · 色调:待决策**橙**(有人卡在那儿等)优先于未读**红**(只是还没看);
  · 负数当 0(计数来自网络,不假设它干净);`>99` → `99+`。
- **两侧**(底栏 `MainPage.NavItem` + 宽屏 `WideSidebar.NavItemBuilder`)都接上,
  且读**同一组** AppStorage 键 —— 两处各算一套,数字迟早对不上,而用户同时看得到它们。
- 计数发布走 `AppStorage`(单向:窗格写、导航栏读),与 `KEY_WINDOW_INSETS` 同一套机制。
  不把这两个数提到 `MainPage`:那样"从没进过通信页"也会去发请求。
- 徽标位置对齐 WebUI 的 `absolute top-0.5 right-1`(压在项的右上角)。
  ★ 第一版我排在文字**下面**,截图一眼可见那颗 3 掉到了「联系人」标签底下、
  还把 48vp 的项撑高了 —— 方阵节奏乱掉。

## 判据(harmony-widescreen 6 → 7)

第 ⑦ 条**直接执行**鸿蒙侧的纯函数,且期望值在测试里**独立算一遍**
(不复用被测函数,否则是"用实现验实现")。

★ 接线部分我写错过一次,变异测试当场拆穿:第一版只判
`assert.match(src, /navBadgeCount\(/)` —— 把**渲染处**的调用换成 `0`
(徽标永远不显示),文件里仍留着一处调用,判据照样全绿。
⇒ 改成判**把值交给 Text 的那一行**,并且认出两侧写法不同(底栏走 helper
`Text(this.navBadgeOf(key))`,侧栏就地内联 `Text(navBadgeText(navBadgeCount(...)))`)。

**4 个变异方向全咬**:通信漏算待决策 ⇒ 红;色调优先级写反 ⇒ 红;
侧栏渲染处换空 ⇒ 红;底栏渲染处换空 ⇒ 红。

设备实测:侧栏「联系人」显示红 **3**(3 个联系人),位置在图标右上角。
2026-09-18 13:30:38 +08:00
7b3028342a 跨端: 宽屏侧栏根本不像 WebUI —— 因为我上一版"复刻"的依据是编的
用户:「你自己看看宽屏的侧边栏和webui有哪怕一丁点的相似之处嘛?」

并排截图(WebUI 1100×700 @2x vs 三折叠展开态 3184×2232)之后,差异一眼可见:

| | WebUI | 鸿蒙(改前) |
|---|---|---|
| 文字标签 | **有**(通信/日历/联系) | 没有 |
| 选中态 | **浅蓝底块** | 只换颜色 |
| 「我的」 | 底部头像按钮进入 | 甩给 `onSettings` → **pushUrl 推页** |
| 品牌标颜色 | `#475569` 石板灰 | 品牌蓝 |
| 项间距 | 48px 项 + 4px gap,**贴顶一簇** | `layoutWeight(1)` 等分铺满(395px/项) |

## 根因:`WideSidebar` 里那段"复刻 WebUI"的注释是**编的**

```
 * WebUI 的 `Sidebar`(60px 宽)是**纯图标轨**(无 label 文字)……
 * 选中态:图标变色(`navFgActive`),**不加背景块、不加指示条、不加文字**
 * (用户 2026-09-16:「底部导航栏不允许有文字」⇒ 侧栏同样按纯图标走)
```

两条都错,而且都能在源码里当场证伪:

- `Sidebar.tsx:110` 明明有 `<span className="text-3xs leading-none">{short}</span>`
  —— 通信/日历/联系三个标签一直都在;
- `index.css:1590` 的 `.nav-item[data-active='true'] { background-color: … }`
  就是底块,而且 CSS 注释**专门**说了侧栏必须有它:
  「宽屏侧栏是 48px 宽的竖条,图标底下那一块底色是它**唯一的选中线索**,
  所以"只变色"不能无差别推广到所有 `.nav-item`。」

我犯的错是**把底栏那条纪律套到了侧栏上**:用户 2026-09-14 说「底部导航栏选中
对应的文字和图标变色即可」、2026-09-16 说「底部导航栏不允许有文字」——
两句都针对**底部导航栏**,而侧栏是另一种东西(`index.css:1595-1606` 把这个区别
写得很清楚)。更糟的是我把这个错误**写进了判据**(`harmony-widescreen` ②③ 与
`harmony-nav` 的宽屏分支),于是判据锁住的是我编的理由,一路全绿。

## 修

- 侧栏项 = **图标 + 文字标签 + 选中底块**(`navActiveBg` = `--nav-active-bg` #DBEAFE,
  判据**直接读 WebUI 的 CSS** 取值,不写死、更不引自己的注释)。
- 品牌标:`navBrandFg` = `#475569`(**像素取证**:2x 截图里品牌标附近最常见的墨色
  是 `rgb(71,85,105) ×206` = `--nav-fg-muted`,即中性石板灰,**不是**品牌蓝);
  尺寸/圆角按 WebUI `w-10 h-10 rounded-xl`(40×40、圆角 16);点它回收件箱。
- 项**贴顶一簇**(`Column({ space: 4 })` = WebUI 的 `gap-1`),不再 `layoutWeight(1)`。
- 删掉单列的"设置"入口(`onSettings` 回调一并删除)—— 那正是用户 2026-09-17 报过的
  「我的页面完全没有遵守 nav 的导航规则」(push 页 ⇒ 侧栏整条消失)。
  「我的」由 `ForEach(NAV_CONTENT_ITEMS)` 覆盖(该常量**含第 4 项**,
  走 `onSelect(3)` = 窗格,与底栏同一套)。
- 补避让:侧栏原先**完全没有** `topInset` ⇒ 全屏之后品牌标被状态栏时钟压住。

## 判据(并修掉它们锁住的错误)

- `harmony-widescreen` ②③ **重写**:从"纯图标 / 只变色"改成
  "有文字标签 / 有选中底块 / 不许留 `onSettings`",并读 WebUI `index.css` 拿真实色值。
- `harmony-nav` 宽屏分支:原来断言「侧栏项**不该有文字**」—— 同一条编造。
  改成"图标(Path)画出来了 **且** 文字命中源码 `NAV_ITEMS`"。
- `harmony-nav` 宽屏形状阈值 `boxH > screenH*0.08` 是**错的**:48vp 项在密度 2.875 下
  是 138px,而阈值要求 >178px ⇒ 四项全被滤掉(当时"通过"只是因为项被另一个 bug
  压成了 39vp)。改成 `*0.04`,并补一条"必须有文字"把**品牌标**(40vp 无文字的可点方块)
  排除在外。

**变异测试 3 个方向全咬**:去掉文字标签 ⇒ 红;去掉选中底块 ⇒ 红;Theme 色值写错 ⇒ 红。

★ 顺带记一条**我差点犯的错**:我一度按 density 3.5 换算,算出"60vp 侧栏被压成 49.4vp",
去查 flex 压缩、加 `.flexShrink(0)` —— 全是假的。实测密度是 **2.875**
(`138px ÷ 48vp = 2.875`),侧栏 173px ÷ 2.875 = **60.2vp**,与声明完全一致。
**没有压缩,是我除错了。** 已撤回那笔改动并把口径写进注释。

harmony-widescreen 6/6、harmony-nav 18/18、harmony-window 9/9、harmony-arkts 5/5、
harmony-contacts 5/5、harmony-calendar 30/30、harmony-system-api 5/5、harmony-logic 28/28。
2026-09-18 12:52:48 +08:00
4ff6b10260 跨端: 联系人页补齐「写信 / 归档」—— 顺带撞出三个只在跑起来才现形的 bug
用户:「你自己看看这些页面和webui有哪怕一丁点的相似之处吗?」
把两个客户端**同一个宽度**并排看之后,缺的很具体:WebUI 联系人卡片有
「写信 / 归档」,鸿蒙一个都没有。补的过程撞出三个缺陷,都不是"代码不合法"——
编译器与既有判据全绿:

## ① 归档打的是**服务端不存在**的路由(死函数)

`MailApi.archiveContact` 打的是 `DELETE /me/contacts/{name}/{path}`:

- `grep 'me/contacts' server/cmd/server/main.go` **零命中** ⇒ 按钮接上去就是 404;
- 全仓**没有任何调用方** ⇒ 它是个从没跑过的死函数,所以"没有归档按钮"这件事
  一直没暴露这个错。

服务端真实形状是 `POST /api/v1/contacts/archive`(`handler.ArchiveContact`),
body 二选一 `{session_id}` / `{address}`。**跟 WebUI 同口径用 session_id**:
address 会随别名变化,只有 session_id 是会话的身份。

## ② 用已存 token 恢复登录**永远进不去主界面**(最恶劣的一个)

`LoginPage.tryRestore` 验完 token 就结束了 —— 设了 `loggedIn = true`
(界面出现「登录成功:jianf」)却**从无跳转**。本文件另外两条成功路径
(`aboutToAppear` 快速路径、`doLogin` 末尾)都有跳转,唯独这条没有,
而它**恰恰是老用户最常走的那条**(重启时 token 还在,`doLogin` 根本不会被调用)。

症状最坏的地方是它**看起来是成功的**。实测(模拟器 + jianf 的永久 key):
日志只有 `→ GET /auth/me` 然后什么都没有。补齐 ① 跳转 ② 账号登记
③ SSE 连接(后两条是 `doLogin` 有而这里缺的,少了它们进主界面是个瘸的状态,
而且因为 `aboutToAppear` 的快速路径靠账号命中,下次启动还会重走这里 ⇒ 永远进不去)。

修后实测:启动即进主界面(可见文本变成「发件箱/授权/收件箱 · 2 组 · 50 封」)。

## ③ 时间戳原样印出来

卡片直接印 `last_activity` ⇒ 屏上是 `2026-09-18T02:50:35.49065Z`。
WebUI 是 `09/18 10:50`(`toLocaleString('zh-CN', {month,day,hour,minute})`,**本地**时区)。
加 `MailGrouping.shortTimeOf`(走 `Date` 取本地字段;**不许 `.slice()`** ——
那是拿 UTC 的月/日当本地时刻,UTC+8 的 09-15 00:30 会显示成 09-14 16:30)。
实测:`112 封 · 09/18 10:50`,与 WebUI 逐字一致。

## 补的界面(三折叠模拟器 3184px 展开态实测)

- 两种视图**各一处**「写信 / 归档」(WebUI 两视图同语义),WebUI 用 `.reveal`
  悬停显形,**鸿蒙不能照抄**:`index.css` 那段注释已经踩过这个坑
  (触摸设备没 hover ⇒ 按钮透明却仍可点,一个看不见却按得动的破坏性按钮更糟),
  WebUI 的修法是只在真支持悬停的设备上隐藏 ⇒ 鸿蒙这两个按钮**常显**。
- 归档先确认,**两视图共用同一个确认框**(WebUI 原话:换个视图就换套确认 UI
  只会让人对「自己点了什么」更没底);文案逐字一致。
- 列表视图的 meta 行原来放 `last_preview`,于是同一联系人在两视图里的关键信息
  不一致 ⇒ 改成与卡片视图同源(`N 封 · 时间`)。
- ★ 一处只有跑起来才会发现的坑:列表视图的 ListItem 钉死 `.height(85)` +
  `clip(true)`,而确认框比 85 高 ⇒ **按钮被裁掉、点不了也退不出**。
  确认态下高度交给内容自己定。

## 判据(新增 harmony-contacts,5 条;4 个变异方向都跑过)

判的都是"按下去会发生什么",不是"按钮在不在":
① 归档打的是服务端真有的路由(同时钉住**没有**再用那条不存在的 DELETE —— 只钉前者的话,
   加个平行实现也能全绿);② 两视图各一处动作且去向正确;③ 确认框只有 1 个定义、
   两视图都调它、文案逐字一致、确认态下点卡片不开会话;④ 时间戳走了格式化且
   函数本身不走 slice;⑤ **切出 `tryRestore` 的函数体**判它自己含跳转
   (只判全文件出现次数的话,另外两条路径里那两句就够让它变绿)。
2026-09-18 12:17:41 +08:00
0e5eec61bd 跨端: 登录页那个"emoji"是 Unicode 符号 —— 顺手修好图标贴左上角(共 4 处)
用户两条反馈,都是**看着界面**报出来的,而编译器与所有既有判据全绿:
①「为什么登陆页不是app图标,而是一个emojy?」
②「你自己看看那个图标的位置正常吗?」

## ① `Text('✉')` 被系统渲染成彩色 emoji

登录页的品牌标识原先写的是 `Text('✉')`(Unicode U+2709)。HarmonyOS 字体链里有
**彩色 emoji 字体**,U+2709 自带 emoji 字形 ⇒ 渲染成一枚黄白色风信封 emoji:

- `.fontColor(Theme.accent)` 对彩色 emoji **无效**(界面显示的是 emoji 自带颜色);
- 与底栏/侧栏那些 `AmIcon` 线描图标不是同一套视觉语言;
- 实测截图硬证:大屏下那枚 emoji 比旁边的文字还显眼。

而本仓**早就有** `ICON_PATHS.brandMark`(就是 App 图标上那个信封,专为品牌标识画的)。
换成 `AmIcon({ iconName: 'brandMark' })` 即可 —— 走 `Path.stroke()`,跟主题色走。

## ② 图标贴在卡片左上角(量出来的)

`AmIcon` 内部那个 `Stack` 是 `iconSize` 那么大、**默认靠左上**排版。调用方写
`AmIcon({…}).width(48).height(48)` 想要个大点的可上色盒子时,**外层盒子变大、图标不动**。

实测(三折叠 3.5 密度,`dumpLayout` 读的**实际 bounds**):

    卡片 [1523,521][1661,659]  138×138px
    图标 [1526,524][1589,587]   63×63px   ← 左边距/上边距都只有 3px
    ⇒ 图标中心偏 10vp

**不是一处**:全仓扫出 4 个同样写法。修法是套一层
`Stack({ alignContent: Alignment.Center })`(仓里回复球与悬浮加号本来就是这么写的,
所以它们一直是对的):

| 位置 | 图标 | 盒子 | 原状态 |
|---|---|---|---|
| 登录页品牌卡 | brandMark 24 | 48×48 | 贴左上 ✗ |
| 用户管理刷新键 | repeat 20 | 40×40 | 贴左上 ✗ |
| 联系人视图切换 | cardView 18 | 40×40 | 贴左上 ✗ |
| 收件箱组头箭头 | chevronRight 12 | 20 槽位 | 贴左 ✗ |
| 回复球 / 悬浮加号 | chatBubble / compose | 56×56 | 本来就对(尺寸在外层 Button 上) |

修后实测:偏移 **−34.5px → 0.5px**(0.14vp,亚像素级)。

## 判据(harmony-arkts 3 → 5 条)

- **图标不许用 Unicode 符号充当**:扫 `Text('…')` 里单个符号的情况。
  ★ 只框 U+2600–U+27BF / U+2B00–U+2BFF / U+FE0F,**刻意不含基本箭头段**(U+2190–U+21FF)——
  `→` 在正文里是标点不是图标,框进来会误伤大量正常文案。
  (我第一版把箭头段也框了,结果自检自己先红 —— 断言写错就是写错,不靠放宽它来「修」。)
- **尺寸不许直接链在 `AmIcon` 上**:把"图标贴左上角"这个坑的**形状**钉住,
  并自检"套了 `Stack` 的正确写法不许被误判"。

**两个变异方向都跑过**:放回 `Text('✉')` ⇒ 红 ✓;给 `AmIcon` 直接加 `.width(48)` ⇒ 红 ✓。
2026-09-18 11:47:51 +08:00
af2d2b5cad 跨端: 鸿蒙动画补齐 —— 并且发现原来的「逐字一致」是假的(令牌存在 ≠ 动画用了它)
用户:「A,同时把鸿蒙app的动画补齐」。

## 先纠一条错的前提(这是本轮最有价值的发现)

`Theme.ets` 的注释与 `harmony-nav` 的判据**都**写着:「三个数与 WebUI **逐字一致**:
`--ease-out-soft: cubic-bezier(0.22,1,0.36,1)`、`--dur-fast: 120ms`、`--dur-base: 180ms`」,
`paneRiseIn()` 就按 `durBase(180)` + `easeOutSoft` 做。

三个令牌**确实存在**(`index.css:310-312`)—— 但这句话把「令牌存在」当成了「动画用了它」:

| WebUI 里 | 真实用途 |
|---|---|
| `--dur-base: 180ms` | **只**用在壁纸淡入(`:747`) |
| `--ease-out-soft (0.22,1,0.36,1)` | **只**用在 transition(壁纸、控件变色) |
| **所有 @keyframes 动画** | 硬编码 **150ms / 200ms** + `cubic-bezier(0.22, 0.61, 0.36, 1)` |

全仓 `(0.22,0.61,0.36,1)` 出现 **5 次**(rise-in×3 / cal-in×2),`(0.22,1,0.36,1)` 只出现
**1 次**(令牌定义处)。**是两根不同的曲线** —— 旧代码的面板入场比 WebUI 慢 30ms 且曲线偏软。

所以令牌拆成两组,各对齐各的:控件类 `durFast + easeOutSoft`;动画类
`durRise(150)/durMenu(140)/durCal(200) + easeRise(0.22,0.61,0.36,1)`。

## 补的动画(对齐 WebUI 三个 @keyframes)

- `menuIn()` —— 弹层:下移 4vp + 缩到 0.985 + 淡入(`@keyframes menu-in`)。
  ★ 适用范围照搬 WebUI 注释那条窄口径(「只给真正是弹层的东西」):那条规则曾挂着
  `glass-control`,于是每次切视图**所有按钮与输入框一起淡入位移**,09-15 被摘掉。
  挂到写邮件页的账号候选列表上。
- `calendarSlide(forward)` —— 日历翻月:从 ±12% 横向滑入(`cal-in-next/prev`)。
  方向由新增 `@State slideForward` 带进 `animateTo` 闭包;网格键加 `monthKey()` 前缀
  保证月份一变所有键全变(否则"跨年同名月"那类边角会静默不播)。
- 写信页整页 `rise-in`、回复框 `rise-in`(WebUI 挂在回复框本身,不是外层遮罩 ——
  挂遮罩上会让整个屏幕一起位移,看起来是"页面在动"而不是"框弹出来")。

**实测确认真的会播**(不是"编译过了"):按本仓记录的手法把 `durCal` 临时改成 8000
做慢动作,连拍三帧 —— 截图硬证**两张月历同时在屏**(九月淡出、十月从右侧 12% 滑入),
验完还原成 200ms。

## 判据:从「令牌存在」改成「动画真的用了那个令牌」

`harmony-nav` 那条判据原文锚在令牌上,所以它对上面那个 bug **一辈子全绿**。
重写为:
- 从 WebUI `index.css` **读出** `rise-in` 的真实时长与曲线(`150ms` + 那条 bezier),
  再断言鸿蒙的 `durRise` 与曲线字面量与之逐字一致 —— 而不是"仓库里有没有 180 这个数";
- ★ 把 `paneRiseIn()` 的**函数体抠出来**单独断言它引的是 `durRise/easeRise`,
  且**不得出现** `durBase/easeOutSoft`。

**这一步不能省 —— 我第一版就漏了它**:断言了令牌存在、也断言了曲线字面量存在,
但没断言函数用了它们;于是把函数体换回旧的错值后判据**仍然全绿**。
变异自检抓住了这一点,补上函数体断言后同一个变异 ⇒ 判红 ✓。
与今天修的另一处同源:**判据要锚在"这个东西被用在哪",不是"它存在"**。
2026-09-18 10:56:57 +08:00
cac026e9e2 跨端: 上下黑边真的消了 —— 全屏 + 避让是"同一套东西的两半",上次只删了一半
用户第三次报同一条:「你再看看页面底部,那么大的黑色,你看从头到尾都没
修好,你能不能好好看看我给你的示例工程怎么处理上下黑边的」。

## 根因:上一次把"两半"当成了"一件事",删掉一半就以为修好了

`6861934` 的注释白纸黑字写着「★ **刻意不用** `setWindowLayoutFullScreen(true)`」,
理由是「实测过:它确实也消掉黑带,但会连状态栏区域一起吃进布局,于是页签栏
被时钟/电量盖住(截图硬证「07:43」与「收件箱」重叠)」。

那次实测**是真的**,结论**下错了**:被盖住不是"不该全屏",而是
**只做了全屏、没做避让**。示例工程里这两件事本来就是**同一套东西的两半**:

    common/.../util/WindowUtil.ets        → setWindowLayoutFullScreen(true)
                                          + getWindowAvoidArea(TYPE_SYSTEM /
                                            TYPE_NAVIGATION_INDICATOR)
    features/mine/.../view/MineView.ets:251 → .margin({ top: statusBarHeight + …,
                                                        bottom: naviIndicatorHeight })

只做前半 ⇒ 内容跑到状态栏底下没人让(那次退回的原因);
只做后半 ⇒ 黑边照旧(这三次报修的原因)。退回的代价是**黑边留了三天**。

## 实测(模拟器 1256x2760,四页一致)

    修前:顶部纯黑 136px、底部纯黑 60px + 手势条 20px
    修后:四页**纯黑段均为 0**;y=0..135 是壁纸(时钟浮在上面,正是示例工程的效果)
          y=2662+ 壁纸铺到底、底栏浮在手势区之上

## 改了什么

- `entryability/EntryAbility.ets`:拆出 `setupFullScreenWindow()`,
  在 `loadContent` **回调里**调(与示例工程同一位置 —— `px2vp` 要用 `getUIContext()`,
  那要有已加载内容才拿得到)。全屏 + 读两个避让区 + 订 `avoidAreaChange`。
  `setWindowSystemBarEnable(['status'])` 保留(状态栏要看得见),但**不再靠它**消黑边。
- `model/WindowInsets.ts`(新):纯逻辑 `insetsFromAvoidArea()`,不 import SDK ——
  判据才能在 node 里直接喂样本验换算。形参叫 `toVp` 而**不是** `px2vp`:
  后者是 SDK 已废弃的全局函数名,`harmony-system-api` 按名字扫,同名形参会误报。
- `pages/MainPage.ets`:`@StorageLink(KEY_WINDOW_INSETS)` 订阅;状态栏高度当
  **内容层**的 `padding-top`(**不是**根 Stack —— 壁纸必须铺到屏幕四边,根上加
  padding 会把壁纸一起缩进去,黑边只是换个地方出现);底栏与内容末尾让开手势条。
  reserve 收成**一个** `recomputeNavReserve()`,宽度变化与避让变化两个触发点共用
  (转屏只改避让不改宽度,各写一份迟早漏一个)。

## 判据:`test/harmony-window.test.mjs`(8 条)

钉的是"两半必须同时存在"——**只钉一半的话,"退回某一半"照样能全绿通过**,
而那次退回恰恰就是删了一半。纯逻辑 3 条(换算/取不到就是 0 不猜/键名是常量)
+ 接线 4 条(全屏在、避让在、布局真消费了值、纯逻辑模块不许 import SDK)
+ 设备行为 1 条(全屏没把应用搞成白屏)。

**变异自检两个方向都跑过**(这是本轮最该记的一步):
- 删掉 `setWindowLayoutFullScreen`(重演 6861934)⇒ 2 条红 ✓
- 删掉 `getWindowAvoidArea`(只全屏不让位)⇒ 1 条红 ✓

★ 第一版判据**锚错了**:正则直接扫全文,而注释里正好有 `setWindowLayoutFullScreen(true)`
  这个串 —— 把真正的调用删掉后判据**仍然全绿**。锚落在"代码对自己的描述"上了。
  加 `stripped()` 剥注释后,变异才咬得住。这条与仓里那条"判据的锚不能落在
  被守对象的自述上"是同一件事,这次是它的实例。

## 顺带修的两条既有判据(不是放宽,是它们把"当时的字符串"当成了"要守的坑")

- `harmony-nav` ④:留白断言写死了 `bottom: NAV_BAR_BOTTOM` 那个字面串。
  它本来要守的是"留白靠 padding 不靠 margin"(margin 在 ArkUI 里加在宽度外面,
  100% + margin 会顶出父容器)—— 那是另一件事。改成剥注释后验三段在不在、
  离底是否**从** `NAV_BAR_BOTTOM` **起**。变异(padding→margin)仍判红 ✓
- `harmony-widescreen` ④:同上,写死了 `? 0 : NAV_CONTENT_RESERVE`。
  改成"宽屏必为 0、窄屏含 NAV_CONTENT_RESERVE(可再加避让)"。

两处都是"加一个正当的避让"与"真犯那个错"会红得一模一样 —— 那就不再是守坑,
是守字符串。

另:`align-refs` / `build-stamp` / `packaging` 三条 broken 是前端 `99a2d7a`
(另一个人改的 `CalendarView.tsx`)带来的,与本轮无关,留给他。
2026-09-18 09:47:22 +08:00
2166f0ed81 fix(harmony): 补齐窗格切换动画(transition 挂在会换的那棵子树上)
用户(2026-09-17):「一方面一点动画都没有」。

★ 第一版写错了,这里记下来 —— 它的错法很典型,下次还会踩:
  只在 onClick 里把 `currentIndex = ...` 包了一层 `getUIContext().animateTo(...)`,
  并把 `.transition(...)` 挂在**内容容器(稳定父节点)**上。
  编译过、判据(当时只钉"有 animateTo")全绿,**但动画是静默失效的**:
  `animateTo` 只负责开一个动画窗口,被换掉的子树自己不声明 transition 就什么都不会动;
  而挂 transition 的那个 Column 在新旧两种状态下**都是同一个节点**,永远不会触发。
  实测取证:把时长临时改成 20s,6 秒后截图仍是硬切(收件箱整版清晰、没有叠影)。

修法(对齐 WebUI `.pane-rise` / `.rise-in`,`index.css:1208`):
· `Theme.paneRiseIn()` —— 4vp 上浮 + 淡入;`riseInOffset = 4` 与
  `@keyframes rise-in { from { opacity:0; transform: translateY(4px) } }` 逐字一致。
· 挂到**if/else 各自的子树根**上(CommPage / ContactsTab / SettingsPane,
  以及常驻+visibility 的日历那一支)—— 这才是会被插入/移除的节点。
· `TransitionEffect.asymmetric`:入场 180ms(durBase)、出场 120ms(durFast)。
  出场更快是刻意的:同长会让新旧两层半透明地叠着,看起来像"闪一下",
  而用户对"闪"敏感(09-14 否掉过整屏淡入)。
· 续用令牌 `durFast=120` / `durBase=180` / `easeOutSoft=cubic-bezier(0.22,1,0.36,1)`。

判据(219 passed / 0 failed):
· 新增「窗格切换有真的过场动画」6 条:令牌数值逐个钉死(120 / 180 /
  cubic-bezier(0.22,1,0.36,1));`transition` 必须挂在 if/else 分支根(≥2 处,
  含日历那一支);必须 asymmetric。
· **变异自检**:删掉那 4 处 `.transition(...)` ⇒ 该条立刻变红;还原 ⇒ 全绿。

留给以后的坑(写进 Theme 注释):`snapshot_display` 单次往返约 1s,
180ms 的过渡**抓不到**(连拍三帧像素级一致,会得出"动画没做"的错误结论)。
要取证就得把时长临时调到 20s 再取样,验完还原。本文件注释里留了这条与那次实测值。

未验:真机手感(模拟器已确认过渡链路生效);出场动画与详情 push 的叠加观感。
2026-09-17 21:33:15 +08:00
686193458a fix(harmony): 底栏黑带 / 联系人点不开 / 我的页不守导航 / 顶栏硬截断
用户(2026-09-17)连报五条:「一点动画都没有」「底部那个黑条是啥意思」
「日历页面、我的页面、联系人页面哪个跟 webui 对齐了」「联系人页面连点都点不开」
「你的顶栏为什么还是硬截断而不是渐变」。逐条查证后修:

── ① 底部黑条 = 系统导航栏区域,不是我们画的 ──
根因:窗口默认给系统导航条留位,那块在深色下是黑的;界面于是看起来底下多一条黑带。
修法:`setWindowSystemBarEnable(['status'])` 隐藏系统导航条(底部那条本就是我们自绘的
悬浮玻璃条),内容铺满全高。
★ 刻意**不用** `setWindowLayoutFullScreen(true)`:实测试过,它也能消掉黑带,但会连
状态栏区域一起吃进布局,页签栏被时钟/电量盖住(截图硬证「07:43」与「收件箱」重叠)。

── ② 联系人点不开(用户原话「连点都点不开」)──
根因:`ContactItem` / `WorkCard` **根本没有 onClick** —— 卡片画出来了,
但没有任何点击路径。当时判据只钉了"字段与 WebUI 一致",没钉"点了会发生什么"。
修法:点卡片打开那条会话的邮件列表,就地在**本窗格内**展示(新增 `SessionMailsView`
+ `GET /sessions/{id}/mails`),底部导航保留;每封可再点进详情。

── ③ 我的页不遵守导航规则 ──
根因:点底栏「我的」走 `pushUrl('pages/SettingsPage')` 推**独立 @Entry 页** ⇒
底部导航整条消失,要按返回才能再切窗格。WebUI 里 `account` 只是一个 `viewMode`,
与收件箱同级、导航常驻。
修法:`SettingsPage` → `SettingsPane`(去掉 @Entry 与返回键,从 main_pages.json 摘除),
作为**第 4 个内容窗格**挂到 `currentIndex === 3`;`NAV_CONTENT_COUNT` 3 → 4,
`NAV_ITEMS` 第 4 项去掉 `route`;拨动动画包 `getUIContext().animateTo`。
实测:`我的` 页底部导航可见,可滚到「退出登录」「管理」。

── ④ 顶栏硬截断 ──
根因:`Scroll` 与 `List` 都能挂 `fadingEdge`(同在 `ScrollableCommonMethod` 上),
但**只有 MainPage 的 List 挂了** —— MailDetailPage / CalendarPage / SettingsPage /
AdminUsersPage / SessionsPage / InboxPage 的滚动容器全漏。同一次滚动里
列表是渐隐、正文是硬切,看起来就是"顶栏没做渐变"。
修法:13 个容器全部补齐(LoginPage 例外:它的 Scroll 是整页根、不是列表,
写了理由)。实测:视口上沿被切断的文字最暗 170–196,而下方不透明正文是 **19**。

── ⑤ 一点动画都没有 ──
实测确认改造前全仓 `animateTo` / `transition` / `animation` **一次都没用过**。
补:Theme 加令牌(durFast 120 / durBase 180 / easeOutSoft = cubic-bezier(0.22,1,0.36,1),
与 WebUI `--dur-*`/`--ease-out-soft` 同值同曲线);切窗格包 `animateTo`;
导航项选中变色加 `.animation`。

判据(218 passed / 0 failed):
· 新增「渐隐覆盖**所有**页面滚动容器」:按文件枚举 + 计数配平,
  并自检扫到的容器总数(防正则写坏 ⇒ 全绿)。**这条一写就抓到 LoginPage**。
· ①/②/⑤ 与 headless 那几条按新事实改写:内容窗格 3→4、
  `normalizeNavIndex(3)` 归一到自己、导航项里**禁止**再出现 `pushUrl`
  (那正是导航消失的形状)。
· 修掉自己引入的两处判据缺陷:`itemCode` 未定义;以及一条**窗口式断言**
  (从内容层 `}` 往后扫,靠数花括号定界,而注释里就有大量 `.padding({`,
  切片长到 1700+ 字符一路扫进 `onAreaChange` ⇒ 假红),改为纯负向断言。

未验:真机观感;横屏 Auto Split 双栏;日历页深度对齐(本轮只补了渐隐)。
2026-09-17 21:15:11 +08:00
549836193d 跨端: fix(推送客户端) 热启点通知**不跳转** —— 真机实测逼出来的那半(只写格子没人读)
这是"收尾"里**静态判据看不出来**的那一类,只有设备能抓住。

## 实测经过(模拟器,可复核)

只写 `PendingRoute` 的那版(上一个提交 423ff9f):

    aa force-stop → aa start --ps data '{…open_mail…}'   ⇒ render MailDetailDestination ✓ 冷启能跳
    (应用已在运行)aa start --ps data '{…open_mail…}'   ⇒ **0 次**              ✗ 热启不跳

根因:冷启时页面**刚挂载**,`aboutToAppear` 会读那个静态格子;
热启时页面**早就挂载完**了、`aboutToAppear` 不会重跑 ⇒ 格子写得进去、**没人读**。
症状正是"点了通知,App 弹到前台,停在列表页" —— 而这恰恰是推送**最常见**的用法
(App 在后台,用户点通知回来看那封信)。

## 修法:补"事件发生时就交出去"的那条路

`PushService.deliverRoute(route)`:**有人监听就当场交出去,没人监听才留在格子里**。
两条路都要留着,因为两种启动各走一条(冷启没人监听、热启有人监听):

- `EntryAbility.onCreate` / `onNewWant` 都走 `deliverRoute`(两条启动方式共用一条投递路径);
- `CommPage.aboutToAppear` 注册监听、`aboutToDisappear` 摘除
  (不摘会叫醒已销毁的页面);
- 冷启与热启**共用同一个落点** `navigateToRoute`(各写一遍必然漏改一处)。

不用页面生命周期兜(`onPageShow` 之类):那会在"用户手动返回列表"时反复触发跳转,
而这里要的是"事件发生的那一刻"。

## 验证

真机(模拟器,装新包后实测):
- 热启 ⇒ `render current custom node: MailDetailDestination` ✓(修之前 0 次)
- 冷启回归 ⇒ 仍然 1 次 ✓(没被这次改动破坏)
- 截图硬证:详情页(返回箭头 + 详情窗格);"加载失败"是我塞的假 mail_id
  (`WARM456`)的**正确**后果 —— 说明确实带着那个 id 去取了

判据:`harmony-push.test.mjs` 18 → 19 条,新增「接线④:热启要能跳」,
**变异验证过两个方向**(deliverRoute 退回"只写格子" ⇒ 红;删掉监听器注册 ⇒ 红)。
它单独存在的理由:接线③那种"有人读"的检查**会放它过去** ——
`aboutToAppear` 里读 pendingRoute 完全满足③,而热启路径是死的。
2026-09-17 19:08:27 +08:00
423ff9fbb2 跨端: feat(推送客户端) 收尾两处接线(pi 邮件 66bbd929 分工)+ 换账号补报
`PushService.ets` 与 `pendingRoute` 都早就写好了,但**没人调用/没人读** ——
而"写好了"与"接上了"是两件事:ArkTS 只编译可达模块,往一个无人 import 的
文件里放必然报错的类型错误,`assembleHap` 照样 BUILD SUCCESSFUL
(pi 与我各复现过一个方向)。这次补的是**边**,不是点。

① 登录成功补报(LoginPage):`EntryAbility.onCreate` 那次在**登录之前**跑,
   那时 `ApiClient` 还没有 token ⇒ 服务端 401,而 `reportToken` 的失败是静默的
   ⇒ "启动时报过"不等于"这个账号登记过"。补三条登录路径,一条都不能漏:
   快速路径(已有账号直接进主界面,老用户走这条)/ tryRestore / doLogin。

② 换账号补报(SettingsPage.switchTo,**我加的第三处**):服务端语义是同一
   token 换账号**转移**而非并存 ⇒ 新账号其实没登记过。幂等标记按
   `accountKey|token` 存,所以这里调一次必然重发,不会被"我报过"挡掉。
   不补的症状:切完账号,通知仍推给上一个账号。

③ pendingRoute 消费端(MainPage.CommPage):`EntryAbility` 已把通知 data
   解析成 `pendingRoute`(冷启 onCreate / 热启 onNewWant 都写了),
   **但没有任何东西读它** ⇒ 点通知只拉起 App、停在列表页。
   在 CommPage 消费(详情页是这个 Navigation 的 navPathStack 上的路由,
   栈只有它持有),消费后**立刻清空**(它是"待处理"不是"当前页",
   不清则返回列表再进来会被反复跳走)。

判据:`harmony-push.test.mjs` 15 → 18 条,三条新判据钉的就是上面这三条边,
**三条都做了变异验证**(各自删掉对应接线即红、恢复即绿)。
`LoginPage` 那条数**恰好 3 处**调用:少了快速路径=老用户永远不补报,
多了要问清是哪条路径。

编译验证(不是"命令成功",是"它真读了这个文件"):三处改动各植入一次必然
类型错误 ⇒ BUILD FAILED 且 `At File:` 指到**新代码那一行**(MainPage:1173 /
LoginPage:108 / SettingsPage:456),恢复后 BUILD SUCCESSFUL。
产物侧证:`modules.abc` 里能查到 `consumePendingRoute`(2) / `reportPushToken`(2)。
2026-09-17 19:03:14 +08:00
2e53871a22 fix(harmony): 底栏真正浮起 + 内容穿过 + 加回文字标签
用户(2026-09-17):「底栏不是玻璃质感,滑动内容无法穿过底栏」,
随后「还是在导航栏加上文字吧,没有文字还是不好看」。

── ① 底栏没浮起:`width('100%') + margin` 在 ArkUI 里不缩宽 ──
NavBar 原来是 `Row().width('100%').margin({left:16,right:16,bottom:12})`。
ArkUI 的 margin 加在宽度**外面**:100% 再配左右 margin 不会缩到
「100% − margin」,而是整个顶出父容器、两侧被裁。
实测底栏左缘 x=0、右缘贴满 1256(应各留 16vp=56px)⇒ 看起来是贴边通栏,
不是浮在壁纸上的胶囊。这与收件箱头卡片修过的是同一个坑。
改法:外层 Column 带 padding(与 WebUI `.narrow-nav` 的 margin 同一几何)。
实测修复后左缘 x=56 = **16.1vp**(期望 16)。

── ② 内容穿不过去:让位加错了层 ──
原来把 `NAV_CONTENT_RESERVE` 加在**窗格的 `padding({bottom})`** 上。
那不只"让最后一行滚得出来",还**缩短了窗格本身** ⇒ 内容永远到不了条底下
⇒ 玻璃条背后只剩一张已经被壁纸层模糊过的壁纸 ⇒ 系统材质无东西可糊
⇒ 看起来就是一块普通浅色面板。**这就是"不是玻璃质感"的真正原因**。
改法:窗格满高(内容滑得到条底下,真正穿过),让位改到各滚动容器的
**内容末尾** `contentEndOffset(this.navReserve)`(5 个 List + 日历日程),
与 WebUI 同构(`.narrow-shell` 是 flex-col,列表满高、`.narrow-nav` 叠在上面)。
实测:条内高频细节 0.21 vs 条外正文 6.04 ⇒ **28.8×** 衰减,模糊确实生效。
连带修:FAB / 回复球原来靠窗格 padding 躲开条,现在自己按 navReserve 抬
(否则压在条上),实测 FAB 底距条顶 24.4vp。

── ③ 加回文字:并纠正一处**事实错误** ──
代码注释里写着「WebUI 的底部导航是**纯图标**」,并据此去掉了文字。
那句是**错的**:`NarrowNav.tsx:83-86` 每个导航项是
`flex flex-col items-center justify-center gap-0.5`,里面先 `<Icon />`
再 `<span className="text-3xs leading-none">{short}</span>`
—— WebUI **一直有文字**(通信 / 日历 / 联系人 / 我的)。
那条判据把一个假事实固化成了规则,还反过来挡住了正确做法。
现在按真实契约钉:图标(24vp,与 WebUI 24×24 同几何)+ 文字,
两者过**同一个**选中三元式(只换颜色,不加背景/指示条)。

判据(全部含变异自检):
· ⑤ 重写:两处选中三元式(图标+文字各一)、必须有 Text(item.label)、
  图标 24vp;变异自检覆盖"缺文字 / 文字不换色 / 图标不上色"。
· ④ 改:留白走外层 padding(并反向禁止 margin 做留白);
  让位改为 ≥5 处 `contentEndOffset`,并禁止退回窗格 padding 让位。
· 连带更新 ②/⑤ 与 appearance/logic/widescreen 的组件签名断言
  (组件多接一个 navReserve)。
· 删掉一段会假红的"从内容层 `}` 往后扫"的窗口式断言:它靠数花括号定界,
  而注释里就有大量 `.padding({` 字面量,切片能长到 1700+ 字符,
  一路扫进 `onAreaChange`(那里合法地出现 NAV_CONTENT_RESERVE)——
  正是本仓库反复记的窗口式判据,故改为纯负向断言。

测试:212 passed / 0 failed;`devecocli build` 通过;
模拟器截图与像素测量逐项核对。
2026-09-17 18:44:36 +08:00
65c3ef8094 fix(harmony): 悬浮加号移进 Navigation 列表侧,修详情页叠按钮
症状(2026-09-17 模拟器截图硬证):窄屏 Stack 模式点开邮件详情,右下角**同时**
出现两个圆形按钮 —— 详情自己的「回复」(蓝色胶囊)与外壳的 compose 加号,
后者悬空压在详情上。

根因:加号原先是 `Navigation` 的**兄弟**,平级放在外层 Stack 里。
详情画在 `Navigation` 内部,而兄弟节点画在 `Navigation` 之上 ⇒ 永远压住详情。

WebUI 的对应物是 `.comm-pane`(`App.tsx`: `{listBody}<ComposeFab />`):
加号在**列表窗格内部**,窄屏滑上来的详情层把列表整块(含加号)盖住,
详情自己的回复按钮才露得出来。Split 模式下加号仍在左栏,与 WebUI 两栏并排一致。

改法:把 `Stack({ alignContent: BottomEnd }) { 列表; 加号 }` 整体放进
`Navigation(this.navPathStack) { ... }` 的构建体内;通信页根的
`bgActive ? Transparent : pageBg` 保留(判据要求 ≥5 处让出页面底)。

判据(harmony-nav.test.mjs 新增一条,含变异自检):
- compose 加号必须在 `Navigation(...)` 的花括号**内**;
- `.navDestination(...)` 之后不得再出现 compose 加号。
用 `git show HEAD:...MainPage.ets`(改动前版本)复核:两条断言在 bug 版本上
**都失败**(inside=False / after=True),说明判据抓的正是这个层级错位,
不是"存在一个 compose 按钮"那种在 bug 版本上也成立的弱断言。

实测(模拟器 1256×2760):详情页只剩自己的「回复」按钮,加号不再悬空;
截图 /tmp/harmony-fabfix4.jpeg。列表页加号位置不变(仍在右下角)。

测试:200 passed / 0 failed。
2026-09-17 16:52:34 +08:00
fc3fff4f36 fix(harmony): 滚动列表上下边缘渐隐,替掉硬截断
用户(2026-09-17):「上下还是硬截断,不是 webui 那种渐变」。

WebUI 的做法是 `index.css` 给 `.overflow-y-auto` 加
`mask-image: linear-gradient(to bottom, transparent 0, #000 min(12px,10%), ...)`。

ArkUI 的对应物是滚动容器自带的 `fadingEdge(enabled, { fadingEdgeLength })`
(API 14+,定义在 `ScrollableCommonMethod` 上 ⇒ List/Scroll/Grid/WaterFlow 都有),
它淡掉的是**渲染结果本身**,与背景无关。

**刻意不用** linearGradient + mask 手搓渐变色遮罩:那是拿背景色画一个
并不存在的"底色",在壁纸/玻璃主题下必然露馅(壁纸本身带颜色,遮罩层对不上)。

- 新增 `LIST_FADE_LENGTH = 12`(NavItems.ts):与 WebUI 的 `min(12px, 10%)`
  同量级,也接近列表内边距(10vp)—— 静止时几乎看不出,滚动时才起作用。
- MainPage 的 5 个列表容器(收件箱/发件箱/授权/联系人卡片/联系人列表)
  全部挂 `.fadingEdge(true, { fadingEdgeLength: LengthMetrics.vp(LIST_FADE_LENGTH) })`。
  显式传长度而不是用默认 32vp:WebUI 那边踩过同一个坑
  (「渐变用的过猛了,比如收件人候选那里」——固定像素用在高度不固定的容器上)。

判据(harmony-nav.test.mjs 新增一条):常量值、五个列表都挂、长度走
LengthMetrics.vp、禁止 linearGradient+mask 顶替;并做了变异自检
(删掉任一处 fadingEdge 必须判红,实测通过)。

实测(模拟器 1256×2760,密度 3.489):
- 滚动后顶边被切断的卡片文字明显洗白(红色评分 5.5 vs 完整卡片 15.5);
- 底边同样渐隐;截图 /tmp/harmony-fade-scroll.jpeg。

测试:199 passed / 0 failed。
2026-09-17 16:44:17 +08:00
ff75339baa fix(harmony): 图标按 px→vp 正确缩放 + 底栏四入口 + 卡片 10vp 缩进
图标过小的根因(实图硬证):`Path.commands` 的坐标按 **px** 解释,不是 vp。
- `Shape.viewPort` 只裁剪不缩放(官方样例"缩放成立"是因为它用 Rect/Circle
  自带 vp 宽高,不是裸 Path.commands);
- 改动前的 `Path.scale({iconSize/24})` 对 iconSize=24 等于 1.0,同样没缩放。
  症状:FAB 内 compose 只画 24px(应 24vp=84px),且偏在盒子左上角。

修法:`scale({vp2px(iconSize)/24})` 把 24 单位路径放大到 iconSize vp;
`scale` 会连带放大描边,故 `strokeWidth = strokeWeight / vp2px(1)` 抵消,
否则 1.8vp 描边会变成 ~7.3vp 的"实心块"(实图已验证)。
用 `this.getUIContext().vp2px()` 而非全局 `vp2px`——后者已废弃,
`harmony-system-api` 判据对全局调用默认判红。

验收(模拟器实图,密度 3.489):
- FAB 内 compose 图标 84px = 24vp ✓(原 24px)
- 底栏图标描边 7-8px ≈ 1.8/24×28vp=7.3px ✓(原为实心块)
- 四个图标均为清晰描边,与 WebUI icons.tsx 同几何

底栏四入口:新增"我的"(person → pages/SettingsPage),对齐 WebUI
通信/日历/联系人/我的;NAV_CONTENT_ITEMS 保持宽屏侧栏三项,
避免宽屏出现重复 person 图标。

卡片缩进:`width('100%') + margin({left:10,right:10})` 在 ArkUI 里不会
缩到 90%——margin 加在宽度外面,卡片顶出父容器(实测左缘只留 ~1px,
列表卡才有正确 10vp)。改为外层容器加 padding。验收:页头卡与邮件卡
左缘同为 x=35(10.0vp)、右缘 x=1219。

推送:reportToken 先申请通知权限再取 token(部分设备权限未开时
getToken 返回空 / 报 1600004),并补两条文件形状判据钉住
client_id 配置与调用顺序。

测试:198 passed / 0 failed。
2026-09-17 16:27:27 +08:00