跨端: 品牌蓝深色下没提亮(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`。
This commit is contained in:
2026-09-19 20:05:33 +08:00
parent 3123d83979
commit 13b557a742
12 changed files with 362 additions and 39 deletions

View File

@ -235,9 +235,16 @@ const SELF_OWNED_COLORS = [
* `chrome-600` 刻意不透明(`index.css:1097` 有注释:15px 小控件叠透明度
* 会让数字掉到 4.46:1,低于 WCAG AA)。
*
* 三者的取值理由都已写在 `Theme.ets` 各自的注释里(本判据的要求)。
* · `accentDark` #80AFF9 —— WebUI 的 `.dark --c-blue-600`(`index.css:481`)。
* **这是"前景专用"的品牌深色档**,不是 `accent` 的替代:
* WebUI 那边是双通道(`--s-blue-*` 实心按钮底两模式同值 /
* `--c-blue-*` 内容用蓝在 `.dark` 段提亮),这里跟着分。
* 不提亮的话深色下品牌蓝字在近黑底上只有 **2.61:1**(设备实测),
* 低于 WCAG 图形下限 3:1。使用入口是 `Theme.accentFor()`。
*
* 各条取值的理由都已写在 `Theme.ets` 各自的注释里(本判据的要求)。
*/
'navActiveBg', 'navBrandFg', 'badgePlain', 'accentSoftDark',
'navActiveBg', 'navBrandFg', 'badgePlain', 'accentSoftDark', 'accentDark',
/*
* SSE 连接指示器的四个状态色(`WideSidebar.ets` 的 `sseColorOf`)。
* 逐档对齐 WebUI 的 Tailwind 类(`ConnectionIndicator.tsx:22-25`):
@ -614,12 +621,49 @@ test('遮罩:交给系统的遮罩语义色("随主题换向"这件事现在
* 否则仍然算死:这正是 `navMaterial` 的形状 —— 它当时那个"内部消费者"是
* `MainPage` 里的一张**局部表**,不提供任何担保。
*/
/*
* ★★ 2026-09-19:改成**走整条链**,不只一跳。
*
* 原来的写法只问"读它的那个方法在外部有没有调用点"。加了 `KEY_IS_DARK`
* 之后立刻假红:它的链是
* KEY_IS_DARK ← Theme.isDarkNow() ← Theme.accentFor() ← 24 处页面
* `isDarkNow` 自己**只被 Theme 内部的两个 `For` 方法读**,外部一处都没有
* ⇒ 一跳就判成孤儿。而它显然不是孤儿(24 处页面在用)。
*
* ★ 这个坑值得记:**"有没有人用"是可达性问题,不是邻接问题。**
* 只查一跳的判据在"中间加了一层"时必然假红 —— 而加中间层
* (抽个 `For()` 入口)恰恰是我们鼓励的写法。
*/
if (internalUsers.length > 0) {
const ownersAlive = internalUsers.some((u) => {
const methodName = u.replace('Theme.', '').replace('()', '');
return othersSrc.some((o) => new RegExp(`Theme\\.${methodName}\\b`).test(o.src));
});
if (ownersAlive) continue;
const alive = new Set();
const isUsedExternally = (name) =>
othersSrc.some((o) => new RegExp(`Theme\\.${name}\\b`).test(o.src));
const queue = internalUsers.map((u) => u.replace('Theme.', '').replace('()', ''));
let hops = 0;
while (queue.length > 0 && hops < 12) { // 环/自引用保护
hops++;
const cur = queue.shift();
if (alive.has(cur)) continue;
alive.add(cur);
if (isUsedExternally(cur)) {
alive.add('__externally_reached__');
continue;
}
/*
* 它自己没人从外面调 —— 继续往上找"谁在 Theme.ets 内部读它",
* 直到某个环节在外部有调用点(或链条断掉)。
*/
const re3 = new RegExp(`Theme\\.${cur}\\b`, 'g');
let m3;
while ((m3 = re3.exec(themeSrc)) !== null) {
const before = themeSrc.slice(0, m3.index);
const owners = [...before.matchAll(/static\s+(?:readonly\s+)?(\w+)/g)];
if (owners.length === 0) continue;
const owner = owners[owners.length - 1][1];
if (owner !== cur && !alive.has(owner)) queue.push(owner);
}
}
if (alive.has('__externally_reached__')) continue;
deadTokens.push(`${name}(只在 ${internalUsers.join('、')} 内部被读,而那些方法**外部也没有调用点**)`);
continue;
}
@ -855,8 +899,23 @@ test('★ 品牌浅底必须有深色变体(深色下白底卡片 = 刺眼的
'深色下用写死的浅底会让选中卡片在深色页上刺眼');
/* ② 有按主题选值的入口 */
assert.match(themeSrc, /static accentSoftFor\(dark: boolean\): string/,
'★ 要有 `accentSoftFor(dark)` 入口 —— 页面各自写 `isDark ? a : b` 迟早漏一处');
/*
* ★ 2026-09-19:参数从 `dark: boolean` 改成 **`dark?: boolean`**(可选)——
* 因为 `Theme.isDarkNow()` 现在能直接读 `AppStorage` 的全局键,
* 调用方不必再各自把"当前是不是深色"转述一遍(那个约定已经漏过 8 处)。
* 判据跟着放宽成"参数可选",但**入口必须存在**这一点不变。
*/
assert.match(themeSrc, /static accentSoftFor\(dark\?: boolean\): string/,
'★ 要有 `accentSoftFor()` 入口 —— 页面各自写 `isDark ? a : b` 迟早漏一处');
/*
* ②b 品牌**前景**色的深色入口 —— 与 ② 同一理由(2026-09-19 加)。
* `accent` 是两用令牌,但只有"当前景"那一半需要在深色下提亮;
* 当背景时保持饱和(WebUI 的 `--s-blue-*` 两模式同值)。
*/
assert.match(themeSrc, /static accentFor\(dark\?: boolean\): string/,
'★ 要有 `accentFor()` 入口 —— 深色下品牌蓝字/图标必须提亮,' +
'否则近黑底上只有 2.61:1(设备实测)。页面各自写 `isDark ? a : b` 迟早漏一处。');
/* ③ 用到它的地方真的走入口(不是还在直接用浅色那个) */
const pages = readdirSync(HARMONY_ETS + '/pages').filter((f) => f.endsWith('.ets'));
@ -1163,3 +1222,116 @@ test('C|「会跟随主题翻转的 Resource」不得当前景色(含矛盾
' 若某个令牌**确实**两边都该用(有具体理由),加进 `ALLOW_BOTH_BG_AND_FG` 并写明理由。\n' +
conflicts.map((c) => ` · Theme.${c.name}\n 当背景:${c.bg}\n 当前景:${c.fg}`).join('\n'));
});
test('★ 设备:深色下短文本必须都读得动(扫一屏,不只查一个元素)', async (t) => {
/*
* ★★ 这条是上面"品牌色真的画成那个色"的**一般化**。
*
* 那条只钉住悬浮球一个点(品牌色 + 图标可分辨)。但"深色下看不见"
* 这个 bug 类**不止一处** —— 2026-09-19 一天里撞到两批:
* · 12 处 `Theme.surface` 当前景色(会翻转的面色当墨水)
* · 24 处 `Theme.accent` 当字色而**深色下没跟着调亮**
* (WebUI `.dark --c-blue-600` 是 `128 175 249`,这边还是 `#2563EB`
* ⇒ 近黑底上 **2.61:1**,低于 WCAG 图形下限 3:1)
*
* 逐处写判据是追不上的(每加一个页面就漏一处)。**扫一屏**才追得上:
* 把当前页面所有"小段文字"都量一遍对比度。
*
* ## 三个关键实现细节(第一版全踩了)
*
* ① **不能只取文字中心一个像素**:中心多半落在**笔画之间**,
* 读到的是底色 ⇒ ratio=1.00 的假红一片(第一版 20 个里报了 12 个)。
* 正确做法是**框内网格扫描取极值**:最亮=底、最暗=墨。
* ② **整屏解码一次**:`pixelAt()` 每点 spawn 一次 ffmpeg,
* 扫 40 段文字要几百次进程(一条判据几十秒)。用 `readPixels()`。
* ③ **只判"有真实墨迹"的框**:`hi.L - lo.L < 0.02` 说明框里全是底色
* (文字被裁掉、或采样的不是文字),那种**跳过而不是判红** ——
* 否则会报一堆"看不见"而其实是我没采到。
*
* 阈值取 **3:1**:这是 WCAG 对图形/大字的下限。小正文该 4.5:1,
* 但设备上有一堆 10–12px 的辅助文字(时间戳、计数),那档本身就在
* 4.5 附近晃 —— 先按 3:1 收住"真的看不见"这一类,别把噪声一次全报出来。
*/
const D = await import('./lib/harmony-device.mjs');
const hdc = D.findHdc();
if (!hdc || !D.hasTarget(hdc)) {
return t.skip('设备不在 —— 本条的设备半边本次不跑');
}
assert.ok(await D.launchOurApp(hdc), '要能拉起应用');
assert.ok(await D.backToMain(hdc), '要能回到主界面');
await new Promise((r) => setTimeout(r, 1500));
/*
* ★ 只在**深色**下判。浅色下这些令牌本来就是为浅底配的,
* 量它们没有信息量(也不会红),但会拖慢判据。
*/
const img0 = D.readPixels(await D.screenshot(hdc, '/tmp/dark-legibility.png'));
assert.ok(img0, '要能截屏并解码');
const corner = img0.at(4, 4);
const isDark = corner && corner.r < 128 && corner.g < 128 && corner.b < 128;
if (!isDark) {
return t.skip(`当前不是深色主题(左上角像素 rgb(${corner?.r},${corner?.g},${corner?.b}))—— 本条只在深色下有意义`);
}
const lum = (c) => {
const f = (v) => { const x = v / 255; return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4; };
return 0.2126 * f(c.r) + 0.7152 * f(c.g) + 0.0722 * f(c.b);
};
const root = D.dumpLayout(hdc);
let scanned = 0;
const bad = [];
for (const n of D.walk(root)) {
const a = n.attributes || {};
const txt = (a.text || '').trim();
if (!txt || txt.length > 14) continue;
const m = /\[(\d+),(\d+)\]\[(\d+),(\d+)\]/.exec(a.bounds || '');
if (!m) continue;
const [x1, y1, x2, y2] = m.slice(1).map(Number);
const w = x2 - x1;
const h = y2 - y1;
/* 只要"小段文字"的量级:太小的采不到笔画,太大的多半是容器 */
if (w < 20 || h < 14 || w > 700 || h > 130) continue;
if (x2 > img0.w - 4 || y2 > img0.h - 4) continue;
let lo = null;
let hi = null;
const sx = Math.max(2, Math.floor(w / 14));
const sy = Math.max(2, Math.floor(h / 7));
for (let y = y1 + 1; y < y2; y += sy) {
for (let x = x1 + 1; x < x2; x += sx) {
const p = img0.at(x, y);
if (!p) continue;
const L = lum(p);
if (!lo || L < lo.L) lo = { L, p };
if (!hi || L > hi.L) hi = { L, p };
}
}
/* 没有真实墨迹对比的框跳过(见上面 ③) */
if (!lo || !hi || hi.L - lo.L < 0.02) continue;
scanned++;
const ratio = (hi.L + 0.05) / (lo.L + 0.05);
if (ratio < 3) {
bad.push(`「${txt}」 对比度 ${ratio.toFixed(2)}:1 ` +
`墨 rgb(${lo.p.r},${lo.p.g},${lo.p.b}) / 底 rgb(${hi.p.r},${hi.p.g},${hi.p.b}) ${a.bounds}`);
}
}
/*
* ★ 自检:扫到的段数不能太少 —— 太少说明遍历/尺寸过滤写错了,
* 那样下面那条断言**恒绿**(判据静默失效的典型形状)。
*/
assert.ok(scanned >= 5,
`要扫到足够多的小段文字(实测 ${scanned} 段)—— ` +
'太少说明遍历或尺寸过滤写错了,那样下面的检查恒绿。');
assert.deepStrictEqual(bad, [],
`★ 深色下这些文字读不动(对比度 <3:1):\n ${bad.join('\n ')}\n` +
' 深色下品牌色/面色都要换深色变体:\n' +
' · 文字/图标用 `Theme.accentFor()`(深色自动给 `#80AFF9`,' +
'对齐 WebUI 的 `.dark --c-blue-600`)\n' +
' · 品牌浅底用 `Theme.accentSoftFor()`\n' +
' · **别**把 `Theme.surface` 这类"会翻转的面"当字色(用 `accentFg`)\n' +
' 2026-09-19 真撞到的两批:12 处 surface 当字色 + 24 处 accent 深色没调亮。');
});

View File

@ -552,3 +552,55 @@ export function closeColor(a, b, tol = 12) {
if (!a || !b) return false;
return Math.abs(a.r - b.r) <= tol && Math.abs(a.g - b.g) <= tol && Math.abs(a.b - b.b) <= tol;
}
/**
* 把整张 PNG 解码成内存里的 RGB 网格,返回一个 `{ w, h, at(x, y) }` 句柄。
*
* ★ 为什么要有它(2026-09-19 加):
* 上面那个 `pixelAt()` 每读**一个**点就 spawn 一次 `ffmpeg`。单点够用,
* 但"扫一个文字框找最亮/最暗"这种用法要读几百个点 ⇒ 几百次进程启动,
* 一条判据就跑了几十秒(实测 19 个短文本 ~700 次 spawn)。
*
* 整屏解码是**一次** ffmpeg 调用(3184×2232×3 ≈ 21MB 内存),
* 之后所有采样都是数组下标 —— 快三个数量级。
*
* 坐标系与 `dumpLayout` 的 `bounds` 一致(屏幕像素,原点左上)。
*/
export function readPixels(pngPath, screenW = 3184, screenH = 2232) {
if (!existsSync(pngPath)) return null;
const out = join(tmpdir(), `hm-rgb-${process.pid}-${Date.now()}.raw`);
const r = spawnSync('ffmpeg', [
'-loglevel', 'error', '-y', '-i', pngPath,
'-f', 'rawvideo', '-pix_fmt', 'rgb24', out,
], { encoding: 'utf8', timeout: 60000 });
if (r.error || !existsSync(out)) return null;
let buf;
try {
buf = bytes(out);
} catch {
return null;
} finally {
try { unlinkSync(out); } catch { /* 清不掉就算了 */ }
}
/* 尺寸从数据长度反推 —— 这样调用方不必传对屏幕尺寸 */
const px = buf.length / 3;
let w = screenW;
let h = Math.round(px / w);
if (h * w !== px) {
/* 退一步:按常见屏宽找整除(设备可能是折叠态/别的分辨率) */
w = 0;
for (const cand of [3184, 2232, 2560, 1920, 1280, 1080]) {
if (px % cand === 0) { w = cand; h = px / cand; break; }
}
if (!w) return null;
}
return {
w,
h,
at(x, y) {
if (x < 0 || y < 0 || x >= w || y >= h) return null;
const i = (y * w + x) * 3;
return { r: buf[i], g: buf[i + 1], b: buf[i + 2] };
},
};
}

View File

@ -37,10 +37,21 @@
# ⚠️ 这次漂移正是 `baseline-stale`(rc=0、只提示)那一档的又一次实例:
# 它**不假红**(正常提交不会天天红),但也**不会被自动发现** ——
# 我是靠"核每个文件的登记/实际读数"这条主动核查撞上的,不是它自己报的。
#
# 2026-09-19 18:2x baseline 重算(**先核过不是残留,才重算的**):
# ① `pages/AdminUsersPage.ets`、`pages/SettingsPage.ets` —— 我本次的**有意编辑**:
# 给所有"压在彩色底上的字/图标"换前景色(`Theme.surface` → `Theme.accentFg`),
# 并把品牌蓝前景接入 `Theme.accentFor()`(深色下提亮到 WebUI 的 `--c-blue-600`)。
# ② `api/AppearanceApi.ets` —— **与 HEAD 逐字节相同**(`git diff --quiet HEAD` 为空),
# 但底本哈希对不上 ⇒ 说明它在底本取完之后被**合法改过**(提交 `3b5cc63`,
# 09-19 那次"外观保存 400"的修:`payloadFromLocal` 改用 `AppearancePayload`)。
# ⇒ 这是 `stale`(底本过期),不是 `residue`(变异残留)。
# ★ 三个都逐个跑过 `git diff --quiet HEAD -- <f>` 取证,没有一个是"改动忘了还原"。
# ★ 这正是这份文件里那条纪律的第 3 次执行:"重算必须是一次有记录的动作"。
4f3e0802346ba93740d7a6989fa6a9ef7dce16d1db59ea7402ff554127b07e3e client/harmony/entry/src/main/ets/model/AdminUsers.ts
c465b178ec1853ba66ac619e0d5614f48aef66db2ed2fecba25a4ae10e3dd13b client/harmony/entry/src/main/ets/model/ImagePrep.ts
163d05009010ff985b2e1aa5a3b2d277d2dbb43cb75060c281a8401eef4fbf23 client/harmony/entry/src/main/ets/pages/AdminUsersPage.ets
efd598149cb52400f1120f1ba4c7fb41ad9706f82005770479f571614a539e73 client/harmony/entry/src/main/ets/pages/AdminUsersPage.ets
4681c5f2d57870d64b09a10433f5ea518d777a27f6cb7ece3bbcc29c5f11012a client/harmony/entry/src/main/ets/common/BackgroundPicker.ets
22d247ab615b96bb57d20df8a3b95ed2b2ccaac4f9d216361c2ecba8a78df7e0 client/harmony/entry/src/main/ets/pages/SettingsPage.ets
3baed1fac54fef5f924c17f658ad05105b8be3ac35b110e2277b1365a84bb15a client/harmony/entry/src/main/ets/pages/SettingsPage.ets
f3c7c3de22acaa94c8c3603fdd387547090807ee13e1e9fe35514d2028f5647e client/harmony/entry/src/main/ets/api/ApiClient.ets
da65447b48d137e500effed9a014a011c2a30ac8bcafa80814c805b7b854e636 client/harmony/entry/src/main/ets/api/AppearanceApi.ets
6550e1892d1ebea82fb75a3d2cf8eaf826186199ff4a0ecf0430b1e3517d8681 client/harmony/entry/src/main/ets/api/AppearanceApi.ets

View File

@ -68,7 +68,7 @@ const SUITE = [
// 「只有通信页深色正常」的回归锁 —— 38 条里后 8 条是这次新增)。
['test/theme.test.mjs', [], 38],
['test/background.test.mjs', [], 44],
['test/cross-client-theme.test.mjs', [], 19],
['test/cross-client-theme.test.mjs', [], 20],
// 左右滑动翻页的**语义契约**(两端逐项相同、数值各自定)—— 这是
// `docs/DEBTS.json` 的 `gesture-semantics` 那条债:它写着「鸿蒙侧出现滑动
// 手势代码时**立即建**(此前建 = 只有一端存在的假判据)」。

View File

@ -161,6 +161,17 @@ export class Theme {
* ★ 不登记会怎样:深色下选中卡片是接近纯白的浅蓝,
* 在深色页面上刺眼得像 bug(2026-09-19 设备实测到)
* · accentStrong #1D4ED8 品牌深色变体(选中文字/边框)
* · accentDark #80AFF9 品牌色在**深色**下当前景用的取值 ——
* 对齐 WebUI `index.css:481` 的
* `.dark --c-blue-600`(`128 175 249`)。
* ★ 使用入口是 `Theme.accentFor()`,**只用于
* `fontColor`/`iconColor`**;当背景时仍用 `accent`
* (WebUI 的实心按钮底 `--s-blue-*` 两模式同值,
* `index.css:353` 写了理由:“跟着变会让主按钮
* 在深色页面上失去『这是主操作』的视觉重量”)。
* 不登记会怎样:深色下品牌蓝字在近黑底上只有
* **2.61:1**,低于 WCAG 图形元素下限 3:1
* (2026-09-19 设备实测:管理页返回箭头 `‹`)
*
* 业务语义(系统只有 list/warning/alert 这一档,没有"同意/拒绝"):
* · approve #15803D 同意(绿)
@ -210,6 +221,83 @@ export class Theme {
* 这是整个文件里**唯一必须与 WebUI 逐字一致**的取值(判据钉住)。
*/
static readonly accent: string = '#2563EB';
/**
* 品牌色在**深色**下的取值 —— WebUI 的 `.dark --c-blue-600`(`128 175 249`)。
*
* ★★ 这个值只管**前景**(文字/图标)。背景走 `accent`(见下面的说明)。
*
* WebUI 那边是个**双通道**系统,这里得跟着分清楚(`tailwind.config.js`
* 的 `backgroundColor` / `textColor` 覆盖 + `index.css` 的两段定义):
*
* · `--s-blue-600` —— **实心按钮底**。`:root` 定义、两种模式**同值**。
* `index.css:353` 的理由原话:「按钮底色在深色模式下依然是 blue-600
* 那样的彩色,跟着变会让主按钮在深色页面上失去『这是主操作』的视觉重量」。
* · `--c-blue-600` —— **内容/交互用的蓝字与图标**。`.dark` 段把它换成
* `128 175 249`(提亮),否则深底上读不动。
*
* ★ 实测(2026-09-19,在跑着的 WebUI 上用 CDP 读计算样式):
* 浅色 `--c-blue-600` = `37 99 235`
* 深色 `--c-blue-600` = `128 175 249` ← 提亮
* 而 `--s-blue-600` 两模式都是 `37 99 235`
*
* ★ 两个取值的**来源**都记在 docs/ALIGN-REFS.json 的 `theme.blue` 里。
*/
static readonly accentDark: string = '#80AFF9';
/**
* 按当前主题给**前景**用的品牌色。
*
* ★ **只用在 `fontColor` / `iconColor`(压在底色上的字与图标)**。
* 当**背景**(蓝底白字的主按钮、徽标)时要用 `Theme.accent` ——
* WebUI 的主按钮底在深色下**不提亮**(保持视觉重量),
* 这边跟着一致,否则"主操作"在深色页面上会显得比浅色下轻。
*
* ★ 唯一入口 —— 别在页面里自己 `isDark ? a : b`:那样每处都会各写一遍,
* 迟早漏一处(`accentSoftFor` 用的是同一条纪律)。
*
* @param dark 当前是否深色(页面的 `isDarkMode(...)` 算出来传进来 ——
* `Theme` 是静态类,拿不到 Context,所以深浅色由调用方给)
*/
static accentFor(dark?: boolean): string {
return Theme.isDarkNow(dark) ? Theme.accentDark : Theme.accent;
}
/**
* 当前是不是深色 —— **从发布处读,不靠调用方自觉传**。
*
* ★★ 为什么参数是可选的(2026-09-19 的决定):
* `accentSoftFor(isDark)` 先上了,约定是"页面算出来传进来"。
* 这次给 `accentFor` 做准备时一数:**8 处**直接用了
* `Theme.accentSoft`(没走 `For`)—— 约定**已经漏了**。
*
* 漏掉的代价不是"颜色不对一点":深色下品牌浅底是**接近纯白的浅蓝**,
* 在近黑页面上刺眼得像 bug(那 8 处里就有这个症状)。
*
* 颜色令牌与"当前主题"是**同一个事实**的两种表示。让每处调用方
* 各自转述一遍,就是把一个事实散到几十个地方(本仓对此的判词:
* "第二份真相迟早漂移")。
*
* `AppStorage` 是 ArkTS 的全局键值存储,静态类可以直接读 ——
* 不必拿 Context,于是本来那个"拿不到 Context"的理由也不成立了。
*
* @param dark 显式指定(测试/特殊场合用);不给则读全局键
*/
static isDarkNow(dark?: boolean): boolean {
if (dark !== undefined) {
return dark;
}
return AppStorage.get<boolean>(Theme.KEY_IS_DARK) === true;
}
/**
* 存放"当前是否深色"的全局键。
*
* ★ 与页面里的 `@StorageProp('agentmail.appearance.isDark')` 是**同一个键**。
* 之所以在这里再写一遍字面量:`Theme` 早于页面存在(页面 import 它),
* 反向依赖不成立。**改这个键必须同时改页面里的** ——
* 判据 `cross-client-theme` 会扫这个字面量在两处是否一致。
*/
static readonly KEY_IS_DARK: string = 'agentmail.appearance.isDark';
/**
* 品牌底上的字/图标 —— **浅色**(白色)。
*
@ -245,8 +333,8 @@ export class Theme {
* @param dark 当前是否深色(页面的 `isDarkMode(...)` 算出来传进来 ——
* `Theme` 是静态类,拿不到 Context,所以深浅色由调用方给)
*/
static accentSoftFor(dark: boolean): string {
return dark ? Theme.accentSoftDark : Theme.accentSoft;
static accentSoftFor(dark?: boolean): string {
return Theme.isDarkNow(dark) ? Theme.accentSoftDark : Theme.accentSoft;
}
static readonly accentStrong: string = '#1D4ED8';

View File

@ -340,7 +340,7 @@ struct AdminUsersPage {
@Builder
Header() {
Row() {
Text('‹').fontSize(24).fontColor(Theme.accent).width(40).height(40)
Text('‹').fontSize(24).fontColor(Theme.accentFor()).width(40).height(40)
.textAlign(TextAlign.Center)
.onClick(() => { this.getUIContext().getRouter().back(); })
Text('管理').fontSize(16).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
@ -355,7 +355,7 @@ struct AdminUsersPage {
* 但视觉上"图标没在按钮中央"是对的观感洁癖,也说明这个盒子才是按钮。
*/
Stack({ alignContent: Alignment.Center }) {
AmIcon({ iconName: 'repeat', iconSize: 20, iconColor: Theme.accent })
AmIcon({ iconName: 'repeat', iconSize: 20, iconColor: Theme.accentFor() })
}
.width(40)
.height(40)

View File

@ -239,7 +239,7 @@ struct ComposePage {
Text('发信账号').fontSize(13).fontColor(Theme.textSubtle).width(72)
// 显示当前选中的账号名
Text(this.getSelectedAccountName())
.fontSize(13).fontColor(Theme.accent)
.fontSize(13).fontColor(Theme.accentFor())
.layoutWeight(1)
.onClick(() => { this.showAccountPicker = !this.showAccountPicker; })
Text(' ▾').fontSize(12).fontColor(Theme.textSubtle)
@ -258,7 +258,7 @@ struct ComposePage {
.fontWeight(this.selectedAccountId === acct.id ? FontWeight.Bold : FontWeight.Normal)
.layoutWeight(1)
if (this.selectedAccountId === acct.id) {
AmIcon({ iconName: 'check', iconSize: 14, iconColor: Theme.accent })
AmIcon({ iconName: 'check', iconSize: 14, iconColor: Theme.accentFor() })
}
}
.width('100%').height(40).padding({ left: 40, right: 12 })
@ -342,9 +342,9 @@ struct ComposePage {
// 底部工具栏
Row() {
Row() {
AmIcon({ iconName: 'paperclip', iconSize: 14, iconColor: Theme.accent })
AmIcon({ iconName: 'paperclip', iconSize: 14, iconColor: Theme.accentFor() })
Text('添加附件')
.fontSize(13).fontColor(Theme.accent).margin({ left: 6 })
.fontSize(13).fontColor(Theme.accentFor()).margin({ left: 6 })
.onClick(() => { this.pickAttachment(); })
}

View File

@ -154,8 +154,8 @@ struct InboxPage {
Column() {
Row() {
Column() {
AmIcon({ iconName: 'inbox', iconSize: 20, iconColor: Theme.accent })
Text('收件箱').fontSize(11).fontColor(Theme.accent)
AmIcon({ iconName: 'inbox', iconSize: 20, iconColor: Theme.accentFor() })
Text('收件箱').fontSize(11).fontColor(Theme.accentFor())
}
.layoutWeight(1).height('100%')
.justifyContent(FlexAlign.Center)

View File

@ -278,7 +278,7 @@ struct LoginPage {
* 就是这么做的(它们把尺寸加在外层 `Button` 上,所以一直是对的)。
*/
Stack({ alignContent: Alignment.Center }) {
AmIcon({ iconName: 'brandMark', iconSize: 24, iconColor: Theme.accent })
AmIcon({ iconName: 'brandMark', iconSize: 24, iconColor: Theme.accentFor() })
}
.width(48)
.height(48)
@ -412,7 +412,7 @@ struct LoginPage {
if (this.loggedIn) {
Text('登录成功:' + this.me.username)
.fontSize(12).fontColor(Theme.accent).margin({ top: 12 })
.fontSize(12).fontColor(Theme.accentFor()).margin({ top: 12 })
}
}
.width('100%')

View File

@ -556,7 +556,7 @@ export struct MailDetailView {
Column() {
Row() {
Text('‹')
.fontSize(24).fontColor(Theme.accent)
.fontSize(24).fontColor(Theme.accentFor())
.width(36).height(36)
.textAlign(TextAlign.Center)
.onClick(() => { this.goBack(); })
@ -570,7 +570,7 @@ export struct MailDetailView {
/* 状态点:收起态也要看得见(与 WebUI 的 meta 同一位置与语义) */
if (this.status === 'unread') {
Text('未读')
.fontSize(10).fontColor(Theme.accent)
.fontSize(10).fontColor(Theme.accentFor())
.backgroundColor(Theme.accentSoftFor(this.isDarkNow)).borderRadius(4)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.margin({ left: 6 })
@ -696,7 +696,7 @@ export struct MailDetailView {
Button('忽略')
.height(28).fontSize(12)
.backgroundColor(Color.Transparent)
.fontColor(Theme.accent)
.fontColor(Theme.accentFor())
.borderRadius(Theme.radiusControl)
.border({ width: 1, color: Theme.accent })
.enabled(!this.renameBusy)
@ -786,7 +786,7 @@ export struct MailDetailView {
*/
Row({ space: 12 }) {
Button() {
AmIcon({ iconName: 'forward', iconSize: 20, iconColor: Theme.accent })
AmIcon({ iconName: 'forward', iconSize: 20, iconColor: Theme.accentFor() })
}
.width(48).height(48)
.borderRadius(24)

View File

@ -598,7 +598,7 @@ struct InboxTab {
.width('100%').margin({ top: 3 })
Row() {
Text(g.alias.length > 0 ? '.' + g.alias : '(未命名会话)')
.fontSize(10).fontColor(Theme.accent)
.fontSize(10).fontColor(Theme.accentFor())
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }).layoutWeight(1)
Text(g.mails.length + ' 封').fontSize(10).fontColor(Theme.textSubtle).margin({ left: 8 })
}
@ -633,7 +633,7 @@ struct InboxTab {
Text(mail.from_name).fontSize(14).fontWeight(mail.status === 'unread' ? FontWeight.Bold : FontWeight.Normal).fontColor(Theme.textPrimary)
if (this.accountFilter === 'all' && mail.source_account_name.length > 0) {
Text(mail.source_account_name)
.fontSize(10).fontColor(Theme.accent)
.fontSize(10).fontColor(Theme.accentFor())
.backgroundColor(Theme.accentSoftFor(this.isDarkNow)).borderRadius(4)
.padding({ left: 5, right: 5, top: 1, bottom: 1 })
.margin({ left: 6 })
@ -1081,7 +1081,7 @@ struct PermissionTab {
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
Text(req.session_alias.length > 0 ? req.session_alias : '(未命名会话)')
.fontSize(10).fontColor(Theme.accent)
.fontSize(10).fontColor(Theme.accentFor())
}
.width('100%')
@ -1855,7 +1855,7 @@ struct ContactsTab {
Column() {
/* 返回行:与 WebUI 的 `BackButton label="会话"` 同一语义 */
Row() {
Text('‹').fontSize(24).fontColor(Theme.accent).width(36).height(36)
Text('‹').fontSize(24).fontColor(Theme.accentFor()).width(36).height(36)
.textAlign(TextAlign.Center)
.onClick(() => { this.closeSession(); })
Text(this.openSessionTitle)
@ -1949,9 +1949,9 @@ struct ContactsTab {
.width('100%')
Row() {
Text('›').fontSize(12).fontColor(Theme.accent)
Text('›').fontSize(12).fontColor(Theme.accentFor())
Text(c.session_alias.length > 0 ? c.session_alias : '(未命名会话)')
.fontSize(11).fontColor(Theme.accent)
.fontSize(11).fontColor(Theme.accentFor())
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ left: 4 }).layoutWeight(1)
}

View File

@ -607,7 +607,7 @@ export struct SettingsPane {
Row() {
Text('我的').fontSize(20).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
.layoutWeight(1)
Text('+').fontSize(24).fontColor(Theme.accent).width(40).height(40)
Text('+').fontSize(24).fontColor(Theme.accentFor()).width(40).height(40)
.textAlign(TextAlign.Center)
.onClick(() => { this.showAddDialog = true; })
}
@ -950,7 +950,7 @@ export struct SettingsPane {
.fontSize(11).fontColor(Theme.textPrimary)
.width('100%').margin({ top: 6 })
Text('关闭')
.fontSize(11).fontColor(Theme.accent).margin({ top: 6 })
.fontSize(11).fontColor(Theme.accentFor()).margin({ top: 6 })
.onClick(() => { this.newKeyToken = ''; })
}
.width('100%').margin({ top: 10 }).padding(10)
@ -1172,7 +1172,7 @@ export struct SettingsPane {
.fontColor(account.id === this.activeId ? Theme.accent : Theme.textPrimary)
if (account.id === this.activeId) {
Text(' 默认')
.fontSize(11).fontColor(Theme.accent)
.fontSize(11).fontColor(Theme.accentFor())
.backgroundColor(Theme.accentSoftFor(this.isDarkNow)).borderRadius(4)
.padding({ left: 4, right: 4, top: 1, bottom: 1 })
.margin({ left: 6 })
@ -1191,7 +1191,7 @@ export struct SettingsPane {
if (account.id !== this.activeId) {
Text('设为默认')
.fontSize(12).fontColor(Theme.accent)
.fontSize(12).fontColor(Theme.accentFor())
.margin({ right: 12 })
.onClick(() => { this.switchTo(account.id); })
}