|
|
f0c7e5cbbc
|
补: 那条 40 行回填**只清 (b) 那一半** —— (a) 留下的 116 行它一条都选不到
商定的回填 SQL 带 `WHERE m.status='read'`,只覆盖**冗余列已经是 read** 的(成因 (b))。
成因 (a)(`read_mail` 不标已读)留下的是 **`mails.status` 仍 unread + `mail_reads` 无行**
⇒ 那个 SQL **一条都选不到**。
## 判据:不靠"我觉得没读过",靠可观测的因果
**收件人回了这封信 ⇒ 它必然读过** ⇒ 此时若无 `mail_reads` 行,则两条记录都没记上
(父信的 `to_name` 恰是子信的 `from_name`)。实测 **248 封**:
read 28 ✅ 回填选得到(属于那 40 行的一部分)
unread 116 ❌ 选不到
archived 104 ❌ 选不到
实例:`fd375458`(dsh→pi)—— **pi 回了 `494b29e4`**(`parent_mail_id` 指向它)⇒ pi 读过;
但该信 `mail_reads` 零行、`mails.status` 仍是 `unread`。
## 结论
落那 40 行只清掉 **(b)**;**(a) 留下的 116 行原样留着**。
不要把它写成"补完历史缺口"—— 它补的是**冗余列与权威列之间**的差,
不是**"读过"与"没记上"之间**的差。后者要另立一条判据。
(判据口径说明:`收件人回过`是**充分**证据,不是全部 ⇒ 真实漏记量 **≥ 248**。)
★ 与本节开头"计数对了不等于账记上了"是**同一个形状**:这次是"补了 40 行"≠"账平了"。
|
2026-09-21 06:32:43 +08:00 |
|
|
|
437be52552
|
修: 自检夹具 $T2 一直没被清理(每跑一次漏一个 /tmp 目录)+ 补一条守它的判据
## 病
`mkdtree_fail` 建的是 `$T2`,而清理语句是 `743e397` **之前**写的、只提了当时存在的
`$T` 和 `$B` ⇒ **加了新夹具没加清理**。每跑一次 `--self-check` 就往 `/tmp` 漏一个目录
(实测:一轮里跑 6 次 → 6 个残留;历次累计清出 **40** 个)。
★ 但真正的病是**第二层**:`$T2` 用的是**裸 `mktemp -d`**,于是它叫 `/tmp/tmp.XXXXXXXX`
—— 和任何 `mktemp -d` 的产物**长得一样**。后果:
**认不出来是谁的,就没法清理、也没法写判据。**
## 我第一版判据是假绿(变异测试当场抓住)
我按 `find -name 'am-prune-selftest-*'` 数残留。但 `$T2` 根本不匹配这个前缀
⇒ **把 `$T2` 从清理列表里删掉(复原 bug),判据照样绿 23/0。**
这就是"判据在,但走不到"—— 它守的是另一个前缀。
## 修
1. `mkdtree_fail` 改用 `mktemp -d "$TMPD/am-prune-selftest-XXXXXX"`,与 `mktree` 同前缀
⇒ 夹具**可识别**;
2. 清理列表补上 `$T2`;
3. 新增判据「自检夹具清干净(本次新建的残留 = N 个,须为 0)」,
用**集合差**(跑前快照 vs 跑后快照)而不是"有没有"或"最近 N 分钟"
⇒ 上次的残留不会误伤,也不依赖时钟。
## 变异 + 对照(都实跑)
变异:`rm -rf "$T" "$B"`(去掉 $T2) ⇒ rc=1,22 通过/1 失败,**点名残留目录** ✓
基线: ⇒ rc=0,23 通过/0 失败,/tmp 残留 0 ✓
对照:预置一个 STALE 残留 ⇒ rc=0 通过(集合差不误伤)✓
(单次自检耗时 ~39s,不是挂起 —— 我第一次用 2 次循环跑,误撞了 60s 上限。)
|
2026-09-21 06:27:24 +08:00 |
|
|
|
3fc503235c
|
补: 「触发条件 ≠ 复发原因」两半拆开(pi 补,我复现后同意)+ 记下 4 vs 7 的口径差
pi 指出我上一笔把一件事写成了一个成因。拆开后是**两条**,缺一不可:
触发条件 快照在**取件时刻**正确,投递与取件之间被读掉 ⇒ 让**这一批**投出已读的信
复发原因 deliveredMails 是**内存** BoundedSet,重启即失忆 ⇒ 让它**下一轮**又挑同一批
**关键结论**:B-7.7 要的落盘账本只治"复发原因"那一半;
**快照失效是另一半,账本治不了**(账本挡得住"整封重投",但挡不住"取件后、投递前被读掉")。
## 4 还是 7:我一开始数和 pi 冲突,查下来是**口径不同,两边都对**
口径A 误投前**有过任何**合法投递 ⇒ 7/7 ← 我第一版数的
口径B 前一轮**也是 catchup** 且间隔 >5h(排除 SSE 首投) ⇒ 4/7 ← pi 的 4
差异全在 7a350f9d / 57b0c703 / 18527c6b:前一次是 SSE 实时投递(catchup=False,仅早 1.6h)。
**对"复发原因"这条论证,口径 B 才是相关的那个** —— 要证"重启后整批重来",
得看"上一轮也是补投",而不是"之前投过"。已在 docs 里写明两个口径各自的读数。
## 另一条口径边界(我量的,pi 没提)
那 7 例全在 09-13/09-14;pi 侧 catchup 投递 **09-15 09:45 之后再没出现过**(距 09-21 已 5.9 天),
且轮次时刻分布在 07/09/10/15/16/17/19/20/22/23 点,**不在 04:00**。
⇒ "每晚重来"对 **dsh 侧成立**(crontab `0 4 * * * restart dsh.service`),对 **pi 侧不成立**
(它自己的宿主重启/唤醒,另一个触发器)。那 7 例是**历史反例**,不是"现在每晚都在发生"。
|
2026-09-21 06:20:03 +08:00 |
|
|
|
8c320121e1
|
措辞: 那个坑记在 8903ce5,不是「本提交下面第 33 行」(提交号写明确,避免指错)
上一笔 `d9c4946` 的注释里我写「错因就是**本提交下面第 33 行**那段自己刚记下的坑」——
但那个 `grep -c '失败'` 的坑是 `8903ce5` 记下的,不是这次提交。改成直接点提交号。
自检仍 rc=0 22/0。
|
2026-09-21 06:17:37 +08:00 |
|
|
|
d9c4946cd7
|
修正: 我 8903ce5 注释里那三个数是**用我自己刚记下的那个坑算出来的**(pi 指出算术不自洽)
pi 指出:注释里写「基线 22/1、shim 16/9」,而 `total` 恒为 22 —— **22/1 和 16/9 都凑不出 22**,
算术不自洽。他说得对,而且错因正好是本提交自己在下面第 33 行记下的那个坑:
那三个数是用 `grep -c '失败'` 数出来的,而"失败"这两个字也出现在**样本名**里
(`坏样本(rm 删不动):必须打出那句点名失败的 [FAIL]`)和 fake-rm 的 stderr
⇒ 基线多数 1、shim 多数 3。
**我在同一段注释里既写下了这个坑、又用它算出的数当成了实测值。**
## 按行首标记 `^\s*(通过|失败)\s` 精数(实测)
基线 ⇒ 通过 22 / 失败 0 (合计 22)
shim(全 rm) ⇒ 通过 16 / 失败 6 (合计 22)
shim(只该目标) ⇒ 通过 16 / 失败 6 (合计 22)
"两种 shim 一样"这条结论**原来只是抄的** —— 这次把"只该目标"那一种也**真的跑了**
(在 `$T/rm` 里只对 `agentmail-gateway.bak-20260101-000000` 失败),确认同为 16/6。
自检仍 rc=0 全绿;`bash -n` OK。
|
2026-09-21 06:16:41 +08:00 |
|
|
|
1e0af51a24
|
修正: 上一笔把「成员换了」写成了实测 —— 「哪一行进来」是**推断**,mails 表没有 updated_at
`cc4db68` 里我写「1494154f 被补标(-1)、c416c98e 新漏(+1),净额 0 ⇒ 池子换了两个人」,
读起来像两条都是读数。**其中第二条我证明不了。**
- **可证**:`1494154f` 04:33:34 排末位 5/5(漏标)→ 06:00:46 拿到 `read_at`(排 3/10)
⇒ 它**确实离开了缺口**(-1)。
- **可推**:40 → 40 而确有 1 行离开 ⇒ **必然有 ≥1 行在同一窗口进入**。
- **不可证**:**哪一行进来了**。`mails` 表**没有 `updated_at`**
(列只有 `status` / `created_at`)⇒ 缺口的历史成员集合**无法重建**。
`c416c98e` 是最可能的候选(同期在末位 10/10 漏标、`status='read'`、`mail_reads` 零行),
但那是**推断不是读数**。
⇒ docs 里已改成三段式(可证 / 可推 / 不可证),并点名"最可能是 c416c98e,但这是推断"。
结论(缺口是流量不是库存、不能只数总数)不受影响 —— 它只依赖"-1 确实发生"这一条,
而那条是实测的。
同族第 4 次:**把"我推出来的"写成"我量到的"。** 前三次是 133 个文件 / 107-81 /
PATH shim 的 in_use 理由。
|
2026-09-21 06:11:20 +08:00 |
|
|
|
cc4db68e68
|
补: 那个静默 bug **还在生产活着** —— 今天现场复现 3 次,并量到「缺口是流量不是库存」
`17908c1` 修了代码,但**线上跑的还是有 bug 的二进制**(构建于 09-19 13:04,早于修复)。
2026-09-21 我自己调 `read_inbox` 时**当场复现三次**,形态与离线探针逐字一致:
**列 N 封、只标上 N-1 封,漏的永远是末位。**
04:10:57 列 5 标 4 末位 474323c3 漏
04:33:34 列 5 标 4 末位 1494154f 漏
06:00:46 列 10 标 9 末位 c416c98e 漏
**跨调用同信同位对照**(最强一档):`474323c3` 在 04:10:57 排**末位 ⇒ 漏**,
在 04:33:34 排**第 2 位 ⇒ 标上**。同一封信、只换位置、结果相反 ⇒ 因果钉在**批次位置**。
## 由此得到一条容易看错的教训:缺口是「流量」不是「库存」
回填前的缺口**连续两次量都是 40 行**,看着像稳定常数,会诱出"可以等部署"的结论。
但**成员每天都在换**:同一天里 `1494154f` 被补标(-1)、`c416c98e` 新漏(+1),
净额 0 ⇒ **总数不变,池子换了两个人**。
⇒ 判"还欠多少"不能只数**总数**,要数**成员集合**(或直接看部署了没有)。
**一个稳定的计数可以掩盖一个持续在发生的错误。**
(与前面几笔同族:`133 个文件`、`107/81`、`41→40` —— 都是"把一个变动量当成常数"。
这次的区别是:那几次是**我读数过期**,这次是**计数本身在掩盖流量**。)
|
2026-09-21 06:09:11 +08:00 |
|
|
|
9336fa768b
|
更正: 「catchUp 从不重投已读邮件」是**我自己写宽的泛化** —— pi 侧量到 7 个真反例
我在 `ecf98d7` 往 B-7.7 里写了一句泛化:
「这 107 次投递中『投递时该读者已有已读行』= 0 次。也就是说 **`catchUp` 从不重投
已读邮件**」。**前半(我量的那 108 次里没有)成立;后半(机制上不会)是错的。**
## 反例(pi 侧,用同一个正确口径量出来的)
带 `catchup:true` 标记的投递共 63 次,其中 **7 次投递时 pi 的已读行已存在**:
1868127e 投 09-14 09:25:31 | read_at 09:22:25 | +186s
e211b554 投 09-14 09:26:29 | read_at 09:22:25 | +244s
2fce271d 投 09-14 09:27:14 | read_at 09:22:25 | +289s
c3638d4e 投 09-14 09:27:57 | read_at 09:22:25 | +332s
7a350f9d 投 09-14 15:19:21 | read_at 15:16:12 | +189s
57b0c703 投 09-14 15:20:44 | read_at 15:16:12 | +272s
18527c6b 投 09-14 15:21:38 | read_at 15:16:12 | +326s
## 根因不是"未读判定写错",是"快照 + 串行"
`catchUp` 先取一份 `status=unread` **清单快照**,再**逐封串行**投(每封起一轮模型)。
这 7 封同属一批快照,而模型投完第 1 封后调了一次 `read_inbox`(`01:22:25`/`07:16:12`)
**把剩下几封一次标成已读** —— 快照早已取好,后面的照投不误。
⇒ **判据"投递时是否已有已读行"测不出这条**:它测"当下快照对不对",
而这里快照**当时是对的**,只是**投的时候过期了**。
**"判据没答 ≠ 判据答错了"** —— 0/108 没答错,它答的是另一个问题。
⇒ 这让"每天重投"多出**第三条**独立成因(前两条:`read_mail` 不标 / `markReadFor` 错位)。
也说明"修好 (b) 重投就会停"是一个**新的假绿期待**。
## 顺带:两个数会一直涨,别当阈值
107/81 → 同一条命令当天下午就是 **108/82**(每来一封新信 +1)。
已改成钉**关系**(重投次数 ≥ 该会话真邮件数;时刻聚在 04:00 整点),
并注明别当验收阈值 —— 与我删掉 `check-deploy-drift.mjs` 里"133 个文件"是同一条教训。
★ 纪律提醒也补了一句:那条"别只看当前 `mail_reads`"的注意事项
**只说明会漏判/误判,不等于不存在真反例** —— 同一口径既排除了 11 个假反例,
也捞出了这 7 个真反例。
|
2026-09-21 05:59:20 +08:00 |
|
|
|
33b6033bdd
|
文档: 去掉判据 ① 里写死的验收数字「133」—— 它第二天就过期了
我在注释里把验收写成「比了 **133** 个文件(全部,不筛后缀)、命中 0」。
那是 pi 给的算式(`/etc/systemd/system` 133 个文件 − 现行口径 129 = 差集 4)当天的快照。
**今天实测是 135** —— 系统装/卸一个 unit 就会变(我这次是 multi-user.target.wants 下多了 3 个)。
⇒ 把「以某个绝对值为验收」改成「以**关系**为验收」:
要钉的是 `比了 N 个` 的 N **必须等于真的读到内容的条数**
(读不到的单列 `unreadable` 并判红 —— 那正是我上一笔修的分母问题)。
验收时看 note 里的**实际 N**,别看历史值。
★ 同族:这与我这几轮反复记的「判据的严格度必须与它真正想守的那件事对齐」是一回事 ——
这里想守的是「分母没有混进没比过的对象」,那是个**关系**,不是一个数。
把关系写成常数,等于把判据焊死在「当天的机器状态」上。
|
2026-09-21 05:19:49 +08:00 |
|
|
|
8903ce5aa4
|
更正: PATH shim 被否掉的**理由是我没量过的推断**,而且量下来是错的
pi 建议用 PATH shim 覆盖 `del()` 的失败路径("假 rm 放 PATH 前面,只对窗口外那一个目标失败")。
我 09-14 否掉了它,并在两处注释里写下理由:「`in_use` 会把命令行里含该路径的进程判成在用」。
**这个理由我没验证过,而实测它不成立。**
## 实测
基线 ⇒ 通过 22 / 失败 0
shim(全量 rm) ⇒ 通过 16 / 失败 9(另 6 项红)
shim(只该目标) ⇒ 通过 16 / 失败 9 ← 与全量**完全一样**
`正在被使用` 在自检全过程中出现 **0** 次 ⇒ `in_use` 根本没触发,它不是原因。
## 真因:夹具与"窗口外那一个"撞了同一个时间戳
自检夹具 `mktree` 建的就是 `$T/agentmail-gateway.bak-20260101-000000`,
而"窗口外该删的那一个"**也是它**(`:210/:221` 断言它被删)。
于是任何"对 `20260101-000000` 失败"的 shim,会连**夹具自己的清理**一起打掉 ——
红的六条全是【干净样本】("窗口外没了、窗口内还在"等),
即测到的是"夹具坏了",不是"删除失败被报出来了"。
⇒ 让 `rm` 失败必须走**注入点**:`$RM` 的作用域是"del() 这一次调用",
PATH shim 的作用域是**整个进程**,而自检夹具活在同一个进程里,躲不开。
**结论(用 `$RM`)当初就是对的,理由说错了** —— 与 pi 这轮纠正我的形状完全相同。
## 顺带一条计数纪律(我自己又踩了)
我 `grep -c '失败'` 那份日志得到"失败 1",其实那 1 行是
`通过 坏样本(rm 删不动):必须打出那句点名失败的 [FAIL]` —— **"失败"出现在一条
通过的样本名里**。按行首标记精数 ⇒ **22/22 全绿**。
与 `grep -c 用例名` 数出假数、`# Subtest:` 头那两次同族:**判据锚在了元文本上**。
|
2026-09-21 05:14:58 +08:00 |
|
|
|
3459605dc0
|
修复: TestStaleLunarRecurringDoesNotFlood 是**日期相关的假红** —— 它要求一个不存在的日期
这条红不是"既有代码缺陷",是**测试的期望写错了**。`go test ./...` 现已 **rc=0 全绿**。
## 形状:断言要求「农历日原样保持」,而目标月可能根本没那一天
if d := lunar.FromSolar(after.EventTime.In(time.Local)); d.Day != lunar.FromSolar(e.EventTime).Day
起点是 `time.Now().AddDate(-1,0,0)`,推进落到哪个农历月**随日期浮动**。
农历月 29/30 天不定 ⇒ 落到 29 天的月份时,"30 日"**不存在**,夹到 29 是
`ToSolar` 明确设计的行为(它连 `clamped` 都返回了)。旧断言却要求 30 ⇒ 必红。
实测(2026-09-21,起点 = 农历七月三十):
落点 = 2026 年农历**八月廿九**,而该月只有 **29** 天
⇒ 正确结果就是 29;旧断言要 30 ⇒ 无论代码对不对都红。
**所以它是一条"日期相关"的假红**:每年那几天必红,与 `git bisect` 的结果无关。
pi 独立复核过它在**上次部署时的 HEAD `e8b260d`** 上同样 FAIL —— 与我一致。
## 我不是靠"看不见"修的:先排除了另外两种解释
- **不是时区/基准不一致**(我第一版猜这个):探针打印了两个基准,
`e.EventTime` 未转本地 / 转本地,**农历日都是 30**,而左侧是 29 ⇒ 基准不是原因。
- 也不是推进逻辑多走了一步:`AddMonths(1)` 对 7月30 得 8月30(`Date` 只加月份),
是**后面 `ToSolar` 往 29 天的月里落时才夹**。
## 修法:期望取 `min(原日, 目标月天数)`
days, err := lunar.DaysInMonth(got.Year, got.Month) // 读不到就 Fatal,不静默
if want > days { want = days }
**没有放松真正的约束**:落到**长月**却少一天,仍然红。
变异验证:把 `RecurLunarMonthly` 的 `AddMonths(1)` 改成 `AddMonths(2)` ⇒
`农历日从 30 变成 29(目标月 30 天,期望 30)` **红**(在最终代码上重跑确认)。
## 由此**新发现**一条真缺陷(本次**不修**,因为要改产品语义)
顺着"夹取"往下量,发现 `addSolarMonthClamped` / 农历 `AddMonths` 的夹取是**粘的**:
公历 每月31日: 1-31 → 2-28 → 3-28 → 4-28 … (3 月有 31 天却停在 28)
农历 每月30日: 7月30 → 8月29 → 9月29 → 10月29 …(9 月有 30 天却停在 29)
一旦被夹过一次,**此后再也回不到原始日**。而 `calendar.go:401-405` 的注释恰恰把
"一次溢出永久改变规则"称作**要避免的** bug —— **注释说的和实现对不上**。
根因:落点被写回 `event_time`,而**没有一列保存原始锚点日**(`calendar_events` 无此列)。
要真修得加锚点列 + 迁移,改的是**产品语义**,不是本次部署能顺带做的 ⇒ 已如实报给 pi。
|
2026-09-21 05:06:53 +08:00 |
|
|
|
258b88da22
|
修复: 判据 ① 的**分母**混着没比过的文件 —— scanned++ 在 readFile 之前,读失败静默跳过
与 A 条(`catch { return; }` 吞 ENOENT ⇒ 报"一致")**同一族**:分母里混着没真比过的
对象 ⇒ 报出来的 `0` 不是闭合的。这条是**我自己写的**代码里的同形错误。
## 形状
scanned++; // ← 先加分母
try { text = String(readFile(full,'utf8')); } catch { continue; } // ← 读不到就静默跳过
if (text.includes(REPO)) offenders.push(full);
⇒ 一个「文件存在、但读不到」(EACCES / 悬空软链 / I/O 错)**被计入"比了 N 个",
却从没被 grep 过**,note 照样报"比了 N 个文件、命中 0"。
实测复现(喂一个"存在但读抛 EACCES"的文件):note 说"比了 **2** 个",真正被 grep 的只有 **1** 个。
## 修法
1. `scanned++` 移到 `readFile` **成功之后** ⇒ 分母 = 真读到内容的条数;
2. 读失败的单列 `unreadable` 并**判红** —— "我没能检查它"与"它没问题"是两件事;
3. 失败 note 把三类分清楚(引用仓库的 / 软链指向仓库的 / **我没读到的**),
第三类点明"这几条没被检查",否则读者会把它读成"它引用了仓库"。
## 变异验证(两边都跑)
变异①`scanned++` 挪回读之前(旧语义)⇒ 新判据**红**;
变异②读失败**静默跳过**(既不红也不计)⇒ 新判据**红**。
修后自检 66 通过 / 3 失败,那 3 条是**既有线上红**(①b 权限 3 文件 600↔644、
⑥ 二进制含 63 处源码路径),与本次改动无关。
## 顺带:把 §六 那条推理写进注释
`DEPLOY_ROOT` **故意硬编码**、不读 `AGENTMAIL_PREFIX` —— 因为前缀写错会以**红**暴露
(② 与 `configNeedle` 都落到 `else fail('指向别处')`),是 **loud failure** 而非假绿。
并写明:真要收应从 `current` 软链推导,而不是再加一个三方要同步的常量。
★ 另记一条口径(pi §一 给的验收是 133,今天实测是 **135**):判据数的分母会随机器
变化(这里是 70 普通文件 + 65 软链),**所以验收标准不该钉死在某个绝对值上**,
该钉的是"分母 = 真读到内容的条数"这个**关系**。
|
2026-09-21 04:54:45 +08:00 |
|
|
|
ecf98d7d36
|
文档: 记下 B-7.7 那条债的**实测实例** —— 每日 04:00 的 dsh 重启让重投每天复发
与契约里 2026-09-04 那个事故**同形**,只是换成了"宿主被定时重启"。链条完整量到:
1. root crontab `0 4 * * * /usr/bin/systemctl restart dsh.service`(第 6 行)
2. 每天 04:00:03 `dsh.service` Stop/Start(journalctl 逐日可见)
3. 新进程 ⇒ `deliveredMails` 空(index.ts:469,内存 BoundedSet)
4. `caughtUp` 重置 ⇒ 首个心跳后 catchUp 跑一次(:470 / :524)
5. 拉 `/mail/inbox?status=unread&limit=20`(:481)⇒ 整个未读积压当天重投一遍
实测(以 `mails` 表为判据):真邮件投递 **107 次 / 81 封**不同邮件,
04 点整点占 09-18:4 / 09-19:3 / 09-20:3 / 09-21:7;投递时刻落在
04:00:23 / 04:01:23 / 04:03:23… 的 ≤5 封批次节奏上(B-7.2)。
`session/end-seed` 也逐日落在 04:00。
★ 关键判别用**时序**:这 107 次里「投递时该读者已有已读行」= **0 次**
⇒ catchUp 从不重投已读邮件,它投的是"当时确实未读"的。"未读"有两个独立成因:
(a) `read_mail` 不标已读(T-12 对已读状态沉默);(b) `markReadFor` 占位符错位
(2026-09-20 修)。两者都让"读过了"没变成"已读"。
⚠️ 我把这里算错过两次,都写进文里当纪律:
① `agent/inbox/spliced` 里混着**非邮件**注入(后台作业完成通知、续跑提示),
按"事件数"统计会当成邮件(我一度报 09-20 有 18 次,真值 **3**);
⇒ 要拿 id 去问 `mails` 表在不在,才算真邮件。**同一事件类型 ≠ 同一种载荷。**
② 判断"有没有重投已读的信"**不能看当前 `mail_reads`**:11 封现在是"已读+曾重投",
看着像反例,其实是**先被重投、后来才补标**的。必须拿**每次投递的时刻**比 `read_at`
—— `mail_reads` 是当前状态,"投递时是否已读"是历史事实,两者不是一回事。
dsh 桥去重仍是内存态 ⇒ B-7.7 要求的落盘账本这条债**仍未销**(已在文里写明)。
|
2026-09-21 04:45:52 +08:00 |
|
|
|
63d430f423
|
文档: API.md 记一条 —— **"计数"对了不代表"账记上了"**(已读权威列静默少行)
接在「已读按调用者记录」那节后面(同一段语义迁移的下游后果)。
`markReadFor` 把 reader 前置成 `$1`,而调用方 `where` 早把 `$1` 用成 recipient
⇒ 绑定表右移一格 ⇒ 最后一封永远插不进(单封一封都不插)。
`marked` 与 `mails.status` 都对,只有权威列 `mail_reads` 静默少行
⇒ 邮件"看起来已读、实际仍算未读" ⇒ 重启补投时被当新信重投。
**为什么 7 天零痕迹**:`marked` 来自**另一条 UPDATE**,而当时那 4 个测试断言的
是冗余列 `mails.status` —— **判据守错了列**。
教训(写成可复用的):**一个值的"计数"来自 A 语句、"记账"来自 B 语句时,
A 对不代表 B 对;判据必须断言决定行为的那个列。**
修在 `17908c1`,回归判据 `internal/repo/markread_authcolumn_test.go`。
|
2026-09-21 04:26:49 +08:00 |
|
|
|
17908c1623
|
修复: 已读**权威列**静默少行 —— markReadFor 占位符整体错位一格(最后一封永远标不上)
★ 这是我自己那封"重投"的根因。我先在自己的收件箱上量到症状:
`read_inbox` 列了 **5 封**,落库只有 **4 封**变成已读,**少的正是最后一封**。
顺着症状读 `repo.go` 才看到机制,不是先读代码再猜。
## 根因:reader 的占位符与调用方的 `$1` 撞号
`markReadFor` 原把 reader **前置**成 `$1`:
append([]any{reader}, args...) // → $1=reader, $2=args[0], …
而两个调用方的 `where` 早把 `$1` 用成了 recipient:
MarkMailsReadFor : $1=recipient,$2..$(N+1)=ids,args=[recipient, ids...]
MarkAllInboxReadForSession : $1=recipient,$2=sessionID,args=[recipient, …]
⇒ 绑定表整体**右移一格**,`IN ($2 … $(N+1))` 实际收到 `(recipient, id1 … idN-1)`:
· **最后一封永远插不进**(idN 从没被绑定)
· **只传 1 封时一封都不插**(`$2` = recipient)
· 带 session 那条 `$2=recipient` 被当成 `session_id` ⇒ 同样一行不插
## ★ 为什么长期零痕迹(比 bug 本身重要)
调用方返回的"标了几封"来自**随后那条 `UPDATE mails`**,它用的是**没被前置**的 args
⇒ **计数正确**、冗余列也正确,**只有权威列 `mail_reads` 静默少行**。
而未读判据是 `mail_reads`(`unreadFor`)⇒ 邮件**看起来已读、实际仍算未读**
⇒ 重启补投(`catchUp`)时被当新信**重投**。
**原有的 4 个测试全都守着这个 bug**:它们断言的是 `statusOf()` —— 读的正是那列
**冗余**。判据读错了列 ⇒ 守的是"看起来对"的值,不是**决定行为**的那个值。
## 实测(探针 + 变异,两边都跑)
修复前:单封 mail_reads=0(期望 1);传 4 封 → n=4 但只插 3 行(丢第 4 封)
修复后:单封 mail_reads=1;传 4 封 → 插 4 行;抄送、幂等、MarkAllInbox 各自 1 行
新判据 5 条在**旧实现上变异验证**:4 条红(含指名"漏标第 [5] 封"),
第 5 条「别人的邮件不该留下行」**正确地不红**(鉴权本来就没坏)。
★ **我中途假绿过一次,如实记**:第一版探针只有 `t.Logf` 没有 `t.Errorf`,
变异态 `go test` 仍 **rc=0** —— 读数(0/3 vs 1/4)确实变了,但**判据没断言**。
这正是本仓那条「判据在,但走不到」。改成 `t.Fatalf` 后才真抓住。
## 我另外自伤了一次(同一个判据抓到我)
`mail_status_readers_test.go` 数的是**原始源码**里 `mails.status` 的出现次数(不剥注释)。
我新写的注释里就有一句"…冗余列 `mails.status` 正确",把清册从 **13 撑到 14** ⇒
`TestMailsStatusReadersAreRegistered` 红。改掉那句措辞后回到 13。
⇒ 记一条:**在那个文件里写注释也会被记账**,说明它数的是"字面出现"而非"真读列"。
(我没有改那个判据 —— 它数得紧是有意的,我改的是自己的措辞。)
## 状态
`go test ./...`:`internal/repo` 仍有一条红 `TestStaleLunarRecurringDoesNotFlood`
(「农历日从 30 变成 29」= **日期相关**)。**我把 `repo.go` 还原成 HEAD 版单跑过,
它同样 FAIL** ⇒ 与本次改动无关的既有红,我没动它。
其余 12 个包全 `ok`。新判据 5/5、原有 `TestMarkMailsReadFor*` 4/4。
|
2026-09-21 04:25:17 +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 |
|
|
|
6ef079365a
|
跨端: 修「两个动作球不能重叠」那条判据自身(它的结束边界把几十行外的东西也吃进来了)
上一步扩 Unicode 图标扫描面时,这条判据立刻报:
MailDetailPage.ets:664(Stack 里有 2 个圆角元素且没有 Row 包住)
而 L664 那个 `Stack` 是**新加的返回键**,里面**只有一个子元素**(chevron 图标)。
查清根因(判据自身两个缺陷):
① **结束边界靠猜缩进**:原来写 `\n\s{8}\}` —— "8 空格 + 右括号"。
而那个 Stack 的 `}` 缩进是 10 空格、它下面还有一整段同缩进的兄弟节点
⇒ 非贪婪匹配一路吃到**下一个** 8 空格的 `}`,
把几十行外两行的**小徽标**(权限/未读,`.borderRadius(4)`)也算了进来。
⇒ 改成**按大括号配平**取块。缩进是可变的,配平是语法事实。
② **"圆角"被当成"圆球"**:原判据数的是所有 `.borderRadius(\d+)`。
而 `borderRadius(4)` 是**圆角方**(徽标),根本不是球。
本判据要防的是"两个**球**叠在同一个角",不是"任何带圆角的东西"。
⇒ 只认 `borderRadius(N)` 且 **N ≥ 16** 的(动作球是 24/28 那一档)。
★ 改完做了**变异验证**(判据自己的要求:必须"在变异下咬得住"):
把包着两个球的 `Row({ space: 12 })` 拆掉、让它们直接待在 `Stack` 里 ——
判据**立刻转红**(30→29 pass/1 fail);还原后 30 pass。
这说明它测的还是原来那件事,只是不再误伤。
|
2026-09-20 13:42:51 +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 |
|
|
|
f604badf86
|
判据: 三条判据锚在"实现位置"上,A 步搬家后假红 —— 改成锚不变量
用户裁定做 A(harmony 补 store 层)之后,`harmony-logic.test.mjs` 红了 4 条。
**一条都不是 bug**:它们读 `pageCode`(`MainPage.ets`)找那些调用,
而调用已经搬进 `common/MailStore.ets`。
这正是本仓反复出现的形状:**判据锚在实现位置上,而不是它声称的东西上**。
一处实现搬家(哪怕搬得更对)就假红 —— 而假红比漏报更坏,
它会让人去改本来对的代码。
★ 修法:引入 `compositionCode`(页面 + store 合起来看),按**归属**拆断言
· **取数/组装**(谁调 `sumUnreadTotals` / `partialLoadNotice` /
`groupMailsBySession`)→ 读 `compositionCode`
· **渲染**(用户看到什么:单封平铺、多封按展开态、组头可点、卡片视图)→ 仍钉 `pageCode`
(那是页面该管的事,不该被搬走)
不变量是"这条纯逻辑真的被用上了"——**用它的人搬去哪一层不该让判据红**。
★ 顺带修掉一条**自己骗自己**的断言(变异测试抓出来的)
「先分家再折叠」原本写成 `splitAt < groupAt` 两个 `indexOf` 比大小,
并断言两个都 `>= 0`。我把顺序反过来做变异 —— **它没红**:
因为反过来之后第二个字面量变了、`indexOf` 返回 -1,红的是"两个都 >=0"
那条,而不是顺序那条。也就是说:**顺序这条判据从来没在判顺序**。
改成直接断言**数据流形状** `groupMailsBySession(split.normal)` ——
它同时表达"折叠的是筛过的那批"与"必须先分家"。再变异(换成 `mergedMails`)
⇒ fail=2,这次真的咬住了。
★ 判据 14 也从一条拆成两条(取数侧 / 渲染侧),名字改成
「折叠逻辑真正接上了(不是"逻辑写好了没人用")—— 取数侧 + 渲染侧都要在」。
harmony-logic 32/32 绿。注册数不变(拆一条加一条,净持平 32)。
|
2026-09-20 11:34:44 +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 |
|
|
|
a7896a7e3a
|
文档: CRITERIA.md §16.1.3 —— 判据锚在"字面相邻"隐含断言"不许再多一层"(假红的来源)
记 pi `7d98245a` §三 报的那条假红(`Motion.dur(...)` 包一层 ⇒ `duration:\s*Theme\.durRise`
失配),连同我自己量到的两半:
· 无障碍层**零判据**:`isAnimationReduceEnabled`/`Motion.dur`/`Motion.reduced`
在 test/ 里 grep 各 0 次;WebUI 侧**有**(animation-audit.test.mjs:112)。
变异(`Motion.dur` 恒 return want)⇒ 失败集合逐条相同 ⇒ **零反应**。
· ★ 绕过面比"都包了"更大:**11 个真动画时长站点里 5 个绕过开关**
(CalendarPage:373,410 / MainPage:2607,2845,2884 裸 `Theme.durX`)
⇒ "收成一个入口"的设计意图**只落了一半**。
通用纪律:**判据锚在"字面相邻"时,它其实断言了"两层之间不许再有一层"** ——
而那个隐含断言几乎从不是作者本意(本意是"这个令牌要被引用")。
二者在实现多一层合法卷绕(包装函数/无障碍开关/单位换算/类型转换)时**必然分叉**,
方向是**假红**。
⇒ 与 §16.1.2 合成一条:**判据的严格度必须与它真正想守的那件事对齐,
而不是与"当时那版实现的写法"对齐。**
|
2026-09-20 05:45:42 +08:00 |
|
|
|
b16d07b625
|
修复: 假红 —— Motion.dur(...) 包一层就让 paneRiseIn 的时长断言失配(pi 报)
pi `7d98245a` §三 报的形状,我**独立复现并量了两棵树**(隔离 worktree,同刻对照):
A 干净 HEAD ⇒ 19 tests / 18 pass / **1 fail**(not ok 16,设备条)
B HEAD + 那份未提交的无障碍改动 ⇒ 17 pass / **2 fail**(多出来的正是 `not ok 8`)
`Theme.paneRiseIn()` 现在写成
.animation({ duration: Motion.dur(Theme.durRise), curve: Theme.easeRise })
而 `harmony-nav.test.mjs:441` 是 `/duration:\s*Theme\.durRise/` —— 要求 `duration:`
与 `Theme.durRise` **紧邻**,中间多一层 `Motion.dur(` 就失配。
语义上 `durRise` **仍被引用**(`Motion.dur(x)=reduced()?0:x`,只在系统开"减少动效"时折成 0)
⇒ **假红**。
★ 放宽的**只是"中间能不能多一层卷绕",判据的区分力一点没动**(3 个方向实测):
/duration:\s*(?:Motion\.dur\()?Theme\.durRise/
① `Motion.dur(Theme.durBase)`(换令牌) ⇒ 仍红 ✓
② 裸 `Theme.durBase`(包一层来蒙混) ⇒ 仍红 ✓
③ 干净 HEAD 形状但换令牌 ⇒ 仍红 ✓
且下面那条 `!/Theme\.durBase/` **逐字仍在**、扫的是**整个函数体**
⇒ 放宽的是**包装**,不是**令牌**。
修后:主树 `18 pass / 1 fail`(只剩 not ok 16 那条设备条);
全套 `red 5 → **4**`(`harmony-nav` 从红名单里出去了,其余 4 条红都是别的会话的)。
★★ 顺带量出一条**我认为更值钱**的:无障碍层**零判据**(pi §三 的另一半,我确认)
· `client/electron/test/` 里 `isAnimationReduceEnabled` / `Motion.dur` / `Motion.reduced`
grep **各 0 次**;对照 WebUI 侧 `animation-audit.test.mjs:112` **有** reduced-motion 判据。
· **pi 的变异我复现了**:把 `Motion.dur` 改成恒 `return want`(整个无障碍开关失效,
源里 6 处受影响)⇒ `harmony-nav` 失败集合与变异前**逐条相同**(`17/2`)⇒ **零反应**。
· ★ 而我还量到一个 pi 没说的:**11 个真·动画时长站点里有 5 个绕过开关** ——
`CalendarPage.ets:373,410`、`MainPage.ets:2607,2845,2884` 都是裸 `Theme.durX`,
只有 6 处走了 `Motion.dur(`。即"收成一个入口"这个设计意图**目前只落了一半**。
⇒ 这一半我**没有**加判据也没改源码:`Motion.ets` **尚未被 git 跟踪**,
对它写判据会让 HEAD 立刻变红(那是别人未完成的在制品,不是我的改动范围)。
已把精确读数报给 pi,由落 `Motion.ets` 的那次提交去补判据。
|
2026-09-20 05:45:03 +08:00 |
|
|
|
1c3335f0c9
|
修复: 第五个落点 —— 判据写对了,却**没有任何人执行它**(check-file-modes.sh 空转)
pi `4fc16f66` 报的是 rc=2 那半个缝(M18)—— 我复核后**已经在 `e99a657` 修好了**,
而它的**父提交正是 pi 报信时读的 `6ee9902`**。又是同一形状的竞态。
但照 pi §四 那句「**每条分支的前提本身也要能被构造**」做审计时,
我发现**第五个落点,而且这一格的判据本身写得完全正确**:
`deploy/check-file-modes.sh` = 源文件权限政策的**唯一**判据。
它甚至专门修过自己那份 `[ -x ]` 恒真的 root 陷阱(`b7dc9e9`),改成直接读权限位 —— 修得对。
**但它从来没有被任何东西执行过。** 全仓 `grep -rn "check-file-modes"` 只有:
· `check-deploy-drift.mjs` 3 处**注释**(谈分工)
· `run-all.mjs:951` **注释**(讲 root 陷阱)
· `summary.py:77` **注释**,且**说法与实现不符**
· `summary.py:431` 一句 `print(...)` 的**文案**
⇒ 唯一一处非注释提及是一句 print 的文案。没有 install.sh / redeploy-* / hook / CI / cron 调它。
**而它当时正红着**:2 个文件是 600 + `jobs/` 缺属主 x 位。
★ 为什么这一类**只能靠判据守**(实测):
`git status` 看不见、`git diff` 看不见、`git ls-files -s` **只记 100644/100755**
(组/其他读位**不进版本库**)⇒ 把受跟踪文件 `chmod 600 ↔ 644` 两次 `git status` 都空。
套件也看不见(`jobs/` 缺 x 位时实测 `diag=none`、`ran=48`,全绿)⇒ **没有第二条通道**。
0600 在"跑的人恰好是属主"时不炸,换身份就是 EACCES,而那串报错**看起来像代码问题**。
修法:
· 接进 `deploy/install.sh`(check-shared-libs 之后),**走 `--check` 累积通道**
(照既有 npm_rc/CHECK_GATE_RC)。⚠️ 我第一版**直接调**,而门禁红时它 `exit 1`、
install.sh 是 `set -e` ⇒ 当场吞掉后面所有诊断:**实测**接线后 `构建 Gateway`
在日志里命中 **0 次**(接线前 2 次)—— 正是 install.sh:141-157 刚修过的同一个毛病,
我自己又造了一遍。改累积后回到 2 次,门禁报 `[FAIL] 权限政策没过(退出码 1)`。
· 修那两个文件:600 → 644 ⇒ 权限政策现在绿(rc=0)。
· 新增判据 `criteria-hygiene` 第 7 条:政策门禁必须被入口脚本在**可执行位置**调用。
★★ 变异验证时**我自己先假绿了一次**(单记):第一版用 `code()` 剥注释,而
`lib/read.mjs` 的 `stripComments()` 只认 `//` 与 `/* */` —— 那是 **JS** 的注释,
**`install.sh` 是 shell,注释是 `#`** ⇒ 对 shell 文件**原样返回** ⇒ 判据读到了
**我写在它上面那段解释里的** `` # `check-file-modes.sh` 红时 `exit 1` ``。
变异①(接线整段删掉)⇒ 第一版 **rc=0(假绿)**;自剥 shell `#` 后 **rc=1** ✓
变异②(只在 echo 里提一句)⇒ rc=1 ✓
变异③(gates 恒空 ∀x∈∅)⇒ rc=1(`只找到 0 个`)✓
(③ 第一次 python 锚点没匹配上、变异没施加,我重测并**先打印"变异已施加"**才算数。)
★ 射程如实标出:只管 `deploy/check-*.sh`(政策门禁族);**不管** `check-deploy-drift.mjs`
这类**按需手动工具**(自带 `--self-check`、文档写明"事后自查")——要求它进入口是**错的**。
它实测同样 0 处调用,但**本判据不覆盖它,也没修它**。判"有没有接线",不判"接得对不对"。
★ 通用纪律(CRITERIA.md §16.1.2):**判据自己也要能"被证明它真的在看代码"。**
若判据读的**语言**与 stripComments 实现的**语言**不一致(shell vs JS、Python vs JS),
那"剥注释"是**假的** ⇒ 判据消费散文,而**变异验证是唯一能戳破它的东西**。
全套 checks=516→**517** pass=512 fail=5 red=5 broken=0 verdict=red(5 条红都是别的会话的);
mutants=48 ran=48 skipped=0 diag=none baseline=7/7✓;5 个自检各 rc=0。
|
2026-09-20 05:22:42 +08:00 |
|
|
|
efb1c1d2ca
|
修复: 第四个落点 —— **变异条目的锚点失效**(hits=0):守具有齿,却不在位
pi `6aa2b17f` 报的第三例(`unlisted`/`ghosts` 退 0)**我复核后已经在 `6ee9902` 修好了**
—— 而它的**父提交正是 pi 报信时读的 `4b841e0`**(差 11 分钟)。又是同一形状的竞态。
但顺着同一条线**审计全部 61 个变异条目的锚点**,发现**还有一个同形状的落点,
而且它在真树上是活的**(不是构造的):
hits=0 SettingsPage.ets 「管理入口不做门禁」(jobs-all.json)
根因:该锚点写的是 **6 空格**,而 `c523c21`(09-17 17:43)把该文件**重排成 4 空格**
⇒ 锚点从此命中 0 次(写进清单时 `bcd4f97`(09-15)它是**对的**)。
为什么没人发现:`summary.py` 把「hits=0 … 过期条目」**只打印**、**不进严重度链**
⇒ `rc=0`、`diag=none` ⇒ 套件 `whyLines: (note || status!==0) ? … : []` 为假 ⇒
**整段丢掉**。端到端实测(真树、干净工作树):套件输出里 grep「过期条目」= **0 次**。
★ 危害是"**一个变异守具被静默关掉**",不是"数字错了":`ran` 少 1、`skipped=1`
是个**中性数字**,读者看不出少了哪一个。而**手工施加那个变异仍能让判据红**
(`harmony-admin.test.mjs` `# fail 1`)⇒ **有齿,只是没挂上**。
修法(沿用 `summary.py` 的**唯一严重度链**):
· 新增 `mutant-anchor-stale` ⇒ **1 档**(清单/数据该改;**不是** 2 档 ——
照 `env-defaults.sh:25` 的反方向:别让"清单没跟上"冒充环境)。
· 修锚点 6→4 空格 ⇒ `ran` 47→48、`skipped` 1→0,守具重新挂上(实测变异能红)。
· `whyLines` 过滤器加 `^\s*hits=`:只说"有锚点过期"不够,**点名的才是可行动的**。
· 两张表都登记(`UPSTREAM_RC` + `DIAG`,`blocksGreen: true`)+ 真跑案例
(迷你仓库里放一个不含锚点的同名文件 ⇒ `hits=0`)+ 读者侧具名案例。
★ 射程如实标出:判据只看 **`hits == 0`**,**不看 `hits == -1`**(文件打不开是另一回事,
且迷你夹具里目标文件本来就不在 ⇒ 算进来会造假红)。**`-1` 那一半无判据守着**(真树 0 条)。
变异验证(4 个方向全抓):
① 新档关闭(回到"只打印")⇒ exitcode-selftest rc=1、2 条红 ✓
② 从 UPSTREAM_RC 删掉新码 ⇒ 反向覆盖点名 ✓
③ blocksGreen true→false ⇒ 双向口径漂移红 ✓
④ 放宽成任何 skipped_detail(含 hits=-1)⇒ rc=1、**5 条红**(夹具假红)
⇒ **`h == 0` 这个射程是承重的** ✓
端到端 A/B(隔离 worktree,同刻对照):
A 锚点已修 ⇒ rc=0、diag=none、ran=48/skipped=0、"锚点已失效" grep **0**
B 锚点退 6 格 ⇒ rc=1、diag=mutant-anchor-stale、ran=47/skipped=1、grep **2**
★ 通用规则(本仓第 4 次同一形状):**"跑了多少个"与"该跑多少个"之间也要有判据。**
被跳过时 `ran` 只少 1、`skipped` 只多 1 —— 都是中性数字,而"少了哪一个"没有通道。
凡"登记一批东西、再逐个挂上"的结构(变异条目、判据、样本表)都要问:
**挂不上的那一个,谁来说?**
自检 5 个各 rc=0;全套 checks=516 pass=511 fail=5 red=5 verdict=red;
mutants=48 ran=48 skipped=0 on_new_criteria=36 diag=none baseline=7/7✓。
CRITERIA.md §16.1.1 记这一笔(含 4 个变异与 A/B 表)。
|
2026-09-20 04:49:50 +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 |
|
|
|
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 |
|
|
|
3123d83979
|
判据: 把「面当字」的防线从死名单改成判矛盾(三次迭代都记下来了)
上一版加的静态防线只 grep `fontColor(Theme.surface)` —— **一个名字**。
那是死名单:以后加了新令牌它不会跟上(本仓已记录过多次这类错法)。
改成从源码推:
**条件 A**:值是 `Resource` 且指向 `sys.color.*` ⇒ **跟随系统主题翻转**
**条件 B**:既当 `backgroundColor` 又当 `fontColor/iconColor` ⇒ **被当成「面」也被当成「字」**
判决 = **A ∩ B**,不需要维护任何名单。
## 三次迭代(每次错得很典型,所以都写进注释了)
- **① 死名单**(只盯 `surface`):新增令牌不会跟上。
- **② 只要 A**("是翻转的 Resource 就不许当前景色"):**太宽** —— 实测立刻
报出 20+ 处"违规",全是 `textMuted` / `textSubtle` / `textPrimary`。
那些**本来就该**当前景色(语义就是文字色,跟随主题翻转是对的)⇒ 假红一片。
- **④ 只要 B**("矛盾就报"):**也宽** —— `accent` / `danger` / `approve` / `warnFg`
两边都用是正常设计(蓝底白字主按钮 + 白底蓝字返回箭头)。
和 `surface` 的**关键区别**:那些是**饱和的字符串常量**(不翻转、语义是"色"),
而 `surface` 是**中性面 + 跟随主题翻转**(当字色 = 把底板当墨水,深色下必然看不见)。
- **最终 = A ∩ B**:恰好命中 `surface` 这一类,且**不随新增令牌失效**。
允许清单现在是**空表**(修完 `surface` 后没有例外),
并写明「已审过、不需要进这张表的」那几个令牌与理由。
## 变异验证(两个都做了,第二个是关键)
1. 把一处 `.accentFg` 改回 `.surface` ⇒ **判红**;还原 ⇒ 绿。
2. **造一个判据从没见过的新令牌**(`surfaceAlt`,同样 `Resource` + `sys.color.*`),
让它同时当背景与前景 ⇒ **判红**(报文里点名 `Theme.surfaceAlt`);还原 ⇒ 绿。
—— 这条比 ① 重要:它证明判据**不是靠记住 `surface` 这个名字**在工作。
## 顺带确认
`pageBg` / `surfaceMuted` / `wallpaperScrim` / `border` / `overlay` 这五个
同类"会翻转的面"**一处都没被当字色用过** —— 这一类现在全清了。
`run-all.mjs` → `checks=513 pass=513 fail=0 skip=0 red=0 broken=0 unreported=0`。
|
2026-09-19 19:39:02 +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 |
|
|
|
ac62dafde1
|
跨端: 往返预算编辑上线(详情页缺的第三块)+ 修折叠头部点不中的真 bug
审计发现详情页缺三块功能之三:**往返预算**(WebUI `BudgetEditor`)。
`MailApi.setBudget` 早就有了,缺的是 `getBudget` 与 UI。
## 一、预算条(对齐 WebUI,两态)
显示态是徽标(`已用/上限 来回` / 不限时「预算不限」/ 用尽时标红),
点它进入编辑态:输入框 + 保存 + 重置 + 取消。
★ 「用尽」的判定**走服务端的 `remaining`**,不自己拿 `used >= max` 算:
不限时服务端给 `remaining: -1`,那个式子在那种情形下会算出"已用尽"。
两端都得用服务端的口径。
★ 「保存」与「重置计数」走**同一条 PUT**(服务端支持两者同时给,
它的注释原话:「加到 20 并从头算」是一次很自然的操作,
拆成两个请求只会让前端多一次往返)。
**契约实测**(不是形态检查):
GET → {"max_rounds":0,"used_rounds":1,"remaining":-1,"unlimited":true}
PUT {"max_rounds":7,"reset":false} → {"max_rounds":7,"remaining":6,"unlimited":false}
PUT {"max_rounds":0,"reset":false} → {"remaining":-1,"unlimited":true} ← 0 = 不限
## 二、真 bug:折叠头部的展开区只有 **14px** 高
设备实测 dump:`Row [1277,112][3122,126]` —— 可点区高度 = 文字高度(14px 字号)。
而全屏之后**状态栏也在 y=112 那个区间** ⇒ 点它反复触发**系统手势(下拉通知)**
而不是展开头部。我为此试了七八次,每次都回到桌面。
而收起态头部里藏着**这个会话的全部旋钮**(收件人/时间/抄送/权限档/预算)——
点不中就等于那些都看不到。
修:给那一行 `height(36)`(与左边返回键同高)。实测可点区
**14px → 43px**(`[1277,112][3122,155]`)。
★ 判据 + **变异验证**:去掉 `.height(36)` ⇒ 判红;还原 ⇒ 绿。
## 三、判据
`harmony-logic` 新增「折叠头部展开区高度 ≥32vp」一条(含变异自检)。
`run-all.mjs` → `checks=511 pass=511 fail=0 skip=0 red=0 broken=0 unreported=0`。
**未验**:预算条在设备上的**观感与点击**(模拟器顶部 155px 是系统手势区,
而头部恰在其中 —— 坐标式点击在那里会被系统抢走;真机上头部在状态栏之下)。
预算条的**数据链路**已用 curl 逐条实测(见上),但"点保存按钮后徽标变化"
这一步没在设备上走通。
|
2026-09-19 17:41:09 +08:00 |
|