跨端: 品牌蓝深色下没提亮(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:
@ -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';
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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(); })
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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%')
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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); })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user