Commit Graph

135 Commits

Author SHA1 Message Date
187609480f 跨端: 鸿蒙修收信/自动已读/发件箱点开 + 页签条通透(用户报的四个问题)
用户报了四个问题,逐个实测复现 → 定位根因 → 修 → 设备复验:

① 「邮件点进去自动已读的能力不正常」
   根因:鸿蒙只有「标记已读」按钮(doMarkRead,对照 WebUI MailView.tsx:522
   那个手动按钮),缺 WebUI 的**自动**路径(MailView.tsx:55-65 的 useEffect:
   可见且 unread 就 markRead)。⇒ 点开邮件不变已读,必须再点按钮。
   修:MailDetailPage.loadMail 成功尾端按 `status==='unread'` 触发 doMarkRead
   (复用按钮那条路,因而天然带上「就地改 status」+「失败弹 toast」)。
   ★ 加 autoReadMailId 守卫:WebUI 靠 useEffect 依赖数组天然只跑一次,
     鸿蒙 loadMail 是显式调用的(下拉刷新会重跑),不守会重复打接口。

② 「接收邮件的能力也有点不正常」
   三个独立缺口,每个都会单独造成"收不到":
   a) **SSE 监听寿命**:原挂在 InboxTab.aboutToAppear,而三个 tab 是
      if/else 条件挂载的 ⇒ 切到发件箱/授权时 InboxTab 被销毁、监听跟着
      移除 ⇒ 在那两栏时收不到任何新邮件。搬到 MainPage(@Entry,全程在)。
   b) **只处理 new_mail**:WebUI 监听 5 种事件(sse.ts:7-13 的 EVENTS);
      服务端权限决策后发的是 session_update(permission.go:387)⇒
      "授权栏里处理过的申请,收件箱还是旧的样子"。补齐四种。
   c) **UTF-8 解码错**:arrayBufferToString 逐字节 String.fromCharCode
      (Latin-1 语义)⇒ 中文解成乱码。改用 util.TextDecoder + stream:true,
      且**每连接一个实例**(stream 会把半截汉字存在解码器内部,
      共享实例会把两个账号的半个字拼在一起)。

③ 「发件箱内容点不开」(第二轮;5483140 补了 onClick 仍点不开)
   根因:发件箱对单封组**既画组头又画行**——
       this.SentGroupHeader(g); if (isFlatGroup(g)) { this.SentRow(...) }
   组头那半张卡没有任何点击处理 ⇒ 点卡片上半部完全无反应。
   而 isFlatGroup 自己的注释写着「单封不成组:套一个可折叠的组头只是
   多一次点击」—— 实现与注释**直接相反**。收件箱一直是正确形状
   (if (isFlatGroup) { MailRow } else { 组头 + 子行 })。
   修:与收件箱取同形,单封组只画 SentRow。

④ 「通信页面我觉得没有 webui 那么通透」
   这是我自己上一轮改错的:把 WebUI 的「页签条无背景」实现成了
   `backgroundColor(Theme.surface)` 实心白。WebUI 的真实层叠是
   .comm-pane 是玻璃卡、CommTabs 在它内部且**自己无背景**(透出卡的白)。
   铺实心白 = 把玻璃卡换成横条白 ⇒ 壁纸再也透不过来。
   ⇒ 改回玻璃族(Theme.navMaterial,与底部导航条同档)+ 通栏 +
      只左上圆角(右上 0,与窗格那道弧重合)+ 底边线。
   判据 harmony-nav.test.mjs:732 在我改错时当场判红,是它先抓到的。

判据(变异验证:还原 bug → 必须判红)
- 新增「单封组不许既画组头又画行」:两栏的 (header, row) 对必须在
  else/三目里二选一。
  ★ 第一版写弱了(用 isFlatGroup(g) 作锚点往后切片,组头在切片之前
    ⇒ 变异测不红)。改成以**行调用**作锚点往两边开窗后,删掉修复
    即判红(实测已验)。
- 新增「列表行必须把 onClick 挂在自己身上」(MailRow/SentRow)。
- harmony-logic 34→37、harmony-nav 22(新增后仍绿)、
  harmony-appearance 27→28、animation-audit 12→15 的登记数同步。

设备复验(全部有实测凭据,不是推断)
- 自动已读:点开前 unread → 点开 4s 后服务端 read(连验两封)。
  列表组头 4→2、侧栏徽标 4→2,三处数字一致。
- 实时收信:App 保持前台不重启,从 gateway 发信 ⇒ 8s 内自动出现
  (新卡片 + 侧栏 2→3 + 页签 2→3 + 3 组 8 封)。
- 发件箱点开:点原先点不动的卡片上半区 ⇒ 右栏出正文 + 蓝色选中态。
- 页签条:截图确认为玻璃通透(不再是实心白横条)。
2026-09-24 16:34:44 +08:00
548314013a 鸿蒙|修发件箱点不开(SentRow 缺 onClick)+ 补列表行判据
用户报的三件事,逐条实测后的结论:

## ① 发件箱内容点不开 —— 真 bug,本次修复

复现:点发件箱「致 pi」那一行
  · 日志里**没有** `GET /mail/{id}`(收件箱同样操作是会发的)
  · 右栏仍是占位(「选择一封邮件查看…」)

根因:点击挂在**外层** `Column` 上,而 `SentRow` 自己**一个 `.onClick` 都没有**。
更要命的是 `isFlatGroup(g)` 那条分支(单封不成组)直接调 `SentRow`,
**外层那层点击根本不经过它** —— 而用户的数据恰好是 1 封不成组 ⇒ 完全点不动。

收件箱的 `MailRow` 是挂在行自己身上的(`.clip(true)` 之后直接 `.onClick`),
所以它两条分支都通。这是本仓反复出现的形状:同一件事两处各写一遍,然后慢慢分叉。

修法:`.onClick` 移进 `SentRow` 自身(与 `MailRow` 同形),
并删掉外层那处(留着会变成"同一行两处都能点")。

修复后实测:发 `GET /mail/2d882e82…`,右栏渲染出详情 ✅

## ② 对话树点不开 —— 实测能点开(不是 bug)

代码里有完整的实现(`openThread()` + `bindSheet` + `tree` 图标按钮)。
点开路径:**先点详情页标题展开头部** → 出现「对话树」→ 点它
(`GET /mail/{id}/thread` 发出,弹层显示 `jianf → pi / 测试`)。

★ 折叠是**两端一致**的行为,不是鸿蒙漏做:
  WebUI `CollapsibleHeader` 的 `useState(false)` 也是默认收起。
  我实测确认了展开后才看得到该按钮。

## ③ 邮件本地缓存 —— 两端都没有(不是鸿蒙落后)

对着源码逐个数:
  WebUI `stores/` 有 persist 的:accountStore(8) / appearanceSync / backgroundStore(15)
                                / contactStore(5) / themeStore(3)
  WebUI **没有** persist 的:`mailStore`(0) / sessionStore(0) / uiStore(0) / authStore(0)
  ⇒ **邮件与会话在 WebUI 也是不缓存的**,每次打开都从服务端拉。
鸿蒙侧:AppearanceStore / AccountManager 有 preferences 持久化,MailStore 没有
  —— 与 WebUI 对齐,不是缺口。

## 判据

`harmony-logic` +1(35 条):**列表行必须把 onClick 挂在自己身上**。
钉的是不变式("行自带点击"),不是某个函数的写法:
`MailRow` / `SentRow` 的属性链里必须有自己的 `.onClick` 且调到 `openMail`。

变异:删掉 `SentRow` 的 `.onClick`(重演原 bug)→ 判据红;还原 → 绿。

套件:harmony-logic 35/35、harmony-nav 22/22、harmony-appearance 28/28、
harmony-contacts 5/5、animation-audit 15/15。
2026-09-24 14:35:07 +08:00
eeecaad79e 鸿蒙|日历翻月滑动动画修好(.transition 不播 → 显式属性驱动)
用户:「日历页面还是没有左右滑动动画」。

## 实测定位(不是推理)

把动画时长临时放大到 5s + Linear(确保抓得到中间帧),点「‹」后连拍,
逐偏移互相关算位移:

    最佳 dx = 0    ROI 平均差 0.00

只留 `translate`、去掉 `opacity` 后直拍三帧:帧1 与帧3 **逐像素相同**。
⇒ 一点横向位移都没产生。

## 根因

`.transition()` 只在组件**挂载/卸载**(`if` 条件切换)时触发。
而翻月只改 `year`/`month`,网格 `Column` **一直存在** ⇒ 过渡永远不触发。
代码里那句注释("键带 monthKey() 前缀 ⇒ 节点重建 ⇒ 过渡必播")说的是
`ForEach` 的**子项**键 —— 外层容器并不会因此重挂。这是我当初写错的假设。

## 修法:改用本仓已验证可播的那套

`MainPage` 的日历窗格入场(`calPaneIn`)用的是
① `@State` 数值;② 显式 `.translate()`/`.opacity()`;③ `animateTo` 同改。
这里照它做(`slideInPct` / `slideInOpacity`),并与 WebUI `cal-in-next/prev`
逐字同源:**12% + 淡入**、方向由 `forward` 决定。

顺序纪律(与 `calPaneIn` 同):**起点值写在 animateTo 外面**,
写进闭包会让渲染层只看见最终值 ⇒ 退化成瞬移。

修复后复验(同一次 5s 放大连拍):
    帧序列 = 旧月静止 → **横向位移 100px** → 新月静止 ✅

## 判据

`animation-audit` 的 `cal-in-*` 条目从"`Theme.calendarSlide` 有调用点"
改为"页面里有 `slideInPct`/`slideInOpacity` 驱动 + `.translate` 接线"。
★ 删掉死方法 `Theme.calendarSlide`(它已无调用点 —— 判据当场抓到,
  这正是"必须盯调用点"那条纪律的价值)。

变异(两条都能红):
· 删掉 `.translate({x: this.slideInPct})` 接线 → cal-in-next 红
· 把起点值写回 animateTo 闭包(退化成瞬移)→ cal-in-prev 红

套件:animation-audit 15/15、harmony-nav 22/22、harmony-appearance 28/28、
harmony-logic 34/34、harmony-contacts 5/5。
2026-09-24 13:56:52 +08:00
f0dc342c1f 鸿蒙|图标 fill 型分族 + 页签条阴影根因修复 + 生成器入库
用户逐条报的观感问题(都在"视觉观感"那一栏,不是功能缺失):

① 侧边栏图标"莫名其妙的加粗"
   根因:43 个图标里**只有品牌标 `brandMark` 是 fill 型**
   (`fill="currentColor"` + 两个实心 `<circle r=".9">`),
   它自己的注释就写着「fill 型,与上面的描边图标集**不同族**,所以不包 Svg」。
   而 `AmIcon` 一律 `fill(Transparent) + stroke()` ⇒ 实心块只剩轮廓、
   实心眼变成小圆环、圆头端点在小尺寸下糊成一坨。
   修法:生成器自动判定两族(`is_fill_family`),产出 `FILL_ICONS` 名单,
   fill 族整块填色、不加描边。

② 详情页权限面板"左边被截断"
   `PermissionPanel` 挂在详情页外层,根部只有 `padding({top:10})` ——
   而正文 `Markdown` 与附件块**各自自带** `left/right:16`。补上同档 16。

③ 顶栏"莫名其妙的底部阴影"
   ★ 这条查错了两轮,记下来:
   · 先以为是窗格投影从半透明玻璃透上来 → 补底边、去材质 —— 都没用;
   · 几何取证才定位真因:`InboxTab` 根容器与页签条是 `Column` 里的**兄弟**
     且**绘制在后**(页签条 y 142→271,InboxTab y 271→2202),
     它的 `PaneModifier` 投影向上扩散 ~30px,正好压住页签条
     (观测到的渐变区 y 240→268,完全吻合)。
   · 双向验证:关掉所有窗格投影 → 全平(证明确实是投影);
     只把 `InboxTab` 换 `plain` → 落差 21→8(证明是**这一层**)。
   修法:`PaneModifier` 加 `plain()`(只要背景语义、不投投影)。
   WebUI 的 `box-shadow` 只给 `.app-shell > *`(三个**并列**面板),
   面板内部不投 —— 这个结构差异就是原因。

   ★ 同时保留页签条的**悬浮玻璃**(用户 09-20 点名要的):
   我中途一度按"跟 webui 同步"把它改成不透明实体面,
   那是**读错了**——用户指的是页面结构对齐,而玻璃是他自己定过的;
   两者不冲突(玻璃是观感选择,阴影是 bug)。已恢复并留注释。

④ 生成器入库(`client/harmony/tools/gen-icons.py`)
   它原先在仓库外(`/root/gotmp`),于是**落后生成物三次提交**没人发现:
   我手改 `Icons.ets` 修 fill 图标后重跑它,修改被**冲掉**(退回旧实现)。
   现在:路径按 `__file__` 解析(任何检出目录都能跑)、
   `is_fill_family` 自动分族、输出段与正确实现逐字一致(diff 为空)。
   `Icons.ets` 由它生成,不再手改。

判据:
· `harmony-appearance` 29 条(+1):窗格投影只给并列窗格,
  面板内部的子 tab 不得再投。按 **struct 归属**判(第一版计数有洞,
  变异①抓不到 —— 改回 `of` 时同文件别处的 `plain` 仍让它绿)。
  两个变异都能红:① InboxTab 改回 of;② 让 plain 也投投影。
  另修一条既有判据的假红:它数 `PaneModifier.of` 出现次数,
  加 `plain` 后误报("让出页面底"的两种合法写法都要算)。
· 套件:harmony-nav 22/22、harmony-appearance 28/28、
  harmony-logic 34/34、harmony-contacts 5/5、animation-audit 15/15。
**功能对齐状态**(回答用户"功能对齐没有"):
  对着 `docs/DEBTS.json` 逐条核过 —— 鸿蒙侧**没有缺页面、没有缺功能**。
  授权栏历史(`harmony-permission-history`)✅ 已做、
  详情页转发/改名建议/预算(`harmony-maildetail-missing-three`)✅ 三块全做完。
  仅剩两条,都不在鸿蒙:`harmony-p4c-boundary-decls` 卡在"需真人操作系统选图器"、
  `permission-expires-at-unused` 缺的是 **WebUI 那一半**。
2026-09-24 13:13:46 +08:00
7151d3f91c 鸿蒙|动画对齐 all_five 落地 + 收敛 pageEnter 死方法
用户裁定「动画对齐 = 五条逐个对照」(all_five, 2026-09-23)。
本轮把五条核对完并把结果**沉淀成常驻判据**(本仓做法:审计不落判据就会漂)。

核对结果(WebUI 侧剥注释后实测,不是靠记忆):
  rise-in      定义✓ 使用✓  → Theme.paneRiseIn() ×6 + Motion.pageEnter() ×3
  pane-in      定义✗ 使用✗  → 9aa702b 删掉的死规则(碑文 index.css:1239)
                              用户 09-14 明确否掉「整屏一起淡」,两侧都不得复活
  menu-in      定义✓ 使用✓  → Theme.menuIn() ×2
  cal-in-next  定义✓ 使用✓  → Theme.calendarSlide(true)
  cal-in-prev  定义✓ 使用✓  → Theme.calendarSlide(false)
⇒ 五条全部落地;无缺口需要新写动画(避免了"自己发明一条")。

顺带修一处真问题:
  Motion.pageEnter() 定义了、返回的正是三个 @Entry 页各自手写的
  那份 {duration, curve} 字面量,却**一个调用点都没有**(死方法)。
  三个页面(AdminUsersPage/ComposePage/MailDetailPage)改为走它。
  这正是本仓反复出现的形状:一处定义 + 三处同源各自演化。

判据(animation-audit.test.mjs 12 → 15):
  ① WebUI 每个**在用**的 keyframe 都有鸿蒙实现,且实现**真的有人穿**
     —— 名单从 CSS 动态取(liveKeyframes),不是写死,将来加删会跟着动。
     「有调用点」是关键:本仓的失败形状是"定义了却没人用"(menuIn 一度如此),
     "方法存在"是弱断言 —— 删掉最后一个调用点它照样绿。
  ② pane-in 不得在**任一侧**复活(只查鸿蒙不够,WebUI 复活同样会两端不一致)。
     负面断言走剥注释版,碑文里的引用不会让它假红。
  ③ 一般化:**任何**返回 TransitionEffect 的动画辅助方法都必须有调用点
     —— 防的是"下一条",pageEnter 就是这里被抓出来的。

变异验证(三条新判据逐条打红,还原后回绿):
  · 删掉 calendarSlide 调用点 → ①③ 红(cal-in-next/prev 无调用点)
  · 鸿蒙复活 paneIn() → ② 红
  · WebUI 复活 @keyframes pane-in + 使用 → ①② 红
  ⇒ 判据有齿,不是橡皮图章。

套件:animation-audit 15/15、harmony-logic 34/34、harmony-nav 22/22、
      harmony-appearance 27/27。hvigorw BUILD SUCCESSFUL。
2026-09-24 11:02:05 +08:00
65de1c3884 跨端对齐:授权栏 navigator_only + 组件按页拆分 + 服务器补 permission_options
用户两项裁定落地(均为 ask_user 明确选择):

① 授权栏口径 = navigator_only(照 WebUI 架构)
   · 新建 pages/PermissionPanel.ets —— 详情页的决策面板,
     对应 MailView.tsx:693 的 PermissionPanel(审批型 / 主动提问 / 已处理 三态)
   · 决策入口从授权栏移到 MailDetailPage;MailDetailPage 原来只显示一个
     「权限请求」小标签、根本没有决策入口(比 WebUI 少一整块,且反了:
     栏里能决策、点进详情反而不能)
   · PermissionTab 删掉内联「同意/拒绝」+ 备注框 + decide():
     整卡可点 → onOpenMail(对齐 WebUI PermissionList.tsx:81 的 pick())
   · PermissionRequest 补 source_account_id(客户端侧记来源,跳详情要定位网关)

② 服务器补 permission_options —— 修一条真实的、跨端共有的缺口
   · mails.permission_options 从 INSERT 起就写进去,但**从来没有任何读路径
     选过它** ⇒ 详情端点永远返回空。WebUI 的决策面板读 mail.permission_options,
     所以提问型的预设选项**两端全部落空**(审批型靠 ['同意','拒绝'] 兜底蒙混)
   · GetMailByID 补选该列 + JSON 反序列化(与 cc_list 同款)

③ 组件按页封装(用户要求「以便与 WebUI 一一对应」)
   MainPage.ets 4592 → 3192 行
   · pages/PermissionTab.ets    720 行  ↔ PermissionList.tsx
   · pages/ContactsTab.ets      796 行  ↔ ContactPanel.tsx
   · pages/NavDestinations.ets  181 行  ↔ Navigation 壳
   · pages/NavShared.ets         65 行  ↔ 跨栏共用件

④ 判据跟着组件搬家(否则静默失效,不是红)
   harmony-logic 的 pageCode / harmony-nav 的 navSrc 改为显式文件名单;
   harmony-appearance 的 PANE_SOURCES 补 ContactsTab;harmony-contacts 三个
   test 并入 ContactsTab;harmony-logic 的决策断言改指 PermissionPanel,
   并新增「授权栏不许再有内联决策」两条(navigator_only 的正形状)。

   animation-audit:共享元素转场判据从「同文件共址」改为「按 id 找驱动」。
   旧形状把 in/out 端必须在同一文件当成代理,而两端**天然在两处**;
   抽出写信页(NavDestinations 持有 in 端)后误报。新判据仍要求每个 id
   都有 Motion.morph 驱动 —— 变异实测:把驱动换成裸 animateTo 仍判红。

判据:files=34 checks=556 red=1(仅 build-stamp,产物待重构建)
2026-09-24 10:10:32 +08:00
8267102cd6 跨端: 修「我的」页显得非常挤(三处真因)+ 顺手清掉 3 条已修的自有 ArkTS 告警
用户:「「我的」页面显得非常挤」

先量再改。取了宽屏(3184px)与窄屏(1008px)两态,用 `dumpLayout` 拿真实 bounds,
反推 density=2.875。三个真因,**宽度是主因**。

## ① 内容列没有宽度上限(主因)

WebUI `AccountPage.tsx:104`:

    <div className="w-full max-w-3xl mx-auto px-4 md:px-6 lg:px-10 py-6 space-y-6">

`max-w-3xl` = **48rem = 768px** + `mx-auto` 居中。
鸿蒙这边原来只有 `.width('100%')` —— **没有任何上限**。

后果(实测):宽屏下卡片铺满 **2923px(1376vp)**,而里面全是
"标签 + 短值"的两列行,文字只占左边一小块 ⇒ 右边一大片留白,
整页读起来像一条被拉长的表格。这正是"挤"的观感来源 ——
不是行距小(实测行距 30vp / 字 14vp,其实偏松),是**横向没有收拢**。

修后实测:内容列 **2208px = 768.0vp**(与目标逐位吻合),
中心 1692.0 vs 父容器 1692.5 ⇒ 居中生效。

★ 形状:`Scroll > Row(居中容器)> Column(带上限)`。
  **不能把居中挂在 `Scroll` 上** —— 官方 `ScrollAttribute` 没有
  `.justifyContent()`。我第一版就是直接给 Scroll 挂,那是个编出来的属性。
  ArkUI 没有 `mx-auto` 的直接对应物,"居中"必须靠一个容器节点。

★ 窄屏(1008px = 350vp < 768)下约束**自动不生效**,逐像素确认窄屏形态未变。

## ② 段间距只有 WebUI 的 1/3

WebUI `space-y-6` = **24px**;我们每张卡各写 `margin({ top: 8 })`(7 处)
与 `margin({ top: 10 })`(1 处)—— 既不统一,也贴得太近,
卡片挨在一起时"段"的边界看不出来,整页就是一大块密集表单。

⇒ 收成一个 `SECTION_GAP = 24` 常量,8 处全部改用它。
实测卡片间实测 **69px = 24.0vp** ✓

## ③ `SectionTitle()` 定义了却只调用一次

WebUI `AccountPage` 有 **5 个 `<h3>`** 段标题(基本资料 / 权限范围 /
修改密码 / 登录状态 / 管理),鸿蒙这边只有 **1 个**('权限范围')——
基本资料那六行是裸放在卡片里的,与下面的权限范围只靠一条 `Divider` 硬分。
⇒ 补「基本资料」标题。没有标题就没有"这是另一段"的语义,只能靠线。

## ④ 顺手清掉 3 条自有 ArkTS 告警(37 → 34)

上一条提交我说"`fill` 那 2 处留给下一轮"—— 不该推,这轮做完:

- **2× `fill` API 是 SDK 26.0.0**(`Circle().fill()`,在 WideSidebar 连接点
  与 InboxPage/MainPage 未读点)。查 SDK 头文件确认不是误报:
  `circle.d.ts` 的 `CircleAttribute.fill()` 标 **@since 26.0.0**,
  而基类 `CommonShapeMethod.fill()` 才是 @since 11 —— 子类重载**遮蔽**了它。
  设备实测点**确实渲染**(那是碰巧兼容),但契约上它比编译目标(23)新。
  ⇒ 换成与 `CalendarPage` 小圆点同一形状(普通容器 + width/height + borderRadius),
  只用 API 11 起的通用属性。
- **1× `'packing' has been deprecated`** → `packToData`(SDK 明确给了
  `@useinstead image.ImagePacker#packToData`;两者签名逐字相同:
  `(PixelMap, PackingOption) => Promise<ArrayBuffer>`,只改名字)。
- **1× `This API is unavailable to 2in1`** → 加 `deviceInfo.deviceType !== '2in1'`
  守卫。SDK 原文:「From API version 12, this API does not take effect on
  2-in-1 devices.」告警仍在(静态检查不看运行时分支),但行为已正确分流 ——
  留着这条注释说明为什么不断言消失。

★ 这条告警的噪声价值:同一批里就藏着 `fill` 那个真隐患。
  逐条筛之前,它只是"每次重编都出现的一行字"。

## 验证

✓ 宽屏:内容列 768.0vp、居中、段间距 24.0vp(`dumpLayout` 实测 bounds)
✓ 窄屏:约束不生效、形态与改前一致(像素对照)
✓ 自有 ArkTS 告警 37 → 34(`fill`×2 与 `packing` 消失)
✓ 编译通过、设备安装后进程存活
2026-09-22 08:58:56 +08:00
56c58d30ad 跨端: 修我自己引入的 2 条红判据 + 3 条陈旧判据 + 补全地址补全的剩余 4 个挂点
用户两句话点破了我这轮的两个过程问题:
  ① 「你就不能把预先存在的问题修复一下?」—— 那 2 条 vitest 红其实是**我自己的回归**,
     我两次把它们归成"预先存在"。查了 `git log -L` 才确认:是我 `125aec1` 改的。
  ② 「为什么不加载鸿蒙开发相关skill?」—— `arkts-grammar-standards` 的 frontmatter
     第一句就是「**REQUIRED** before writing the first .ets file of a session」,
     而我整个 session 写了十几个 `.ets`,一次都没读。

## ① 我自己的回归:`AddressInput` 问错层(`125aec1` 引入)

原始实现是**按层传 0/1/2 个参数**:

    parts.hasDot  ? api.suggestAddress(parts.name, parts.path)
    : parts.hasAt ? api.suggestAddress(parts.name)
    :               api.suggestAddress();

我在 2in1 键盘那轮"简化"成恒定 `api.suggestAddress(q.name, q.path)`,
理由是"服务端把空串当没给"。**那个理由对,但它改了组件文档化的契约**:

    AddressInput.test.tsx:73 「问 name 层时不带任何参数」
    AddressInput.test.tsx:86 「写了 @ 没写 . 时带 name 去问 path 层」

两条从此常红。修法**不是改测试** —— 参数个数在这里就是**层语义**:
问 name 层就不该传任何筛选参数。改回按层分岔,但判据用 `q.kind`
(`queryFor` 的产物,"问哪一层"的唯一来源),而不是再自己从 `parts.hasDot` 推一遍。

**`AddressInput.test.tsx`: 14/14 通过**(原 12 passed / 2 failed)。

## ② 实证推翻一条**错的注释**(正是 #① 的病根)

`MailApi.ets` 写着:「服务端的判据是"参数有没有给"…**省略会让它退回到上一层**」
—— 前后两句都错。服务端(`contacts.go:152`)只有 `Query().Get()`、**没有 `Has()`**。
实测(网关 8180):

    ?name=pi        → kind=path, 62 条
    ?name=pi&path=  → kind=path, 62 条   ← 与上一行逐字节相同
    ?path=          → kind=name,  6 条

⇒ 缺参与空串完全等价。**注释里的错误事实会变成代码里的错误决定** ——
我正是因为信了它才去"简化"的。改动注释,并说明代价。

## ③ 三条陈旧判据(都是"钉字面文本"而非"钉行为")

- `harmony-2in1`:钉 `/typeof raw === 'string' ? raw : ''/`,而守卫已搬进
  `normalizeCandidates`(为让转发条/日历共用)。改成钉**两件真的事**:
  守卫在(三字段各一道)+ 调用点真的走它。**双向变异验证**:
  去掉守卫 ⇒ 红;写信页绕开自己读 `.title` ⇒ 红。
- `harmony-nav`:`src.slice(at, at + 4000)` —— 那个 4000 是拍脑袋的,
  我在转发弹层加了候选列表后 `.transition()` 被推到 6107 字符处 ⇒ 假红
  「转发弹层没有挂过渡」。改用已有的 `braceBody()`(按花括号配对找块尾)。
  ★ 这类假红的副作用是诱人去**加大那个数字**,而正确修法是换掉它。
- `build-stamp`/`packaging`:重跑 `npm run build` + `electron-builder`。

## ④ 补全剩余 4 个挂点(不再等用户一个一个指)

`codegraph_callers AddressInput` 给出 7 个调用点。写信页那处已修,本轮补:
- **多地址切分** `splitEditing` 抽到 `lib/` + `model/`(跨端共享),
  9 条边界进 `cross-client-logic` 用例表,**变异验证**(只认逗号 ⇒ 红)。
- **转发条**收件人 + 抄送 → 都挂补全(复用同一套,`fwdSuggestField` 区分字段)。
- **日历事件编辑器**收件人 → 挂补全;为此把 `AddressSuggestionResponse`
  从 `MailApi.ets` 移到 `model/Models.ets`(它在 `CalendarApi` 也要用,
  让两个 API 类互相 import 是错的依赖方向)。
- **`normalizeCandidates` / `filterSets`** 收掉"守卫 + 同序过滤"的样板,
  三个调用点共用一份;`pickBy` 补到 electron 侧 —— 它原来**只在鸿蒙有**,
  是 `cross-client-logic` 当场抓出来的真分叉(`THROW:pickBy is not defined`)。

★ 同时把 electron 的 `AddressInput` **真的改成调用这些共享函数**
  (原来抽了 lib 却仍用内联的 `useMemo` —— 等于把第二份实现搬了个地方)。
  `items`/`meta` 改用归一化后的三元组,渲染层不再各自守 `omitempty`。

## ⑤ 终于去读了 skill(用户质问后)

读了 `arkts-grammar-standards`(含 `arkui-structure-rules.md`、`recipes-core.md`)、
`arkts-error-fixes`、`arkts-runtime-fix`。**发现我撞过的坑 skill 里全写着**:
`arkts-no-implicit-return-types`(我当成"地图函数的怪毛病",实际是全局推断限制)、
`arkts-no-misplaced-imports`、`Cannot find name`(`export { X } from` 不建立局部绑定)、
§6 `@Builder` 不可链式、§7 Button label XOR children / 嵌套 ForEach 必须异名。
**多花至少三轮编译往返。**

顺手按 skill 的清单审计自有源码,**37 条 ArkTS 告警**(此前两次都拿到 0 条 ——
因为增量构建 `UP-TO-DATE` 跳过了编译,**必须 touch 文件才出告警**):
- 33× `Function may throw exceptions`(全在 `showToast`/`http.request`,已有 catch)
- 2× `'fill' API is supported since SDK 26.0.0,当前 23` ⇒ **真隐患**
  (`Circle().fill()`,设备实测黄点确实渲染,但 SDK 变动时会出问题)
- 1× `This API is unavailable to 2in1`、1× `'packing' deprecated`

## 验证

✓ `vitest run` **266/266**(15 文件全绿;此前 2 红是我引入的)
✓ `cross-client-logic` 7/7,且 `splitEditing`/`normalizeCandidates` 变异会红
✓ `harmony-2in1` 12/12、`harmony-nav` 21/21、`build-stamp` 7/7、`packaging` 5/5
✓ `tsc --noEmit` 通过
✓ hvigor 完整重编译 SUCCESSFUL

✗ 未做:`fill` 那 2 处换回 SDK23 可用的写法(当前设备实测无害,留给下一轮)
2026-09-21 23:31:15 +08:00
d429e4af24 跨端: 修发件箱「严重问题」+ 抄送字段/补全(用户点名的两处系统性遗漏)
用户两句话把问题指到了根上:
  ① 「发件箱存在严重问题」
  ② 「webui 存在好几个自动填充位置,比如抄送,转发等。你为什么要我一个
     一个点出来呢?skill 也给你了 codegraph 也给你了,why 不好好用呢?」

第 ② 句是对的。我这轮一直在用 `grep`/`sed` 手工翻,`codegraph_explore`
只调了一次。`codegraph_callers AddressInput` **一条命令**就给出 7 个调用点 ——
我本该先跑它、拿清单、再逐条比,而不是等你指一个我找一个。

## ① 发件箱:文字对比度 1.06:1(读不出来)

设备实测(宽屏 3184,发件箱):

    行标题   rgb(209,210,212) 压 rgb(241,242,244) ⇒ **1.35:1**
    正文预览 rgb(224,224,224) 压 rgb(254,254,254) ⇒ **1.31:1**
    时间     rgb(234,235,237) 压 rgb(241,242,244) ⇒ **1.06:1**

根因:`SentRow` **一个修饰符都没挂**。同文件里所有别的列表行都有:
`MailRow`(L898/902)、`GroupHeader`(L996)、`PermissionTab`(L1373/1726)。
`SentGroupHeader` 更离谱 —— 它铺的是 `Theme.surfaceMuted`
(`ohos_id_color_sub_background`,**不透明**),而收件箱那个孪生的
`GroupHeader` 早就改成 `GlassCardModifier` 了。

⇒ 「同一个错误的两个副本,只修了一个」。

为什么"少个修饰符"会变成"字看不见":没有卡底 ⇒ 行底就是**壁纸自己**。
用户的壁纸是浅色人物图,那些位置恰好是浅灰(`241,242,244`),
而字色同向 ⇒ 掉到 1.3:1。玻璃卡的作用**正是把"壁纸不可预知"变成
"基材恒为白"**。没有这层,文字就得跟用户的壁纸赌运气。

修后实测:**19.77 / 19.10 / 9.81 / 20.56 : 1**。

★ 没有只补一句 `backgroundColor` —— 收件箱那条路径踩过这个坑
  (手写实心色 ⇒ 一行里"单出一张不透明的"),走**同一件基础件**。
★ 同时去掉调用点上重复的那层玻璃(两层 `backgroundEffect` 会走两次),
  并对齐 `MailRow` 的选中态三目。

## ② 抄送字段:不是"缺补全",是**字段本身就不存在**

`codegraph_callers AddressInput` 给出 WebUI 的 6 个挂点:

    ComposePage:214   收件人    ✓ 鸿蒙有(且有补全)
    ComposePage:277   抄送      ✗ **字段都没有**
    MailView:425      回复·收件人  (回复固定收件人,不需要)
    MailView:428      回复·抄送    ✗ 字段不存在
    MailView:1087     转发·抄送    ✗ 无补全
    CalendarEventEditor:471 日历事件·收件人 ✗ 无补全

而**服务端 `SendMailRequest.CC` 一直存在**,转发条里的抄送我们**反而做了**。
⇒ 写信页缺这一项是单点遗漏,不是设计选择。

## ③ 多地址切分:`splitEditing` 抽成跨端共享纯函数

WebUI `AddressInput` 从第一天起就有 `allowMultiple`(抄送框里
`a@x, b@y, c@z`,补全只作用于**最后一段**)。这段逻辑原先只活在
`AddressInput.tsx:38-43` 的 `useMemo` 里 ⇒ 判据 import 不到、鸿蒙没基准可抄。

移到 `lib/addressSuggest.ts` + `model/AddressSuggest.ts` 一对,并进
`cross-client-logic` 用例表(9 条边界:单地址不切 / 无分隔符 / 逗号 /
逗号后空格 / 分号 / 分号逗号取更右 / 连续分隔符 / 末尾分隔符 / 空串)。

**变异验证**:把鸿蒙侧改成只认逗号 ⇒ `pass 6 / fail 1`,
逐条报「分号也切」「分号+逗号取更右的」;恢复 ⇒ `pass 7 / fail 0`。

## ④ 鸿蒙侧接线

- `ComposeView` 加 `@State cc`,UI 加「抄送」行(提示文案与 WebUI 逐字一致:
  「多个地址用逗号分隔」);
- 补全从"硬编码读 `this.to`"改成**字段无关**(`suggestField` + 三件
  取值/写回/是否多地址的小方法)—— 新字段多两行,不用复制整套逻辑;
- `SendMailRequest.cc` 补上(**原来填了也发不出去**,静默丢字段)。

★ 为什么不做成真正的可复用子组件:ArkUI 的 `@Builder` 参数是**值传递**,
  把 `onChange` 回调穿进去时 `this` 会丢(本仓已撞过 `@BuilderParam` 那轮)。
  字段标记法没这个问题。

## 验证

✓ 发件箱四行逐行取像素,全部 ≥9.8:1(原 1.06~1.35)
✓ 视觉确认:每条组头/邮件行都有独立玻璃卡(原为空底直通壁纸)
✓ `cross-client-logic` 7/7,且变异会红
✓ 编译通过、进程存活

## 未做(诚实交代)

✗ 转发条 `forwardCc`、日历事件收件人的补全**还没接**(上面 ② 的 4 个 ✗ 我
  只修了写信页那一处)。它们是**同一条线索的剩余项**,不是新发现 ——
  但我不该再一次只修被点到的那一个。
2026-09-21 22:28:02 +08:00
df79f34ab8 跨端: 周视图空列补「—」占位(只有 if 没有 else)
用户:「宽屏布局下日历的周视图日视图还是一塌糊涂」。

WebUI `WeekGrid`(`CalendarView.tsx:649`)是**两条分支**:

    {list.length === 0
      ? <div className="text-xs text-gray-300 px-1 py-2">—</div>
      : list.map(e => <EventChip .../>)}

我们只有 `ForEach`,**没有 else** ⇒ 没日程的那几列是**全空**的。

为什么"反正也没内容"不成立:七列等高,有日程的列有卡片、没日程的列一片白
⇒ 看上去像"这几列没渲染出来"。一个破折号把
「查明过、这天真没有」 与 「还没查 / 没画」 区分开 ——
与右栏「这一天没有日程」是同一个道理(本页注释里早有这句话)。

颜色用 `textSubtleFor()` 而不是 WebUI 的字面 `text-gray-300`:
三级文字就是这两个档(周格 gray-300 / 时刻表 gray-200)的对应物,
且它自带深浅两套 —— 手写 gray-300 在深色下会发亮(本仓已撞过 12 次)。

验证:宽屏 3184 周视图实测 —— 21/22/23/26/27 五列各行首出现「—」,
24/25 两列照旧显示日程条。
2026-09-21 21:02:01 +08:00
e6a4070a95 跨端: 把可读性那批深色语义底**登记进清册**(判据抓到了,它报得对)
上一提交(`cbb1e1e`)给 `dangerBg`/`approveBg`/`warnBg`/`chipSpentBg`/
`chipWarnBg`/`chipNeutralBg` 补的深色变体,**忘了登记**。
`cross-client-theme` 当场报红:

    Theme.ets 里出现未登记的手写色:dangerBgDark、approveBgDark、warnBgDark、
    chipSpentBgDark、chipWarnBgDark、chipNeutralBgDark、chipNeutralFgDark、
    chipSpentFgDark、chipWarnFgDark
    —— 属于品牌/业务语义色就登记进 SELF_OWNED_COLORS 并在 Theme.ets 的
    登记表里写一行理由,否则该走 $r('sys.*')

**这条判据报得对**,不是误报:
  · 这 9 个色是**跨端身份**(逐字取自 WebUI `.dark` 段 `index.css:486-520`),
    系统语义色里没有\"淡底\"这一档,更没有深浅两套淡底 ⇒ 必须自己写;
  · 但\"必须自己写\"恰恰是要登记的理由 —— 不登记的话,
    下一个人看到 `#2E1F25` 只会当成随手挑的深红。

修(两处,判据要求两处都有):
  1. `SELF_OWNED_COLORS` 补 9 个名字 + 一段理由(含 tailwind 档位对照);
  2. `Theme.ets` 的色清册注释里补 9 行值 + 来源,并说明\"原来只有浅色档\"
     这个**共同的病根**(不是九个孤立遗漏)。

★ 教训:\"补了深色变体\"是一个**新 token**,不是\"改了个值\"。
  `cbb1e1e` 提交信息里我把它们写成\"Added dark variants\"——
  但在判据眼里它们就是 9 个没登记的手写色,跟\"随便写死的颜色\"无法区分。
  **判据不认识意图,只认识清册。**

验证:`cross-client-theme` 21/21 绿(原 20 pass / 2 fail)。
2026-09-21 20:53:10 +08:00
506f207c64 跨端: 修「输入框打开时回复球还在上面」——正是 morph 缺掉的那半截
用户:「你的图标变成输入框,图标变成邮件的动画呢?」

## 真 bug:球没有 `if`,一直挂在树上

实测证据(宽屏 3184,点球打开回复条后抓图):
    底层面板已展开(「回复给 pi@root.realtext」+ 输入框 + 取消/发送),
    而**蓝球仍然浮在它上方** —— 测得蓝块 `x 2948..3104 y 1500..1628`,
    1008 个蓝像素。球不但与「发送」抢同一块地方,还压住输入框右端。

WebUI 是 `if (!open) return <button ... />`(`MailView.tsx:990`)——
**收起态才渲染球**。点开时球从 DOM 消失,才有"球长成输入框"。

## 讽刺的是:注释写的就是对的,代码没跟上

我 2026-09-21 加 morph 时在那段注释里写的是:

    `follow: true` 是给"if 范式下**始终在组件树上**的组件"用的;
    我们这两端是 `if/else` 互斥出现(一端在树上时另一端不在),
    所以用默认 `follow: false`。

——「互斥出现」。可球那一端**根本没有 `if`**。
⇒ `geometryTransition` 的 out 端从未消失:
   · 视觉上:球压在展开的面板上(上面的截图);
   · 动画上:没有"球这个形态结束"的那一刻,morph 只剩 in 端在动,
     用户看到的就不是"图标变成输入框",而是"球还在,下面忽然多了个框"。

★ 教训:`geometryTransition` 的契约是"**两个**组件、一个 in 一个 out"。
  我按契约写了注释,却没按契约写代码 —— 而注释不会报错。
  这类"文档与实现分叉"只有**在设备上看一眼**才抓得到。

修:`if (!this.showReplyBox && !this.showForwardBox) { ... }`
(转发条也占底部同一块地方,所以两个条件都要)。

## 验证

✓ 设备实测:打开回复条后**蓝球已消失**(同一段像素扫描,0 个蓝像素)
✓ 探针复测 morph 中间帧:把 `Motion.durMorph` 临时改成 6000ms 后连拍 5 帧,
  面板顶边 y = **2201 → 1707 → 1679 → 1679 → 1679**
  ⇒ 中间帧真实存在,不是"瞬移"(改回 220ms 后重新编译安装)
✓ 编译通过、无新 jscrash

✗ 未验证:220ms 原始时长下的**观感**——抓图往返 1.5-3s,探针比被测对象
  慢一个数量级(同 `harmony-morph-unverified-middleframes`)。
  只能说"动画在播且播的是几何形变",不能说"220ms 手感对"。
2026-09-21 20:39:44 +08:00
dfcf13ff9d 跨端: 邮件正文/日历三处真 bug(可读性、居中对齐、日视图多余表头)
用户:「还有那邮件还是居中对齐,可读性也极差,而且还看起来根本不像邮件」
+「宽屏布局下日历的周视图日视图还是一塌糊涂」
+「你的图标变成输入框,图标变成邮件的动画呢?你能不能自己好好审计一下」

这轮**不再逐条打补丁**,改成先审计再改。下面每条都有实测数据。

## ① 邮件正文可读性:1.34:1 → 16.67:1

实测(深色,详情页正文):
    文字 rgb(35,33,52)  压 底色 rgb(0,0,0)  ⇒ **1.34:1**(完全不可读)
    同屏只有行内代码(红色 span)看得见(5.02:1)
    头部信息区更差:rgb(34,35,36) 压 rgb(32,33,35) = **1.02:1**

根因:`Markdown({ text: this.body })` —— **只传了文本,没传 `controller`**。
`@luvi/lv-markdown-in` 于是用它自己的默认样式,那套是给**浅色**配的
(实测 rgb(35,33,52) ≈ WebUI 浅色 `--c-gray-800` = `31 41 55`)。

⇒ 这是「**第三方组件不继承宿主主题**」那一类错:它不读 `$r('sys.color.*')`,
  也不读 `AppStorage`,必须显式注入。新增 `mdController()`,
  照 WebUI `.markdown`(`index.css:592-628`)逐条映射 17 个着色入口:

    .markdown            → Theme.textPrimary
    .markdown a          → Theme.accentFor()
    .markdown blockquote → Theme.textMuted / Theme.border
    .markdown code       → Theme.surfaceMuted

★ 17 个 setter 名逐一比对过库的 `.d.ets`(防拼错静默失效)。
★ 不放在 `@State` 里:控制器是普通对象,ArkUI 不会因它内部变化而重渲染 ——
  每次 `build()` 现取,主题一变就拿到新色(存一份反而**不会**刷新,正是本 bug 的形状)。

## ② 邮件「居中对齐」:ArkUI `Column` 默认就是居中

根因**不在那个 `Text`**,而在 **`Column` 的交叉轴默认对齐是
`HorizontalAlign.Center`** —— 官方《线性布局 (Row/Column)》原文:
「HorizontalAlign.Center(**默认值**):子元素在水平方向居中对齐」。

子元素没显式给宽时跟着内容宽居中 ⇒「回复给 …」那行浮在面板中间。
WebUI 是块级流、天然左对齐,所以"同样的代码"看起来不一样。

修:回复条/转发条两个 `Column` 显式 `.alignItems(HorizontalAlign.Start)`。

## ③ 日历:周/日视图「今天」的数字看不见(1.00:1)

实测(宽屏 3184、周视图第 1 列「21」):
    字 rgb(254,254,254) 压 底 rgb(254,254,254) ⇒ **1.00:1**

根因:`cellFg()` **不看档位**,选中一律返回 `accentFg`(白)。
而两档的"选中底色"根本不是同一个东西:
    · 月视图:整格铺**实心** `Theme.accent` ⇒ 白字对;
    · 周/日视图:底色改由日期头承担,用的是**淡蓝** `accentSoftFor()` ⇒ 白字看不见。
⇒ 按档位分流:周/日档返回 `Theme.accent`(蓝字压淡蓝 ≈5.4:1)。
同一处的农历小字也一并改(同一个三目)。

## ④ 日历:日视图多画了一条无意义的星期表头

周表头 `Row` 在 `if (calScale === 'day')` **之前**渲染 ⇒ 日视图上方挂着
「一 二 三 四 五 六 日」,而一天只有一个日子,七个标签全落空。

WebUI 三种视图都不是这么做的:
    · `MonthGrid` 表头在**自己内部**(`grid-cols-7` 第一行);
    · `WeekGrid` 星期名在**每一列内部**(sticky);
    · `DayGrid` **完全没有**星期行(顶上是日期+农历)。
⇒ 加 `if (this.calScale !== 'day')`。

## 诚实交代:我一度想当然,量了才发现不用改

我本来打算"修"周视图**表头与列对不齐**,理由是"表头均分可用宽、
网格列均分 minWidth(322) 的更大宽"。实测列中心 `[356,607,860,1114,1365,1621,1873]`
与表头中心基本吻合(偏差 ≤11px,在文字宽度内)—— **那是我推的,不是量的**。
先量再改,省掉一次"改坏对的东西"。

## 审计产出(供后续,不只是本次修)

· WebUI 全仓动画清单:5 个 `@keyframes`(`cal-in-next/prev`、`menu-in`、
  `pane-in`、`rise-in`)+ 7 处 `transition` + 3 处 `element.animate()`(全是 FLIP morph)。
· 鸿蒙侧挂点计数:`animateTo` 9、`transition()` 12、`pageTransition` 3、
  `geometryTransition` 4、`PressEffectModifier` 60。
· **`Motion.pageEnter` 是自己加的、零调用点**(`pageTransition` 走的是
  三个页各自的 `pageTransition()`)⇒ 死代码,待清。

## 验证

✓ ①②③④ 全部在设备上取**像素/布局**验证(1.34→16.67、1.00→5.44、日视图表头消失)
✓ 编译通过、进程存活、无新 jscrash
✗ 未验证:动画本体(图标→输入框 morph)—— 220ms,而抓图往返 1.5-3s,
  探针比被测对象慢一个数量级(同 `harmony-morph-unverified-middleframes`)
2026-09-21 20:23:46 +08:00
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
1571eb2ec4 跨端: 出厂默认地址改成 example.com(原来是开发者自己的生产域名)
用户 2026-09-21:「为什么现在登陆页面默认填写我们的服务器地址?不应该是 example 地址吗」

★ 用户的直觉是对的:**这确实是错的**,而且比"预填"更彻底。

## 现状:三处互相打脸

  ① 输入框的 placeholder:
       TextInput({ placeholder: 'https://example.com/api/v1', text: this.serverAddr })
                            ↑ 「请填你自己的」
  ② 同一个输入框的 text 预填:
       @State serverAddr: string = DEFAULT_API_BASE
       export const DEFAULT_API_BASE = 'https://mail.jianfgit.xyz/api/v1'
                            ↑ 「就是这个」—— 与 ① 直接矛盾
  ③ 校验失败的 5 条提示文案全在教用户填同一个生产域名:
       '请填写服务器地址,例如 https://mail.jianfgit.xyz/api/v1'
                            ↑ 用户是照着它填的

通用客户端把**某一个人的后端**写成出厂默认,等于宣称"本产品只有一个后端";
更坏的是用户会以为"直接登录就行",而连的其实是别人的机器。

## WebUI 的做法(对照)

它**一个域名都不写死**(`api/config.ts:30`):
    const base = (runtime || build || '/api/v1').trim();
因为 WebUI 跑在服务端自己发出来的页面上,"同源"天然正确。
**鸿蒙是独立 App,没有"同源"可依** —— 所以只能要求用户填,而默认值只能是格式示例。

## 改法

· `DEFAULT_API_BASE` → `https://example.com/api/v1`(与 placeholder 同一句话);
· `ApiBase.ts` 里 5 条面向用户的 error 文案 → 同样换成 `example.com`。

★ 注释里的 `mail.jianfgit.xyz` **保留不动** —— 那是"当时线上发生了什么"的取证
  (那段讲的是 2026-09-15 用户报"连不上服务器"的两个坑),删掉就销毁了依据。
  判据因此是**剥注释后再扫**代码。

★ 为什么不留空串:这个字段必须填对,留空用户不知道格式;
  而预填一个**看起来能用的真域名**更坏。

## 判据:原先只钉"格式",抓不到"值是谁的"

原来的断言是「https + 带 /api/v1」—— 而 `https://mail.jianfgit.xyz/api/v1`
**两条都满足** ⇒ 全绿。**格式判据抓不到语义事故**:那个值可以完全合法却仍然是错的。

⇒ 新增两条**语义**判据:
  ① 出厂默认必须落在 **RFC 2606 保留域名**(`example.com/net/org`、`.test`、`.invalid`);
  ② 面向用户的**提示文案**里不许出现真实域名(剥注释后扫)。

变异逐条验过会红:
    默认值改回生产域名                        → 红 ✓
    把某条 error 文案换成 my-real-server.com  → 红 ✓
    两者都在                                  → 13/13 全绿 ✓

★ 期间修了判据自己的一个 bug:我用了 `code('model/ApiBase.ts')`,
  而 `code()` 是相对 **electron 包**解析的 ⇒ `ENOENT .../client/electron/model/ApiBase.ts`
  —— **判据自己崩了**(整条不跑、退出码 1)。已改用本文件既有的 `read(rel)`。

## 设备验证

清掉 preferences 里的旧地址后冷启,`uitest dumpLayout`:
    text  'https://example.com/api/v1'
    hint  'https://example.com/api/v1'
⇒ 预填与提示**同话**,不再互相矛盾。

(老装机 preferences 里已有的地址**保持不动** —— 那是用户自己配的服务器,不该被清。)
2026-09-21 17:26:58 +08:00
f4d5a75976 跨端: 补 MailSummary 的解析边界兜底(列表这条主流的 omitempty 一直是敞的)
判决书来自 `harmony-arkts` 那条判据,它在我加了授权栏历史之后报出两处调用点:

    MainPage.ets: parts.push(e.reason.length > 0 ? …)                 ← 假阳性
    MainPage.ets: if (m.mail_type === 'permission_request' && m.permission_result.length > 0)   ← 真 bug

## 真 bug:`MailSummary` 没有解析边界兜底

本仓早就为这个形状付过代价,**而且修法只修了一半**:

· `MailDetail` 有 `normalize()`,并在 `MailApi.mailDetail()` 接了(当时那次是**整页白屏**);
· 而列表用的 `MailSummary` **没有** —— 于是 `mail_type` / `permission_result` /
  `session_alias` 这些带 `omitempty` 的字段在缺失时是 `undefined`,
  而三处调用点直接读 `.length`。

服务端 `omitempty` 的语义是**整个 key 不出现**(不是给空串),
ArkTS 裸 cast(`JSON.parse(raw) as T`)缺键给 `undefined`、**不会**应用类里那个 `= ''`。

⇒ 这是同一形状的**第四次**(前三次:`MailDetail` 白屏、`participantAddress` 的 trim、
`AddressSuggestion.title`)。前三次都是"读的人临时守一下",
**而列表这条主流一直敞着**。

修法(与 `MailDetail` 同一处、同一纪律):给 `MailSummary` 加 `normalize()`,
在 `MailApi.inbox()` / `sent()` / `sessionMails()` **三处**解析边界接上。

★ 为什么不在调用点加 `??`:`MailSummary` 上还有 `mail_type`/`session_alias`
  同样带 omitempty —— 逐个调用点加就是"每加一处就得记得做一次",
  本仓反复在消的形状。归一化做一次、覆盖全部字段。

## 假阳性:同名词撞车

`e.reason` 的 `e` 是 **`AccountError`** —— 鸿蒙**本地类**(`MailStore.ets:73`),
只经 `AccountError.of(account, reason)` 构造 ⇒ `reason` 恒为 string。
而判据收集的那个 `json:"reason,omitempty"` 属于 `RenameProposal`
(`rename_proposal.go:39`,**完全不同的**接口)。已按既有
「已逐个核实过的豁免」格式加 ⑤,附取证。

## 期间修了判据自己的一个洞(变异实测)

我给 ⑥ 加豁免时第一版写的是**无条件**正则 —— 变异实测
(把 `MailSummary.normalize` 里那行 `m.permission_result = str(...)` 删掉)
**判据照样全绿**:豁免把那一行永久致盲了。

这正是本仓反复消的形状:**豁免口自己没人管**。

⇒ 改成"**有前提**的豁免":先断言 `MailSummary.normalize` 里确实有那两行,
命中才豁免。变异复验:

    删掉 normalize 里 permission_result → 判据红 ✓
    删掉 normalize 里 mail_type        → 判据红 ✓
    两者都在                          → 全绿 ✓

⇒ 豁免表达的是"**因为上游归一了**,所以这里可以不写 ?? ",
  而不是"这一行不用管"。
2026-09-21 17:06:19 +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
f16ebf4942 跨端: 「我的」页段顺序对齐 WebUI(拆掉那两个焊在一起的 section)
用户「都做啊」的第二件 = 继续逐页对齐 WebUI。这一轮做「我的」页。

## 先比对,再动手(不是凭印象说"八段一一对应")

文件头注释一直写着「八个 section 与 WebUI 一一对应」,实测逐段列出后
**顺序并不对应**:

    WebUI(AccountPage.tsx,按出现位置)
      基本资料 → 权限范围 → 修改密码 → 多账号 → 客户端连接密钥 → 外观
              → 登录状态 → 管理

    鸿蒙(改前)
      ProfileSection(基本资料+权限范围) → 多账号 → 外观 → 系统通知
              → 密钥 → SecuritySection(修改密码+退出) → 管理

⇒ 三处实质差异:
  ① **「修改密码」与「登录状态」被焊成一个 `SecuritySection`**
     —— WebUI 那边它们是**两个独立 `<section>`**,中间还夹着三段。
     顺序错位的**根因就是这一处**:焊在一起就没法插东西进去。
  ② 「外观」被排到「密钥」**之前**(WebUI 是密钥在前)。
  ③ 「多账号」在「修改密码」之后、而不是之前。

## 改动

· `SecuritySection` → 拆成 `PasswordSection` + `LoginSection`(各带自己的圆角卡)。
· 顺序改成与 WebUI 逐段相同。

## 「系统通知」保留,但**标明它是鸿蒙独有**

`PushSection`(HarmonyOS Push Kit:新邮件由系统弹通知、点通知直达)
在 WebUI 侧**没有对应物** —— 全仓 grep「推送/通知设置」为 0 处。
浏览器里没有等价能力,所以**这不是分叉,是平台差异**。
已就地写明理由,并把它放在外观之后、登录状态之前(与"账号自身"那几段分开)。

★ 这类"我方多一段"的情况,本仓的处理口径是**写清楚它不是漏做的**,
  而不是删掉去凑一致 —— 与 §7.12「有意差异表」同一形状。

## 设备验证(不是只看代码)

    改前:  我的 → 用户名 → … → 修改密码 → 保存 → **多账号 → 外观 → 系统通知 → 密钥 → (改密+退出)**
    改后:  我的 → 用户名 → … → **修改密码 → 多账号 → 客户端连接密钥 → 外观 → 系统通知 → 登录状态 → 管理**

第二屏实测(`uitest dumpLayout` + fling):
    「多账号 → jianf → 默认 → 删除 → 客户端连接密钥 → 新建 → … → 外观 → 已同步 → 跟随系统」
⇒ 与 WebUI 的 `多账号 → 密钥 → 外观` 逐段一致。进程存活(20970),无新 jscrash。
2026-09-21 16:23:34 +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
ba16230a63 跨端: 回复球 → 回复条 加共享元素转场(WebUI 的 FLIP「按钮长成面板」)
用户 2026-09-21:「我记得 webui 行为是按钮变成对应的写邮件页面或输入框吧,你做的啥?」

## WebUI 的真实行为(读它的实现,不是凭印象)

`MailView.tsx:990-1010` + `ComposePage.tsx:57-79` 做的是 **FLIP**
(First-Last-Invert-Play):

    const from = 球的 getBoundingClientRect();       // ① 起点
    const to   = 面板的 getBoundingClientRect();      // ② 终点
    const scale = Math.max(0.04, from.width / to.width);
    const dx = from.left + from.width/2 - (to.left + to.width/2);
    const dy = from.top  + from.height/2 - (to.top + to.height/2);
    el.animate([
      { transform:`translate(${dx}px,${dy}px) scale(${scale})`,
        opacity: 0.3, borderRadius: '28px' },        // ← 起点圆角 = 球的半径
      { transform:'none', opacity:1, borderRadius:'0px' }
    ], { duration: 220, easing: 'cubic-bezier(0.22, 0.61, 0.36, 1)' });

观感就是**球从自己的位置和半径"长"成一整幅面板**。用户说的
「按钮变成输入框」正是这一条 —— 而且它有**两处**:回复球→回复条、
以及写信 FAB→写信页。

★ 它还显式尊重无障碍:`prefers-reduced-motion` 时直接 return(不打动画)。

## 鸿蒙的对应能力:`geometryTransition`

官方文档(`ts-transition-animation-geometrytransition`)原文:

  · 「通过安排绑定的 in/out 组件的 frame、position,将视觉焦点由旧视图位置
    引导到新视图位置」—— 就是 FLIP 的系统实现;
  · 「**必须配合 `animateTo` 使用**才有动画效果,动效时长、曲线跟随
    `animateTo` 中的配置,**不支持 `animation` 动画**」;
  · 「同一个 id 只能有**两个**组件绑定,且分别作为 in(新视图)和
    out(旧视图)两种不同类型角色,**不能多个组件绑定同一个 id**」。

⇒ 三条约束逐条满足:
  · 两端绑同一个 id `reply-morph`(且**只有**这两处,判据可查);
  · 状态切换写在 `animateTo` 的**闭包内**(不是 `.animation()`);
  · 用默认 `follow: false`(两端是 `if/else` 互斥出现,不是"始终在树上")。

## 时长/曲线的取值(不是我拍的)

**220ms + `cubic-bezier(0.22, 0.61, 0.36, 1)`** —— 逐字取 WebUI 那行 FLIP 的参数。
那个贝塞尔恰好就是本仓 `Theme.easeRise` 的定义(`easeRise` 当初就是从这行抄来的),
所以代码里直接引它,没有新写一条曲线。

新增令牌 `Theme.durMorph = 220`,**没有**复用 `durRise`(200):两者是不同动效
(元素出入场 vs 共享元素续接),WebUI 那边也是两个值(150 / 220)——
合成一个的话以后想单独调其中一个就得先拆开,拆的时候必漏调用点。

## 设备验证(做了什么 / 没做成什么)

✓ 点球之后**回复条确实展开**(`dumpLayout` 命中「回复给 pi@root.realtest」
  与「输入回复内容」两处)——功能链路通。
✗ **没能在设备上看到中间帧**:`snapshot_display` 一次往返 ~1.5-3s,
  而这条动画 220ms ⇒ 探针比被测对象慢一个数量级。连拍 4 张
  y=1600 的白区跨度全是 `(30,1007)`(即每张都已是终态)。
  ★ 这正是此前"连测七八轮没有中间帧、并编出三个错误理论"的同一个坑
  (那次的结论记在本仓:**探针对被测变化不敏感时,量的是噪声**)。
  ⇒ 这一条的**动画本体**只能由用户在真机上看,我不声称已验。

顺带把「取消」键改成反向 morph(`closeReplyWithMorph()`)——
WebUI `MailView.tsx:945` 专门记过这个不对称:「打开有 morph、收起是瞬间消失 —— 不对称」。
2026-09-21 15:39:44 +08:00
75c667ac24 跨端: 日历 12 处点击补按压反馈(用户:「打开日程一点动画都没有」)
## 实测量出来的覆盖面,不是印象

`grep` 出日历页 17 个 `onClick`,逐个看它所在的块有没有
`PressEffectModifier`(全仓统一的按压反馈):

    补前:17 处里 **5 处有、12 处没有**
    补后:17/17

缺的 12 处正是"打开日程之后会去点的那些":
`‹` / `›` 翻月、「今天」、月/周/日 三档、导入、导出、新建、
以及编辑器里的若干选项。用户「一点动画都没有」说的就是它们。

★ 为什么其它页面有、日历没有:按压反馈是**逐站点的**(`PressEffectModifier.of()`
  挂在一个组件上),不是全局的。我此前给导航/详情/写信页补过,
  日历这一页当时没扫到 —— 这类"每加一处就得记得挂一次"的形状,
  本仓已经栽过两次(`applyPressedAttribute` 只对 Button 有效、
  18 个玻璃卡里只有 1 个挂了按压)。**这次是第三次。**

## 设备验证(按住不放,同一区域取平均亮度)

    「今天」按钮按住时  rgb(216.6,216.6,216.6)
    常态                rgb(238.4,238.4,238.4)

⇒ 差 21.8 个亮度级,是**看得见**的变化(不是"代码里有这句话")。

## 顺带说明我这次差点犯的错

第一版我用"在 `onClick` 那一行后面插一行"的脚本批量加 —— 而其中 6 处
`onClick` 是**多行箭头函数体**(`.onClick(() => {\n … \n})`),
插在中间直接把文件写坏(编译报 `Declaration or statement expected`)。
已 `git checkout` 还原,改成先按括号配对**求出整条语句的结束行**再插。
★ 教训:批量改代码要按**语法单元**定位,不能按行号 + 行内容猜。

## 仍然没做的(如实登记,不夸大)

· `pageTransition` 仍是 **0 处** —— 所有 `@Entry` 页都是硬切。
  WebUI 那边也没有页面级转场(这是双方一致的有意选择),
  但用户明确提过「转场动画呢」,需要单独一轮决定要不要引入。
· 「按钮变成页面 / 按钮变成输入框」的形变(WebUI 用 `data-morph-trigger`
  + FLIP)在鸿蒙侧需要 `geometryTransition`,目前只在 `NavDestination` 上可用。
2026-09-21 15:27:53 +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
139fa19f90 跨端: 转发入口移到头部动作行、右下只留回复球(我多摆了一个球)
承接上一条。用户之前说「还有其他行为都要一一对齐,例如邮件展示页面」,
我当时补了头部的「标记已读 / 对话树」,却把**转发**做成了右下角的第二个球。

照 WebUI 核对(`MailView.tsx:520-544` 与 `:994`):
  · 头部动作行是**三个**:标记已读 → 对话树 → **转发**
    (`ForwardIcon` + 文字,`text-gray-500` = 导航样式)
  · 右下角只有**一个**球:`reply-fab`,点开才是回复框。**转发从来不是球。**

多出来那个球是我自己发明的,两个问题:
  ① 它没有任何对应物,纯属"看着缺就补一个";
  ② 转发在 WebUI 是**导航**(灰、与对话树并列),球是**主操作**
     (蓝、抢注意力)—— 把导航做成主操作,页面里就有了两个同等重量的动作,
     看不出主次。

改动:头部动作行补上「转发」(顺序与样式逐项对齐 WebUI),删掉右下角那个球。
设备已验:头部一行是「对话树 · 转发」(同 y),右下只剩一个蓝色回复球。

`files=33 checks=530 pass=530 fail=0`,`mutants=52 ran=52 skipped=0`,`baseline=7/7✓`。
2026-09-21 02:34:44 +08:00
6f1b4352cd 跨端: 回复/转发改内联底栏 + 入场动画对称化(照鸿蒙文档纠正三处误判)
用户:「点击回复按键与新建邮件部分的动画与 webui 不一致,动画不符合鸿蒙视觉
要求」。两个问题是分开的:结构是覆盖式弹层 vs WebUI 的内联底栏;动画则是我
单方面发明的不对称过渡 + 150ms 低于鸿蒙规范下限。

## 结构:覆盖式弹层 → 底部内联条(回复 / 转发)

WebUI `MailView.tsx:410/1057` 的 `ReplyBar`/`ForwardBar` 是 `border-t` 分出的
**内联底栏**,与正文并列(正文 `flex-1 overflow-y-auto` 保持可见可滚),高度由
内容决定。我们原先是整屏遮罩 + `height('60%')` + `position({x:0,y:0})`。

三条用户可感知的差异:弹层盖住正文(写回复时看不到原文)/固定 60% 高(写一行
也占半屏)/遮罩整屏变暗。结构不用动外层 —— 原版那两处本来就是正文 Stack 的
**兄弟**(同在 `Column` 里 ⇒ 本来竖直排列),错只错在给条加了遮罩/定高/绝对定位。

## 动画

① `paneRiseIn()` / `calendarSlide()` 去 `asymmetric`,改**对称**。
   WebUI 是 `animation: rise-in 150ms … both` —— `both` 就是进出同一条关键帧。
   我原先让出场只做 `opacity` 且更短(120ms),"出现时浮上来、消失时只淡出",
   正是"与 webui 不一致"的来源。当初写不对称的理由(换窗格时两层同时半透明会
   "闪")只对**换窗格**成立,对回复框/转发条不成立 —— 我把两种场景混用了。

② `durRise` 150 → **200ms**(用户选定"折中")。WebUI 是 150(web 常规档),
   鸿蒙官方「元素淡入/位移进入」建议 **200-300ms**,150 比下限还低 25%。

## 照文档纠正三处误判(本轮的真正收获)

我为了搞清"为什么动画不播",先后编出过三个错误理论,读文档后逐条推翻:

① **不是 "NavDestination 吃掉子组件的 `.transition()`"**。
   实测:`ComposeView` 根上的 `.transition()` 一直在播。我之所以连测七八轮都报
   "没有中间帧",是因为**拿平均亮度当探针** —— 白底窗格 50% 透明叠在浅色背景上
   平均亮度几乎不变。换成**位移**探针后,立刻看到"整栏下移 300vp 且半透明"的
   中间帧。教训:**探针对被测变化不敏感时,量的是噪声**。

② **不 `customTransition` 也能做**。`NavDestination` 确实有 `customTransition`
   (API 15+),但它是**整页转场**,我们要的只是内容块的一次上浮淡入。
   (顺带记一条:`NavDestinationTransition.curve` 的类型是枚举 `Curve`,
   不收 `ICurve` —— 试过用 `curves.cubicBezierCurve` 会编译报错。)

③ **`.opacity()` 在 `NavDestination` 上是生效的**。先前判定"不生效"同样是那个
   废探针害的;换 `opacity(0)` 二元判定后整页消失,证明它一直生效。

## 连带修一个真 bug(判据抓的)

回复/转发改成内联后**失去了"弹层有固定高度"这层键盘保护** —— 官方默认
`KeyboardAvoidMode.OFFSET`(整页上移)会把贴底的「取消/发送/转发」顶出屏幕。
在 `EntryAbility` 里显式设 `RESIZE`(按剩余高度重排)。坑:`@kit.ArkUI` 与全局
作用域各有一个同名 `KeyboardAvoidMode`,**只有前者有 `RESIZE`**。

## 判据(4 条红全部结算,逐条说明为什么不是放宽)

· `harmony-nav` durRise:从"逐字等于 150"改为**区间 200-300**(钉住用户裁定,
  退回 150 与写 800 都红,已变异验证)。
· `harmony-nav` asymmetric:**反转**为"不得 asymmetric"(旧断言把上一版设计锁住,
  而 WebUI 本来就是对称的)。
· `harmony-admin` 弹层高度:原断言数的形状只属于废弃的覆盖式弹层 → 改为
  **新结构下的等价不变式**(键盘避让必须 RESIZE)。这条判据当年抓的是真 bug,
  该 bug 换了形态仍在,所以不能简单删。
· `cross-client-theme`:删掉我中途废弃留下的孤儿令牌 `riseCurveEnum`。

新增 4 条变异条目(全部 `红✓`);`mutants=52 ran=52 skipped=0`。
`files=33 checks=530 pass=530 fail=0`,`baseline=7/7✓`。
设备已验:回复/转发确为内联底栏(正文可见、`border-t` 分隔)。
2026-09-21 02:19:30 +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
d9bb4f766b 跨端: 三条判据转绿(登记新令牌 + 把一条断言错实现的判据改成断言真不变式)
上一步(写邮件右栏)之后整套跑红 3 条,逐个查清:

══ ① cross-client-theme —— 判据是对的,我漏登记

新加的 `accentEdge` / `accentEdgeDark` 是**手写色**,而这个仓有一条硬规矩:
`Theme.ets` 里每个 `static readonly X: string = '#……'` 都必须在
`SELF_OWNED_COLORS` 名单里 + 在文件内的「手写色登记表」注释里写一行理由。
(这条规矩本身就是为防"新写死一个色悄悄溜过去"而立的 —— 第二处断言
 会检查名单里没有化石名,所以名单不能只加不改。)

按规矩补两处:
· 名单里登记,并写清它对齐的是 WebUI 的哪个 token
  (`MailList.tsx:280` 的 `border-blue-200`,与 `bg-blue-50` 成对)
· `Theme.ets` 登记表里写一行理由 —— 重点记下**为什么"选中"必须两个 token**:
  选中与未读的底色相同(都是 `accentSoft`),只靠底色的话
  "选中一封未读邮件"看不出任何变化,那圈边才是信息。
  并把表头计数 24 → 26 同步改掉。

══ ② harmony-widescreen —— 判据错了,它断言的是一个 WebUI 明令禁止的实现

原断言:`assert.match(code_, /clip\(this\.isWide\)/, '面板内容要被圆角裁剪')`
—— 它把"圆角"和"裁切"当成**同一件事**。而 WebUI `index.css:968`
在**同一个规则块**里就写明了这条教训:

    .app-shell > * {
      border-radius: var(--radius-card);
      // ★ 这里**不能**写 overflow: hidden(2026-09-14 用户:
      //   「通信页面完全无法上下滑动」)。
      // 面板自己就是滚动容器,而这条规则的特异性比 Tailwind 的 .overflow-y-auto
      // 高 ⇒ 滚动被静默干掉:实测当时**一个可滚动容器都不存在**(scrollerCount=0)。
      // 圆角仍然生效(border-radius 不影响滚动)

鸿蒙这边同一个形状:那层是内容列的根、`Navigation` 的 `List` 在它里面,
写了 `.clip(this.isWide)` 就是 `List` 节点还在(高 1750px)但**滚不动**
(实测 fling 后第一封仍在 y=526)——这正是用户当时报的"列表没法滚动"。

⇒ 判据改成断言**真正的不变式**:宽屏「圆角开(14)+ 面来自设计令牌 +
**不写** `.clip(this.isWide)`」。原来那句测的是"某句实现写着没写着",
而且那个实现是错的 —— 判据跟着错误实现一起钉住了 bug。

══ ③ build-stamp —— 判据要求重构建,照做(**没有**去改 BUILD_INFO.json)

产物记的是 `c0ab3f5`,HEAD 已是新提交。这条判据自己的文本写得很清楚:
> **别去改 BUILD_INFO.json 里的 gitRev / srcHash 了事** —— 那是把这条判据废掉。
> 正确修法只有一个:重跑构建。

所以 `npm run build` 重跑(rev 0)。注意 srcHash 本来就没变
(`6d1195a4008ebca7`)—— 因为 TRACKED 只扫 electron 侧(`src`/`index.html`/
`vite.config.ts`/…),我这几步改的全是 harmony;**变的只有 gitRev**。
这正说明这条判据在比对"产物来自哪个提交",而不是"文件内容有没有动"。

★ 三条各自的性质不同,值得分开记:① 是判据对、我漏登记(补);
② 是判据错、钉住了一个反向实现(改判据);③ 是判据对且给了唯一正确修法(照办)。
2026-09-20 13:05:59 +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
5c04b41900 跨端: 修一个真崩溃(omitempty)+ 邮件详情补"标记已读/对话树"两个动作
用户:「还有其他行为都要一一对齐,例如邮件展示页面」。做这件事时**撞出一个真崩溃**。

══ ① 崩溃:`Cannot read property trim of undefined`(整页白屏、应用重启)

崩在展开邮件头部的那一刻。崩溃日志
`jscrash-com.jianf.agentmail-...-20260920123121173.log`:
    at participantAddress (model/ReplyTarget.ts:87:40)
    at fromAddress (pages/MailDetailPage.ets:393:12)

根因是**服务端 `omitempty` + 客户端裸转型**这个组合:
· 服务端 `Mail` 有 12 个字段带 `json:"...,omitempty"`(models.go:140-238)——
  Go 对零值**根本不输出这个 key**。实测 `/api/v1/mail/{id}`:
      session_workspace   ★缺失
      body_preview        ★缺失
      permission_result   ★缺失
      attachments         ★缺失
· `ApiClient` 是 `JSON.parse(rawText) as T`(裸转型、无归一化)——
  ArkTS 对"JSON 里没这个 key"**不会**套用 class 的 `= ''` 默认值
  (那只在**整个对象**缺失时生效)⇒ 字段变成 `undefined`。
· 于是 `workspace.trim()` 当场抛。

★ 这正是用户要做的 **B**(两套纯逻辑各写一份)的实证分叉:
  electron 写的是 `(workspace || '').trim()`,鸿蒙写的是 `workspace.trim()`。
  少了那两个 `||`,代价是一个崩溃。已在 `ReplyTarget.ts` 照 electron 逐字对齐。

★ 但**不在那里了事**(同一个坑还有十几个字段,逐个打补丁必然漏):
  在**解析边界**加一层归一化 `MailDetail.normalize()`,接在 `mailApi.mailDetail()` 上。
  下游从此可以按"字段一定存在"来写(那本来就是类型声明该保证的事)。
  不改服务端去掉 omitempty —— 那会动已发布的 API 契约,代价大得多;
  而且 electron 一直靠 `?.`/`|| ''` 兜,说明这个契约是既成事实。

★ 为什么以前没暴露:`fromAddress()` 只在**展开头部**时才调用,
  而展开头部是个 14px 的薄弱点击区(之前修过一次)。我把动作行加进展开区,
  等于把这条路走宽了 —— 一展开就崩。

══ ② 又一处 B 分叉:对话树回包类型整个是错的(接口 200,界面空白)

鸿蒙 `ThreadResponse` 声明的是 `dir / has_more_up / has_more_down / next_up /
next_down` —— **服务端一个都没有**;服务端真正返回的 `root_mail_id /
anchor_depth / has_more / next_offset` 这里**一个都没声明**。
更糟的是 `ThreadApiResponse` 还包了一层 `thread`,而服务端是**平铺**的:
    {"anchor_depth":1,"anchor_mail_id":"...","has_more":false,"hidden":0,
     "next_offset":60,"nodes":[...],"root_mail_id":"...","total":2}
⇒ `resp.thread.nodes` 永远读不到 ⇒ 点「对话树」什么都不显示。
已按 electron 的 `types/index.ts:179 ThreadPage` 与服务端实测回包对齐。

★ 教训记下来:`JSON.parse as T` 是裸转型,**照自己直觉声明第三方回包类型,
  编译器不会查**。改成 electron 那样的 `ThreadPage` 形状后才对。

══ ③ 补两个动作(对齐 WebUI `MailView.tsx:520-544`)

· **标记已读** —— 仅 `status === 'unread'` 时出现;成功后**就地**把 `this.status`
  改成 `'read'`(只发请求不改状态的话按钮还挂着,用户会以为没生效再点一次);
  失败要 toast(写操作静默失败比报错更坏)。不做乐观更新 ——
  "标已读失败了却显示已读"比慢 0.2 秒更糟。
· **对话树** —— 盖在内容之上的弹层(不改路由),与 WebUI 的 `onThread` 同义。
· 转发不重复(右下球已有)。
· 位置、顺序、显隐条件逐项对齐:动作行在**元信息行之前**(WebUI 是 `mb-1.5`
  那一行),蓝色表示动作、灰色表示导航,与 WebUI 的 `text-blue-600` /
  `text-gray-500` 同一取舍。

设备验证:展开头部不再崩(无新 faultlog);`POST /mail/{id}/read` 已发出且
「未读」徽标与按钮同时消失;对话树弹出并显示真实数据(2 封,dsh → jianf)。

★ 工具坑记一笔:这台模拟器上 `devecocli ui tap` **点了不生效**,
  `hdc shell "uitest uiInput click X Y"` 才有效 —— 为此白跑过两轮。
2026-09-20 12:48:37 +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
110de79401 跨端: 三级文字**浅色也一样不够**(12 处 2.85:1)+ 判据不再只验一半
## 一、这是"我自己推的理由"被实测推翻

上一条我把 `textSubtleFor()` 写成**只改深色**,理由是:

> 「浅色下这些令牌本来就是为浅底配的,量它们没有信息量」

**那个理由是错的。** 切到浅色主题复扫,立刻抓到 12 处:

```
"用户名 / 显示名 / 角色 / 状态 / 创建时间 / 最后登录"  2.85:1
"已同步" / "37%" / "3px"(外观区)                    2.85:1
"这一天没有日程"                                      2.85:1
```
`ink rgb(153,153,153)` 压白底 —— 全是 `textSubtle`。

对照 WebUI 浅色(`index.css` `:root`):
- `gray-400` = `107 114 128` = **4.83:1**
- `gray-500` = `90 98 112`  = **6.15:1**

⇒ 系统三级色**两个主题下都不够**。它的语义只是"比二级更淡",
**不保证任何可读性下限**;WebUI 那边两套主题都做过适配,这边一档都没有。

修:`textSubtleFor()` 两个主题都返回 `textMuted`
(系统二级色,语义就是"次要文字",且**跟随主题** ——
不必再维护两个手写常量、不必登记)。

## 二、判据从"只验深色"改成"两个主题都验"

原来那条设备判据**在浅色下直接 skip**,skip 的理由正是上面那句错话。
现在两个主题都扫,断言消息里带上主题名(`深色主题下…` / `浅色主题下…`)。

★ 纪律:**色令牌的可读性是两个主题各自的事,不能只验一半。**
  "另一半没问题"若没量过,就只是推测。

**变异验证**:把 `textSubtleFor()` 改回"两主题都用 textSubtle"
(此时设备正在浅色主题)⇒ **判红**;还原 ⇒ 绿。

## 三、设备复扫(浅色主题)

```
[通信]   扫 41 段,低对比 0
[日历]   扫 84 段,低对比 0
[联系人] 扫 50 段,低对比 0
[我的]   扫 40 段,低对比 0
```
**215 段文字全部 ≥3:1。**

两个主题合起来:深色 219 段 + 浅色 215 段,全绿。

## 四、顺手改掉的

测试名从「深色下短文本必须都读得动」改成「**当前主题**下…」
—— 名字得跟着它实际判的东西走(否则下一个人会以为它只管深色)。

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

**当前设备主题**:浅色(本轮为验另一半切过去的;服务端 `theme=light`)。
2026-09-19 20:58:59 +08:00