diff --git a/client/electron/test/cross-client-theme.test.mjs b/client/electron/test/cross-client-theme.test.mjs index 523cf19..8979897 100644 --- a/client/electron/test/cross-client-theme.test.mjs +++ b/client/electron/test/cross-client-theme.test.mjs @@ -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 深色没调亮。'); +}); diff --git a/client/electron/test/lib/harmony-device.mjs b/client/electron/test/lib/harmony-device.mjs index 75a3c17..866ed15 100644 --- a/client/electron/test/lib/harmony-device.mjs +++ b/client/electron/test/lib/harmony-device.mjs @@ -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] }; + }, + }; +} diff --git a/client/electron/test/mutants/baseline.sha b/client/electron/test/mutants/baseline.sha index 58f3706..ceba0bd 100644 --- a/client/electron/test/mutants/baseline.sha +++ b/client/electron/test/mutants/baseline.sha @@ -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 -- ` 取证,没有一个是"改动忘了还原"。 +# ★ 这正是这份文件里那条纪律的第 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 diff --git a/client/electron/test/run-all.mjs b/client/electron/test/run-all.mjs index 36c2e51..3ccef59 100644 --- a/client/electron/test/run-all.mjs +++ b/client/electron/test/run-all.mjs @@ -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` 那条债:它写着「鸿蒙侧出现滑动 // 手势代码时**立即建**(此前建 = 只有一端存在的假判据)」。 diff --git a/client/harmony/entry/src/main/ets/common/Theme.ets b/client/harmony/entry/src/main/ets/common/Theme.ets index 02b100c..4567656 100644 --- a/client/harmony/entry/src/main/ets/common/Theme.ets +++ b/client/harmony/entry/src/main/ets/common/Theme.ets @@ -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(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'; diff --git a/client/harmony/entry/src/main/ets/pages/AdminUsersPage.ets b/client/harmony/entry/src/main/ets/pages/AdminUsersPage.ets index 9a0c8d3..0d9c631 100644 --- a/client/harmony/entry/src/main/ets/pages/AdminUsersPage.ets +++ b/client/harmony/entry/src/main/ets/pages/AdminUsersPage.ets @@ -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) diff --git a/client/harmony/entry/src/main/ets/pages/ComposePage.ets b/client/harmony/entry/src/main/ets/pages/ComposePage.ets index 2e2bf1c..033afdb 100644 --- a/client/harmony/entry/src/main/ets/pages/ComposePage.ets +++ b/client/harmony/entry/src/main/ets/pages/ComposePage.ets @@ -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(); }) } diff --git a/client/harmony/entry/src/main/ets/pages/InboxPage.ets b/client/harmony/entry/src/main/ets/pages/InboxPage.ets index 511b38d..1ae39be 100644 --- a/client/harmony/entry/src/main/ets/pages/InboxPage.ets +++ b/client/harmony/entry/src/main/ets/pages/InboxPage.ets @@ -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) diff --git a/client/harmony/entry/src/main/ets/pages/LoginPage.ets b/client/harmony/entry/src/main/ets/pages/LoginPage.ets index c74a467..32b7c4d 100644 --- a/client/harmony/entry/src/main/ets/pages/LoginPage.ets +++ b/client/harmony/entry/src/main/ets/pages/LoginPage.ets @@ -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%') diff --git a/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets b/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets index fa63634..8844603 100644 --- a/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets @@ -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) diff --git a/client/harmony/entry/src/main/ets/pages/MainPage.ets b/client/harmony/entry/src/main/ets/pages/MainPage.ets index 52f1a85..5e92f34 100644 --- a/client/harmony/entry/src/main/ets/pages/MainPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MainPage.ets @@ -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) } diff --git a/client/harmony/entry/src/main/ets/pages/SettingsPage.ets b/client/harmony/entry/src/main/ets/pages/SettingsPage.ets index 0901ff7..6081f15 100644 --- a/client/harmony/entry/src/main/ets/pages/SettingsPage.ets +++ b/client/harmony/entry/src/main/ets/pages/SettingsPage.ets @@ -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); }) }