用户:「还有那邮件还是居中对齐,可读性也极差,而且还看起来根本不像邮件」
+「宽屏布局下日历的周视图日视图还是一塌糊涂」
+「你的图标变成输入框,图标变成邮件的动画呢?你能不能自己好好审计一下」
这轮**不再逐条打补丁**,改成先审计再改。下面每条都有实测数据。
## ① 邮件正文可读性:1.34:1 → 16.67:1
实测(深色,详情页正文):
文字 rgb(35,33,52) 压 底色 rgb(0,0,0) ⇒ **1.34:1**(完全不可读)
同屏只有行内代码(红色 span)看得见(5.02:1)
头部信息区更差:rgb(34,35,36) 压 rgb(32,33,35) = **1.02:1**
根因:`Markdown({ text: this.body })` —— **只传了文本,没传 `controller`**。
`@luvi/lv-markdown-in` 于是用它自己的默认样式,那套是给**浅色**配的
(实测 rgb(35,33,52) ≈ WebUI 浅色 `--c-gray-800` = `31 41 55`)。
⇒ 这是「**第三方组件不继承宿主主题**」那一类错:它不读 `$r('sys.color.*')`,
也不读 `AppStorage`,必须显式注入。新增 `mdController()`,
照 WebUI `.markdown`(`index.css:592-628`)逐条映射 17 个着色入口:
.markdown → Theme.textPrimary
.markdown a → Theme.accentFor()
.markdown blockquote → Theme.textMuted / Theme.border
.markdown code → Theme.surfaceMuted
★ 17 个 setter 名逐一比对过库的 `.d.ets`(防拼错静默失效)。
★ 不放在 `@State` 里:控制器是普通对象,ArkUI 不会因它内部变化而重渲染 ——
每次 `build()` 现取,主题一变就拿到新色(存一份反而**不会**刷新,正是本 bug 的形状)。
## ② 邮件「居中对齐」:ArkUI `Column` 默认就是居中
根因**不在那个 `Text`**,而在 **`Column` 的交叉轴默认对齐是
`HorizontalAlign.Center`** —— 官方《线性布局 (Row/Column)》原文:
「HorizontalAlign.Center(**默认值**):子元素在水平方向居中对齐」。
子元素没显式给宽时跟着内容宽居中 ⇒「回复给 …」那行浮在面板中间。
WebUI 是块级流、天然左对齐,所以"同样的代码"看起来不一样。
修:回复条/转发条两个 `Column` 显式 `.alignItems(HorizontalAlign.Start)`。
## ③ 日历:周/日视图「今天」的数字看不见(1.00:1)
实测(宽屏 3184、周视图第 1 列「21」):
字 rgb(254,254,254) 压 底 rgb(254,254,254) ⇒ **1.00:1**
根因:`cellFg()` **不看档位**,选中一律返回 `accentFg`(白)。
而两档的"选中底色"根本不是同一个东西:
· 月视图:整格铺**实心** `Theme.accent` ⇒ 白字对;
· 周/日视图:底色改由日期头承担,用的是**淡蓝** `accentSoftFor()` ⇒ 白字看不见。
⇒ 按档位分流:周/日档返回 `Theme.accent`(蓝字压淡蓝 ≈5.4:1)。
同一处的农历小字也一并改(同一个三目)。
## ④ 日历:日视图多画了一条无意义的星期表头
周表头 `Row` 在 `if (calScale === 'day')` **之前**渲染 ⇒ 日视图上方挂着
「一 二 三 四 五 六 日」,而一天只有一个日子,七个标签全落空。
WebUI 三种视图都不是这么做的:
· `MonthGrid` 表头在**自己内部**(`grid-cols-7` 第一行);
· `WeekGrid` 星期名在**每一列内部**(sticky);
· `DayGrid` **完全没有**星期行(顶上是日期+农历)。
⇒ 加 `if (this.calScale !== 'day')`。
## 诚实交代:我一度想当然,量了才发现不用改
我本来打算"修"周视图**表头与列对不齐**,理由是"表头均分可用宽、
网格列均分 minWidth(322) 的更大宽"。实测列中心 `[356,607,860,1114,1365,1621,1873]`
与表头中心基本吻合(偏差 ≤11px,在文字宽度内)—— **那是我推的,不是量的**。
先量再改,省掉一次"改坏对的东西"。
## 审计产出(供后续,不只是本次修)
· WebUI 全仓动画清单:5 个 `@keyframes`(`cal-in-next/prev`、`menu-in`、
`pane-in`、`rise-in`)+ 7 处 `transition` + 3 处 `element.animate()`(全是 FLIP morph)。
· 鸿蒙侧挂点计数:`animateTo` 9、`transition()` 12、`pageTransition` 3、
`geometryTransition` 4、`PressEffectModifier` 60。
· **`Motion.pageEnter` 是自己加的、零调用点**(`pageTransition` 走的是
三个页各自的 `pageTransition()`)⇒ 死代码,待清。
## 验证
✓ ①②③④ 全部在设备上取**像素/布局**验证(1.34→16.67、1.00→5.44、日视图表头消失)
✓ 编译通过、进程存活、无新 jscrash
✗ 未验证:动画本体(图标→输入框 morph)—— 220ms,而抓图往返 1.5-3s,
探针比被测对象慢一个数量级(同 `harmony-morph-unverified-middleframes`)
1203 lines
66 KiB
Plaintext
1203 lines
66 KiB
Plaintext
/*
|
||
* AgentMail 鸿蒙客户端 — 设计令牌
|
||
*
|
||
* ## 这个文件在 2026-09-14 换了一次"来源"(jianf:「鸿蒙要求用系统方案」)
|
||
*
|
||
* 原先这里的每一格都是**手抄 WebUI 的色值**(`#F8FAFC`、`#0F172A`…)。现在分成两类,
|
||
* 分界线是"**系统有没有这份语义**":
|
||
*
|
||
* - **表面 / 文字 / 分隔 / 遮罩 / 圆角 / 材质 —— 交给系统**(`$r('sys.color.*')`、
|
||
* `$r('sys.float.*')`、`BlurStyle.*`)。理由不是"看起来更原生",而是三条实际的:
|
||
* ① 它们会**跟随深色模式**(手抄的值不会,这正是 WebUI 那边"半深不浅"的病根);
|
||
* ② 跟随系统动效/无障碍设置;
|
||
* ③ 少一处会漂移的副本 —— 这些格子的值不再由我们决定,也就不会再和系统打架。
|
||
* - **品牌色与业务语义色 —— 仍然自己写**:`accent = #2563EB` 是**跨客户端身份**
|
||
* (两个客户端是同一个产品),不能退化成随主题/厂商皮肤变的系统强调色;
|
||
* 权限三档(蓝/绿/琥珀)与预算三档(灰/橙/红)在系统里**没有对应物**
|
||
* (系统只有 warning/alert 两个"情绪色"),硬套会把语义丢掉。
|
||
*
|
||
* ## 名字不是猜的
|
||
*
|
||
* `sys.*` 的名字一旦写错,**编译期不报**、只有真机运行到那一行才炸 —— 而本机没有设备。
|
||
* 所以名字全部对着 SDK 自带的系统资源名表核过:
|
||
* `sdk/default/openharmony/toolchains/id_defined.json`(本机 API 26,7826 条)。
|
||
* 而且有一条判据(`client/electron/test/harmony-system-api.test.mjs`)持续盯着这件事:
|
||
* 源码里每个 `$r('sys.<type>.<name>')` 都必须在表里存在且类型相符。
|
||
*
|
||
* ## 保留的取舍
|
||
*
|
||
* - WebUI 的"正文面"是 0.88 不透明的玻璃(字要读得清);鸿蒙用的系统卡片底色也是不透明的,
|
||
* 结论一致:**正文面不透,只有浮在内容之上的那一层用材质**("玻璃只出现在一层")。
|
||
* - 字体大小仍与 WebUI 的 text-2xs/xs/sm 对齐(字号不是"系统方案"要解决的问题,
|
||
* 两边的版式意图是同一套)。
|
||
*/
|
||
|
||
import { curves } from '@kit.ArkUI';
|
||
import { Motion } from './Motion';
|
||
|
||
export class Theme {
|
||
// ─────────────── 表面:系统语义色 ───────────────
|
||
|
||
/** 页面底色(系统 `ohos_id_color_background`:浅色白/深色深灰,自动跟随主题) */
|
||
static readonly pageBg: Resource = $r('sys.color.ohos_id_color_background');
|
||
/**
|
||
* 承载文字的**面** = 列表卡片底色(对应"每项一张卡/气泡")。
|
||
*
|
||
* ⚠️⚠️ **它只能当背景色,绝不能当前景色(`fontColor` / `iconColor`)**。
|
||
*
|
||
* 它是一个**跟随系统主题翻转**的 Resource:浅色下接近白、深色下接近黑。
|
||
* 我们曾经在 **12 处**把它当"压在彩色底上的字/图标色"用 ——
|
||
* 浅色下碰巧对(白字压蓝底),**深色下字变成黑的**,看起来像元素消失了。
|
||
*
|
||
* 2026-09-19 设备实测到的现场:写邮件悬浮球是品牌蓝 `#2563EB`,
|
||
* 但球心读出来是 `rgb(32,34,36)`(近黑)—— 那是深色主题下的 `surface`。
|
||
* 截图里那个铅笔图标几乎是隐形的。
|
||
*
|
||
* 压在**品牌色**底上要用 `accentFg`;压在别的语义色(danger/warn/approve)
|
||
* 底上同理 —— 那些底都是深色,前景都该是浅的。
|
||
*
|
||
* 判据:`cross-client-theme` 的设备条会读悬浮球**圆心**的像素,
|
||
* 要求图标与底色 WCAG 对比度 ≥3:1。
|
||
*/
|
||
static readonly surface: Resource = $r('sys.color.ohos_id_color_list_card_bg');
|
||
/** 次级面(分组底、列表行 hover) */
|
||
static readonly surfaceMuted: Resource = $r('sys.color.ohos_id_color_sub_background');
|
||
/** 分隔线 */
|
||
static readonly border: Resource = $r('sys.color.ohos_id_color_list_separator');
|
||
|
||
// ─────────────── 文字:系统语义色(三级) ───────────────
|
||
|
||
static readonly textPrimary: Resource = $r('sys.color.ohos_id_color_text_primary');
|
||
static readonly textMuted: Resource = $r('sys.color.ohos_id_color_text_secondary');
|
||
static readonly textSubtle: Resource = $r('sys.color.ohos_id_color_text_tertiary');
|
||
|
||
/*
|
||
* ──────── 三级文字在**深色**下的可读性下限(2026-09-19 加)────────
|
||
*
|
||
* ★★ 问题(设备实测,不是推测):
|
||
* 系统三级色 `ohos_id_color_text_tertiary` 在**深色**下是 `rgb(102,103,105)`,
|
||
* 压在卡片面 `rgb(32,34,36)` 上只有 **2.82–3.05:1**。
|
||
* 它被用在**小字**上(10–11px 的时间戳、农历、空态、辅助说明,全局 66 处)
|
||
* ⇒ 远低于 WCAG AA 对正文的 4.5:1。
|
||
*
|
||
* ★ 这不是鸿蒙独有的问题 —— WebUI 早就遇到过并写下了结论
|
||
* (`client/electron/src/index.css` 的 `.dark` 段注释,逐字引用):
|
||
*
|
||
* 「`gray-400/500`(次要文字)→ **提亮**。深底上的浅色 gray-400
|
||
* 只有约 2:1 对比度,远低于 WCAG AA 的 4.5:1 —— **看得见但读不动**。」
|
||
*
|
||
* 它的做法是在 `.dark` 段把 gray-400 从 `107 114 128` 提亮到 `138 146 161`,
|
||
* gray-500 从 `90 98 112` 提到 `165 173 186`。实测那两组压在深卡片上
|
||
* 分别是 **5.51:1 / 7.63:1**,都在 AA 之上。
|
||
*
|
||
* ★ 系统色为什么没有这层保护:`ohos_id_color_text_tertiary` 的语义是
|
||
* 「比二级更淡」——它只保证**层次关系**,不保证可读性下限。
|
||
* 当"更淡"用在浅底上时刚好合适;深底上同样的亮度就掉下去了。
|
||
* (这与 `Theme.surface` 当前景色是同一类:**系统语义 ≠ 我们的可读性要求**。)
|
||
*
|
||
* ★ 修法:**不换掉系统令牌**(上面那条"系统拥有的维度"纪律照旧),
|
||
* 而是提供一个「按主题取三级文字」的入口:深色下改用二级色。
|
||
* 之所以用二级而不是另写一个白:
|
||
* · 二级色深色下实测 `rgb(166,167,167)` ≈ 7.15:1,在 AA 之上;
|
||
* · 它与 WebUI 的 `gray-500` 是同一个档位(两端视觉层次一致);
|
||
* · 不引入新的手写色(不用再过登记表)。
|
||
*
|
||
* ★ 浅色下**不改**:浅色下系统三级色压在浅底上够用(与 WebUI 的
|
||
* gray-400 `107 114 128` 同量级),换了反而会让层次变平。
|
||
*
|
||
* ★ 使用入口是 `Theme.textSubtleFor()` —— 与 `accentFor` / `accentSoftFor`
|
||
* 同一条纪律:**别在页面里自己写 `isDark ? a : b`**。
|
||
*/
|
||
static textSubtleFor(dark?: boolean): Resource {
|
||
/*
|
||
* ★★ 2026-09-19 **浅色也要换**(第一版只改了深色,漏了另一半)。
|
||
*
|
||
* 切到浅色主题复扫,立刻抓到 12 处:
|
||
* 浅色 `textSubtle` 实测 `rgb(153,153,153)` 压白底 = **2.85:1**
|
||
* 而 WebUI 浅色的 gray-400 是 `rgb(107,114,128)` = **4.83:1**,
|
||
* gray-500 是 `rgb(90,98,112)` = **6.15:1**。
|
||
*
|
||
* ⇒ 系统三级色**两个主题下都不够**,不是"只有深色有问题"。
|
||
* 同一档位在 WebUI 那边两套主题都做了可读性适配,
|
||
* 而系统语义色只管"比二级更淡"这层关系。
|
||
*
|
||
* 所以两个主题都返回二级色(`textMuted`)。
|
||
* ★ 为什么不各写一个手写值:系统二级色本身就是"次要文字",
|
||
* 语义对得上,且**跟随主题**(不必再维护两个常量、不必登记)。
|
||
*/
|
||
return Theme.textMuted;
|
||
}
|
||
|
||
// ─────────────── 遮罩与材质:交给系统 ───────────────
|
||
|
||
/**
|
||
* 遮罩(模态/淡出层):系统遮罩色。
|
||
*
|
||
* 这里原来存着 `overlayColor` + `overlayAlpha` 两个常量,再用 `overlay()` 拼成
|
||
* `#AARRGGBB` —— 之所以要"色与透明度分开",是因为**遮罩色必须随主题换向**
|
||
* (浅色主题用白把图案洗淡、深色主题必须换黑,否则浅色照片在深色界面里糊成一块亮斑)。
|
||
* 那件事现在由系统做:一个语义色就够,且换向不会再漏。
|
||
*/
|
||
static readonly overlay: Resource = $r('sys.color.ohos_id_color_mask_regular');
|
||
|
||
/**
|
||
* 壁纸遮盖层的颜色 —— **不是** `overlay`(那是模态遮罩),两者语义不同。
|
||
*
|
||
* pi 2026-09-14 提出的方向性疑问,我按他说的去 SDK 里把值读出来了
|
||
* (`ets/build-tools/ets-loader/sysResource.js` 给 名字→id,
|
||
* `previewer/common/resources/entry/resources.txt` 给 id→值,两张表交叉验证):
|
||
*
|
||
* | 令牌 | 浅色主题 | 深色主题 |
|
||
* |---|---|---|
|
||
* | `ohos_id_color_mask_regular`(= `overlay`) | `#99182431` **深蓝灰** | `#b2000000` 黑 |
|
||
* | `ohos_id_color_background` | `#ffffffff` **白** | `#ff18181a` 近黑 |
|
||
*
|
||
* 也就是说 **`mask_*` 在两套主题下都是深色**(它的 light/regular/thick 是**浓度档**,
|
||
* 不是深浅两套值),用途是**模态遮罩**(弹层背后压暗)。
|
||
* 而 WebUI 的 `--bg-scrim` 在 `:root` 是**白**、`.dark` 才是黑,注释原话是
|
||
* 「浅色下用白把花哨的图案洗淡,深色下用黑压暗」—— 它做的是"**朝页面底色淡化**"。
|
||
*
|
||
* 所以壁纸遮盖若用 `mask`:**浅色主题下会把预设压暗,而 WebUI 是把它洗淡 —— 方向相反**。
|
||
* 这是"机制上确定不同",不是观感。改用**页面底色系**:那才是"朝底色淡化"的系统对应物,
|
||
* 浅色=白底方向、深色=深底方向,自动换向,与 WebUI 是同一个意图。
|
||
*
|
||
* ⚠️ 为什么不干脆复用 `pageBg`(值一样是 `ohos_id_color_background`):
|
||
* 页面底在背景开启时会被换成语义上的**透明**(`bgActive ? Color.Transparent : Theme.pageBg`),
|
||
* 而遮盖层**永远需要一个真实颜色**。两个用途的生命周期不同,共用一个名字迟早坏一头
|
||
* —— 这正是这个仓库撞过四次的模式(`text-white` vs `bg-white`、`--c-*` vs `--s-*`)。
|
||
*/
|
||
static readonly wallpaperScrim: Resource = $r('sys.color.ohos_id_color_background');
|
||
|
||
/**
|
||
* 导航/浮层的材质档次。**不再有 `#B8FFFFFF` / `#B80F172A` 这种手写玻璃 alpha** ——
|
||
* 那两个值等于"我们替系统猜了深色该怎么做",与"用系统方案"直接冲突;
|
||
* 而材质档次是系统给的,深浅两套颜色由系统按主题挑。
|
||
*
|
||
* 用 `COMPONENT_THICK`:贴在界面组件上的一层材质(导航条正属于这一类)。
|
||
*/
|
||
static readonly navMaterial: BlurStyle = BlurStyle.COMPONENT_THICK;
|
||
|
||
/**
|
||
* **卡片**的材质档次。
|
||
*
|
||
* 与 `navMaterial` 分开而不是共用:导航条是**厚重**的一层浮条(长期压住内容),
|
||
* 卡片是**轻薄**的一块列表项。两者同档,"每项都是一张卡"的层次感就没了
|
||
* —— WebUI 那边两者也确实是不同的 alpha(面板 0.88 / 卡 0.78,`index.css`)。
|
||
*
|
||
* 用 `BACKGROUND_THIN`(不是 `COMPONENT_THIN`):设备实测(模拟器 3184×2232、
|
||
* 壁纸 aurora + 压暗 37)卡片采样:
|
||
* · `COMPONENT_THIN` → `252,254,254`,而它两侧缝隙是 `203,213,228`
|
||
* ⇒ 卡片把壁纸吃掉了 94%,看着仍是"白卡",用户报的就是这个;
|
||
* · `BACKGROUND_THIN` → `185,190,201`,壁纸透得出来,且文字对比度仍够。
|
||
* `BACKGROUND_*` 系是"背景景深"材质,本来就为"垫在内容下面"设计,
|
||
* 与卡片这个用途正好对上。
|
||
*/
|
||
static readonly cardMaterial: BlurStyle = BlurStyle.BACKGROUND_THIN;
|
||
|
||
/*
|
||
* ── 关于「参数化玻璃」(`backgroundEffect`):**本工程不用** ──
|
||
*
|
||
* 2026-09-21 实测记录(我被自己的实验推翻,留证以免后人再走一遍)。
|
||
*
|
||
* 这里原先有两个令牌 `glassCardRadius = 18` / `glassCardSaturation = 1.5`,
|
||
* 注释自己写着「与 WebUI `.narrow-nav` 逐字同源」—— 也就是**底部导航条**
|
||
* 那一档(`index.css:1204`:`backdrop-filter: blur(18px) saturate(1.5)`),
|
||
* 名字却叫 `glass**Card**…`。名与实不符立刻兑现了代价:
|
||
* 我照名字把它挂到了**每张卡片**上,而 WebUI 的卡片根本没有 `backdrop-filter`
|
||
* (`.glass-card` 只有白 + alpha)⇒ 多层模糊叠加成灰雾 + 滚动掉帧。
|
||
*
|
||
* 上一轮"卡片去模糊"之后,这两个令牌**失去全部消费者** ——
|
||
* 是 `cross-client-theme` 的**孤儿令牌判据**把它们报出来的
|
||
* (「这些 Theme 令牌在页面/组件里一次都没被引用」),
|
||
* 而不是烂在那里没人知道。
|
||
*
|
||
* ── 我接着做了一次实验,然后被实测否掉 ──
|
||
*
|
||
* 我推断「真归属是导航条」,于是把底栏从
|
||
* `backgroundBlurStyle(Theme.navMaterial)` 改成
|
||
* `backgroundEffect({radius:18, saturation:1.5})`,理由是
|
||
* 「固定材质档没有 `saturate` 旋钮、观感偏灰」。
|
||
*
|
||
* 实测(模拟器窄屏 1008×2232,取底栏中心列 x=504 的像素):
|
||
*
|
||
* y backgroundEffect backgroundBlurStyle
|
||
* 1960 rgb(191,199,209) rgb(234,235,239) ← 差 -36 亮度
|
||
* 2000 rgb(198,204,212) rgb(234,235,239) ← 差 -31
|
||
* 2060 rgb(206,211,219) rgb(234,235,239) ← 差 -24
|
||
*
|
||
* ⇒ `backgroundEffect` **只给模糊、不给底色**,壁纸原样透上来,
|
||
* 整条底栏暗了 24~36 个亮度级 —— 比原来的"偏灰"糟得多。
|
||
*
|
||
* ★ 为什么:WebUI 的 `.narrow-nav` 是**两条声明**组合出来的 ——
|
||
* background-color: rgb(var(--nav-bg));
|
||
* backdrop-filter: blur(18px) saturate(1.5);
|
||
* 我只搬了后者、丢了前者。而 `backgroundBlurStyle` 的**系统材质本身**
|
||
* 就同时含「色调 + 模糊 + 深浅两套」,正好把这两条一起给了。
|
||
*
|
||
* ★ 更要紧的一层:WebUI 那个 `--nav-bg` 是**手写 alpha**,跟不了深色主题
|
||
* (§7.12 那一行的原话:「手写 alpha 跟不了深色」)。⇒ 在这里系统材质
|
||
* 不是"退而求其次",而是**唯一能同时满足深浅两套**的方案。
|
||
* §7.12 当初把这条判成"有意差异"是对的,我的"改进"才是退步。
|
||
*
|
||
* ⇒ 结论:`navMaterial`(`BlurStyle.COMPONENT_THICK`)保持不变;
|
||
* 两个令牌**删除**(不删就是孤儿,孤儿判据会一直红着)。
|
||
*
|
||
* ★ 留这段注释而不是直接删干净的原因:**下一个人会再想一遍同样的事**
|
||
* (WebUI 那行 `saturate(1.5)` 就摆在那里,很显眼)。
|
||
* 把"试过了、实测数字在此、结论是不要"写在这里,
|
||
* 比让他重跑一遍模拟器便宜。
|
||
*/
|
||
/*
|
||
* ── 卡片的"玻璃":**白色 + alpha,不做模糊** ──
|
||
*
|
||
* ★★ 2026-09-21 更正(用户:「邮件看着没有玻璃效果」,我实测后才发现自己搞错了)。
|
||
*
|
||
* WebUI 的 `.glass-card`(`index.css:1629`)**根本没有 `backdrop-filter`**:
|
||
*
|
||
* .glass-card { background-color: rgb(255 255 255 / 0.92); }
|
||
* html[data-bg='on'] … { background-color: rgb(255 255 255 / 0.78); }
|
||
* ↑ 就是"白 + alpha",没有 blur
|
||
*
|
||
* 全仓 `backdrop-filter` **只出现两处**(`index.css:844` 与 `:1204`):
|
||
* · `.glass-control` —— 控件档,`blur(8px) saturate(1.1)`
|
||
* · `.narrow-nav` —— **底部导航条**,`blur(18px) saturate(1.5)`
|
||
*
|
||
* 而我给**每张卡片**都挂了 `blur(18px) saturate(1.5)` —— 用的是**导航条那档**参数。
|
||
* 这正是 `index.css:990` 明确警告的做法:
|
||
* 「再 backdrop-filter 一次纯属叠加:不会更"玻璃",只会更脏更糊,
|
||
* 而且每层都要重新采样一次背景(滚动时明显掉帧)」
|
||
*
|
||
* ⇒ 后果:列表里每张卡各糊一次,多层叠加后正文发灰、滚动掉帧 ——
|
||
* 比"没有玻璃"更糟。修法就是照 WebUI:**卡片只用白 + alpha**。
|
||
*/
|
||
/*
|
||
* 卡片的白 + alpha(ArkUI 的 `#AARRGGBB`:前两位是 alpha)。
|
||
*
|
||
* glassCard #EBFFFFFF ≈ 白 / 0.92 ← WebUI `--glass-card-a: 0.92`
|
||
* glassCardWall #C7FFFFFF ≈ 白 / 0.78 ← WebUI `--glass-card-wall-a: 0.78`
|
||
*
|
||
* ★ 为什么必须**显式写 alpha**而不是像以前那样用 `Color.Transparent`:
|
||
* 卡片在壁纸上要是一层"薄白纱"(壁纸透上来但被压淡),
|
||
* 全透明等于壁纸原样穿过 —— 卡片的边界没了,文字也没有底衬(可读性掉)。
|
||
* WebUI 的 0.78 就是在这两者之间取的平衡值。
|
||
*
|
||
* ★ 深浅两色同一个值:因为**基色恒为白**(WebUI `--glass-base` 注释:
|
||
* 「基材恒为白,主题之间只差 alpha」)。深色下靠 `brightness` 与
|
||
* 文字令牌保证对比度,而不是把底也调黑。
|
||
*/
|
||
/*
|
||
* ── 卡片底:白色 + alpha(**与 WebUI `.glass-card` 逐字同源**)──
|
||
*
|
||
* index.css:1629 .glass-card → rgb(255 255 255 / 0.92)
|
||
* index.css html[data-bg='on'] … → rgb(255 255 255 / 0.78)
|
||
* ⇒ glassCard #EBFFFFFF(0.92 × 255 ≈ 235 = 0xEB)
|
||
* ⇒ glassCardWall #C7FFFFFF(0.78 × 255 ≈ 199 = 0xC7)
|
||
*
|
||
* ★ 为什么必须是**手写色**而不是 `$r('sys.*')`:
|
||
* 系统材质(`bgMaterial*` / `compBackground*`)是**不透明**的整块色板,
|
||
* 没有"白 92% 叠在壁纸上"这一档。而 WebUI 的玻璃观感**正是**这个 alpha
|
||
* —— 它不是模糊(`.glass-card` 全仓没有 `backdrop-filter`,只有
|
||
* `.glass-control` 与 `.narrow-nav` 有)。换成系统材质 ⇒ 壁纸被整块盖死,
|
||
* 那正是用户报的「玻璃不透明」。
|
||
*
|
||
* ★ 为什么用"白 + alpha"而不去调亮度模拟:
|
||
* 壁纸是**用户可换的图**,颜色不可预知;只有半透明白能同时适配浅壁纸
|
||
* 与深壁纸(深色档另有 `DARK_DIM_MIN` 兜底)。
|
||
*
|
||
* 已登记进 `cross-client-theme.test.mjs` 的 `SELF_OWNED_COLORS`
|
||
* (那条判据会红着提醒"理由不能只存在于写它那个人的记忆里")。
|
||
*/
|
||
static readonly glassCard: string = '#EBFFFFFF';
|
||
static readonly glassCardWall: string = '#C7FFFFFF';
|
||
/*
|
||
* ★★ 2026-09-21 **深色下的玻璃 alpha(这是「没有玻璃效果」的真正原因)**。
|
||
*
|
||
* ── 实测症状(设备,深色主题,壁纸 aurora 开启)──
|
||
*
|
||
* 同一行上交替采样(`/tmp/g1.raw`,1008×2232):
|
||
* y=670 卡片缝隙 rgb(199,199,199) 卡片内 rgb(27,37,50)
|
||
* y=880 卡片缝隙 rgb(201,203,201) 卡片内 rgb(27,36,53)
|
||
*
|
||
* 缝隙是**浅灰 199**、卡片是**深色** —— 界面看起来像"深色卡片浮在灰纸板上"。
|
||
* 而 199 恰好 = `0.78 × 255`(`glassCardWall` 的白 alpha 压在近黑壁纸上,
|
||
* 实测壁纸层 x=4..24 为 rgb(0,1,3))。
|
||
*
|
||
* ── 根因:`glassCard`/`glassCardWall` 是**单一值、不分深浅**──
|
||
*
|
||
* 我当初写的理由是"基色恒为白,主题之间只差 alpha"——**后半句才是 WebUI 的实情**,
|
||
* 而我只抄了"基色恒白",把"只差 alpha"理解成了"alpha 也不用变"。
|
||
*
|
||
* WebUI 深色档(`index.css` §`.dark`):
|
||
* --glass-card-a: 0.06 ← 浅色是 0.92
|
||
* --glass-card-hover-a: 0.1
|
||
* --glass-card-wall-a: 0.04 ← 浅色是 0.78
|
||
*
|
||
* 即深色下白纱要**几乎撤掉**(0.92 → 0.06,差 **15 倍**),让深色壁纸透上来;
|
||
* 浅色下才用厚白纱盖住亮壁纸。我用同一个 0.92 配两套主题 ⇒
|
||
* 深色下那层白纱把壁纸整片糊成浅灰 199,**玻璃感与深色同时消失**。
|
||
*
|
||
* ── 修法 ──
|
||
*
|
||
* 与 `accentFor` / `textSubtleFor` 同一范式:`*For(dark?)` 访问器读
|
||
* `Theme.isDarkNow()`(从 `AppStorage` 那个发布键读,不靠调用方自觉传)。
|
||
*
|
||
* `0.06 × 255 ≈ 15 = 0x0F`、`0.04 × 255 ≈ 10 = 0x0A`。
|
||
* 这两个值仍走登记过的 `SELF_OWNED_COLORS` 豁免(理由与浅色那两个相同:
|
||
* 系统材质没有"白 6% 叠壁纸"这一档,而玻璃观感正是这个 alpha)。
|
||
*
|
||
* ★ 为什么不复用 `Theme.glassCard` 只调 `brightness`:
|
||
* `brightness` 是**乘性**的,改的是所有颜色而不只是那层白纱;
|
||
* 而这里要改的恰好只是"白纱有多厚"。两者不是同一个旋钮。
|
||
*/
|
||
static readonly glassCardDark: string = '#0FFFFFFF';
|
||
static readonly glassCardWallDark: string = '#0AFFFFFF';
|
||
|
||
/**
|
||
* 卡片底(**主题感知**)—— 浅色厚白纱、深色近乎透明。
|
||
*
|
||
* 调用方只需给"是不是壁纸档",深浅由令牌自己从发布处读。
|
||
*/
|
||
static glassCardFor(wall: boolean, dark?: boolean): string {
|
||
const isDark: boolean = Theme.isDarkNow(dark);
|
||
if (wall) {
|
||
return isDark ? Theme.glassCardWallDark : Theme.glassCardWall;
|
||
}
|
||
return isDark ? Theme.glassCardDark : Theme.glassCard;
|
||
}
|
||
/**
|
||
* 「悬浮玻璃板」的另外两半:**投影 + 发丝描边**。
|
||
*
|
||
* 只做模糊+饱和仍然"像一块平的白方块" —— 玻璃板之所以像板,靠的是**边缘**:
|
||
* 它要看起来**浮在壁纸之上**。WebUI 的配方(`.app-shell > *`):
|
||
*
|
||
* box-shadow: 0 8px 28px rgb(0 0 0 / 0.16);
|
||
*
|
||
* 只有模糊没有投影 ⇒ 一片雾;加上投影 ⇒ 一块板。
|
||
* **这是本次"不够炫酷"的主因**(我先前只调了模糊与饱和度,方向就偏了)。
|
||
*
|
||
* ★ 用**系统预置阴影档** `ShadowStyle.OUTER_FLOATING_SM`("浮起的小面板"),
|
||
* 而不是手写 `{radius, color, offsetY}`。三个理由:
|
||
* ① 手写色是 `#AARRGGBB` 或 `rgba(...)` —— 两者都被判据明令禁止
|
||
* (B 条:"半透明属于系统材质/语义色的职责"、"鸿蒙侧不写 CSS 颜色函数"),
|
||
* 而这条禁令**是对的**:见 ②;
|
||
* ② 手写色在**深色下看不见**(深底叠深影 = 无变化),而 WebUI 在 `.dark` 里
|
||
* 要把同一处阴影从 0.16 加重到 0.55 才有效 —— 那是"替系统猜深浅"的典型;
|
||
* ③ 系统档自带深/浅两套取值,与 `radiusCard` 走 `sys.float.*` 是同一个道理。
|
||
*
|
||
* 发丝描边同理不再手写:`Theme.border`(系统分隔线色)已经在用,
|
||
* 深浅两套由系统给。
|
||
*/
|
||
static readonly glassShadow: ShadowStyle = ShadowStyle.OUTER_FLOATING_SM;
|
||
|
||
|
||
|
||
|
||
// ─────────────── 圆角:系统尺寸 ───────────────
|
||
|
||
/** 卡片圆角:系统"卡片"圆角(不再与 WebUI 的 14vp 绑死 —— 允许各自跟随系统) */
|
||
static readonly radiusCard: Resource = $r('sys.float.ohos_id_corner_radius_card');
|
||
/** 控件圆角:系统"按钮"圆角 */
|
||
static readonly radiusControl: Resource = $r('sys.float.ohos_id_corner_radius_button');
|
||
|
||
/*
|
||
* ★ 2026-09-16:宽屏玻璃面板几何(与 WebUI `app-shell` 的 CSS 变量**同值**)。
|
||
* `--radius-card: 0.875rem` = 14px、`--pane-gap: 10px`(`index.css`)。
|
||
* 写死常量而不是 `$r('sys.*')`:因为 WebUI 是 14/10,而系统的
|
||
* `corner_radius_card` 是 16 —— 宽屏面板必须用 WebUI 的值才能"一比一复刻"。
|
||
* 原有的 `radiusCard`/`radiusControl` 保持系统跟随,不动它们。
|
||
*/
|
||
static readonly glassRadius: number = 14;
|
||
static readonly paneGap: number = 10;
|
||
|
||
/*
|
||
* ─────────────── 手写色**登记表**(改这里要一起改判据) ───────────────
|
||
*
|
||
* 为什么需要这张表:本文件里"自己写的色值"是有理由的(品牌身份 + 系统没有对应物的
|
||
* 业务语义色),但**理由不能靠记忆**。判据(`cross-client-theme.test.mjs` 的
|
||
* A2 条)会枚举本文件里所有 `static readonly X: string = '#……'`,
|
||
* 发现**未登记的名字就判红** —— 于是"新写死一个色"必须显式过一道:
|
||
* 要么登记在这里并写清理由,要么它本来就该走 `$r('sys.*')`。
|
||
*
|
||
* 这条是 pi 读出来的真缺口:只枚举 11 个"必须是系统资源"的名字,挡不住
|
||
* 第 12 个**新加的手写色**(它不在名单里,于是 A/B/裸色值三条都碰不到它)。
|
||
* 「枚举挡实例,类才挡漂移」—— 这次枚举的是**名字**,所以要有名单。
|
||
*
|
||
* 登记项(28 个,名字与判据里的 SELF_OWNED_COLORS 逐字一致 —— 逐个列出而不是缩写,
|
||
* 这样 grep 一个名字就能找到它的理由):
|
||
*
|
||
* 玻璃(**白基材 + alpha**,深浅各一档;系统材质没有这一档):
|
||
* · glassCard / glassCardWall #EBFFFFFF / #C7FFFFFF 浅色:白 0.92 / 0.78
|
||
* · glassCardDark / glassCardWallDark #0FFFFFFF / #0AFFFFFF 深色:白 0.06 / 0.04
|
||
* ★★ 2026-09-21 **这一对是"没有玻璃效果"的真正原因**(设备实测撞出来的)。
|
||
* 我原先把 `glassCard`/`glassCardWall` 写成**单一值、不分深浅**,
|
||
* 理由是"基色恒为白" —— 但 WebUI 那句话的后半句才是实情:
|
||
* 「基材恒为白,**主题之间只差 alpha**」(`index.css:406`、`:1546`)。
|
||
* 我只抄了"基色恒白",把"只差 alpha"误读成"alpha 也不用变"。
|
||
*
|
||
* WebUI `.dark`:`--glass-card-a: 0.06` / `--glass-card-wall-a: 0.04`
|
||
* (浅色是 0.92 / 0.78,**深浅差 15 倍**)。
|
||
*
|
||
* 设备实测(深色 + aurora 壁纸,`/tmp/g1.raw` 1008×2232):
|
||
* 卡片缝隙 rgb(199,199,199) 卡片内 rgb(27,37,50)
|
||
* 199 恰好 = 0.78×255 —— 那层厚白纱把黑壁纸糊成了浅灰,
|
||
* 界面读起来像"深色卡片浮在灰纸板上",玻璃感与深色一起消失。
|
||
* 改用 #0FFFFFFF(0.06)后同一处实测 rgb(3..16) —— 壁纸透过来了。
|
||
*
|
||
* 入口:`Theme.glassCardFor(wall, dark?)`(从 `AppStorage` 的
|
||
* `KEY_IS_DARK` 读深浅,不靠调用方自觉传)。
|
||
*
|
||
* 品牌(跨客户端身份,系统给不了):
|
||
* · accent #2563EB 与 WebUI 的 --c-blue-600 逐字一致
|
||
* · accentFg #FFFFFF 品牌底上的文字
|
||
* · accentSoft #EFF6FF 品牌浅底(选中态背景,浅色主题用)
|
||
* · accentSoftDark #1C2536 品牌浅底在**深色**下的取值 —— 对齐 WebUI
|
||
* `index.css:475` 的 `.dark --c-blue-50`(`28 37 54`)。
|
||
* ★ 不登记会怎样:深色下选中卡片是接近纯白的浅蓝,
|
||
* 在深色页面上刺眼得像 bug(2026-09-19 设备实测到)
|
||
* · accentStrong #1D4ED8 品牌深色变体(选中文字/边框)
|
||
* · accentEdge #BFDBFE 品牌浅底的**边框** —— 对齐 WebUI
|
||
* `MailList.tsx:280` 的 `border-blue-200`
|
||
* (与 `accentSoft` = `bg-blue-50` 成对)。
|
||
* ★ 为什么"选中"必须是**底+边**两个 token:
|
||
* 选中与未读的**底色相同**(都是 accentSoft),
|
||
* 只靠底色的话"选中一封未读邮件"看不出任何变化
|
||
* ⇒ 那圈边才是区分"我正在读这一封"的信息。
|
||
* 第一版只搬了底忘了边,选中态淡到几乎看不见。
|
||
* · accentEdgeDark #2B3B57 上者的**深色**取值 —— 对齐 WebUI `.dark`
|
||
* 段的 `--c-blue-200`。入口 `accentEdgeFor()`。
|
||
* · dangerDark #F8A4A4 危险/拒绝在**深色**下当前景的取值 ——
|
||
* 对齐 WebUI `.dark --c-red-700`(`248 164 164`)。
|
||
* ★ 入口 `Theme.dangerFor()`。
|
||
* 不登记会怎样:深色下「归档」按钮只有 2.47:1
|
||
* (2026-09-19 设备实测,联系人页 7 处)
|
||
* · approveDark #76DB9B 同意在**深色**下当前景的取值 ——
|
||
* 对齐 WebUI `.dark --c-green-700`。入口 `approveFor()`。
|
||
* · warnFgDark #F9C368 警示文字在**深色**下的取值 ——
|
||
* 对齐 WebUI `.dark --c-amber-700`。入口 `warnFgFor()`。
|
||
* · 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 同意(绿)
|
||
* · approveBg #F0FDF4 同意底
|
||
* · approveFg #15803D 同意文字
|
||
* · danger #B91C1C 拒绝/危险(红)
|
||
* · dangerBg #FEF2F2 拒绝底
|
||
* · warnBg #FFFBEB 警示底(对应 WebUI --c-amber-50)
|
||
* · warnFg #B45309 警示文字(对应 WebUI --c-amber-700)
|
||
*
|
||
* 档位胶囊(档位是产品语义:系统不认识 plan / workspace / full):
|
||
* · chipNeutralBg #F3F4F6 plan 档底(最宽档但不报警)
|
||
* · chipNeutralFg #5A6270 plan 档文字
|
||
* · chipSpentBg #FEE2E2 预算耗尽底
|
||
* · chipSpentFg #B91C1C 预算耗尽文字
|
||
* · chipWarnBg #FFEDD5 预算紧张底
|
||
* · chipWarnFg #C2410C 预算紧张文字
|
||
*
|
||
* 导航选中/未选中的**跨端身份色**(2026-09-19 补,对应用户
|
||
* 「底栏数字为什么显示在图标下面?」与「宽屏侧栏根本不像 WebUI」那两轮):
|
||
* · navActiveBg #DBEAFE WebUI `--nav-active-bg`(`index.css:1595-1606`)
|
||
* = 侧栏选中项的浅蓝底块;宽屏侧栏的选中线索就是它
|
||
* · navBrandFg #475569 WebUI `--nav-fg-muted` = 侧栏未选中项图标色
|
||
* · badgePlain #475569 WebUI `bg-chrome-600`(`index.css:92`)=
|
||
* 'plain' 档徽标底色(联系人数)。★ 必须石板灰而非红:
|
||
* 三档被压成两档时 `'plain'` 会走 danger,看着像"有未读"
|
||
*
|
||
* ★★ 2026-09-21 补登记:`glassCard` / `glassCardWall`
|
||
*
|
||
* · glassCard #EBFFFFFF WebUI `.glass-card`(`index.css:1629`)
|
||
* `background-color: rgb(255 255 255 / 0.92)`
|
||
* (0.92 × 255 ≈ 235 = 0xEB)
|
||
* · glassCardWall #C7FFFFFF 上者的"有壁纸"档 —— WebUI
|
||
* `html[data-bg='on'] .glass-card` 的 `… / 0.78`
|
||
* (0.78 × 255 ≈ 199 = 0xC7)
|
||
*
|
||
* 为什么必须自己写而不是走 `$r('sys.*')`:系统材质
|
||
* (`bgMaterial*` / `compBackground*`)是**不透明**的整块色板,
|
||
* 没有"白 92% 叠在壁纸上"这一档。而 WebUI 的玻璃观感**正是**这个 alpha
|
||
* —— 它**不是**模糊(`.glass-card` 全仓没有 `backdrop-filter`;
|
||
* 只有 `.glass-control` 与 `.narrow-nav` 有)。换成系统材质 ⇒ 壁纸被
|
||
* 整块盖死,那正是用户报的「玻璃不透明」。
|
||
*
|
||
* 为什么是"白 + alpha"而不是调亮度模拟:壁纸是**用户可换的图**,
|
||
* 颜色不可预知;只有半透明白能同时适配浅壁纸与深壁纸
|
||
* (深色档另有 `DARK_DIM_MIN` 兜底)。
|
||
*
|
||
* SSE 连接指示器的四个状态色(逐档对齐 WebUI 的 Tailwind 类,
|
||
* `ConnectionIndicator.tsx:22-25`):
|
||
* · sseConnected #22C55E bg-green-500 已连接
|
||
* · sseConnecting #FACC15 bg-yellow-400 正在连接
|
||
* · sseReconnecting #FB923C bg-orange-400 重连中
|
||
* · sseDisconnected #F87171 bg-red-400 已断开
|
||
* ★ 不复用 warnFg/danger:那两个是**文字色**,而状态点是 8px 实心圆,
|
||
* 用文字色会发脏、深色底上不够跳(WebUI 也是 text-* 与 bg-* 两套)。
|
||
*
|
||
* 不在这里的手写色只有一种合法去处:`$r('sys.*')`(跟随系统/深色模式)。
|
||
*/
|
||
|
||
// ─────────────── 品牌色:跨客户端身份,必须自己写 ───────────────
|
||
|
||
/**
|
||
* 品牌蓝(按钮、链接、焦点环)。
|
||
*
|
||
* **不要**为了"用系统方案"把它换成系统的强调色:系统强调色会随主题/厂商皮肤变,
|
||
* 一旦换过去,"两个客户端是同一个产品"这件事就靠不住了。
|
||
* 这是整个文件里**唯一必须与 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';
|
||
/**
|
||
* 品牌底上的字/图标 —— **浅色**(白色)。
|
||
*
|
||
* ★ 底色深、前景浅。`danger` / `warnFg` / `approve` 那些语义色底同属这一类,
|
||
* 所以压在它们上面的字/图标**也用这个**(写 `accentFg` 比另起一个
|
||
* `onDangerFg` 更实际:此项目里那些底的颜色都够深,一个白就够)。
|
||
*
|
||
* ⚠️ 别写成 `Theme.surface` —— 那是**面**(会跟随系统主题翻转),
|
||
* 2026-09-19 有 12 处这么写错了(见 `surface` 的注释)。
|
||
*/
|
||
static readonly accentFg: string = '#FFFFFF';
|
||
/** 品牌蓝的浅底 / 深前景(与 WebUI 的 blue-50 / blue-700 成对) */
|
||
static readonly accentSoft: string = '#EFF6FF';
|
||
/**
|
||
* 品牌浅底在**深色**下的取值 —— WebUI `index.css:475` 的 `.dark --c-blue-50`
|
||
* 是 `28 37 54`(= `#1C2536`)。
|
||
*
|
||
* ★ 为什么需要它(2026-09-19 设备实测):`accentSoft` 是**写死的浅蓝**,
|
||
* 而它被用作「选中态背景」(多账号卡片、导航选中项…)。深色主题下
|
||
* 那几处仍是**接近纯白的浅蓝** —— 在深色页面上刺眼得像个 bug。
|
||
* WebUI 靠 CSS 变量反转发解决(`.dark` 段把 `--c-blue-50` 换成深蓝黑),
|
||
* 静态常量没有那层机制 ⇒ 必须显式提供深色取值。
|
||
*
|
||
* ★ 配套的 `accentSoftFor(isDark)` 才是使用入口 —— **不要在页面里
|
||
* 自己 `isDark ? a : b`**:那样每处都会各写一遍,迟早漏一处
|
||
* (WebUI 那条"由 test/theme.test.mjs 逐档断言"就是为防这个)。
|
||
*/
|
||
static readonly accentSoftDark: string = '#1C2536';
|
||
|
||
/**
|
||
* 品牌浅底的**边框**(与 WebUI 的 `border-blue-200` 成对)。
|
||
*
|
||
* ★★ 2026-09-20 补。WebUI 的"选中"是**底 + 边**两个 token 一起用
|
||
* (`MailList.tsx:280`:`active ? 'bg-blue-50 border-blue-200'`):
|
||
* · 底 `#EFF6FF` = 我们的 `accentSoft`
|
||
* · 边 `#BFDBFE` = 这一条
|
||
* 我第一版只搬了底、忘了边 ⇒ 选中态只有一层很淡的蓝,
|
||
* 在浅色壁纸上几乎看不出来(实测对比 WebUI 明显更弱)。
|
||
*
|
||
* 深色取值对齐 WebUI `.dark` 段的 `--c-blue-200`。
|
||
*/
|
||
static readonly accentEdge: string = '#BFDBFE';
|
||
static readonly accentEdgeDark: string = '#2B3B57';
|
||
|
||
/** 按当前主题给品牌浅底的边框(与 `accentSoftFor` 同一纪律)。 */
|
||
static accentEdgeFor(dark?: boolean): string {
|
||
return Theme.isDarkNow(dark) ? Theme.accentEdgeDark : Theme.accentEdge;
|
||
}
|
||
|
||
/**
|
||
* 按当前主题给品牌浅底。
|
||
*
|
||
* @param dark 当前是否深色(页面的 `isDarkMode(...)` 算出来传进来 ——
|
||
* `Theme` 是静态类,拿不到 Context,所以深浅色由调用方给)
|
||
*/
|
||
static accentSoftFor(dark?: boolean): string {
|
||
return Theme.isDarkNow(dark) ? Theme.accentSoftDark : Theme.accentSoft;
|
||
}
|
||
static readonly accentStrong: string = '#1D4ED8';
|
||
|
||
/** 导航文字:未选中 / 选中(对应 --nav-fg-muted / --nav-active-fg) */
|
||
static readonly navFg: Resource = $r('sys.color.ohos_id_color_text_secondary');
|
||
static readonly navFgActive: string = Theme.accentStrong;
|
||
/*
|
||
* 侧栏**选中块的底色**(WebUI `--nav-active-bg: 219 234 254`)。
|
||
*
|
||
* ★ 为什么侧栏有底块、底栏没有(这是两种导航**纪律不同**,不是不一致):
|
||
* `index.css:1595-1606` 的原话:「底部导航的选中态**只换颜色**……
|
||
* 只作用在 `.narrow-nav` 上:宽屏侧栏是 48px 宽的竖条,图标底下那一块底色
|
||
* 是它**唯一的选中线索**,所以"只变色"不能无差别推广到所有 `.nav-item`。」
|
||
* 我 2026-09-18 之前把底栏那条纪律套到了侧栏上,还在 `WideSidebar` 的注释里
|
||
* 写成「WebUI 的 Sidebar 是纯图标轨(无 label 文字)」—— 那是**编的**:
|
||
* `Sidebar.tsx:110` 明明有 `<span className="text-3xs">{short}</span>`,
|
||
* `nav-item[data-active='true']` 也明明有底色。并排截图一眼就能看出来。
|
||
*/
|
||
static readonly navActiveBg: string = '#DBEAFE';
|
||
/*
|
||
* 侧栏**品牌标**的墨色(WebUI `--nav-fg-muted: 71 85 105` = #475569)。
|
||
*
|
||
* ★ 用像素取证过,不是照着色阶猜的:在 2x 缩放的 WebUI 侧栏截图上统计
|
||
* 品牌标附近最常见的墨色 → `rgb(71,85,105) ×206`,正是 `--nav-fg-muted`。
|
||
* 也就是说 WebUI 的品牌标是**中性石板灰**(`.nav-item` 默认色),
|
||
* **不是**品牌蓝。鸿蒙这边原来画成 `Theme.accent`(蓝)—— 并排一看就不同色。
|
||
*
|
||
* 深色主题下 `--nav-fg-muted: 148 158 175`(见 index.css:1559)。
|
||
* 鸿蒙暂未跟:`$r('sys.color.*')` 那套只在系统语义色上跟随,而这个值
|
||
* 是 WebUI 自己定的中性色。这一条**记在案上**,不假装已经跟了。
|
||
*/
|
||
static readonly navBrandFg: string = '#475569';
|
||
|
||
/**
|
||
* 导航项徽标的**中性档**底色(`navBadgeTone` 的 `'plain'` 档)。
|
||
*
|
||
* ★ 对齐 WebUI `Sidebar.tsx:203-204` 的 `bg-chrome-600 text-chrome-100`:
|
||
* `chrome-600` 在 `index.css:92` 是 **#475569**(深色模式 #3A414E,`index.css:393`)。
|
||
* `index.css:1097` 还有一条注释:`chrome-600/700` **刻意不透明** ——
|
||
* 徽标是 15px 的小控件,再叠透明度会让数字掉到 4.46:1,低于 WCAG AA 的 4.5。
|
||
* 与 `navBrandFg` 同值不是巧合:两者在 WebUI 里都是 `chrome-600`。
|
||
*
|
||
* ★★ 2026-09-19 修 bug:底栏与侧栏**两处**的徽标底色原先都写成
|
||
* `tone === 'perm' ? warnFg : danger` —— 三档被压成两档,
|
||
* 于是 `'plain'`(联系人数)走了**红**,看起来像“有未读”。
|
||
* 并排截图一眼可见:WebUI 是石板灰的 4,鸿蒙是红的 3。
|
||
*/
|
||
static readonly badgePlain: string = '#475569';
|
||
|
||
/*
|
||
* 状态点的四个色(SSE 连接指示器)—— **逐档对齐 WebUI** 的 Tailwind 色值。
|
||
*
|
||
* 来源:`client/electron/src/components/ConnectionIndicator.tsx:22-25`
|
||
* connecting `bg-yellow-400` = #FACC15
|
||
* connected `bg-green-500` = #22C55E
|
||
* reconnecting `bg-orange-400` = #FB923C
|
||
* disconnected `bg-red-400` = #F87171
|
||
*
|
||
* ★ 为什么不复用现有的 `warnFg`/`danger`:
|
||
* 那两个是**文字色**(`warnFg` #B45309 是深橙,给正文用的),
|
||
* 而状态点是 8px 的实心圆 —— 用文字色会显得发脏、且在深色底上不够跳。
|
||
* WebUI 也是分开的两套(文字走 `text-*`,这个点走 `bg-*`)。
|
||
*
|
||
* ★ 为什么必须进 Theme 而不是就地写字面量(`WideSidebar.ets` 原先就是就地写):
|
||
* 判据 `cross-client-theme` 有一条「鸿蒙页面里不得出现任何裸色值」,
|
||
* 理由写在 `read.mjs` 的头顶:「解释性注释与它解释的标识符同名」那类误报,
|
||
* 以及更重要的 —— 裸色值散在页面里,改配色时**没有任何一处会提醒你漏改了**。
|
||
*/
|
||
static readonly sseConnecting: string = '#FACC15';
|
||
static readonly sseConnected: string = '#22C55E';
|
||
static readonly sseReconnecting: string = '#FB923C';
|
||
static readonly sseDisconnected: string = '#F87171';
|
||
|
||
// ─────────────── 业务语义色:系统没有对应物,继续自己写 ───────────────
|
||
|
||
/** 语义色:同意 / 拒绝(对应 WebUI 的 approve/danger) */
|
||
static readonly approve: string = '#15803D';
|
||
static readonly danger: string = '#B91C1C';
|
||
static readonly dangerBg: string = '#FEF2F2';
|
||
static readonly approveBg: string = '#F0FDF4';
|
||
static readonly approveFg: string = '#15803D';
|
||
/** 警示面/前景(对应 WebUI 的 --c-amber-50 / --c-amber-700)—— 权限 full 档、待决策徽标 */
|
||
static readonly warnBg: string = '#FFFBEB';
|
||
static readonly warnFg: string = '#B45309';
|
||
/*
|
||
* ★★ 2026-09-21 **语义面的深色档**(与 `warnFgDark` 同一批漏掉的另一半)。
|
||
*
|
||
* 背景.test/`cross-client-theme` 之前只盯了**前景**(`*For()` 那一族),
|
||
* 语义**面**(`dangerBg` / `approveBg` / `warnBg` / `chipSpentBg` / `chipWarnBg`)
|
||
* 全部只有浅色值。实测(邮件详情页深色)「权限」「预算」两行:
|
||
* `#F3F4F6` 白胶囊上压浅色字 ⇒ 白底白字,完全读不出。
|
||
*
|
||
* 取值照 WebUI `.dark` 段逐字对齐(`index.css:486-520`):
|
||
* --c-red-50 `46 31 37` ⇒ #2E1F25 dangerBgDark
|
||
* --c-red-100 `58 34 39` ⇒ #3A2227 chipSpentBgDark
|
||
* --c-green-50 `25 44 39` ⇒ #192C27 approveBgDark
|
||
* --c-amber-50 `46 40 31` ⇒ #2E281F warnBgDark
|
||
* --c-amber-100 `59 48 29` ⇒ #3B301D
|
||
* --c-orange-100 `60 41 31` ⇒ #3C291F chipWarnBgDark
|
||
*
|
||
* ★ 为什么不是一个通用的 "bgDark":这六个在浅色下**本来就不同**
|
||
* (红/绿/琥珀/橙各自一档),压成一个等于把语义丢了 ——
|
||
* “预算用尽”与“预算将尽”就分不出来了。
|
||
*/
|
||
static readonly dangerBgDark: string = '#2E1F25';
|
||
static readonly approveBgDark: string = '#192C27';
|
||
static readonly warnBgDark: string = '#2E281F';
|
||
static readonly chipSpentBgDark: string = '#3A2227';
|
||
static readonly chipWarnBgDark: string = '#3C291F';
|
||
|
||
/*
|
||
* ──── 语义色的**深色档**(2026-09-19 加)────
|
||
*
|
||
* ★★ 和 `accentDark` 完全同一个问题、同一套证据:WebUI 的语义色也是
|
||
* **双通道**。浅色下 `red-700` 是 `185 28 28`(深红,给白底用);
|
||
* 深色下它被换成 `248 164 164`(**浅红**),否则深底上读不动。
|
||
*
|
||
* 实测(设备,深色主题):联系人页的「归档」按钮是
|
||
* `Theme.danger` = `#B91C1C` 压在卡片面上 ⇒ **2.47:1**,
|
||
* 低于 WCAG 图形下限 3:1。同样的还有日历页的
|
||
* 「今天」标(`accentStrong` 底 + 深字)等 4 处。
|
||
*
|
||
* ★ 取值直接对 WebUI `index.css` 的 `.dark` 段(`grep '^ --c-'` 可得):
|
||
* red-700 `248 164 164`
|
||
* green-700 `118 219 155`
|
||
* amber-700 `249 195 104`
|
||
*
|
||
* ★ 入口是下面那组 `*For(dark?)` —— 与 `accentFor` 同一条纪律:
|
||
* **别在页面里自己 `isDark ? a : b`**(约定已经漏过 8 处)。
|
||
*/
|
||
static readonly dangerDark: string = '#F8A4A4';
|
||
static readonly approveDark: string = '#76DB9B';
|
||
static readonly warnFgDark: string = '#F9C368';
|
||
|
||
/** 按主题给拒绝/危险**前景**色(压在面上的红字/红图标)。 */
|
||
static dangerFor(dark?: boolean): string {
|
||
return Theme.isDarkNow(dark) ? Theme.dangerDark : Theme.danger;
|
||
}
|
||
|
||
/** 按主题给同意**前景**色。 */
|
||
static approveFor(dark?: boolean): string {
|
||
return Theme.isDarkNow(dark) ? Theme.approveDark : Theme.approve;
|
||
}
|
||
|
||
/** 按主题给警示**前景**色。 */
|
||
static warnFgFor(dark?: boolean): string {
|
||
return Theme.isDarkNow(dark) ? Theme.warnFgDark : Theme.warnFg;
|
||
}
|
||
|
||
/**
|
||
* 权限档位 → 徽标底色 / 文字色。
|
||
*
|
||
* 与 WebUI 的 `PermissionChip.tsx` **同一映射**(plan=蓝 / workspace=绿 / full=琥珀),
|
||
* 认不出的档位与空档位按 workspace 处理 —— 映射只有这一处实现,
|
||
* 页面不再各自 if-else 挑颜色。
|
||
*
|
||
* 为什么不用系统情绪色:系统只有 warning/alert 两个,凑不出三档;
|
||
* 而档位是**可被文档与判据按名字引用的枚举标识**(plan/workspace/full),
|
||
* 用情绪色顶替它,界面对了语义丢了。
|
||
*/
|
||
static permBg(mode: string): string {
|
||
if (mode === 'plan') {
|
||
return Theme.accentSoft;
|
||
}
|
||
if (mode === 'full') {
|
||
return Theme.warnBg;
|
||
}
|
||
return Theme.approveBg;
|
||
}
|
||
|
||
static permFg(mode: string): string {
|
||
if (mode === 'plan') {
|
||
return Theme.accentStrong;
|
||
}
|
||
if (mode === 'full') {
|
||
return Theme.warnFg;
|
||
}
|
||
return Theme.approveFg;
|
||
}
|
||
|
||
/** 中性 chip(对应 WebUI 的 --c-gray-100 / --c-gray-500)—— 预算条"还宽裕"档 */
|
||
static readonly chipNeutralBg: string = '#F3F4F6';
|
||
static readonly chipNeutralFg: string = '#5A6270';
|
||
/*
|
||
* ★★ 2026-09-21 **加深色变体**(用户:「你看看这可读性」)。
|
||
*
|
||
* 实测(深色主题,邮件详情页的「权限」「预算」两行):
|
||
* 浅灰底 `#F3F4F6` 上压 `Theme.textMuted` ⇒ 两个色都是浅色档,
|
||
* 深色下变成**白胶囊 + 白字**(截图里就是两个看不见字的白块)。
|
||
*
|
||
* `#F3F4F6` 就是 WebUI 浅色的 `--c-gray-100`(`243 244 246`,
|
||
* `index.css:53`);它`.dark` 段的同一个是 `32 36 44`(`index.css:374`)。
|
||
* 所以深色取值 = `#20242C`(与 `DARK_GRAY_100` 同值)。
|
||
*
|
||
* 字色 `#5A6270` 对齐 WebUI 的 `--c-gray-600`;深色用 `#9BA3B0`
|
||
*(WebUI `.dark --c-gray-600` 一族的量级)—— 压在 `#20242C` 上约 7:1。
|
||
*
|
||
* ★ 为什么这是个**早就该修**的:同文件 `permissionMode` 那几行的注释里
|
||
* 就写着「`surface` 是会跟随主题翻转的面色 …… 深色下变成近黑 ⇒ 字看不见。
|
||
* 这是本仓同一个错法的第 N 次出现(同批一共修了 12 处)」。
|
||
* 那批修的是**前景**(`fontColor`),而 `chipNeutralBg` 这个**背景**
|
||
* 当时漏了 —— 于是同一行里前景换了、背景没换,反而更糟。
|
||
*/
|
||
static readonly chipNeutralBgDark: string = '#20242C';
|
||
static readonly chipNeutralFgDark: string = '#9BA3B0';
|
||
/**
|
||
* 语义**面**的颜色访问器(主题感知)—— `dangerFor` 那一族的背景版。
|
||
*
|
||
* 与 `chipBgFor` 同一范式。为什么需要:`dangerBg` 这些只有浅色值,
|
||
* 深色下把浅色调的底留在深色页面上(实测邮件详情页的「权限」「预算」行)。
|
||
*/
|
||
static dangerBgFor(dark?: boolean): string {
|
||
return Theme.isDarkNow(dark) ? Theme.dangerBgDark : Theme.dangerBg;
|
||
}
|
||
|
||
static approveBgFor(dark?: boolean): string {
|
||
return Theme.isDarkNow(dark) ? Theme.approveBgDark : Theme.approveBg;
|
||
}
|
||
|
||
static warnBgFor(dark?: boolean): string {
|
||
return Theme.isDarkNow(dark) ? Theme.warnBgDark : Theme.warnBg;
|
||
}
|
||
|
||
static chipSpentBgFor(dark?: boolean): string {
|
||
return Theme.isDarkNow(dark) ? Theme.chipSpentBgDark : Theme.chipSpentBg;
|
||
}
|
||
|
||
static chipWarnBgFor(dark?: boolean): string {
|
||
return Theme.isDarkNow(dark) ? Theme.chipWarnBgDark : Theme.chipWarnBg;
|
||
}
|
||
|
||
/** 预算用尽(对应 WebUI 的 --c-red-100 / --c-red-700) */
|
||
static readonly chipSpentBg: string = '#FEE2E2';
|
||
/*
|
||
* 预算 chip 的**前景**深色档 —— 与背景同一批漏的。
|
||
*
|
||
* `#B91C1C` 是 WebUI 浅色的 `--c-red-700`(`185 28 28`);
|
||
* 深色段同一个变量是 `248 164 164`(`index.css:493`)⇒ `#F8A4A4`。
|
||
* 橙色同理:`--c-orange-700` 深色 `251 168 111` ⇒ `#FBA86F`。
|
||
*/
|
||
static readonly chipSpentFgDark: string = '#F8A4A4';
|
||
static readonly chipSpentFg: string = '#B91C1C';
|
||
/** 预算将尽(对应 WebUI 的 --c-orange-100 / --c-orange-700) */
|
||
static readonly chipWarnBg: string = '#FFEDD5';
|
||
static readonly chipWarnFg: string = '#C2410C';
|
||
static readonly chipWarnFgDark: string = '#FBA86F';
|
||
|
||
/**
|
||
* 往返预算档位 → chip 底色 / 字色。
|
||
*
|
||
* 与 WebUI 的 `BudgetChip` **同一映射**(`WorkCard.tsx`):
|
||
* 剩 0 = 红(用尽)、剩 ≤1 = 橙(将尽,任务需要人介入)、其余 = 中性灰。
|
||
* 档位本身由 `MailGrouping.ts` 的 `budgetState()` 算(那是可被判据执行的一层),
|
||
* 这里只管"哪个档用什么颜色"。这三个色**不进跨端"取值相同"判据**(业务局部),
|
||
* 但必须进枚举完整性判据(三档齐全、认不出的归一到中性档)。
|
||
*/
|
||
/**
|
||
* 权限/预算 chip 的底色(**主题感知**)。
|
||
*
|
||
* 与 `glassCardFor` 同一范式:深浅从 `Theme.isDarkNow()` 读,不靠调用方传。
|
||
* 实测漏修后果:深色下 `#F3F4F6` 白胶囊 + 浅色字 = 完全读不出。
|
||
*/
|
||
static chipBgFor(dark?: boolean): string {
|
||
return Theme.isDarkNow(dark) ? Theme.chipNeutralBgDark : Theme.chipNeutralBg;
|
||
}
|
||
|
||
/** 权限/预算 chip 的字色(主题感知)。`chipBgFor` 的配对。 */
|
||
static chipFgFor(dark?: boolean): string {
|
||
return Theme.isDarkNow(dark) ? Theme.chipNeutralFgDark : Theme.chipNeutralFg;
|
||
}
|
||
|
||
static budgetBg(state: string, dark?: boolean): string {
|
||
if (state === 'spent') {
|
||
return Theme.chipSpentBgFor(dark);
|
||
}
|
||
if (state === 'warn') {
|
||
return Theme.chipWarnBgFor(dark);
|
||
}
|
||
return Theme.chipBgFor(dark);
|
||
}
|
||
|
||
static budgetFg(state: string, dark?: boolean): string {
|
||
if (state === 'spent') {
|
||
return Theme.isDarkNow(dark) ? Theme.chipSpentFgDark : Theme.chipSpentFg;
|
||
}
|
||
if (state === 'warn') {
|
||
return Theme.isDarkNow(dark) ? Theme.chipWarnFgDark : Theme.chipWarnFg;
|
||
}
|
||
return Theme.chipFgFor(dark);
|
||
}
|
||
|
||
/** 字体大小(与 WebUI 的 text-2xs/xs/sm 对齐) */
|
||
static readonly fontTiny: number = 11;
|
||
static readonly fontSmall: number = 12;
|
||
static readonly fontBody: number = 14;
|
||
|
||
/*
|
||
* ── 动画令牌(对照 WebUI `index.css`)──
|
||
*
|
||
* 用户(2026-09-17):「一方面一点动画都没有」。
|
||
* 实测确认:改造前全仓 `animateTo` / `transition` / `animation` **一次都没用过** ——
|
||
* 所有页面切换、详情推入、面板展开都是硬切。
|
||
*
|
||
* ★★ 2026-09-18 更正:这里原先写着「三个数与 WebUI **逐字一致**」,**那句是错的**。
|
||
* 它把「令牌存在」当成了「动画用了那个令牌」。实测(`client/electron/src/index.css`):
|
||
*
|
||
* `--dur-base: 180ms` 只用在**壁纸淡入**(:747 `transition: opacity …`)
|
||
* `--ease-out-soft (0.22,1,0.36,1)` 只用在 **transition**(壁纸、控件变色 :1169)
|
||
* 而**所有 @keyframes 动画**用的是 150ms + `cubic-bezier(0.22, 0.61, 0.36, 1)`
|
||
*
|
||
* 全仓 `(0.22,0.61,0.36,1)` 出现 **5 次**(rise-in ×3 / cal-in ×2),
|
||
* `(0.22,1,0.36,1)` 只出现 **1 次**(就是令牌定义处)。**这是两根不同的曲线。**
|
||
* 旧代码把面板入场按 `(0.22,1,0.36,1)` + 180ms 做 ⇒ 比 WebUI **慢 30ms 且曲线偏软**,
|
||
* 而当时的判据(`harmony-nav` "缓动必须是 0.22,1,0.36,1")锚在**令牌**上,
|
||
* 所以这个错它一辈子抓不到。
|
||
*
|
||
* 所以下面分**两组**,各自对齐各自该对齐的东西:
|
||
* 控件类(transition) —— `durFast 120` + `easeOutSoft`
|
||
* 动画类(@keyframes)—— 各自时长 + `easeRise`
|
||
*/
|
||
/** 控件变色(hover / active)—— WebUI `--dur-fast: 120ms` */
|
||
static readonly durFast: number = 120;
|
||
/**
|
||
* 壁纸淡入 —— WebUI `--dur-base: 180ms`。
|
||
*
|
||
* ★ **只用于壁纸/整体淡入,不是面板入场**(面板入场是 `durRise`)。
|
||
* 这个区分是 2026-09-18 查出来的,之前两者被当成同一个数。
|
||
*/
|
||
static readonly durBase: number = 180;
|
||
/**
|
||
* 面板/内容入场。
|
||
*
|
||
* ★★ 2026-09-20 150 → **200**(用户:「动画不符合鸿蒙视觉要求」,选定"折中 200ms")。
|
||
*
|
||
* 两个来源**打架**,这里取了折中:
|
||
* · WebUI `@keyframes rise-in` 实测 **150ms**(本仓既有基准)
|
||
* · 鸿蒙官方动效规范(developer.huawei.com「动效属性」页):
|
||
* 「元素淡入/位移进入 **200-300ms**」+ EaseOut
|
||
* 150ms 比鸿蒙建议的**下限还短 25%** —— 在鸿蒙上确实偏"赶",
|
||
* 系统的原生页面转场普遍用 250-350ms。
|
||
*
|
||
* 取 **200ms(规范区间下限)** 而不是直接上 250ms:
|
||
* WebUI 那一侧的值是用户当年**逐个调过**的(09-14 否掉过"整屏淡入"、
|
||
* 09-15 报过"动画卡顿"),一下子拉到 250 会让两端观感差得更远。
|
||
* 200ms 两边都不冒犯,且落在鸿蒙规范区间内。
|
||
*
|
||
* ★ 这条**不是**"令牌值"而是"动画值"—— 上面那段注释讲过两者的区别,
|
||
* 别拿 `durBase`(180) 或 `durFast`(120) 来替它。
|
||
*/
|
||
static readonly durRise: number = 200;
|
||
/**
|
||
* **共享元素转场**(`geometryTransition`,即"按钮长成面板")的时长。
|
||
*
|
||
* 取 WebUI FLIP 的原值 **220ms**(`ComposePage.tsx:75`)——
|
||
* 就是那个"球长成写信页 / 回复框"的动画。曲线用 `Theme.easeRise`,
|
||
* 它的三次贝塞尔正是 WebUI 那行 `cubic-bezier(0.22, 0.61, 0.36, 1)`。
|
||
*
|
||
* ★ 为什么单独一个令牌而不是复用 `durRise`:「元素出入场」与
|
||
* 「共享元素在两个位置间续接」是两种动效,WebUI 那边也是两个值
|
||
* (rise-in 150 / morph 220)。合一个的话以后想单独调其中一个
|
||
* 就得先把它拆开 —— 拆的时候一定会漏掉某处调用。
|
||
*/
|
||
static readonly durMorph: number = 220;
|
||
/**
|
||
* **主题切换**交叉淡出的时长。
|
||
*
|
||
* 取值理由:主题切换是**整屏**变化,比单个组件的入场要慢一点才不刺眼
|
||
* (260ms 落在"能看清是个过渡"与"不让人觉得卡"之间)。
|
||
*
|
||
* ★ 与 `durRise`(200) / `durMorph`(220) 分开:三者是三种不同的动效。
|
||
* 本仓为此已经写过一次教训 —— **时长令牌合并后,想单独调一个就得先拆开,
|
||
* 而拆的时候一定会漏掉某处调用点**。
|
||
*/
|
||
static readonly durThemeFade: number = 260;
|
||
/** 弹层入场 —— WebUI `.animate-menu-in` 实测 **140ms** */
|
||
static readonly durMenu: number = 140;
|
||
/** 日历翻月 —— WebUI `.cal-slide-next/prev` 实测 **200ms** */
|
||
static readonly durCal: number = 200;
|
||
|
||
/**
|
||
* **transition** 用的缓动:`cubic-bezier(0.22, 1, 0.36, 1)` = WebUI `--ease-out-soft`。
|
||
*
|
||
* 用 `curves.cubicBezierCurve` 而不是 `Curve.EaseOut` 之类的枚举:
|
||
* 枚举是系统预设的**另一根**曲线,观感与 WebUI 对不上。
|
||
*/
|
||
static readonly easeOutSoft: ICurve = curves.cubicBezierCurve(0.22, 1, 0.36, 1);
|
||
|
||
/**
|
||
* **@keyframes 动画**用的缓动:`cubic-bezier(0.22, 0.61, 0.36, 1)`。
|
||
*
|
||
* ★ 与 `easeOutSoft` **不是同一根曲线**(前者第二段控制点是 0.61,后者是 1)。
|
||
* 两者别互换:WebUI 里 transition 用 `--ease-out-soft`,而所有动画
|
||
* (rise-in / cal-in)硬编码用的是这一根。
|
||
*/
|
||
static readonly easeRise: ICurve = curves.cubicBezierCurve(0.22, 0.61, 0.36, 1);
|
||
|
||
/**
|
||
* 窗格入场时的上浮量(vp)。
|
||
*
|
||
* 4 —— **与 WebUI `@keyframes rise-in` 逐字一致**(`index.css:1208`):
|
||
* `from { opacity: 0; transform: translateY(4px); }`
|
||
* 只上浮 4vp,是"轻轻落位"而不是"整块飞进来"。用户对"闪/跳"敏感
|
||
* (09-14 否掉过整屏淡入),所以这个数**不许**随手调大。
|
||
*/
|
||
static readonly riseInOffset: number = 4;
|
||
|
||
/**
|
||
* 窗格入场过渡:4vp 上浮 + 淡入(对齐 WebUI `@keyframes rise-in`)。
|
||
*
|
||
* 做成方法而不是常量,是因为 `TransitionEffect` 是**有状态的构建器**
|
||
* (`combine`/`animation` 会就地改自身),多处共用同一个实例会互相干扰;
|
||
* 每处调用各拿一个新的。
|
||
*
|
||
* ★ 时长/曲线用 `durRise` + `easeRise`(**150ms + 0.22,0.61,0.36,1**)——
|
||
* 即 WebUI `rise-in` 的**真实取值**。改之前用的是 `durBase(180)` + `easeOutSoft`,
|
||
* 那是令牌值而不是动画值(见上面令牌区的更正说明)。
|
||
*
|
||
* ★ 两边**不对称**:出场用 `durFast`(120ms)比入场快。
|
||
* WebUI 那边 `rise-in` 是对称的(一个 @keyframes 两用)—— 因为它是**逐个元素**
|
||
* 挂载即播,新旧两棵子树不会同时可见。而 ArkUI 这里是 `if/else` 换子树,
|
||
* 两层会**同时半透明地叠着**,同长看起来就是“闪一下”(实测 6 秒取样确认),
|
||
* 所以出场必须更快地让位。这是两端机制不同带来的**必要差异**,不是随手拍数。
|
||
*
|
||
* ★★ 2026-09-19 重要限定(**这是“最严重的动画问题”的根**):
|
||
* 本方法给的是 **TransitionEffect**,而 ArkUI 的 `.transition()` 只在
|
||
* **挂载/卸载**时触发(SDK 原话:"Set the transition effect of component when it
|
||
* **appears and disappears**")。
|
||
*
|
||
* 所以它**只能用于 `if/else` 换子树的那种窗格**(通信/联系人/我的)。
|
||
* 对**常驻挂载**的窗格(日历:用 `visibility` 控制),`.transition()` **永远不会触发**
|
||
* —— 切过去就是硬弹、一帧动画都没有,而代码看上去是“挂了动画的”。
|
||
*
|
||
* 常驻窗格不能用 TransitionEffect,要用 `animateTo` + 显式的 `@State`
|
||
* 透明度/位移属性去驱动(与 WebUI 的 `html.view-switch` 重放窗口同一思路)——
|
||
* 见 `MainPage` 的 `calPaneOpacity` / `calPaneShift`。
|
||
*/
|
||
static paneRiseIn(): TransitionEffect {
|
||
/*
|
||
* ★★ 2026-09-20 改成**对称**(用户:「点击回复按键…动画与 webui 不一致」)。
|
||
*
|
||
* 改之前是 `asymmetric`:入场做 `opacity + translate`(150ms),
|
||
* 退场**只做 opacity**(120ms)。于是同一个"回复框"出现时会浮上来、
|
||
* 消失时却只淡出 —— **两头的动作不是同一件事**。
|
||
*
|
||
* WebUI 那边是一条 `@keyframes rise-in` 加 `both`:
|
||
* animation: rise-in 150ms cubic-bezier(0.22,0.61,0.36,1) both;
|
||
* `both` = 进出都用同一条关键帧 ⇒ 天然对称。
|
||
*
|
||
* ★ 当初为什么写成不对称:注释里记过理由 ——「ArkUI 是 if/else 换子树,
|
||
* 两层会同时半透明地叠着,同长看起来就是闪一下」。这个担心对
|
||
* **换窗格**(通信↔日历那种整块替换)成立;但**对回复框/转发条不成立** ——
|
||
* 它出现时底下没有"正在消失的另一半",而是一块一直存在的正文。
|
||
* 我把两种场景混用了同一个过渡。
|
||
*
|
||
* ⇒ 现在对称:进出都是 `opacity + translate`、同一条曲线、同一时长。
|
||
* 换窗格那几处如果将来发现"闪",应该用各自更合适的过渡去解,
|
||
* 而不是让**所有**用到 rise 的地方一起背这个不对称。
|
||
*/
|
||
return TransitionEffect.opacity(0).combine(
|
||
TransitionEffect.translate({ y: Theme.riseInOffset })
|
||
).animation({ duration: Motion.dur(Theme.durRise), curve: Theme.easeRise });
|
||
}
|
||
|
||
/**
|
||
* 弹层入场:下移 4vp + 缩到 0.985 + 淡入(对齐 WebUI `@keyframes menu-in`)。
|
||
*
|
||
* WebUI `.animate-menu-in` 的注释把适用范围钉得很窄,这里照搬同一个口径:
|
||
* 「只给**真正是弹层**的东西(候选/下拉菜单自己穿)」——
|
||
* 它原本还挂着 `html.view-switch .glass-control`,于是**每次切视图页面上所有
|
||
* 按钮与输入框一起淡入位移**(几十个元素同时动),2026-09-15 被摘掉。
|
||
* 所以本方法**只给弹层**(账号选择器、下拉候选),不要挂到常驻控件上。
|
||
*
|
||
* 入场方向是**从上往下**(`translateY(-4px)` → 0):弹层从触发点的下方展开,
|
||
* 从上方“落”下来。与 `rise-in` 的**上浮**方向相反,别混。
|
||
*/
|
||
static menuIn(): TransitionEffect {
|
||
return TransitionEffect.opacity(0).combine(
|
||
TransitionEffect.translate({ y: -4 })
|
||
).combine(
|
||
TransitionEffect.scale({ x: 0.985, y: 0.985 })
|
||
).animation({ duration: Motion.dur(Theme.durMenu), curve: Theme.easeRise });
|
||
}
|
||
|
||
/**
|
||
* 日历翻月的横向滑入(对齐 WebUI `@keyframes cal-in-next/prev`)。
|
||
*
|
||
* `forward = true` → 下一月,从**右侧**滑入(`translateX(12%)`);
|
||
* `forward = false` → 上一月,从**左侧**滑入(`translateX(-12%)`)。
|
||
*
|
||
* 百分比在 ArkUI 的 `translate` 里是**相对元素自身尺寸**的 —— 与 CSS 语义一致,
|
||
* 所以 12% 就是“滑入自身宽度的 12%”,不是拍一个 vp 数(那样在大屏上会变小)。
|
||
*/
|
||
static calendarSlide(forward: boolean): TransitionEffect {
|
||
/*
|
||
* ★★ 2026-09-20 改成**对称**(同 `paneRiseIn`,理由见那处)。
|
||
*
|
||
* WebUI 这两个类也是 `both`:
|
||
* .cal-slide-next { animation: cal-in-next 200ms cubic-bezier(…) both; }
|
||
* 即**进出同一条关键帧**。
|
||
*
|
||
* 我原来写的不对称里,退场只做 `opacity`(120ms)—— 于是翻月时
|
||
* 新月份"滑进来"、旧月份只是"淡走",一进一出不是同一件事。
|
||
*
|
||
* ★ 对称之后,进出的 `x` 是**同一个**(`forward` 已决定方向),
|
||
* 所以不会出现"从右边进、从右边出"的别扭感:退出时它往同一侧滑走,
|
||
* 与 WebUI 那条 `both` 的行为一致。
|
||
*/
|
||
return TransitionEffect.opacity(0).combine(
|
||
TransitionEffect.translate({ x: forward ? '12%' : '-12%' })
|
||
).animation({ duration: Motion.dur(Theme.durCal), curve: Theme.easeRise });
|
||
}
|
||
}
|