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/SKILL.md b/design/SKILL.md index f39fc1f..994bd5a 100644 --- a/design/SKILL.md +++ b/design/SKILL.md @@ -13,3 +13,11 @@ If creating visual artifacts (slides, mocks, throwaway prototypes, etc), copy as If the user invokes this skill without any other guidance, ask them what they want to build or design, ask some questions, and act as an expert designer who outputs HTML artifacts _or_ production code, depending on the need. Hard rules: 一屏一个蓝色主按钮;无渐变;无 emoji;浮层永远深色玻璃;文案短、用"你"、无句号。 + +## 单源与闸(改 UI 前必读) + +- **登记先行(L1 无例外)**:新颜色/字号/间距 → `tokens/*.css`;新组件 → `components/` + 登记簿 `index.html` 加卡;新图标 → `icons.js` + `icon-map.json`。登记后跑 `node design-pipeline/export-tokens.mjs` 再写端代码。 +- **业务代码禁止**:字面色(hex/rgb/hsl,一律 var(--*) / DuduTheme)、内联 ``(走 `Icon` 组件)、未登记的 SF Symbol。确属例外加行内 `ds-ignore: 理由`。 +- **闸**:`node design-pipeline/check-ds.mjs`(真相源自检 7 道)· `check-code.mjs`(四端代码侧)· `export-tokens.mjs --check`(产物零 diff)。pre-commit(`git config core.hooksPath .githooks` 启用)与 CI 双挂。 +- **预览评审**:`node design/serve.mjs` → http://localhost:5180(登记簿 index.html 与 ui_kits)。 +- 全景说明:`doc/frontend-overview.html`。 diff --git a/design/assets/outfit-latin.woff2 b/design/assets/outfit-latin.woff2 new file mode 100644 index 0000000..c94ced7 Binary files /dev/null and b/design/assets/outfit-latin.woff2 differ 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/doc/frontend-overview.html b/doc/frontend-overview.html new file mode 100644 index 0000000..e56f4d6 --- /dev/null +++ b/doc/frontend-overview.html @@ -0,0 +1,168 @@ + + + + + +dudu 前端全景:设计真相源与防漂移体系 + + + + +
+

dudu 前端全景

+

设计真相源(design/)· 五端消费 · 防漂移闸体系 —— 参考 jiu ds-flow 思想的非 Flutter 落地

+
+ 2026-07-11 + 登记簿:design/index.html + 闸:design-pipeline/check-ds · check-code · export-tokens --check +
+
+ +
+ +
+

一、一次 UI 改动的标准路径

+
改颜色/字号/间距   design/tokens/*.css 登记
+                     │
+                     ▼
+        node design-pipeline/export-tokens.mjs
+                     │(直写四端消费位置)
+   ┌──────────┬──────┴──────┬──────────────┐
+   ▼          ▼             ▼              ▼
+desktop    iOS Swift    Android Kotlin   官网 web/
+CSS 变量   DuduTheme    DuduTheme        tokens.css
+(直引源) (生成)      (生成)          (生成,含字体内嵌)
+
+改组件 / 图标      先在 design/ 登记(components/ + index.html 卡;
+                   icons.js + icon-map.json)→ 再到各端实现
+提交               pre-commit 自动跑对应闸;CI 三道闸复验
+
核心原则(ds-flow):设计决策只有一个出生地(design/),代码永远是镜像;绕过单源的行为由闸在提交/CI 时拦截。规则写了没上闸 = 没有规则。
+
+ +
+

二、真相源结构(design/)

+ + + + + + + + + + +
路径角色
index.html登记簿(活文档主页):token 色板 / 组件卡 / 图标映射 / guidelines / ui_kits 单一入口。新元素必须在此登记(check-ds ④ 强制)。评审:node design/serve.mjs
tokens/*.css令牌单源:colors(含 --brand-wechat / --overlay-wave)/ typography / spacing / effects / fonts(Outfit 自托管,禁 CDN)
icons.js + icon-map.json图标单源(Lucide 线条 24 网格)+ 语义名→三端实现映射表;业务代码禁内联 <path>
components/(core/forms/voice)参考组件(React),桌面端直接运行时消费;Icon.jsx 渲染 icons.js
ui_kits/(desktop/mobile)屏级交互原型(新屏 design-first 的出生地)
guidelines/*.card.html规范卡(品牌/色板/字体/间距/动效)
readme.md / SKILL.md设计基调、文案基调、视觉硬规则;agent 使用入口
_ds_manifest.jsonClaude 设计导出的机读清单——非权威、可能滞后,真相以 tokens/ 与登记簿为准
+
+ +
+

三、五端消费方式对照

+ + + + + + + +
技术栈令牌组件/图标
桌面 Mac/WinTauri 2 + Reactvite alias @dudu/design 直引 tokens CSS(无生成层)直接 import design/components;Icon 组件渲染 icons.js
iOSSwiftUIios/dudu/Shared/DuduTheme.swift(codegen 直写)SF Symbols,须 ∈ icon-map ios 列(check-code 同集闸)
AndroidComposeDuduTheme.kt(codegen 直写)→ DuduPalette CompositionLocal暂无图标使用;启用后同集闸生效
官网静态单页web/tokens.css(codegen:custom props + Outfit data URI)页面只允许 var(--*);深色板块挂 data-theme="dark" 复用 dark 令牌
原型自身React(浏览器内 Babel)styles.css @import tokens与桌面同一套组件源码
+
codegen 直写消费位置(2026-07-11 改):此前生成到 design-pipeline/generated/ 再手工拷贝进 app,--check 守不到真实文件、拷贝已漂移。现直写 + CI 校验真实消费文件,漂移类型整个消除。
+
+ +
+

四、各道闸全景

+ + + + + +
拦什么何时跑
check-ds.mjs
真相源自检(7 道)
① dark 孤儿 token ② var() 断链/@import 缺失 ③ design/ 内裸色 ∉ tokens 值集 ④ 组件未登记登记簿 ⑤ icons.js 重复 key/填充色 ⑥ icon-map ↔ icons.js 双向不同集 ⑦ 界面文案 emojipre-commit(动 design/)+ CI
export-tokens.mjs --check四端令牌产物与源不一致(改了 tokens 忘 regen / 手改生成文件)pre-commit(动 design/)+ CI
check-code.mjs
代码侧单源(四端)
desktop 字面色/内联 <path>;iOS 字面色/未登记 SF Symbol;Android Color(0x;web 字面色pre-commit(动端代码)+ CI
+

豁免机制

+
    +
  • 行级 ds-ignore: 理由 注释——必须写理由(mac 红绿灯窗饰、手机外壳装饰、alpha 派生色等)
  • +
  • codegen 产物(DuduTheme.*、web/tokens.css)自动豁免
  • +
+

启用

+
# 一次性(每个 clone)
+git config core.hooksPath .githooks
+
+# 手动全量
+node design-pipeline/check-ds.mjs && node design-pipeline/export-tokens.mjs --check && node design-pipeline/check-code.mjs
+
+ +
+

五、三层分治规则(L1 / L2 / L3)

+ + + + + +
规则
L1 设计系统元素
颜色/字号/间距/组件/图标
先登记 design/(tokens / components+登记簿 / icons+map)再写端代码,无例外。闸强制。
L2 屏级屏以 ui_kits 原型为基准;小迭代直改代码但不得引入 L1 违规(闸兜底);整屏改版原型先行。
L3 新屏design-first:ui_kits 加屏(serve.mjs 起服务给 URL 评审,不截图)→ 各端实现 → 登记。
+
+ +
+

六、已知滞后与后续项

+
    +
  • todo #26桌面 fidelity 像素闸(Playwright 截 ui_kits 原型 vs Tauri 页面 pixelmatch)——静态闸先行,像素级验收后续补
  • +
  • 待治理ui_kits 预览页经 unpkg CDN 加载 React/Babel,离线/无代理网络不可用(评审时需网络;可选改为 vendor 本地化)
  • +
  • 说明_ds_manifest.json 为 Claude 设计导出产物,未纳入闸;以 tokens/ 与登记簿为权威
  • +
  • 说明移动端不做跨渲染器像素比对(字体度量漂移大,jiu 教训);iOS/Android 一致性由 token codegen + 同集闸保障
  • +
+
+ +
dudu · 前端全景 · 2026-07-11 · 变更走 feat/design-truth-source
+ +
+ + diff --git a/doc/index.html b/doc/index.html index b0d142b..ca283aa 100644 --- a/doc/index.html +++ b/doc/index.html @@ -53,6 +53,7 @@ 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); }
-