Files
MailUI4Agents/client/harmony/entry/src/main/ets/common/Theme.ets
JianFeeeee 44e277e0b6 跨端: 补「当前选中邮件」高亮 —— 鸿蒙原来完全没有这个概念
用户:「你自己看看跟 webui 相比,观感真的差很多」。逐项比对后找到的**行为缺口**
(不是配色问题,是少了一个状态)。

WebUI 一直有:`MailList.tsx:104/116/229` 把 `currentMail?.mail_id` 传进卡片,
卡片据此上 `active ? 'bg-blue-50 border-blue-200'`。
鸿蒙**一个都没有** —— 点开一封邮件后,左侧列表那一行和旁边几行长得一模一样:
你不知道自己正在读哪一封、读完了该往哪回。

修法:
· `CommPage` 拥有 `@State currentMailId`,`openMail()` 里记下,以 `@Prop` 下发给
  `InboxTab`/`SentTab`。**单点写、多点读** —— 两个 tab 各存一份必然会分叉
  (收件箱和发件箱都能触发同一个动作)。
· 存 `mail_id` 而非索引/组键:SSE 会让列表重排,索引会错位。
· 三级优先 **选中 > 未读 > 普通**。★ 这里有个必须成对的理由:
  选中与未读底色**相同**(都是 `accentSoft`),只靠底色的话
  "选中一封未读邮件"看不出任何变化 ⇒ 选中必须额外加那圈边。
  这正是 WebUI 两个 token 并存的原因,不是随手加的边框。
· 新增 `Theme.accentEdge/accentEdgeDark/accentEdgeFor()`(= tailwind blue-200
  及其深色值)——我第一版只搬了底、忘了边,选中态淡到几乎看不见。

★ 顺带记一个工具坑:`devecocli ui tap` 在这台模拟器上**点了不生效**
(截图前后一样、布局树无变化),而 `hdc shell "uitest uiInput click X Y"`
有效。以后点不动就先换这条,别以为是代码没生效 —— 我为此白跑了两轮。
2026-09-20 12:24:17 +08:00

838 lines
46 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* 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;
/*
* ── 玻璃的**参数化**配方(比材质档更接近 WebUI)──
*
* `BlurStyle` 是**固定档**(系统给的那几组),而 WebUI 的玻璃是**显式参数**:
*
* .narrow-nav backdrop-filter: blur(18px) saturate(1.5) ← 浮条
* --bg-blur-panel: 10px ← 面板
*
* 关键差别是 **saturate(饱和度增强)**:它让透过玻璃的颜色更鲜艳,
* 正是"玻璃感"的主要来源。`BlurStyle` 没有这个旋钮,
* 所以纯用材质档会偏灰 —— 用户说"不够炫酷"就是这个观感。
*
* ArkUI 的参数化 API 是 `backgroundEffect({ radius, saturation, brightness })`,
* 字段与 CSS 的 `backdrop-filter` 一一对应,于是可以**照抄 WebUI 的数**。
* 取值规则(不是拍的):
* · `CARD_RADIUS = 18` / `CARD_SATURATION = 1.5` —— 与 WebUI `.narrow-nav`
* 逐字同源(那是 WebUI 里最"玻璃"的一档)。卡片面积小,用最有质感的档;
* · 面板面积大、透太多会伤正文可读性 ⇒ 用 `--bg-blur-panel: 10px`,
* 饱和度取 1.3(比 1.5 收一点 —— 大面积高饱和会让整页发艳)。
*
* ★ 为什么不直接抄 1.5 给面板:WebUI 那边这两个值本来就是**分开的**
* (10px 面板 / 18px 浮条),抄就得抄全套,不能只挑一个数。
*/
static readonly glassCardRadius: number = 18;
static readonly glassCardSaturation: number = 1.5;
/**
* 「悬浮玻璃板」的另外两半:**投影 + 发丝描边**。
*
* 只做模糊+饱和仍然"像一块平的白方块" —— 玻璃板之所以像板,靠的是**边缘**:
* 它要看起来**浮在壁纸之上**。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/裸色值三条都碰不到它)。
* 「枚举挡实例,类才挡漂移」—— 这次枚举的是**名字**,所以要有名单。
*
* 登记项(24 个,名字与判据里的 SELF_OWNED_COLORS 逐字一致 —— 逐个列出而不是缩写,
* 这样 grep 一个名字就能找到它的理由):
*
* 品牌(跨客户端身份,系统给不了):
* · 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 品牌深色变体(选中文字/边框)
* · 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,看着像"有未读"
*
* 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-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';
/** 预算用尽(对应 WebUI 的 --c-red-100 / --c-red-700) */
static readonly chipSpentBg: string = '#FEE2E2';
static readonly chipSpentFg: string = '#B91C1C';
/** 预算将尽(对应 WebUI 的 --c-orange-100 / --c-orange-700) */
static readonly chipWarnBg: string = '#FFEDD5';
static readonly chipWarnFg: string = '#C2410C';
/**
* 往返预算档位 → chip 底色 / 字色。
*
* 与 WebUI 的 `BudgetChip` **同一映射**(`WorkCard.tsx`):
* 剩 0 = 红(用尽)、剩 ≤1 = 橙(将尽,任务需要人介入)、其余 = 中性灰。
* 档位本身由 `MailGrouping.ts` 的 `budgetState()` 算(那是可被判据执行的一层),
* 这里只管"哪个档用什么颜色"。这三个色**不进跨端"取值相同"判据**(业务局部),
* 但必须进枚举完整性判据(三档齐全、认不出的归一到中性档)。
*/
static budgetBg(state: string): string {
if (state === 'spent') {
return Theme.chipSpentBg;
}
if (state === 'warn') {
return Theme.chipWarnBg;
}
return Theme.chipNeutralBg;
}
static budgetFg(state: string): string {
if (state === 'spent') {
return Theme.chipSpentFg;
}
if (state === 'warn') {
return Theme.chipWarnFg;
}
return Theme.chipNeutralFg;
}
/** 字体大小(与 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;
/** 面板/内容入场 —— WebUI `@keyframes rise-in` 实测 **150ms** */
static readonly durRise: number = 150;
/** 弹层入场 —— 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 {
return TransitionEffect.asymmetric(
TransitionEffect.opacity(0).combine(
TransitionEffect.translate({ y: Theme.riseInOffset })
).animation({ duration: Motion.dur(Theme.durRise), curve: Theme.easeRise }),
TransitionEffect.opacity(0).animation({ duration: Motion.dur(Theme.durFast), 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 {
return TransitionEffect.asymmetric(
TransitionEffect.opacity(0).combine(
TransitionEffect.translate({ x: forward ? '12%' : '-12%' })
).animation({ duration: Motion.dur(Theme.durCal), curve: Theme.easeRise }),
TransitionEffect.opacity(0).animation({ duration: Motion.dur(Theme.durFast), curve: Theme.easeRise })
);
}
}