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 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/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); }
-