From 208d0cda128bcd1834724df308d9bac2504a1e50 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Sat, 11 Jul 2026 00:05:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(design):=20design/=20=E7=9C=9F=E7=9B=B8?= =?UTF-8?q?=E6=BA=90=20+=20=E9=98=B2=E6=BC=82=E7=A7=BB=E9=9D=99=E6=80=81?= =?UTF-8?q?=E9=97=B8=EF=BC=88#25=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真相源整固: - design/index.html 登记簿:token 色板(var() 实时渲染)/ 组件登记卡 / 图标与跨端映射 / guidelines / ui_kits 单一入口,check-ds 强制登记 - design/icons.js 图标单源(Lucide 线条)+ icon-map.json 跨端映射表 (web/iOS/Android 三列,iOS 12 个 SF Symbol 全部收编登记) - components/core/Icon.jsx 单源图标渲染器;MicBar/RecognitionOverlay 接入 - tokens 新增 --brand-wechat(-press)(微信官方绿)与 --overlay-wave - fonts.css 弃 Google Fonts CDN,Outfit 可变字体自托管 (assets/outfit-latin.woff2,40KB;中国网络下 CDN 不可用会卡字体) - design/serve.mjs 零依赖预览服务器(登记簿与原型评审入口) codegen 强化(export-tokens.mjs): - 产物直写消费位置:ios/dudu/Shared/DuduTheme.swift、 android/.../design/DuduTheme.kt——删除 design-pipeline/generated/ 中间拷贝层(此前 app 内是手工拷贝,--check 守不到真实消费文件已漂移) - 新增 web/tokens.css 产物(custom props 透传 + Outfit data URI 内嵌) - tokens 文件遍历排序,产物确定性 存量收编: - desktop:三个窗口内联 SVG → Icon 组件;tray 裸 rgba → token+opacity - Android:LoginScreen 微信绿常量 → DuduPalette.brandWechat(Press), Color.White → textOnAccent - 官网 web/index.html:接入 tokens.css,34 处硬编码 hex 全部 var() 化; 深色板块(metrics/cta/footer)改挂 data-theme="dark" 复用 dark 令牌; 移除 Google Fonts CDN - design 包内 8 处裸色:微信绿 token 化、装饰色 ds-ignore 白名单、 同值色改 var() 引用 防漂移闸(零依赖 Node): - design-pipeline/check-ds.mjs:真相源自检 7 道(dark⊆light / var 链 / 裸色∈tokens 值集 / 组件登记 / icons 唯一 / icon-map 双向同集 / 禁 emoji) - design-pipeline/check-code.mjs:代码侧单源(desktop 禁字面色+内联 path / iOS 禁字面色+SF Symbol 同集 / Android 禁 Color(0x / web 禁字面色), ds-ignore 行级豁免制 - .githooks/pre-commit(启用:git config core.hooksPath .githooks) - CI design job 扩展为三道闸全绿 - 反向验证通过:注入裸色/孤儿 token/未登记组件/未登记 symbol 均被拦截 Co-Authored-By: Claude Fable 5 --- .githooks/pre-commit | 19 ++ .github/workflows/ci.yml | 8 +- README.md | 17 +- .../src/main/java/app/dudu/app/LoginScreen.kt | 7 +- .../main/java/app/dudu/design/DuduPalette.kt | 6 + .../main/java/app/dudu/design/DuduTheme.kt | 6 + design-pipeline/check-code.mjs | 105 +++++++++ design-pipeline/check-ds.mjs | 175 ++++++++++++++ design-pipeline/export-tokens.mjs | 43 +++- .../generated/android/DuduTheme.kt | 156 ------------- design-pipeline/generated/ios/DuduTheme.swift | 211 ----------------- design/assets/outfit-latin.woff2 | Bin 0 -> 40712 bytes design/components/core/Icon.d.ts | 13 ++ design/components/core/Icon.jsx | 25 ++ design/components/forms/Switch.jsx | 2 +- design/components/voice/MicBar.jsx | 12 +- .../components/voice/RecognitionOverlay.jsx | 10 +- design/guidelines/brand-logo.card.html | 2 +- design/guidelines/colors-neutrals.card.html | 2 +- design/icon-map.json | 16 ++ design/icons.js | 36 +++ design/index.html | 213 ++++++++++++++++++ design/serve.mjs | 96 ++++++++ design/tokens/colors.css | 9 + design/tokens/fonts.css | 16 +- design/ui_kits/desktop/DesktopShared.jsx | 8 +- design/ui_kits/desktop/SettingsTray.jsx | 2 +- design/ui_kits/mobile/AppScreens.jsx | 2 +- design/ui_kits/mobile/MobileShared.jsx | 4 +- desktop/src/feedback/main.jsx | 11 +- desktop/src/login/main.jsx | 3 +- desktop/src/onboarding/main.jsx | 17 +- desktop/src/tray/main.jsx | 2 +- ios/dudu/Shared/DuduTheme.swift | 9 + web/index.html | 86 +++---- web/tokens.css | 159 +++++++++++++ 36 files changed, 1014 insertions(+), 494 deletions(-) create mode 100755 .githooks/pre-commit create mode 100644 design-pipeline/check-code.mjs create mode 100644 design-pipeline/check-ds.mjs delete mode 100644 design-pipeline/generated/android/DuduTheme.kt delete mode 100644 design-pipeline/generated/ios/DuduTheme.swift create mode 100644 design/assets/outfit-latin.woff2 create mode 100644 design/components/core/Icon.d.ts create mode 100644 design/components/core/Icon.jsx create mode 100644 design/icon-map.json create mode 100644 design/icons.js create mode 100644 design/index.html create mode 100644 design/serve.mjs create mode 100644 web/tokens.css diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..67842fc --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,19 @@ +#!/bin/sh +# dudu pre-commit 闸:设计系统单源强制(启用:git config core.hooksPath .githooks) +# 动 design/ → 真相源自检 + codegen 零 diff;动各端 UI 代码/官网 → 代码侧单源。 +set -e + +CHANGED=$(git diff --cached --name-only) + +if echo "$CHANGED" | grep -q "^design/"; then + echo "[pre-commit] design/ 变更 → check-ds + export-tokens --check" + node design-pipeline/check-ds.mjs + node design-pipeline/export-tokens.mjs --check +fi + +if echo "$CHANGED" | grep -qE "^(desktop/src/|ios/.*\.swift|android/.*\.kt|web/)"; then + echo "[pre-commit] 端代码变更 → check-code" + node design-pipeline/check-code.mjs +fi + +exit 0 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 961b932..3d193a0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,12 +19,16 @@ jobs: go vet ./... go test ./... - design-tokens: + design-system: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: "22" - - name: Check generated tokens up to date + - name: 真相源自检(check-ds) + run: node design-pipeline/check-ds.mjs + - name: 令牌产物零 diff(export-tokens --check) run: node design-pipeline/export-tokens.mjs --check + - name: 代码侧颜色/图标单源(check-code) + run: node design-pipeline/check-code.mjs diff --git a/README.md b/README.md index cab8e3a..da77f6c 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,8 @@ server/ Go 后端(gin + gorilla/websocket + PostgreSQL + Redis) desktop/ 桌面端 Tauri 2(Mac + Windows) android/ Kotlin:主 App + IME Service ios/ Swift:主 App + Keyboard Extension -design/ 官方设计系统(tokens / React 组件 / UI kits)—— 界面唯一真相源 -design-pipeline/ 令牌导出管线:tokens.css → DuduTheme.swift / DuduTheme.kt +design/ 官方设计系统(tokens / 组件 / 图标 / UI kits,登记簿 index.html)—— 界面唯一真相源 +design-pipeline/ 令牌导出管线 + 防漂移闸(export-tokens / check-ds / check-code) doc/ 设计文档(HTML) todo/ 项目 TODO(todo.html 看板,经 /todo skill 管理) ``` @@ -35,13 +35,24 @@ go run ./cmd/server # 默认 :8080,ASR_PROVIDER=mock go test ./... ``` -令牌导出(design/tokens 变更后执行并提交产物): +令牌导出(design/tokens 变更后执行并提交产物;直写 ios/android/web 消费位置): ```bash node design-pipeline/export-tokens.mjs # 生成 node design-pipeline/export-tokens.mjs --check # CI 校验 ``` +设计系统防漂移闸(首次 clone 后启用 pre-commit): + +```bash +git config core.hooksPath .githooks # 启用提交前闸(一次性) +node design-pipeline/check-ds.mjs # 真相源自检(design/ 自身 7 道) +node design-pipeline/check-code.mjs # 代码侧颜色/图标单源(四端) +node design/serve.mjs # 登记簿与原型评审(http://localhost:5180) +``` + +全景说明见 `doc/frontend-overview.html`;改 UI 前先读 `design/index.html`(登记簿)。 + ## 关键约定 - 协议改动只改 `server/pkg/protocol`,各端对照实现 diff --git a/android/app/src/main/java/app/dudu/app/LoginScreen.kt b/android/app/src/main/java/app/dudu/app/LoginScreen.kt index d2541b8..ee44a79 100644 --- a/android/app/src/main/java/app/dudu/app/LoginScreen.kt +++ b/android/app/src/main/java/app/dudu/app/LoginScreen.kt @@ -23,7 +23,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -42,8 +41,6 @@ import app.dudu.wechat.wechatAuth import kotlinx.coroutines.launch /** 微信绿——全局唯一例外色(仅登录按钮,见设计核查清单)。 */ -private val WechatGreen = Color(0xFF07C160) -private val WechatGreenPress = Color(0xFF06AD56) /** * 登录页(13B):居中字标 + 底部微信绿胶囊按钮。 @@ -121,11 +118,11 @@ fun LoginScreen(onLoggedIn: () -> Unit, onBack: () -> Unit) { .height(56.dp) .alpha(if (loading) 0.6f else 1f) .clip(RoundedCornerShape(999.dp)) - .background(if (pressed) WechatGreenPress else WechatGreen) + .background(if (pressed) p.brandWechatPress else p.brandWechat) .clickable(interactionSource = src, indication = null, enabled = !loading) { doLogin() }, contentAlignment = Alignment.Center, ) { - DText(if (loading) "登录中" else "微信一键登录", 16.sp, Color.White, weight = FontWeight.Medium) + DText(if (loading) "登录中" else "微信一键登录", 16.sp, p.textOnAccent, weight = FontWeight.Medium) } } Spacer(Modifier.height(12.dp)) diff --git a/android/app/src/main/java/app/dudu/design/DuduPalette.kt b/android/app/src/main/java/app/dudu/design/DuduPalette.kt index 734f14a..b9fa850 100644 --- a/android/app/src/main/java/app/dudu/design/DuduPalette.kt +++ b/android/app/src/main/java/app/dudu/design/DuduPalette.kt @@ -38,6 +38,8 @@ data class DuduPalette( val overlayText: Color, val overlayText2: Color, val overlayText3: Color, + val brandWechat: Color, + val brandWechatPress: Color, ) val DuduLightPalette = DuduPalette( @@ -66,6 +68,8 @@ val DuduLightPalette = DuduPalette( overlayText = DuduTheme.Light.overlayText, overlayText2 = DuduTheme.Light.overlayText2, overlayText3 = DuduTheme.Light.overlayText3, + brandWechat = DuduTheme.Light.brandWechat, + brandWechatPress = DuduTheme.Light.brandWechatPress, ) val DuduDarkPalette = DuduPalette( @@ -94,6 +98,8 @@ val DuduDarkPalette = DuduPalette( overlayText = DuduTheme.Dark.overlayText, overlayText2 = DuduTheme.Dark.overlayText2, overlayText3 = DuduTheme.Dark.overlayText3, + brandWechat = DuduTheme.Dark.brandWechat, + brandWechatPress = DuduTheme.Dark.brandWechatPress, ) val LocalDuduPalette = staticCompositionLocalOf { DuduLightPalette } diff --git a/android/app/src/main/java/app/dudu/design/DuduTheme.kt b/android/app/src/main/java/app/dudu/design/DuduTheme.kt index 0039d6d..9e1f321 100644 --- a/android/app/src/main/java/app/dudu/design/DuduTheme.kt +++ b/android/app/src/main/java/app/dudu/design/DuduTheme.kt @@ -29,6 +29,8 @@ object DuduTheme { val blue900 = Color(0xFF1E2B6B) val border1 = Color(0xFFE4E6EB) val border2 = Color(0xFFD2D5DD) + val brandWechat = Color(0xFF07C160) + val brandWechatPress = Color(0xFF06AD56) val danger = Color(0xFFDC2626) val dangerSoft = Color(0xFFFEE2E2) val gray0 = Color(0xFFFFFFFF) @@ -50,6 +52,7 @@ object DuduTheme { val overlayText = Color(0xFFF2F4FA) val overlayText2 = Color(0xFF9AA3BD) val overlayText3 = Color(0xFF646E8C) + val overlayWave = Color(0xFF7C9BFF) val positive = Color(0xFF16A34A) val positiveSoft = Color(0xFFDCFCE7) val red100 = Color(0xFFFEE2E2) @@ -87,6 +90,8 @@ object DuduTheme { val blue900 = Color(0xFF1E2B6B) val border1 = Color(0xFF272C38) val border2 = Color(0xFF343A49) + val brandWechat = Color(0xFF07C160) + val brandWechatPress = Color(0xFF06AD56) val danger = Color(0xFFF05B5B) val dangerSoft = Color(0x29F05B5B) val gray0 = Color(0xFFFFFFFF) @@ -108,6 +113,7 @@ object DuduTheme { val overlayText = Color(0xFFF2F4FA) val overlayText2 = Color(0xFF9AA3BD) val overlayText3 = Color(0xFF646E8C) + val overlayWave = Color(0xFF7C9BFF) val positive = Color(0xFF34C46A) val positiveSoft = Color(0x2934C46A) val red100 = Color(0xFFFEE2E2) diff --git a/design-pipeline/check-code.mjs b/design-pipeline/check-code.mjs new file mode 100644 index 0000000..13112b1 --- /dev/null +++ b/design-pipeline/check-code.mjs @@ -0,0 +1,105 @@ +#!/usr/bin/env node +/** + * check-code.mjs — 代码侧颜色/图标单源闸(分端扫描,零依赖,Node >= 18) + * + * 各端规则(违规 exit 1;行内 `ds-ignore: 理由` 豁免): + * desktop/src(jsx/css/js):禁字面色(hex/rgb/hsl),须 var(--*); + * 禁内联 (图标须经 @dudu/design 的 Icon/icons.js) + * ios(swift) :禁 Color(red:/UIColor(/Color(0x/#colorLiteral,须 DuduTheme; + * systemName 用的 SF Symbol 须 ∈ design/icon-map.json 的 ios 列 + * android(kt) :禁 Color(0x,须 DuduTheme(生成文件本身豁免) + * web(html/css) :禁字面色(令牌由 GENERATED tokens.css 提供,页面只允许 var(--*)) + * + * 用法:node design-pipeline/check-code.mjs + */ + +import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs'; +import { resolve, dirname, join, relative, basename } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dir = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(__dir, '..'); +const errors = []; +const err = (file, line, msg) => errors.push(`${relative(ROOT, file)}:${line} ${msg}`); + +function walk(dir, exts, out = []) { + if (!existsSync(dir)) return out; + for (const name of readdirSync(dir)) { + if (name === 'node_modules' || name === 'dist' || name === 'target' || name.startsWith('.')) continue; + const p = join(dir, name); + if (statSync(p).isDirectory()) walk(p, exts, out); + else if (exts.some((e) => name.endsWith(e))) out.push(p); + } + return out; +} + +const COLOR_RE = /#[0-9a-fA-F]{3,8}\b|(?:rgba?|hsla?)\(/g; + +function scanColors(files, label) { + for (const f of files) { + readFileSync(f, 'utf8').split('\n').forEach((line, i) => { + if (line.includes('ds-ignore:')) return; + const stripped = line.replace(/var\(--[\w-]+\)/g, ''); // var() 里不含字面色,先剥掉便于报错定位 + const m = stripped.match(COLOR_RE); + if (m) err(f, i + 1, `${label} 字面色 ${m[0]}(改用设计令牌,或加 ds-ignore: 理由)`); + }); + } +} + +// ─── desktop:字面色 + 内联 ─────────────────────────────────────────── + +const desktopFiles = walk(join(ROOT, 'desktop/src'), ['.jsx', '.js', '.css', '.html']); +scanColors(desktopFiles, 'desktop'); +for (const f of desktopFiles) { + readFileSync(f, 'utf8').split('\n').forEach((line, i) => { + if (line.includes('ds-ignore:')) return; + if (/]/.test(line)) + err(f, i + 1, 'desktop 内联 (图标须登记 design/icons.js 并经 Icon 组件渲染)'); + }); +} + +// ─── iOS:字面色 + SF Symbol 同集 ──────────────────────────────────────────── + +const iconMap = JSON.parse(readFileSync(join(ROOT, 'design/icon-map.json'), 'utf8')); +const iosSymbols = new Set( + Object.entries(iconMap).filter(([k]) => k !== '$schema').map(([, v]) => v.ios).filter(Boolean) +); + +for (const f of walk(join(ROOT, 'ios'), ['.swift'])) { + const isGenerated = basename(f) === 'DuduTheme.swift'; + readFileSync(f, 'utf8').split('\n').forEach((line, i) => { + if (line.includes('ds-ignore:')) return; + if (!isGenerated && /Color\(red:|UIColor\(red:|Color\(\s*0x|#colorLiteral/.test(line)) + err(f, i + 1, 'iOS 字面色(改用 DuduTheme.*,或加 ds-ignore: 理由)'); + for (const m of line.matchAll(/systemName:\s*"([^"]+)"/g)) { + if (!iosSymbols.has(m[1])) + err(f, i + 1, `iOS SF Symbol "${m[1]}" 未登记 design/icon-map.json(ios 列)`); + } + }); +} + +// ─── Android:字面色 ───────────────────────────────────────────────────────── + +for (const f of walk(join(ROOT, 'android/app/src'), ['.kt'])) { + if (basename(f) === 'DuduTheme.kt') continue; // codegen 产物 + readFileSync(f, 'utf8').split('\n').forEach((line, i) => { + if (line.includes('ds-ignore:')) return; + if (/Color\(\s*0x/.test(line)) + err(f, i + 1, 'Android 字面色(改用 DuduTheme / DuduPalette,或加 ds-ignore: 理由)'); + }); +} + +// ─── web:字面色(tokens.css 为 GENERATED 豁免)────────────────────────────── + +scanColors( + walk(join(ROOT, 'web'), ['.html', '.css']).filter((f) => basename(f) !== 'tokens.css'), + 'web' +); + +// ─── 汇总 ──────────────────────────────────────────────────────────────────── + +if (errors.length) { + console.error(`❌ check-code:${errors.length} 处违规\n` + errors.map((e) => ' ' + e).join('\n')); + process.exit(1); +} +console.log('✅ check-code:desktop / iOS / Android / web 单源全绿'); diff --git a/design-pipeline/check-ds.mjs b/design-pipeline/check-ds.mjs new file mode 100644 index 0000000..0f5c5dc --- /dev/null +++ b/design-pipeline/check-ds.mjs @@ -0,0 +1,175 @@ +#!/usr/bin/env node +/** + * check-ds.mjs — 真相源自检闸(扫 design/ 自身,零依赖,Node >= 18) + * + * 七道检查(违规 exit 1,逐条打印文件:行): + * ① tokens dark 键 ⊆ light 键(防孤儿 dark token) + * ② token 值内 var(--x) 引用链可解析;styles.css @import 的文件存在 + * ③ design/ 内(guidelines/components/ui_kits/index.html/styles.css)裸色 + * 必须 ∈ tokens 值集,或行内带 `ds-ignore:` 豁免注释 + * ④ components/**\/*.jsx 导出的组件必须在登记簿 design/index.html 出现 + * ⑤ icons.js:key 唯一、path 为描边风格(禁 fill="#...") + * ⑥ icon-map.json:web 列 ↔ icons.js key 双向同集;值为 string|null + * ⑦ 组件/ui_kits 界面文案禁 emoji(键帽符号 ⌘⇧⌫↵⌥ 不在 emoji 区,天然放行) + * + * 用法:node design-pipeline/check-ds.mjs + */ + +import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs'; +import { resolve, dirname, join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dir = dirname(fileURLToPath(import.meta.url)); +const DESIGN = resolve(__dir, '../design'); +const errors = []; +const err = (file, line, msg) => + errors.push(`${relative(resolve(__dir, '..'), file)}:${line} ${msg}`); + +// ─── 工具 ──────────────────────────────────────────────────────────────────── + +function walk(dir, exts, out = []) { + for (const name of readdirSync(dir)) { + const p = join(dir, name); + if (statSync(p).isDirectory()) walk(p, exts, out); + else if (exts.some((e) => name.endsWith(e))) out.push(p); + } + return out; +} + +function parseBlock(css, selector) { + const out = {}; + const re = new RegExp(`${selector.replace(/[[\]"=]/g, (m) => '\\' + m)}\\s*\\{([\\s\\S]*?)\\}`, 'g'); + let m; + while ((m = re.exec(css))) { + const body = m[1].replace(/\/\*[\s\S]*?\*\//g, ''); + for (const line of body.split(';')) { + const mm = line.match(/--([\w-]+)\s*:\s*(.+)/); + if (mm) out[mm[1].trim()] = mm[2].trim(); + } + } + return out; +} + +// ─── 解析 tokens ───────────────────────────────────────────────────────────── + +const tokenFiles = readdirSync(join(DESIGN, 'tokens')).filter((f) => f.endsWith('.css')).sort(); +const light = {}, dark = {}; +for (const f of tokenFiles) { + const css = readFileSync(join(DESIGN, 'tokens', f), 'utf8'); + Object.assign(light, parseBlock(css, ':root')); + Object.assign(dark, parseBlock(css, '[data-theme="dark"]')); +} + +// tokens 合法值集(hex 大写 + rgba 去空格),供 ③ 比对 +const tokenValues = new Set(); +for (const v of [...Object.values(light), ...Object.values(dark)]) { + for (const hex of v.matchAll(/#[0-9a-fA-F]{3,8}\b/g)) tokenValues.add(hex[0].toUpperCase()); + for (const fn of v.matchAll(/(?:rgba?|hsla?)\([^)]*\)/g)) tokenValues.add(fn[0].replace(/\s+/g, '')); +} + +// ① dark ⊆ light +for (const k of Object.keys(dark)) { + if (!(k in light)) err(join(DESIGN, 'tokens'), 0, `① 孤儿 dark token:--${k} 未在 :root 定义`); +} + +// ② var() 链可解析 + @import 存在 +for (const [scopeName, scope] of [['light', light], ['dark', dark]]) { + for (const [k, v] of Object.entries(scope)) { + for (const ref of v.matchAll(/var\(--([\w-]+)\)/g)) { + if (!(ref[1] in light) && !(ref[1] in dark)) + err(join(DESIGN, 'tokens'), 0, `② --${k}(${scopeName})引用了不存在的 var(--${ref[1]})`); + } + } +} +{ + const styles = readFileSync(join(DESIGN, 'styles.css'), 'utf8'); + for (const im of styles.matchAll(/@import\s+(?:url\()?['"]([^'"]+)['"]/g)) { + if (!existsSync(join(DESIGN, im[1]))) err(join(DESIGN, 'styles.css'), 0, `② @import 目标不存在:${im[1]}`); + } +} + +// ③ design/ 裸色须 ∈ tokens 值集(ds-ignore 豁免;tokens/ 与资产 svg 不扫) +const scanFiles = [ + ...walk(join(DESIGN, 'guidelines'), ['.html', '.css']), + ...walk(join(DESIGN, 'components'), ['.jsx', '.css', '.html']), + ...walk(join(DESIGN, 'ui_kits'), ['.jsx', '.css', '.html']), + join(DESIGN, 'index.html'), + join(DESIGN, 'styles.css'), +]; +for (const f of scanFiles) { + const lines = readFileSync(f, 'utf8').split('\n'); + lines.forEach((line, i) => { + if (line.includes('ds-ignore:')) return; + for (const m of line.matchAll(/#[0-9a-fA-F]{3,8}\b|(?:rgba?|hsla?)\([^)]*\)/g)) { + const val = m[0].startsWith('#') ? m[0].toUpperCase() : m[0].replace(/\s+/g, ''); + // #fff/#FFF 简写归一到 6 位再比对 + const norm = /^#[0-9A-F]{3}$/.test(val) + ? '#' + [...val.slice(1)].map((c) => c + c).join('') + : val; + if (!tokenValues.has(norm)) + err(f, i + 1, `③ 裸色 ${m[0]} 不在 tokens 值集(改用 var(--*) 或加 ds-ignore: 理由)`); + } + }); +} + +// ④ 组件登记:components/**/*.jsx 导出名必须现于 index.html +const registry = readFileSync(join(DESIGN, 'index.html'), 'utf8'); +for (const f of walk(join(DESIGN, 'components'), ['.jsx'])) { + const src = readFileSync(f, 'utf8'); + for (const m of src.matchAll(/export\s+(?:function|const)\s+([A-Z]\w+)/g)) { + if (!registry.includes(`${m[1]}`) && !registry.includes(`${m[1]} `) && !new RegExp(`[^<]*\\b${m[1]}\\b`).test(registry)) + err(f, 0, `④ 组件 ${m[1]} 未在登记簿 design/index.html 登记`); + } +} + +// ⑤ icons.js:key 唯一 + 描边风格 +{ + const f = join(DESIGN, 'icons.js'); + const src = readFileSync(f, 'utf8'); + const keys = [...src.matchAll(/^\s{2}(\w+):/gm)].map((m) => m[1]); + const seen = new Set(); + for (const k of keys) { + if (seen.has(k)) err(f, 0, `⑤ icons.js 重复 key:${k}`); + seen.add(k); + } + if (/fill="#/.test(src)) err(f, 0, '⑤ icons.js 含填充色(应为 stroke/currentColor 线条风格)'); + globalThis.__iconKeys = seen; +} + +// ⑥ icon-map.json ↔ icons.js 双向同集 +{ + const f = join(DESIGN, 'icon-map.json'); + const map = JSON.parse(readFileSync(f, 'utf8')); + const webUsed = new Set(); + for (const [name, v] of Object.entries(map)) { + if (name === '$schema') continue; + for (const col of ['web', 'ios', 'android']) { + if (!(col in v)) err(f, 0, `⑥ ${name} 缺 ${col} 列(未使用请置 null)`); + else if (v[col] !== null && typeof v[col] !== 'string') err(f, 0, `⑥ ${name}.${col} 应为 string|null`); + } + if (v.web) { + if (!globalThis.__iconKeys.has(v.web)) err(f, 0, `⑥ ${name}.web=${v.web} 不存在于 icons.js`); + webUsed.add(v.web); + } + } + for (const k of globalThis.__iconKeys) { + if (!webUsed.has(k)) err(f, 0, `⑥ icons.js 的 ${k} 未登记进 icon-map.json(web 列)`); + } +} + +// ⑦ 界面文案禁 emoji(components + ui_kits 的 jsx/card.html) +const EMOJI = /[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{FE0F}]/u; +for (const f of [...walk(join(DESIGN, 'components'), ['.jsx', '.html']), ...walk(join(DESIGN, 'ui_kits'), ['.jsx', '.html'])]) { + readFileSync(f, 'utf8').split('\n').forEach((line, i) => { + if (line.includes('ds-ignore:')) return; + if (EMOJI.test(line)) err(f, i + 1, '⑦ 界面文案/原型含 emoji(品牌规范禁用;确属注释示例加 ds-ignore: 理由)'); + }); +} + +// ─── 汇总 ──────────────────────────────────────────────────────────────────── + +if (errors.length) { + console.error(`❌ check-ds:${errors.length} 处违规\n` + errors.map((e) => ' ' + e).join('\n')); + process.exit(1); +} +console.log('✅ check-ds:真相源自检 7 道全绿'); diff --git a/design-pipeline/export-tokens.mjs b/design-pipeline/export-tokens.mjs index 4942e87..39cf92a 100644 --- a/design-pipeline/export-tokens.mjs +++ b/design-pipeline/export-tokens.mjs @@ -3,9 +3,11 @@ * export-tokens.mjs — 设计令牌导出管线(零依赖,Node >= 18) * * 解析 design/tokens/*.css 的 CSS 自定义属性(:root = light,[data-theme="dark"] = dark), - * 生成移动端原生主题文件,保证三端令牌单一来源: - * generated/ios/DuduTheme.swift — Color(light/dark 动态) + 尺寸/字号常量 - * generated/android/DuduTheme.kt — Compose Color/Dp/Sp 常量(Light/Dark 两组) + * 生成各端主题产物并直写消费位置(无中间拷贝层,--check 守的即真实消费文件): + * ios/dudu/Shared/DuduTheme.swift — Color(light/dark 动态) + 尺寸常量 + * android/app/src/main/java/app/dudu/design/DuduTheme.kt — Compose Color/Dp 常量(Light/Dark) + * web/tokens.css — 官网令牌层(custom props 原样透传 + + * Outfit 字体 data URI 内嵌,单文件自包含) * * 用法:node design-pipeline/export-tokens.mjs [--check] * --check CI 校验模式:重新生成并与已提交产物比对,不一致退出 1 @@ -17,8 +19,10 @@ import { fileURLToPath } from 'node:url'; const __dir = dirname(fileURLToPath(import.meta.url)); const TOKENS_DIR = resolve(__dir, '../design/tokens'); -const OUT_IOS = resolve(__dir, 'generated/ios/DuduTheme.swift'); -const OUT_ANDROID = resolve(__dir, 'generated/android/DuduTheme.kt'); +const OUT_IOS = resolve(__dir, '../ios/dudu/Shared/DuduTheme.swift'); +const OUT_ANDROID = resolve(__dir, '../android/app/src/main/java/app/dudu/design/DuduTheme.kt'); +const OUT_WEB = resolve(__dir, '../web/tokens.css'); +const FONT_WOFF2 = resolve(__dir, '../design/assets/outfit-latin.woff2'); // ─── 解析 ──────────────────────────────────────────────────────────────────── @@ -37,7 +41,7 @@ function parseBlock(css, selector) { return out; } -const files = readdirSync(TOKENS_DIR).filter((f) => f.endsWith('.css')); +const files = readdirSync(TOKENS_DIR).filter((f) => f.endsWith('.css')).sort(); let light = {}, dark = {}; for (const f of files) { const css = readFileSync(resolve(TOKENS_DIR, f), 'utf8'); @@ -166,11 +170,38 @@ ${dims} `; } +// ─── 生成 Web(官网令牌层)─────────────────────────────────────────────────── + +function web() { + const props = (scope) => + Object.entries(scope).map(([k, v]) => ` --${k}: ${v};`).join('\n'); + const fontB64 = readFileSync(FONT_WOFF2).toString('base64'); + return `/* 自动生成 — 请勿手改。来源 design/tokens/*.css + assets/outfit-latin.woff2 + 重新生成:node design-pipeline/export-tokens.mjs */ +@font-face { + font-family: 'Outfit'; + font-style: normal; + font-weight: 400 700; + font-display: swap; + src: url(data:font/woff2;base64,${fontB64}) format('woff2'); +} + +:root { +${props(light)} +} + +[data-theme="dark"] { +${props(dark)} +} +`; +} + // ─── 输出 / 校验 ───────────────────────────────────────────────────────────── const outputs = [ [OUT_IOS, swift()], [OUT_ANDROID, kotlin()], + [OUT_WEB, web()], ]; const check = process.argv.includes('--check'); diff --git a/design-pipeline/generated/android/DuduTheme.kt b/design-pipeline/generated/android/DuduTheme.kt deleted file mode 100644 index 0039d6d..0000000 --- a/design-pipeline/generated/android/DuduTheme.kt +++ /dev/null @@ -1,156 +0,0 @@ -// 自动生成 — 请勿手改。来源 design/tokens/*.css -// 重新生成:node design-pipeline/export-tokens.mjs - -package app.dudu.design - -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp - -object DuduTheme { - object Light { - val accent = Color(0xFF4F6EF7) - val accentHover = Color(0xFF3D58DB) - val accentPress = Color(0xFF2F44B3) - val accentSoft = Color(0xFFEEF1FE) - val accentSoft2 = Color(0xFFDFE5FD) - val accentText = Color(0xFF3D58DB) - val amber100 = Color(0xFFFCEFD7) - val amber500 = Color(0xFFE8890C) - val bgApp = Color(0xFFF6F7FA) - val blue100 = Color(0xFFDFE5FD) - val blue200 = Color(0xFFC4CFFB) - val blue300 = Color(0xFF9FB1F9) - val blue400 = Color(0xFF7590F8) - val blue50 = Color(0xFFEEF1FE) - val blue500 = Color(0xFF4F6EF7) - val blue600 = Color(0xFF3D58DB) - val blue700 = Color(0xFF2F44B3) - val blue800 = Color(0xFF25368C) - val blue900 = Color(0xFF1E2B6B) - val border1 = Color(0xFFE4E6EB) - val border2 = Color(0xFFD2D5DD) - val danger = Color(0xFFDC2626) - val dangerSoft = Color(0xFFFEE2E2) - val gray0 = Color(0xFFFFFFFF) - val gray100 = Color(0xFFF0F1F4) - val gray200 = Color(0xFFE4E6EB) - val gray25 = Color(0xFFFCFCFD) - val gray300 = Color(0xFFD2D5DD) - val gray400 = Color(0xFFA6ABB8) - val gray50 = Color(0xFFF6F7FA) - val gray500 = Color(0xFF7A8090) - val gray600 = Color(0xFF5A6072) - val gray700 = Color(0xFF434957) - val gray800 = Color(0xFF2B2F3A) - val gray900 = Color(0xFF1B1E26) - val gray950 = Color(0xFF11131A) - val green100 = Color(0xFFDCFCE7) - val green500 = Color(0xFF16A34A) - val overlayBg = Color(0xF2181A22) - val overlayText = Color(0xFFF2F4FA) - val overlayText2 = Color(0xFF9AA3BD) - val overlayText3 = Color(0xFF646E8C) - val positive = Color(0xFF16A34A) - val positiveSoft = Color(0xFFDCFCE7) - val red100 = Color(0xFFFEE2E2) - val red500 = Color(0xFFDC2626) - val surface2 = Color(0xFFF0F1F4) - val surface3 = Color(0xFFE4E6EB) - val surfaceCard = Color(0xFFFFFFFF) - val text1 = Color(0xFF1B1E26) - val text2 = Color(0xFF5A6072) - val text3 = Color(0xFFA6ABB8) - val textOnAccent = Color(0xFFFFFFFF) - val warning = Color(0xFFE8890C) - val warningSoft = Color(0xFFFCEFD7) - } - - object Dark { - val accent = Color(0xFF5C77E8) - val accentHover = Color(0xFF6E89F9) - val accentPress = Color(0xFF4A63D6) - val accentSoft = Color(0x295C77E8) - val accentSoft2 = Color(0x425C77E8) - val accentText = Color(0xFF7D95F7) - val amber100 = Color(0xFFFCEFD7) - val amber500 = Color(0xFFE8890C) - val bgApp = Color(0xFF0F1116) - val blue100 = Color(0xFFDFE5FD) - val blue200 = Color(0xFFC4CFFB) - val blue300 = Color(0xFF9FB1F9) - val blue400 = Color(0xFF7590F8) - val blue50 = Color(0xFFEEF1FE) - val blue500 = Color(0xFF4F6EF7) - val blue600 = Color(0xFF3D58DB) - val blue700 = Color(0xFF2F44B3) - val blue800 = Color(0xFF25368C) - val blue900 = Color(0xFF1E2B6B) - val border1 = Color(0xFF272C38) - val border2 = Color(0xFF343A49) - val danger = Color(0xFFF05B5B) - val dangerSoft = Color(0x29F05B5B) - val gray0 = Color(0xFFFFFFFF) - val gray100 = Color(0xFFF0F1F4) - val gray200 = Color(0xFFE4E6EB) - val gray25 = Color(0xFFFCFCFD) - val gray300 = Color(0xFFD2D5DD) - val gray400 = Color(0xFFA6ABB8) - val gray50 = Color(0xFFF6F7FA) - val gray500 = Color(0xFF7A8090) - val gray600 = Color(0xFF5A6072) - val gray700 = Color(0xFF434957) - val gray800 = Color(0xFF2B2F3A) - val gray900 = Color(0xFF1B1E26) - val gray950 = Color(0xFF11131A) - val green100 = Color(0xFFDCFCE7) - val green500 = Color(0xFF16A34A) - val overlayBg = Color(0xF51E212B) - val overlayText = Color(0xFFF2F4FA) - val overlayText2 = Color(0xFF9AA3BD) - val overlayText3 = Color(0xFF646E8C) - val positive = Color(0xFF34C46A) - val positiveSoft = Color(0x2934C46A) - val red100 = Color(0xFFFEE2E2) - val red500 = Color(0xFFDC2626) - val surface2 = Color(0xFF1E222C) - val surface3 = Color(0xFF262B37) - val surfaceCard = Color(0xFF171A22) - val text1 = Color(0xFFECEEF4) - val text2 = Color(0xFF9AA1B2) - val text3 = Color(0xFF5F6678) - val textOnAccent = Color(0xFFFFFFFF) - val warning = Color(0xFFF2A33C) - val warningSoft = Color(0x29F2A33C) - } - - // Dimensions (dp) - val controlLg = 44.dp - val controlMd = 36.dp - val controlSm = 28.dp - val controlXl = 56.dp - val radiusFull = 999.dp - val radiusLg = 14.dp - val radiusMd = 10.dp - val radiusSm = 6.dp - val radiusXl = 20.dp - val radiusXs = 4.dp - val space1 = 4.dp - val space10 = 40.dp - val space12 = 48.dp - val space16 = 64.dp - val space2 = 8.dp - val space3 = 12.dp - val space4 = 16.dp - val space5 = 20.dp - val space6 = 24.dp - val space8 = 32.dp - val text2xl = 24.dp - val text3xl = 32.dp - val text4xl = 44.dp - val textBase = 14.dp - val textLg = 17.dp - val textMd = 15.dp - val textSm = 13.dp - val textXl = 20.dp - val textXs = 12.dp -} diff --git a/design-pipeline/generated/ios/DuduTheme.swift b/design-pipeline/generated/ios/DuduTheme.swift deleted file mode 100644 index 78cfab5..0000000 --- a/design-pipeline/generated/ios/DuduTheme.swift +++ /dev/null @@ -1,211 +0,0 @@ -// 自动生成 — 请勿手改。来源 design/tokens/*.css -// 重新生成:node design-pipeline/export-tokens.mjs - -import SwiftUI - -public enum DuduTheme { - /// light/dark 动态色(跟随系统外观) - static func dynamic(light: Color, dark: Color) -> Color { - Color(UIColor { trait in - trait.userInterfaceStyle == .dark ? UIColor(dark) : UIColor(light) - }) - } - - // MARK: - Colors - public static let accent = dynamic( - light: Color(.sRGB, red: 0.3098, green: 0.4314, blue: 0.9686, opacity: 1.00), - dark: Color(.sRGB, red: 0.3608, green: 0.4667, blue: 0.9098, opacity: 1.00)) - public static let accentHover = dynamic( - light: Color(.sRGB, red: 0.2392, green: 0.3451, blue: 0.8588, opacity: 1.00), - dark: Color(.sRGB, red: 0.4314, green: 0.5373, blue: 0.9765, opacity: 1.00)) - public static let accentPress = dynamic( - light: Color(.sRGB, red: 0.1843, green: 0.2667, blue: 0.7020, opacity: 1.00), - dark: Color(.sRGB, red: 0.2902, green: 0.3882, blue: 0.8392, opacity: 1.00)) - public static let accentSoft = dynamic( - light: Color(.sRGB, red: 0.9333, green: 0.9451, blue: 0.9961, opacity: 1.00), - dark: Color(.sRGB, red: 0.3608, green: 0.4667, blue: 0.9098, opacity: 0.16)) - public static let accentSoft2 = dynamic( - light: Color(.sRGB, red: 0.8745, green: 0.8980, blue: 0.9922, opacity: 1.00), - dark: Color(.sRGB, red: 0.3608, green: 0.4667, blue: 0.9098, opacity: 0.26)) - public static let accentText = dynamic( - light: Color(.sRGB, red: 0.2392, green: 0.3451, blue: 0.8588, opacity: 1.00), - dark: Color(.sRGB, red: 0.4902, green: 0.5843, blue: 0.9686, opacity: 1.00)) - public static let amber100 = dynamic( - light: Color(.sRGB, red: 0.9882, green: 0.9373, blue: 0.8431, opacity: 1.00), - dark: Color(.sRGB, red: 0.9882, green: 0.9373, blue: 0.8431, opacity: 1.00)) - public static let amber500 = dynamic( - light: Color(.sRGB, red: 0.9098, green: 0.5373, blue: 0.0471, opacity: 1.00), - dark: Color(.sRGB, red: 0.9098, green: 0.5373, blue: 0.0471, opacity: 1.00)) - public static let bgApp = dynamic( - light: Color(.sRGB, red: 0.9647, green: 0.9686, blue: 0.9804, opacity: 1.00), - dark: Color(.sRGB, red: 0.0588, green: 0.0667, blue: 0.0863, opacity: 1.00)) - public static let blue100 = dynamic( - light: Color(.sRGB, red: 0.8745, green: 0.8980, blue: 0.9922, opacity: 1.00), - dark: Color(.sRGB, red: 0.8745, green: 0.8980, blue: 0.9922, opacity: 1.00)) - public static let blue200 = dynamic( - light: Color(.sRGB, red: 0.7686, green: 0.8118, blue: 0.9843, opacity: 1.00), - dark: Color(.sRGB, red: 0.7686, green: 0.8118, blue: 0.9843, opacity: 1.00)) - public static let blue300 = dynamic( - light: Color(.sRGB, red: 0.6235, green: 0.6941, blue: 0.9765, opacity: 1.00), - dark: Color(.sRGB, red: 0.6235, green: 0.6941, blue: 0.9765, opacity: 1.00)) - public static let blue400 = dynamic( - light: Color(.sRGB, red: 0.4588, green: 0.5647, blue: 0.9725, opacity: 1.00), - dark: Color(.sRGB, red: 0.4588, green: 0.5647, blue: 0.9725, opacity: 1.00)) - public static let blue50 = dynamic( - light: Color(.sRGB, red: 0.9333, green: 0.9451, blue: 0.9961, opacity: 1.00), - dark: Color(.sRGB, red: 0.9333, green: 0.9451, blue: 0.9961, opacity: 1.00)) - public static let blue500 = dynamic( - light: Color(.sRGB, red: 0.3098, green: 0.4314, blue: 0.9686, opacity: 1.00), - dark: Color(.sRGB, red: 0.3098, green: 0.4314, blue: 0.9686, opacity: 1.00)) - public static let blue600 = dynamic( - light: Color(.sRGB, red: 0.2392, green: 0.3451, blue: 0.8588, opacity: 1.00), - dark: Color(.sRGB, red: 0.2392, green: 0.3451, blue: 0.8588, opacity: 1.00)) - public static let blue700 = dynamic( - light: Color(.sRGB, red: 0.1843, green: 0.2667, blue: 0.7020, opacity: 1.00), - dark: Color(.sRGB, red: 0.1843, green: 0.2667, blue: 0.7020, opacity: 1.00)) - public static let blue800 = dynamic( - light: Color(.sRGB, red: 0.1451, green: 0.2118, blue: 0.5490, opacity: 1.00), - dark: Color(.sRGB, red: 0.1451, green: 0.2118, blue: 0.5490, opacity: 1.00)) - public static let blue900 = dynamic( - light: Color(.sRGB, red: 0.1176, green: 0.1686, blue: 0.4196, opacity: 1.00), - dark: Color(.sRGB, red: 0.1176, green: 0.1686, blue: 0.4196, opacity: 1.00)) - public static let border1 = dynamic( - light: Color(.sRGB, red: 0.8941, green: 0.9020, blue: 0.9216, opacity: 1.00), - dark: Color(.sRGB, red: 0.1529, green: 0.1725, blue: 0.2196, opacity: 1.00)) - public static let border2 = dynamic( - light: Color(.sRGB, red: 0.8235, green: 0.8353, blue: 0.8667, opacity: 1.00), - dark: Color(.sRGB, red: 0.2039, green: 0.2275, blue: 0.2863, opacity: 1.00)) - public static let danger = dynamic( - light: Color(.sRGB, red: 0.8627, green: 0.1490, blue: 0.1490, opacity: 1.00), - dark: Color(.sRGB, red: 0.9412, green: 0.3569, blue: 0.3569, opacity: 1.00)) - public static let dangerSoft = dynamic( - light: Color(.sRGB, red: 0.9961, green: 0.8863, blue: 0.8863, opacity: 1.00), - dark: Color(.sRGB, red: 0.9412, green: 0.3569, blue: 0.3569, opacity: 0.16)) - public static let gray0 = dynamic( - light: Color(.sRGB, red: 1.0000, green: 1.0000, blue: 1.0000, opacity: 1.00), - dark: Color(.sRGB, red: 1.0000, green: 1.0000, blue: 1.0000, opacity: 1.00)) - public static let gray100 = dynamic( - light: Color(.sRGB, red: 0.9412, green: 0.9451, blue: 0.9569, opacity: 1.00), - dark: Color(.sRGB, red: 0.9412, green: 0.9451, blue: 0.9569, opacity: 1.00)) - public static let gray200 = dynamic( - light: Color(.sRGB, red: 0.8941, green: 0.9020, blue: 0.9216, opacity: 1.00), - dark: Color(.sRGB, red: 0.8941, green: 0.9020, blue: 0.9216, opacity: 1.00)) - public static let gray25 = dynamic( - light: Color(.sRGB, red: 0.9882, green: 0.9882, blue: 0.9922, opacity: 1.00), - dark: Color(.sRGB, red: 0.9882, green: 0.9882, blue: 0.9922, opacity: 1.00)) - public static let gray300 = dynamic( - light: Color(.sRGB, red: 0.8235, green: 0.8353, blue: 0.8667, opacity: 1.00), - dark: Color(.sRGB, red: 0.8235, green: 0.8353, blue: 0.8667, opacity: 1.00)) - public static let gray400 = dynamic( - light: Color(.sRGB, red: 0.6510, green: 0.6706, blue: 0.7216, opacity: 1.00), - dark: Color(.sRGB, red: 0.6510, green: 0.6706, blue: 0.7216, opacity: 1.00)) - public static let gray50 = dynamic( - light: Color(.sRGB, red: 0.9647, green: 0.9686, blue: 0.9804, opacity: 1.00), - dark: Color(.sRGB, red: 0.9647, green: 0.9686, blue: 0.9804, opacity: 1.00)) - public static let gray500 = dynamic( - light: Color(.sRGB, red: 0.4784, green: 0.5020, blue: 0.5647, opacity: 1.00), - dark: Color(.sRGB, red: 0.4784, green: 0.5020, blue: 0.5647, opacity: 1.00)) - public static let gray600 = dynamic( - light: Color(.sRGB, red: 0.3529, green: 0.3765, blue: 0.4471, opacity: 1.00), - dark: Color(.sRGB, red: 0.3529, green: 0.3765, blue: 0.4471, opacity: 1.00)) - public static let gray700 = dynamic( - light: Color(.sRGB, red: 0.2627, green: 0.2863, blue: 0.3412, opacity: 1.00), - dark: Color(.sRGB, red: 0.2627, green: 0.2863, blue: 0.3412, opacity: 1.00)) - public static let gray800 = dynamic( - light: Color(.sRGB, red: 0.1686, green: 0.1843, blue: 0.2275, opacity: 1.00), - dark: Color(.sRGB, red: 0.1686, green: 0.1843, blue: 0.2275, opacity: 1.00)) - public static let gray900 = dynamic( - light: Color(.sRGB, red: 0.1059, green: 0.1176, blue: 0.1490, opacity: 1.00), - dark: Color(.sRGB, red: 0.1059, green: 0.1176, blue: 0.1490, opacity: 1.00)) - public static let gray950 = dynamic( - light: Color(.sRGB, red: 0.0667, green: 0.0745, blue: 0.1020, opacity: 1.00), - dark: Color(.sRGB, red: 0.0667, green: 0.0745, blue: 0.1020, opacity: 1.00)) - public static let green100 = dynamic( - light: Color(.sRGB, red: 0.8627, green: 0.9882, blue: 0.9059, opacity: 1.00), - dark: Color(.sRGB, red: 0.8627, green: 0.9882, blue: 0.9059, opacity: 1.00)) - public static let green500 = dynamic( - light: Color(.sRGB, red: 0.0863, green: 0.6392, blue: 0.2902, opacity: 1.00), - dark: Color(.sRGB, red: 0.0863, green: 0.6392, blue: 0.2902, opacity: 1.00)) - public static let overlayBg = dynamic( - light: Color(.sRGB, red: 0.0941, green: 0.1020, blue: 0.1333, opacity: 0.95), - dark: Color(.sRGB, red: 0.1176, green: 0.1294, blue: 0.1686, opacity: 0.96)) - public static let overlayText = dynamic( - light: Color(.sRGB, red: 0.9490, green: 0.9569, blue: 0.9804, opacity: 1.00), - dark: Color(.sRGB, red: 0.9490, green: 0.9569, blue: 0.9804, opacity: 1.00)) - public static let overlayText2 = dynamic( - light: Color(.sRGB, red: 0.6039, green: 0.6392, blue: 0.7412, opacity: 1.00), - dark: Color(.sRGB, red: 0.6039, green: 0.6392, blue: 0.7412, opacity: 1.00)) - public static let overlayText3 = dynamic( - light: Color(.sRGB, red: 0.3922, green: 0.4314, blue: 0.5490, opacity: 1.00), - dark: Color(.sRGB, red: 0.3922, green: 0.4314, blue: 0.5490, opacity: 1.00)) - public static let positive = dynamic( - light: Color(.sRGB, red: 0.0863, green: 0.6392, blue: 0.2902, opacity: 1.00), - dark: Color(.sRGB, red: 0.2039, green: 0.7686, blue: 0.4157, opacity: 1.00)) - public static let positiveSoft = dynamic( - light: Color(.sRGB, red: 0.8627, green: 0.9882, blue: 0.9059, opacity: 1.00), - dark: Color(.sRGB, red: 0.2039, green: 0.7686, blue: 0.4157, opacity: 0.16)) - public static let red100 = dynamic( - light: Color(.sRGB, red: 0.9961, green: 0.8863, blue: 0.8863, opacity: 1.00), - dark: Color(.sRGB, red: 0.9961, green: 0.8863, blue: 0.8863, opacity: 1.00)) - public static let red500 = dynamic( - light: Color(.sRGB, red: 0.8627, green: 0.1490, blue: 0.1490, opacity: 1.00), - dark: Color(.sRGB, red: 0.8627, green: 0.1490, blue: 0.1490, opacity: 1.00)) - public static let surface2 = dynamic( - light: Color(.sRGB, red: 0.9412, green: 0.9451, blue: 0.9569, opacity: 1.00), - dark: Color(.sRGB, red: 0.1176, green: 0.1333, blue: 0.1725, opacity: 1.00)) - public static let surface3 = dynamic( - light: Color(.sRGB, red: 0.8941, green: 0.9020, blue: 0.9216, opacity: 1.00), - dark: Color(.sRGB, red: 0.1490, green: 0.1686, blue: 0.2157, opacity: 1.00)) - public static let surfaceCard = dynamic( - light: Color(.sRGB, red: 1.0000, green: 1.0000, blue: 1.0000, opacity: 1.00), - dark: Color(.sRGB, red: 0.0902, green: 0.1020, blue: 0.1333, opacity: 1.00)) - public static let text1 = dynamic( - light: Color(.sRGB, red: 0.1059, green: 0.1176, blue: 0.1490, opacity: 1.00), - dark: Color(.sRGB, red: 0.9255, green: 0.9333, blue: 0.9569, opacity: 1.00)) - public static let text2 = dynamic( - light: Color(.sRGB, red: 0.3529, green: 0.3765, blue: 0.4471, opacity: 1.00), - dark: Color(.sRGB, red: 0.6039, green: 0.6314, blue: 0.6980, opacity: 1.00)) - public static let text3 = dynamic( - light: Color(.sRGB, red: 0.6510, green: 0.6706, blue: 0.7216, opacity: 1.00), - dark: Color(.sRGB, red: 0.3725, green: 0.4000, blue: 0.4706, opacity: 1.00)) - public static let textOnAccent = dynamic( - light: Color(.sRGB, red: 1.0000, green: 1.0000, blue: 1.0000, opacity: 1.00), - dark: Color(.sRGB, red: 1.0000, green: 1.0000, blue: 1.0000, opacity: 1.00)) - public static let warning = dynamic( - light: Color(.sRGB, red: 0.9098, green: 0.5373, blue: 0.0471, opacity: 1.00), - dark: Color(.sRGB, red: 0.9490, green: 0.6392, blue: 0.2353, opacity: 1.00)) - public static let warningSoft = dynamic( - light: Color(.sRGB, red: 0.9882, green: 0.9373, blue: 0.8431, opacity: 1.00), - dark: Color(.sRGB, red: 0.9490, green: 0.6392, blue: 0.2353, opacity: 0.16)) - - // MARK: - Dimensions (pt) - public static let controlLg: CGFloat = 44 - public static let controlMd: CGFloat = 36 - public static let controlSm: CGFloat = 28 - public static let controlXl: CGFloat = 56 - public static let radiusFull: CGFloat = 999 - public static let radiusLg: CGFloat = 14 - public static let radiusMd: CGFloat = 10 - public static let radiusSm: CGFloat = 6 - public static let radiusXl: CGFloat = 20 - public static let radiusXs: CGFloat = 4 - public static let space1: CGFloat = 4 - public static let space10: CGFloat = 40 - public static let space12: CGFloat = 48 - public static let space16: CGFloat = 64 - public static let space2: CGFloat = 8 - public static let space3: CGFloat = 12 - public static let space4: CGFloat = 16 - public static let space5: CGFloat = 20 - public static let space6: CGFloat = 24 - public static let space8: CGFloat = 32 - public static let text2xl: CGFloat = 24 - public static let text3xl: CGFloat = 32 - public static let text4xl: CGFloat = 44 - public static let textBase: CGFloat = 14 - public static let textLg: CGFloat = 17 - public static let textMd: CGFloat = 15 - public static let textSm: CGFloat = 13 - public static let textXl: CGFloat = 20 - public static let textXs: CGFloat = 12 -} diff --git a/design/assets/outfit-latin.woff2 b/design/assets/outfit-latin.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..c94ced76dc4ef7b152775c5c2a2d92a354e6d118 GIT binary patch literal 40712 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 R-*40;qyGCIw{u?r003=Oo#y}m literal 0 HcmV?d00001 diff --git a/design/components/core/Icon.d.ts b/design/components/core/Icon.d.ts new file mode 100644 index 0000000..6af0295 --- /dev/null +++ b/design/components/core/Icon.d.ts @@ -0,0 +1,13 @@ +import * as React from 'react'; +import type { icons } from '../../icons.js'; + +export interface IconProps extends React.SVGProps { + /** design/icons.js 登记的图标名 */ + name: keyof typeof icons; + /** 边长 px(24 网格等比缩放),默认 16 */ + size?: number; + /** 描边宽度,默认 2 */ + sw?: number; +} + +export declare function Icon(props: IconProps): React.JSX.Element; diff --git a/design/components/core/Icon.jsx b/design/components/core/Icon.jsx new file mode 100644 index 0000000..d61b9ee --- /dev/null +++ b/design/components/core/Icon.jsx @@ -0,0 +1,25 @@ +// Icon:渲染 design/icons.js 单源图标(Lucide 线条、24 网格、currentColor)。 +// 业务代码禁止内联 (check-code 闸拦截)——新图标先登记 icons.js + icon-map.json。 +import { icons } from '../../icons.js'; + +export function Icon({ name, size = 16, sw = 2, ...rest }) { + // 安全前提:body 只能来自 icons.js 的静态常量(源码入库、无用户输入), + // 故 dangerouslySetInnerHTML 无 XSS 面。禁止改为接受外部传入的 SVG 字符串。 + const body = icons[name]; + if (!body) throw new Error(`unknown icon: ${name}(先在 design/icons.js 登记)`); + return ( + + ); +} diff --git a/design/components/forms/Switch.jsx b/design/components/forms/Switch.jsx index 39cf9f8..4ee5844 100644 --- a/design/components/forms/Switch.jsx +++ b/design/components/forms/Switch.jsx @@ -8,7 +8,7 @@ const ddSwitchCss = ` .dd-switch::after { content: ''; position: absolute; top: 3px; left: 3px; width: 18px; height: 18px; border-radius: 50%; - background: #fff; box-shadow: 0 1px 3px rgba(17,19,26,0.25); + background: #fff; box-shadow: 0 1px 3px rgba(17,19,26,0.25); /* ds-ignore: 拇指投影,gray-950 的 25% 派生 */ transition: transform var(--dur-base) var(--ease-out); } .dd-switch[aria-checked="true"] { background: var(--accent); } diff --git a/design/components/voice/MicBar.jsx b/design/components/voice/MicBar.jsx index b1faaa0..7353154 100644 --- a/design/components/voice/MicBar.jsx +++ b/design/components/voice/MicBar.jsx @@ -1,4 +1,5 @@ import { Waveform } from './Waveform'; +import { Icon } from '../core/Icon'; const ddMicBarCss = ` .dd-micbar { @@ -24,14 +25,7 @@ if (typeof document !== 'undefined' && !document.getElementById('dd-micbar-css') } function DdMicIcon({ size = 20 }) { - return ( - - ); + return ; } const DD_MICBAR_LABELS = { @@ -46,7 +40,7 @@ export function MicBar({ state = 'idle', label, ...rest }) { return ( diff --git a/design/components/voice/RecognitionOverlay.jsx b/design/components/voice/RecognitionOverlay.jsx index e4ea1dd..9a0fb5d 100644 --- a/design/components/voice/RecognitionOverlay.jsx +++ b/design/components/voice/RecognitionOverlay.jsx @@ -1,4 +1,5 @@ import { Waveform } from './Waveform'; +import { Icon } from '../core/Icon'; const ddOverlayCss = ` .dd-overlay { @@ -35,15 +36,10 @@ export function RecognitionOverlay({ }) { return (
- + {state === 'listening' ? (
- + 聆听中…
) : ( diff --git a/design/guidelines/brand-logo.card.html b/design/guidelines/brand-logo.card.html index b56e211..58453db 100644 --- a/design/guidelines/brand-logo.card.html +++ b/design/guidelines/brand-logo.card.html @@ -18,6 +18,6 @@
dudu logo
dudu logo blue
dudu logo white
-
dudu app icon
+
dudu app icon
diff --git a/design/guidelines/colors-neutrals.card.html b/design/guidelines/colors-neutrals.card.html index 345edff..cb50ea9 100644 --- a/design/guidelines/colors-neutrals.card.html +++ b/design/guidelines/colors-neutrals.card.html @@ -10,7 +10,7 @@ .sw { display: flex; flex-direction: column; justify-content: flex-end; padding: 7px 6px; box-sizing: border-box; } .sw span { font-size: 9.5px; font-family: var(--font-mono); } .lt span { color: var(--gray-600); } - .dk span { color: rgba(255,255,255,0.75); } + .dk span { color: rgba(255,255,255,0.75); } /* ds-ignore: 深色色块上的标注文字,色卡专用 */ diff --git a/design/icon-map.json b/design/icon-map.json new file mode 100644 index 0000000..b93518c --- /dev/null +++ b/design/icon-map.json @@ -0,0 +1,16 @@ +{ + "$schema": "语义图标 → 各端实现的映射表(真相源)。web = design/icons.js 的 key;ios = SF Symbol 名;android = Compose/资源名。null = 该端暂未使用。业务代码使用的图标必须能在对应列找到,check-ds/check-code 闸强制。", + "mic": { "web": "mic", "ios": "mic", "android": null }, + "micFill": { "web": null, "ios": "mic.fill", "android": null }, + "micSlash": { "web": null, "ios": "mic.slash", "android": null }, + "check": { "web": "check", "ios": "checkmark", "android": null }, + "close": { "web": "x", "ios": "xmark", "android": null }, + "chevronRight": { "web": null, "ios": "chevron.right", "android": null }, + "settings": { "web": null, "ios": "gearshape", "android": null }, + "person": { "web": null, "ios": "person.fill", "android": null }, + "globe": { "web": null, "ios": "globe", "android": null }, + "logout": { "web": null, "ios": "rectangle.portrait.and.arrow.right", "android": null }, + "imagePlus": { "web": "imagePlus", "ios": "photo.badge.plus", "android": null }, + "feedback": { "web": null, "ios": "bubble.left.and.bubble.right.fill", "android": null }, + "accessibilityKey": { "web": "key", "ios": null, "android": null } +} diff --git a/design/icons.js b/design/icons.js new file mode 100644 index 0000000..acbe4fa --- /dev/null +++ b/design/icons.js @@ -0,0 +1,36 @@ +/* ============================================================ + dudu · UI 图标单源(web 侧) + Lucide 线条图标(ISC):24 网格、2px 圆头描边、currentColor。 + 新增图标:从 lucide.dev 复制 path → 在此登记 → icon-map.json + 补跨端映射 → 登记簿(design/index.html)自动展示。 + 禁止在业务代码里内联 ——check-code 闸会拦。 + ============================================================ */ + +/** 24 网格图标 path 片段(不含 外壳) */ +export const icons = { + /** 麦克风(识别/录音) */ + mic: '', + /** 对勾(完成/选中) */ + check: '', + /** 关闭 */ + x: '', + /** 添加图片(反馈附图) */ + imagePlus: + '', + /** 钥匙(权限引导) */ + key: '', +}; + +/** 品牌 mark(48 网格、3.5 圆头——圆嘴 + 三条声波,与 assets/logo-mark.svg 同构) */ +export const logoMark = + ''; + +/** 生成完整 字符串(框架无关,官网/innerHTML 场景用) */ +export function svgIcon(name, { size = 16, sw = 2 } = {}) { + const body = icons[name]; + if (!body) throw new Error(`unknown icon: ${name}`); + return ( + `` + ); +} diff --git a/design/index.html b/design/index.html new file mode 100644 index 0000000..c129d48 --- /dev/null +++ b/design/index.html @@ -0,0 +1,213 @@ + + + + + +dudu 设计系统 · 登记簿 + + + + +
+ +
+
+

dudu 设计系统 · 登记簿

+

单一真相源活文档 —— 所有 token / 组件 / 图标必须在本页登记(check-ds 闸强制)。评审预览:node design/serve.mjs

+
+ +
+ +
+

规则速览(L1 · 设计元素单源)

+
新增颜色/字号/间距:先登记 tokens/*.cssnode design-pipeline/export-tokens.mjs 再生成各端 → 业务代码只引 token。
+
新增组件:先建 components/<组>/X.jsx(+ .d.ts)并在本页「组件登记」加卡,再到各端实现。
+
新增图标:先登记 icons.js(Lucide 24 网格 2px 圆头)+ icon-map.json 跨端映射,业务代码禁止内联 <path>。
+
违规谁拦:design-pipeline/check-ds.mjs(真相源自检)/ check-code.mjs(代码侧单源)/ export-tokens.mjs --check(产物零 diff),pre-commit 与 CI 双挂。
+ +

各端消费方式

+ + + + + + +
消费方式令牌文件
桌面(Tauri web 层)vite alias @dudu/design 直引 CSS 变量与组件design/tokens/*.css(源)
iOS(SwiftUI)DuduTheme.* 常量(codegen 直写)ios/dudu/Shared/DuduTheme.swift
Android(Compose)DuduTheme.Light/DarkDuduPalette 注入android/.../design/DuduTheme.kt
官网(web/)<link href="tokens.css">,页面只允许 var(--*)web/tokens.css(codegen,含 Outfit 内嵌)
+
+ +
+

Tokens 色板

+

色块直接渲染 var(--token),与 tokens/colors.css 永远同步;右上角切主题查看 dark 值。完整定义见 colors.css · typography.css · spacing.css · effects.css · fonts.css

+

品牌蓝阶梯

+
+
blue-50
+
blue-100
+
blue-200
+
blue-300
+
blue-400
+
blue-500 ★
+
blue-600
+
blue-700
+
blue-800
+
blue-900
+
+

中性阶梯(冷灰)

+
+
gray-0
+
gray-25
+
gray-50
+
gray-100
+
gray-200
+
gray-300
+
gray-400
+
gray-500
+
gray-600
+
gray-700
+
gray-800
+
gray-900
+
gray-950
+
+

语义与功能

+
+
accent
+
accent-hover
+
accent-press
+
accent-soft
+
positive
+
warning
+
danger
+
bg-app
+
surface-card
+
surface-2
+
surface-3
+
border-1
+
+

浮层与第三方品牌

+
+
overlay-bg
+
overlay-text
+
overlay-text-2
+
overlay-text-3
+
overlay-wave
+
brand-wechat
+
brand-wechat-press
+
+
+ +
+

组件登记

+

每个 components/**/*.jsx 组件必须在此有登记卡(check-ds 第④道)。演示卡:core · forms · voice

+

core

+
+
Button
主/次/幽灵/危险按钮,三种尺寸
variant · size · block — components/core/Button.jsx
+
IconButton
方形图标按钮
size · label(无障碍必填)— components/core/IconButton.jsx
+
Badge
状态徽标
tone: green/blue/orange/red/neutral — components/core/Badge.jsx
+
Kbd / HotkeyCombo
键帽与快捷键组合(⌘ ⇧ Space)
keys=['⌘','⇧','Space'] — components/core/Kbd.jsx
+
Card
标准卡片容器
padding — components/core/Card.jsx
+
ProgressBar
用量进度条,超阈自动警告色
value/max · warnAt=0.9 — components/core/ProgressBar.jsx
+
Icon
icons.js 单源图标渲染器
name · size · sw — components/core/Icon.jsx
+
+

forms

+
+
Input
文本输入框
size: md/lg — components/forms/Input.jsx
+
Switch
开关
checked · onChange — components/forms/Switch.jsx
+
SettingRow
设置行(标签 + 描述 + 右侧控件)
label · description — components/forms/SettingRow.jsx
+
+

voice

+
+
RecognitionOverlay
桌面识别浮层 · 聆听/流式两态
state · text — components/voice/RecognitionOverlay.jsx
+
MicBar
移动端按住说话 · 四种状态
state — components/voice/MicBar.jsx
+
Waveform
波形条(唯一循环动画,仅录音中)
active · bars · height · color — components/voice/Waveform.jsx
+
+
+ +
+

图标登记(icons.js + icon-map.json)

+

Lucide 线条 · 24 网格 · 2px 圆头 · currentColor。下方由 icons.js 实时渲染(需经 serve.mjs 访问;file:// 打开时仅显示映射表)。

+
+
+
+ +
+

Guidelines 规范卡

+
+
Logo
圆形嘴巴 + 声波线条 · 线性风格
+
Wordmark
dudu 几何线条字标
+
品牌蓝阶梯
blue-50 → 900
+
中性阶梯
gray-0 → 950 冷灰
+
表面与文字
bg → surface → text,Light/Dark
+
功能色
绿=录音/成功 · 琥珀=警告 · 红=错误
+
识别浮层色
双模式深色玻璃
+
字体家族
Outfit(自托管)+ 系统中文栈
+
字号阶梯
12 → 44
+
识别文本规范
final 实色 · partial 弱色
+
间距网格
4px 基准
+
圆角阶梯
xs 4 → full 999
+
控件高度
桌面 28/36 · 移动 44/56
+
层级与阴影
card → menu → window → overlay
+
动效
快入慢出 · 120/200/320ms · 无弹跳
+
+
+ +
+

UI Kits(屏级原型)

+
+
桌面端 UI Kit
识别浮层 · 托盘菜单 · 设置 · 登录购买(可交互,Light/Dark)
+
移动端 UI Kit
语音键盘(按住说话)+ 主 App 四屏(可交互,Light/Dark)
+
+

已知滞后:ui_kits 预览页仍经 unpkg CDN 加载 React/Babel,离线/无代理网络下不可用(治理项见 doc/frontend-overview.html)。

+
+ + + +
+ + diff --git a/design/serve.mjs b/design/serve.mjs new file mode 100644 index 0000000..de7d6c2 --- /dev/null +++ b/design/serve.mjs @@ -0,0 +1,96 @@ +// 零依赖本地热重载静态服务器 +// 用法: node serve.mjs [port] 默认 5180 +import http from 'node:http'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.dirname(fileURLToPath(import.meta.url)); +const PORT = Number(process.argv[2]) || 5180; + +const MIME = { + '.html': 'text/html; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.woff2': 'font/woff2', +}; + +const clients = new Set(); +const RELOAD_SNIPPET = `\n\n`; + +const server = http.createServer((req, res) => { + if (req.url === '/__reload') { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }); + res.write('retry: 500\n\n'); + clients.add(res); + req.on('close', () => clients.delete(res)); + return; + } + + let urlPath = decodeURIComponent(req.url.split('?')[0]); + if (urlPath === '/') urlPath = '/index.html'; + const filePath = path.join(ROOT, urlPath); + if (!filePath.startsWith(ROOT)) { res.writeHead(403); res.end('forbidden'); return; } + + fs.readFile(filePath, (err, data) => { + if (err) { res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' }); res.end('

404

'); return; } + const ext = path.extname(filePath).toLowerCase(); + const mime = MIME[ext] || 'application/octet-stream'; + const noCache = { 'Cache-Control': 'no-store, no-cache, must-revalidate', 'Pragma': 'no-cache', 'Expires': '0' }; + if (ext === '.html') { + const html = data.toString('utf8').replace('', RELOAD_SNIPPET + ''); + res.writeHead(200, { 'Content-Type': mime, ...noCache }); + res.end(html); + } else { + res.writeHead(200, { 'Content-Type': mime, ...noCache }); + res.end(data); + } + }); +}); + +// 设计系统守门:启动 + 每次改动自动跑 check-ds.mjs,控制台报 PASS/FAIL +import { spawn } from 'node:child_process'; +function runDsCheck() { + const p = spawn(process.execPath, [path.join(ROOT, 'tools', 'check-ds.mjs')], { cwd: ROOT }); + let out = ''; + p.stdout.on('data', d => out += d); + p.stderr.on('data', d => out += d); + p.on('close', code => { + const t = new Date().toLocaleTimeString(); + if (code === 0) console.log(`\x1b[32m[设计系统 ✓ ${t}] screens 颜色全部走 token、变量均已定义\x1b[0m`); + else { + const m = out.match(/❶[^\n]*共 (\d+)[\s\S]*?❷[^\n]*共 (\d+)/); + const sum = m ? `硬编码颜色 ${m[1]} · 未定义 token ${m[2]}` : '存在违规'; + console.log(`\x1b[31m[设计系统 ✗ ${t}] ${sum} —— 运行 node tools/check-ds.mjs 看详情\x1b[0m`); + } + }); +} + +let debounce; +fs.watch(ROOT, { recursive: true }, (_ev, file) => { + if (file && file.endsWith('serve.mjs')) return; + clearTimeout(debounce); + debounce = setTimeout(() => { + for (const c of clients) c.write('data: reload\n\n'); + if (file && /\.(html|css|js)$/.test(file)) runDsCheck(); + }, 80); +}); + +server.listen(PORT, () => { + console.log(`prototype live at http://localhost:${PORT}/ (watching ${ROOT})`); + runDsCheck(); +}); diff --git a/design/tokens/colors.css b/design/tokens/colors.css index f38cad2..1a899fb 100644 --- a/design/tokens/colors.css +++ b/design/tokens/colors.css @@ -73,8 +73,13 @@ --overlay-text: #F2F4FA; --overlay-text-2: #9AA3BD; --overlay-text-3: #646E8C; + --overlay-wave: #7C9BFF; --focus-ring: 0 0 0 3px var(--blue-200); + + /* 第三方品牌色(微信登录/支付按钮,官方规范色,双主题同值)*/ + --brand-wechat: #07C160; + --brand-wechat-press: #06AD56; } [data-theme="dark"] { @@ -108,6 +113,10 @@ --overlay-text: #F2F4FA; --overlay-text-2: #9AA3BD; --overlay-text-3: #646E8C; + --overlay-wave: #7C9BFF; --focus-ring: 0 0 0 3px rgba(92, 119, 232, 0.4); + + --brand-wechat: #07C160; + --brand-wechat-press: #06AD56; } diff --git a/design/tokens/fonts.css b/design/tokens/fonts.css index 9560830..b64bdc2 100644 --- a/design/tokens/fonts.css +++ b/design/tokens/fonts.css @@ -1,6 +1,14 @@ /* dudu · 品牌字体 - Outfit(Google Fonts CDN)— 圆润几何无衬线,用于品牌拉丁字 - "dudu"、数字与价格;中文 UI 文本走系统字体栈(PingFang SC / + Outfit(圆润几何无衬线,可变字重 400-700,latin 子集)——用于品牌 + 拉丁字 "dudu"、数字与价格;中文 UI 文本走系统字体栈(PingFang SC / MiSans / HarmonyOS Sans / 雅黑)。 - 注:暂以 CDN 引入;如有自托管字体文件请替换为 @font-face。 */ -@import url("https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&display=swap"); + 自托管于 ../assets/outfit-latin.woff2(40KB),禁外部 CDN—— + 中国网络下 fonts.googleapis.com 不可用会卡字体加载。 + 官网产物由 design-pipeline/export-tokens.mjs 生成 data URI 内嵌版。 */ +@font-face { + font-family: 'Outfit'; + font-style: normal; + font-weight: 400 700; + font-display: swap; + src: url('../assets/outfit-latin.woff2') format('woff2'); +} diff --git a/design/ui_kits/desktop/DesktopShared.jsx b/design/ui_kits/desktop/DesktopShared.jsx index d9eb652..9bca961 100644 --- a/design/ui_kits/desktop/DesktopShared.jsx +++ b/design/ui_kits/desktop/DesktopShared.jsx @@ -83,9 +83,9 @@ function MacWindow({ title, width = 460, children, style }) { display: 'flex', alignItems: 'center', gap: 7, padding: '10px 14px', background: 'var(--surface-2)', borderBottom: '1px solid var(--border-1)', }}> - - - + + + {title}
{children} @@ -109,7 +109,7 @@ function FakeQr({ size = 148, seed = 7 }) { } else { on = rnd() > 0.52; } - if (on) cells.push(); + if (on) cells.push(); } } return ( diff --git a/design/ui_kits/desktop/SettingsTray.jsx b/design/ui_kits/desktop/SettingsTray.jsx index ab7b9d3..89a38a0 100644 --- a/design/ui_kits/desktop/SettingsTray.jsx +++ b/design/ui_kits/desktop/SettingsTray.jsx @@ -15,7 +15,7 @@ function TrayMenu() { transition: 'background var(--dur-fast) var(--ease-out)', }}> {children} - {right && {right}} + {right && {right}} ); }; diff --git a/design/ui_kits/mobile/AppScreens.jsx b/design/ui_kits/mobile/AppScreens.jsx index b84e0ec..c391c0a 100644 --- a/design/ui_kits/mobile/AppScreens.jsx +++ b/design/ui_kits/mobile/AppScreens.jsx @@ -69,7 +69,7 @@ function LoginScreen() { ); @@ -65,11 +66,7 @@ function AddBox({ onClick, dragging }) { display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'border-color var(--dur-fast) var(--ease-out)', }}> - {/* Lucide image-plus */} - - - - + ); } @@ -81,7 +78,7 @@ function SuccessView({ onAgain }) { width: 56, height: 56, borderRadius: '50%', background: 'var(--positive-soft)', color: 'var(--positive)', display: 'flex', alignItems: 'center', justifyContent: 'center', }}> - +
已收到 · 谢谢你
diff --git a/desktop/src/login/main.jsx b/desktop/src/login/main.jsx index 7591fd3..4ce400d 100644 --- a/desktop/src/login/main.jsx +++ b/desktop/src/login/main.jsx @@ -5,6 +5,7 @@ import { createRoot } from 'react-dom/client'; import '@dudu/design/styles.css'; import { Button } from '@dudu/design/components/core/Button'; import { Badge } from '@dudu/design/components/core/Badge'; +import { Icon } from '@dudu/design/components/core/Icon'; import { invoke } from '../shared/tauri'; import { setThemePref } from '../shared/theme'; import { usePoll } from '../shared/usePoll'; @@ -172,7 +173,7 @@ function LoginApp() { width: 56, height: 56, borderRadius: '50%', background: 'var(--positive-soft)', color: 'var(--positive)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 28, }}> - +
已到账 {pack.minutes} 分钟
diff --git a/desktop/src/onboarding/main.jsx b/desktop/src/onboarding/main.jsx index deedb03..c00fcd9 100644 --- a/desktop/src/onboarding/main.jsx +++ b/desktop/src/onboarding/main.jsx @@ -6,6 +6,7 @@ import '@dudu/design/styles.css'; import { Button } from '@dudu/design/components/core/Button'; import { Badge } from '@dudu/design/components/core/Badge'; import { HotkeyCombo } from '@dudu/design/components/core/Kbd'; +import { Icon } from '@dudu/design/components/core/Icon'; import { invoke } from '../shared/tauri'; import { setThemePref } from '../shared/theme'; import { usePoll } from '../shared/usePoll'; @@ -56,17 +57,9 @@ function PermCard({ icon, title, desc, granted, onGrant }) { ); } -const MicIcon = ( - - - -); +const MicIcon = ; -const KeyIcon = ( - - - -); +const KeyIcon = ; function OnboardingApp() { const [step, setStep] = useState(0); @@ -105,9 +98,7 @@ function OnboardingApp() { width: 64, height: 64, borderRadius: 18, background: 'var(--accent)', color: 'var(--text-on-accent)', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto', }}> - - - + dudu 语音输入 按住快捷键说话,松开文字就出现在光标处
全程比打字快三倍
diff --git a/desktop/src/tray/main.jsx b/desktop/src/tray/main.jsx index 7239673..2a274d8 100644 --- a/desktop/src/tray/main.jsx +++ b/desktop/src/tray/main.jsx @@ -18,7 +18,7 @@ function Item({ children, right, onClick }) { transition: 'background var(--dur-fast) var(--ease-out)', }}> {children} - {right && {right}} + {right && {right}}
); } diff --git a/ios/dudu/Shared/DuduTheme.swift b/ios/dudu/Shared/DuduTheme.swift index 78cfab5..d588408 100644 --- a/ios/dudu/Shared/DuduTheme.swift +++ b/ios/dudu/Shared/DuduTheme.swift @@ -75,6 +75,12 @@ public enum DuduTheme { public static let border2 = dynamic( light: Color(.sRGB, red: 0.8235, green: 0.8353, blue: 0.8667, opacity: 1.00), dark: Color(.sRGB, red: 0.2039, green: 0.2275, blue: 0.2863, opacity: 1.00)) + public static let brandWechat = dynamic( + light: Color(.sRGB, red: 0.0275, green: 0.7569, blue: 0.3765, opacity: 1.00), + dark: Color(.sRGB, red: 0.0275, green: 0.7569, blue: 0.3765, opacity: 1.00)) + public static let brandWechatPress = dynamic( + light: Color(.sRGB, red: 0.0235, green: 0.6784, blue: 0.3373, opacity: 1.00), + dark: Color(.sRGB, red: 0.0235, green: 0.6784, blue: 0.3373, opacity: 1.00)) public static let danger = dynamic( light: Color(.sRGB, red: 0.8627, green: 0.1490, blue: 0.1490, opacity: 1.00), dark: Color(.sRGB, red: 0.9412, green: 0.3569, blue: 0.3569, opacity: 1.00)) @@ -138,6 +144,9 @@ public enum DuduTheme { public static let overlayText3 = dynamic( light: Color(.sRGB, red: 0.3922, green: 0.4314, blue: 0.5490, opacity: 1.00), dark: Color(.sRGB, red: 0.3922, green: 0.4314, blue: 0.5490, opacity: 1.00)) + public static let overlayWave = dynamic( + light: Color(.sRGB, red: 0.4863, green: 0.6078, blue: 1.0000, opacity: 1.00), + dark: Color(.sRGB, red: 0.4863, green: 0.6078, blue: 1.0000, opacity: 1.00)) public static let positive = dynamic( light: Color(.sRGB, red: 0.0863, green: 0.6392, blue: 0.2902, opacity: 1.00), dark: Color(.sRGB, red: 0.2039, green: 0.7686, blue: 0.4157, opacity: 1.00)) diff --git a/web/index.html b/web/index.html index 677eff4..b4de129 100644 --- a/web/index.html +++ b/web/index.html @@ -5,49 +5,21 @@ dudu — 按住说话,文字上屏 - - - + @@ -367,7 +337,7 @@ footer { background: var(--ink); color: var(--ink-text-2); } -
+
<500ms
@@ -489,7 +459,7 @@ footer { background: var(--ink); color: var(--ink-text-2); } -
+

从下一句话开始
用说的

@@ -499,7 +469,7 @@ footer { background: var(--ink); color: var(--ink-text-2); }
-