diff --git a/client/electron/test/cross-client-theme.test.mjs b/client/electron/test/cross-client-theme.test.mjs new file mode 100644 index 0000000..3e622a5 --- /dev/null +++ b/client/electron/test/cross-client-theme.test.mjs @@ -0,0 +1,61 @@ +/** + * 两个客户端必须用**同一份设计词表**。 + * + * 用户下一步要求:「同步 ui 设计到客户端」。同步的第一件事不是把每个页面重画一遍, + * 而是两边共用同一套令牌(颜色/圆角/玻璃透明度)—— 否则每加一个页面就重抄一遍色值, + * 两个客户端会越走越远,而且这种漂移**没有任何判据会红**。 + * + * 这里只断言"两边对同一件事的取值一致",不断言实现方式(WebUI 用 CSS 变量、 + * 鸿蒙用 ArkTS 常量,本来就该不同)。 + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(HERE, '..', '..', '..'); +const web = readFileSync(join(ROOT, 'client/electron/src/index.css'), 'utf8'); +const harmony = readFileSync( + join(ROOT, 'client/harmony/entry/src/main/ets/common/Theme.ets'), + 'utf8' +); + +const hex = h => h.toUpperCase(); + +test('品牌蓝一致', () => { + // WebUI: --c-blue-600 应该是 37 99 235 = #2563EB + const webBlue = web.match(/--c-blue-600:\s*(\d+)\s+(\d+)\s+(\d+)/); + assert.ok(webBlue, 'WebUI 要有 --c-blue-600'); + const asHex = hex( + '#' + + [webBlue[1], webBlue[2], webBlue[3]] + .map(n => Number(n).toString(16).padStart(2, '0')) + .join('') + ); + const harmonyBlue = harmony.match(/accent: string = '(#[0-9A-Fa-f]{6})'/); + assert.ok(harmonyBlue, '鸿蒙要有 accent'); + assert.equal(hex(harmonyBlue[1]), asHex, `品牌蓝不一致:WebUI ${asHex} vs 鸿蒙 ${harmonyBlue[1]}`); +}); + +test('卡片圆角一致(WebUI 0.875rem = 14px)', () => { + assert.match(web, /--radius-card:\s*0\.875rem/); + assert.match(harmony, /radiusCard: number = 14/); +}); + +test('导航玻璃不透明度一致(都是 0.72)', () => { + assert.match(web, /--nav-bg:\s*255 255 255 \/ 0\.72/); + // 0.72 × 255 ≈ 184 = 0xB8(鸿蒙用 #AARRGGBB,顺序与 CSS 不同) + assert.match(harmony, /navBgLight: string = '#B8FFFFFF'/); +}); + +test('★ 判据自检:把鸿蒙的品牌蓝改成别的必须判红', () => { + const mutated = harmony.replace(/accent: string = '#2563EB'/, "accent: string = '#FF0000'"); + const m = mutated.match(/accent: string = '(#[0-9A-Fa-f]{6})'/); + assert.notEqual(hex(m[1]), '#2563EB'); +}); + +test('鸿蒙的令牌文件说明了与 WebUI 的对应关系(不是凭空一套)', () => { + assert.match(harmony, /与 WebUI 的令牌\*\*一一对应|对应 WebUI/); +}); diff --git a/client/harmony/entry/src/main/ets/common/Theme.ets b/client/harmony/entry/src/main/ets/common/Theme.ets new file mode 100644 index 0000000..ba3cc0f --- /dev/null +++ b/client/harmony/entry/src/main/ets/common/Theme.ets @@ -0,0 +1,50 @@ +/* + * AgentMail 鸿蒙客户端 — 设计令牌 + * + * 与 WebUI 的令牌**一一对应**(见 client/electron/src/index.css 的 `:root` 段)。 + * 用户要求「下一步就是同步 ui 设计到客户端了」——同步的第一步不是把每个页面都改一遍, + * 而是让两边**用同一份词表**:颜色、圆角、玻璃透明度、模糊档位。 + * 否则每加一个页面就会重新抄一遍色值,两个客户端慢慢就长得不一样了。 + * + * 注意几个刻意的取舍: + * - WebUI 用 `rgb(r g b / a)`,鸿蒙用 `#AARRGGBB` —— 顺序不同,转换时必须显式写清; + * 0.72 × 255 ≈ 184 = 0xB8、0.88 × 255 ≈ 224 = 0xE0。 + * - WebUI 的"正文面"是 0.88 不透明的玻璃(字要读得清),鸿蒙这里给的是**实色** + * surface:没有壁纸时不需要透,透反而降低可读性。 + * - 只有**导航**用半透明 + 模糊(浮在内容之上,模糊有遮蔽意义)——与 WebUI 同一结论。 + */ +export class Theme { + /** 页面底色(对齐 WebUI 的 bg-gray-50 / slate-100) */ + static readonly pageBg: string = '#F8FAFC'; + /** 承载文字的面:实色,优先可读性(对应 WebUI --bg-glass 在无壁纸时的观感) */ + static readonly surface: string = '#FFFFFFFF'; + /** 次级面(列表行 hover、分组底) */ + static readonly surfaceMuted: string = '#F1F5F9'; + /** 分隔线 */ + static readonly border: string = '#E2E8F0'; + + /** 导航底:浅色玻璃 0.72(对应 WebUI --nav-bg: 255 255 255 / 0.72) */ + static readonly navBgLight: string = '#B8FFFFFF'; + /** 深色主题下的导航底:0.72 的 #0F172A(对应 --nav-bg 的 .dark 值) */ + static readonly navBgDark: string = '#B80F172A'; + /** 导航文字:未选中 / 选中(对应 --nav-fg-muted / --nav-active-fg) */ + static readonly navFg: string = '#475569'; + static readonly navFgActive: string = '#1E40AF'; + + /** 品牌蓝(按钮、链接、焦点环) */ + static readonly accent: string = '#2563EB'; + static readonly accentFg: string = '#FFFFFF'; + /** 语义色:同意 / 拒绝(对应 WebUI 的 approve/danger) */ + static readonly approve: string = '#15803D'; + static readonly danger: string = '#B91C1C'; + static readonly dangerBg: string = '#FEF2F2'; + + /** 圆角:卡片 14、控件 8(与 WebUI 的 --radius-card / --radius-control 一致) */ + static readonly radiusCard: number = 14; + static readonly radiusControl: number = 8; + + /** 字体大小(与 WebUI 的 text-2xs/xs/sm 对齐) */ + static readonly fontTiny: number = 11; + static readonly fontSmall: number = 12; + static readonly fontBody: number = 14; +} diff --git a/client/harmony/entry/src/main/ets/pages/MainPage.ets b/client/harmony/entry/src/main/ets/pages/MainPage.ets index c0d53e9..7cd46a2 100644 --- a/client/harmony/entry/src/main/ets/pages/MainPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MainPage.ets @@ -3,6 +3,7 @@ * 收件箱 / 会话 / 联系人 三个 TabContent */ import { ApiClient, ApiError } from '../api/ApiClient'; +import { Theme } from '../common/Theme'; import { MailApi, InboxResponse } from '../api/MailApi'; import { AccountManager, AccountInfo } from '../api/AccountManager'; import { SseService, SseEvent } from '../api/SseService'; @@ -613,10 +614,12 @@ struct MainPage { @State currentIndex: number = 0; @Builder - TabBarBuilder(title: string, icon: string) { + TabBarBuilder(title: string, icon: string, index: number) { Column() { Text(icon).fontSize(20) - Text(title).fontSize(11).fontColor(this.currentIndex === 0 ? '#1A73E8' : '#666666') + Text(title) + .fontSize(Theme.fontTiny) + .fontColor(this.currentIndex === index ? Theme.navFgActive : Theme.navFg) } .width('100%') .justifyContent(FlexAlign.Center) @@ -627,17 +630,17 @@ struct MainPage { TabContent() { InboxTab() } - .tabBar(this.TabBarBuilder('收件箱', '📬')) + .tabBar(this.TabBarBuilder('收件箱', '📬', 0)) TabContent() { SessionsTab() } - .tabBar(this.TabBarBuilder('会话', '💬')) + .tabBar(this.TabBarBuilder('会话', '💬', 1)) TabContent() { ContactsTab() } - .tabBar(this.TabBarBuilder('联系人', '👤')) + .tabBar(this.TabBarBuilder('联系人', '👤', 2)) } .onChange((index: number) => { this.currentIndex = index;