Merge branch 'feat/design-truth-source' into worktree-latency-probe-12b
This commit is contained in:
Executable
+19
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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`,各端对照实现
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(--*);
|
||||
* 禁内联 <path>(图标须经 @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:字面色 + 内联 <path> ───────────────────────────────────────────
|
||||
|
||||
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 (/<path[\s>]/.test(line))
|
||||
err(f, i + 1, 'desktop 内联 <path>(图标须登记 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 单源全绿');
|
||||
@@ -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(`<b>${m[1]}</b>`) && !registry.includes(`<b>${m[1]} `) && !new RegExp(`<b>[^<]*\\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 道全绿');
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)、内联 `<path>`(走 `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`。
|
||||
|
||||
Binary file not shown.
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
import * as React from 'react';
|
||||
import type { icons } from '../../icons.js';
|
||||
|
||||
export interface IconProps extends React.SVGProps<SVGSVGElement> {
|
||||
/** design/icons.js 登记的图标名 */
|
||||
name: keyof typeof icons;
|
||||
/** 边长 px(24 网格等比缩放),默认 16 */
|
||||
size?: number;
|
||||
/** 描边宽度,默认 2 */
|
||||
sw?: number;
|
||||
}
|
||||
|
||||
export declare function Icon(props: IconProps): React.JSX.Element;
|
||||
@@ -0,0 +1,25 @@
|
||||
// Icon:渲染 design/icons.js 单源图标(Lucide 线条、24 网格、currentColor)。
|
||||
// 业务代码禁止内联 <path>(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 (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={sw}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
dangerouslySetInnerHTML={{ __html: body }}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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); }
|
||||
|
||||
@@ -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 (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"></path>
|
||||
<path d="M19 10v2a7 7 0 0 1-14 0v-2"></path>
|
||||
<line x1="12" x2="12" y1="19" y2="22"></line>
|
||||
</svg>
|
||||
);
|
||||
return <Icon name="mic" size={size} />;
|
||||
}
|
||||
|
||||
const DD_MICBAR_LABELS = {
|
||||
@@ -46,7 +40,7 @@ export function MicBar({ state = 'idle', label, ...rest }) {
|
||||
return (
|
||||
<button type="button" className={`dd-micbar dd-micbar--${state}`} {...rest}>
|
||||
{state === 'recording'
|
||||
? <Waveform active bars={9} height={18} color="rgba(255,255,255,0.95)" />
|
||||
? <Waveform active bars={9} height={18} color="rgba(255,255,255,0.95)" /> /* ds-ignore: accent 底上白波形 95% 派生 */
|
||||
: <DdMicIcon />}
|
||||
<span>{text}</span>
|
||||
</button>
|
||||
|
||||
@@ -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 (
|
||||
<div className="dd-overlay" style={{ width, ...style }} role="status">
|
||||
<Waveform active bars={12} height={24} color="#7C9BFF" />
|
||||
<Waveform active bars={12} height={24} color="var(--overlay-wave)" />
|
||||
{state === 'listening' ? (
|
||||
<div className="dd-overlay__status">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"></path>
|
||||
<path d="M19 10v2a7 7 0 0 1-14 0v-2"></path>
|
||||
<line x1="12" x2="12" y1="19" y2="22"></line>
|
||||
</svg>
|
||||
<Icon name="mic" size={16} />
|
||||
<span>聆听中…</span>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -18,6 +18,6 @@
|
||||
<div class="cell light"><img src="../assets/logo-mark-ink.svg" width="72" height="72" alt="dudu logo"></div>
|
||||
<div class="cell tint"><img src="../assets/logo-mark-blue.svg" width="72" height="72" alt="dudu logo blue"></div>
|
||||
<div class="cell dark"><img src="../assets/logo-mark-white.svg" width="72" height="72" alt="dudu logo white"></div>
|
||||
<div class="cell blue"><img src="../assets/app-icon.svg" width="76" height="76" alt="dudu app icon" style="border-radius: 17px; box-shadow: 0 4px 14px rgba(0,0,0,.25);"></div>
|
||||
<div class="cell blue"><img src="../assets/app-icon.svg" width="76" height="76" alt="dudu app icon" style="border-radius: 17px; box-shadow: 0 4px 14px rgba(0,0,0,.25);"></div><!-- ds-ignore: 色卡演示投影,非 UI 规范 -->
|
||||
</body>
|
||||
</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: 深色色块上的标注文字,色卡专用 */
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/* ============================================================
|
||||
dudu · UI 图标单源(web 侧)
|
||||
Lucide 线条图标(ISC):24 网格、2px 圆头描边、currentColor。
|
||||
新增图标:从 lucide.dev 复制 path → 在此登记 → icon-map.json
|
||||
补跨端映射 → 登记簿(design/index.html)自动展示。
|
||||
禁止在业务代码里内联 <path>——check-code 闸会拦。
|
||||
============================================================ */
|
||||
|
||||
/** 24 网格图标 path 片段(不含 <svg> 外壳) */
|
||||
export const icons = {
|
||||
/** 麦克风(识别/录音) */
|
||||
mic: '<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" x2="12" y1="19" y2="22"/>',
|
||||
/** 对勾(完成/选中) */
|
||||
check: '<path d="M20 6 9 17l-5-5"/>',
|
||||
/** 关闭 */
|
||||
x: '<path d="M18 6 6 18M6 6l12 12"/>',
|
||||
/** 添加图片(反馈附图) */
|
||||
imagePlus:
|
||||
'<path d="M16 5h6"/><path d="M19 2v6"/><path d="M21 11.5V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7.5"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>',
|
||||
/** 钥匙(权限引导) */
|
||||
key: '<path d="m15.5 7.5 3 3L22 7l-3-3"/><path d="m21 2-9.6 9.6"/><circle cx="7.5" cy="15.5" r="5.5"/>',
|
||||
};
|
||||
|
||||
/** 品牌 mark(48 网格、3.5 圆头——圆嘴 + 三条声波,与 assets/logo-mark.svg 同构) */
|
||||
export const logoMark =
|
||||
'<circle cx="24" cy="24" r="18"/><path d="M17 20.5v7"/><path d="M24 16.5v15"/><path d="M31 20.5v7"/>';
|
||||
|
||||
/** 生成完整 <svg> 字符串(框架无关,官网/innerHTML 场景用) */
|
||||
export function svgIcon(name, { size = 16, sw = 2 } = {}) {
|
||||
const body = icons[name];
|
||||
if (!body) throw new Error(`unknown icon: ${name}`);
|
||||
return (
|
||||
`<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" ` +
|
||||
`stroke-width="${sw}" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>dudu 设计系统 · 登记簿</title>
|
||||
<link rel="stylesheet" href="./styles.css">
|
||||
<style>
|
||||
/* 登记簿版式(色值一律 var(--*),本页也受 check-ds 闸约束) */
|
||||
body { margin: 0; background: var(--bg-app); color: var(--text-1); font-family: var(--font-sans); line-height: var(--leading-normal); }
|
||||
.wrap { max-width: 1080px; margin: 0 auto; padding: 0 24px 80px; }
|
||||
header.hero { padding: 40px 0 8px; display: flex; align-items: center; justify-content: space-between; }
|
||||
header.hero h1 { font-size: var(--text-2xl); margin: 0; }
|
||||
header.hero p { color: var(--text-2); margin: 4px 0 0; font-size: var(--text-sm); }
|
||||
.theme-btn { height: var(--control-sm); padding: 0 14px; border-radius: var(--radius-sm); border: 1px solid var(--border-2); background: var(--surface-card); color: var(--text-1); cursor: pointer; font-size: var(--text-sm); }
|
||||
section { background: var(--surface-card); border: 1px solid var(--border-1); border-radius: var(--radius-lg); padding: 22px 26px; margin-top: 20px; box-shadow: var(--shadow-card); }
|
||||
h2 { font-size: var(--text-lg); margin: 0 0 12px; }
|
||||
h3 { font-size: var(--text-base); margin: 18px 0 8px; color: var(--accent-text); }
|
||||
.rule { border-left: 3px solid var(--accent); background: var(--accent-soft); padding: 8px 14px; border-radius: 0 var(--radius-sm) var(--radius-sm) 0; font-size: var(--text-sm); margin: 8px 0; }
|
||||
.sw-row { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.sw { width: 88px; font-size: var(--text-xs); color: var(--text-2); }
|
||||
.sw i { display: block; height: 40px; border-radius: var(--radius-sm); border: 1px solid var(--border-1); margin-bottom: 3px; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: var(--text-sm); margin-top: 8px; }
|
||||
th, td { border: 1px solid var(--border-1); padding: 6px 10px; text-align: left; }
|
||||
th { background: var(--surface-2); }
|
||||
a { color: var(--accent-text); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
code { background: var(--surface-2); padding: 1px 6px; border-radius: var(--radius-xs); font-family: var(--font-mono); font-size: var(--text-xs); }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 10px; }
|
||||
.entry { border: 1px solid var(--border-1); border-radius: var(--radius-md); padding: 12px 14px; }
|
||||
.entry b { font-size: var(--text-base); }
|
||||
.entry .sub { color: var(--text-2); font-size: var(--text-xs); margin: 3px 0 6px; }
|
||||
.entry .meta { font-size: var(--text-xs); color: var(--text-3); }
|
||||
#icon-grid { display: flex; flex-wrap: wrap; gap: 14px; }
|
||||
#icon-grid .ic { width: 96px; text-align: center; font-size: var(--text-xs); color: var(--text-2); }
|
||||
#icon-grid .ic .box { height: 48px; display: flex; align-items: center; justify-content: center; border: 1px solid var(--border-1); border-radius: var(--radius-sm); margin-bottom: 4px; color: var(--text-1); }
|
||||
.note { color: var(--text-3); font-size: var(--text-xs); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
|
||||
<header class="hero">
|
||||
<div>
|
||||
<h1>dudu 设计系统 · 登记簿</h1>
|
||||
<p>单一真相源活文档 —— 所有 token / 组件 / 图标必须在本页登记(check-ds 闸强制)。评审预览:<code>node design/serve.mjs</code></p>
|
||||
</div>
|
||||
<button class="theme-btn" onclick="const r=document.documentElement; r.dataset.theme = r.dataset.theme==='dark'?'':'dark'">切换 Light / Dark</button>
|
||||
</header>
|
||||
|
||||
<section>
|
||||
<h2>规则速览(L1 · 设计元素单源)</h2>
|
||||
<div class="rule">新增<b>颜色/字号/间距</b>:先登记 <code>tokens/*.css</code> → <code>node design-pipeline/export-tokens.mjs</code> 再生成各端 → 业务代码只引 token。</div>
|
||||
<div class="rule">新增<b>组件</b>:先建 <code>components/<组>/X.jsx</code>(+ .d.ts)并在本页「组件登记」加卡,再到各端实现。</div>
|
||||
<div class="rule">新增<b>图标</b>:先登记 <code>icons.js</code>(Lucide 24 网格 2px 圆头)+ <code>icon-map.json</code> 跨端映射,业务代码禁止内联 <path>。</div>
|
||||
<div class="rule">违规谁拦:<code>design-pipeline/check-ds.mjs</code>(真相源自检)/ <code>check-code.mjs</code>(代码侧单源)/ <code>export-tokens.mjs --check</code>(产物零 diff),pre-commit 与 CI 双挂。</div>
|
||||
|
||||
<h3>各端消费方式</h3>
|
||||
<table>
|
||||
<tr><th>端</th><th>消费方式</th><th>令牌文件</th></tr>
|
||||
<tr><td>桌面(Tauri web 层)</td><td>vite alias <code>@dudu/design</code> 直引 CSS 变量与组件</td><td><code>design/tokens/*.css</code>(源)</td></tr>
|
||||
<tr><td>iOS(SwiftUI)</td><td><code>DuduTheme.*</code> 常量(codegen 直写)</td><td><code>ios/dudu/Shared/DuduTheme.swift</code></td></tr>
|
||||
<tr><td>Android(Compose)</td><td><code>DuduTheme.Light/Dark</code> → <code>DuduPalette</code> 注入</td><td><code>android/.../design/DuduTheme.kt</code></td></tr>
|
||||
<tr><td>官网(web/)</td><td><code><link href="tokens.css"></code>,页面只允许 var(--*)</td><td><code>web/tokens.css</code>(codegen,含 Outfit 内嵌)</td></tr>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Tokens 色板</h2>
|
||||
<p class="note">色块直接渲染 var(--token),与 tokens/colors.css 永远同步;右上角切主题查看 dark 值。完整定义见 <a href="./tokens/colors.css">colors.css</a> · <a href="./tokens/typography.css">typography.css</a> · <a href="./tokens/spacing.css">spacing.css</a> · <a href="./tokens/effects.css">effects.css</a> · <a href="./tokens/fonts.css">fonts.css</a></p>
|
||||
<h3>品牌蓝阶梯</h3>
|
||||
<div class="sw-row">
|
||||
<div class="sw"><i style="background:var(--blue-50)"></i>blue-50</div>
|
||||
<div class="sw"><i style="background:var(--blue-100)"></i>blue-100</div>
|
||||
<div class="sw"><i style="background:var(--blue-200)"></i>blue-200</div>
|
||||
<div class="sw"><i style="background:var(--blue-300)"></i>blue-300</div>
|
||||
<div class="sw"><i style="background:var(--blue-400)"></i>blue-400</div>
|
||||
<div class="sw"><i style="background:var(--blue-500)"></i>blue-500 ★</div>
|
||||
<div class="sw"><i style="background:var(--blue-600)"></i>blue-600</div>
|
||||
<div class="sw"><i style="background:var(--blue-700)"></i>blue-700</div>
|
||||
<div class="sw"><i style="background:var(--blue-800)"></i>blue-800</div>
|
||||
<div class="sw"><i style="background:var(--blue-900)"></i>blue-900</div>
|
||||
</div>
|
||||
<h3>中性阶梯(冷灰)</h3>
|
||||
<div class="sw-row">
|
||||
<div class="sw"><i style="background:var(--gray-0)"></i>gray-0</div>
|
||||
<div class="sw"><i style="background:var(--gray-25)"></i>gray-25</div>
|
||||
<div class="sw"><i style="background:var(--gray-50)"></i>gray-50</div>
|
||||
<div class="sw"><i style="background:var(--gray-100)"></i>gray-100</div>
|
||||
<div class="sw"><i style="background:var(--gray-200)"></i>gray-200</div>
|
||||
<div class="sw"><i style="background:var(--gray-300)"></i>gray-300</div>
|
||||
<div class="sw"><i style="background:var(--gray-400)"></i>gray-400</div>
|
||||
<div class="sw"><i style="background:var(--gray-500)"></i>gray-500</div>
|
||||
<div class="sw"><i style="background:var(--gray-600)"></i>gray-600</div>
|
||||
<div class="sw"><i style="background:var(--gray-700)"></i>gray-700</div>
|
||||
<div class="sw"><i style="background:var(--gray-800)"></i>gray-800</div>
|
||||
<div class="sw"><i style="background:var(--gray-900)"></i>gray-900</div>
|
||||
<div class="sw"><i style="background:var(--gray-950)"></i>gray-950</div>
|
||||
</div>
|
||||
<h3>语义与功能</h3>
|
||||
<div class="sw-row">
|
||||
<div class="sw"><i style="background:var(--accent)"></i>accent</div>
|
||||
<div class="sw"><i style="background:var(--accent-hover)"></i>accent-hover</div>
|
||||
<div class="sw"><i style="background:var(--accent-press)"></i>accent-press</div>
|
||||
<div class="sw"><i style="background:var(--accent-soft)"></i>accent-soft</div>
|
||||
<div class="sw"><i style="background:var(--positive)"></i>positive</div>
|
||||
<div class="sw"><i style="background:var(--warning)"></i>warning</div>
|
||||
<div class="sw"><i style="background:var(--danger)"></i>danger</div>
|
||||
<div class="sw"><i style="background:var(--bg-app)"></i>bg-app</div>
|
||||
<div class="sw"><i style="background:var(--surface-card)"></i>surface-card</div>
|
||||
<div class="sw"><i style="background:var(--surface-2)"></i>surface-2</div>
|
||||
<div class="sw"><i style="background:var(--surface-3)"></i>surface-3</div>
|
||||
<div class="sw"><i style="background:var(--border-1)"></i>border-1</div>
|
||||
</div>
|
||||
<h3>浮层与第三方品牌</h3>
|
||||
<div class="sw-row">
|
||||
<div class="sw"><i style="background:var(--overlay-bg)"></i>overlay-bg</div>
|
||||
<div class="sw"><i style="background:var(--overlay-text)"></i>overlay-text</div>
|
||||
<div class="sw"><i style="background:var(--overlay-text-2)"></i>overlay-text-2</div>
|
||||
<div class="sw"><i style="background:var(--overlay-text-3)"></i>overlay-text-3</div>
|
||||
<div class="sw"><i style="background:var(--overlay-wave)"></i>overlay-wave</div>
|
||||
<div class="sw"><i style="background:var(--brand-wechat)"></i>brand-wechat</div>
|
||||
<div class="sw"><i style="background:var(--brand-wechat-press)"></i>brand-wechat-press</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>组件登记</h2>
|
||||
<p class="note">每个 <code>components/**/*.jsx</code> 组件必须在此有登记卡(check-ds 第④道)。演示卡:<a href="./components/core/core.card.html">core</a> · <a href="./components/forms/forms.card.html">forms</a> · <a href="./components/voice/voice.card.html">voice</a></p>
|
||||
<h3>core</h3>
|
||||
<div class="grid">
|
||||
<div class="entry"><b>Button</b><div class="sub">主/次/幽灵/危险按钮,三种尺寸</div><div class="meta">variant · size · block — components/core/Button.jsx</div></div>
|
||||
<div class="entry"><b>IconButton</b><div class="sub">方形图标按钮</div><div class="meta">size · label(无障碍必填)— components/core/IconButton.jsx</div></div>
|
||||
<div class="entry"><b>Badge</b><div class="sub">状态徽标</div><div class="meta">tone: green/blue/orange/red/neutral — components/core/Badge.jsx</div></div>
|
||||
<div class="entry"><b>Kbd</b> / <b>HotkeyCombo</b><div class="sub">键帽与快捷键组合(⌘ ⇧ Space)</div><div class="meta">keys=['⌘','⇧','Space'] — components/core/Kbd.jsx</div></div>
|
||||
<div class="entry"><b>Card</b><div class="sub">标准卡片容器</div><div class="meta">padding — components/core/Card.jsx</div></div>
|
||||
<div class="entry"><b>ProgressBar</b><div class="sub">用量进度条,超阈自动警告色</div><div class="meta">value/max · warnAt=0.9 — components/core/ProgressBar.jsx</div></div>
|
||||
<div class="entry"><b>Icon</b><div class="sub">icons.js 单源图标渲染器</div><div class="meta">name · size · sw — components/core/Icon.jsx</div></div>
|
||||
</div>
|
||||
<h3>forms</h3>
|
||||
<div class="grid">
|
||||
<div class="entry"><b>Input</b><div class="sub">文本输入框</div><div class="meta">size: md/lg — components/forms/Input.jsx</div></div>
|
||||
<div class="entry"><b>Switch</b><div class="sub">开关</div><div class="meta">checked · onChange — components/forms/Switch.jsx</div></div>
|
||||
<div class="entry"><b>SettingRow</b><div class="sub">设置行(标签 + 描述 + 右侧控件)</div><div class="meta">label · description — components/forms/SettingRow.jsx</div></div>
|
||||
</div>
|
||||
<h3>voice</h3>
|
||||
<div class="grid">
|
||||
<div class="entry"><b>RecognitionOverlay</b><div class="sub">桌面识别浮层 · 聆听/流式两态</div><div class="meta">state · text — components/voice/RecognitionOverlay.jsx</div></div>
|
||||
<div class="entry"><b>MicBar</b><div class="sub">移动端按住说话 · 四种状态</div><div class="meta">state — components/voice/MicBar.jsx</div></div>
|
||||
<div class="entry"><b>Waveform</b><div class="sub">波形条(唯一循环动画,仅录音中)</div><div class="meta">active · bars · height · color — components/voice/Waveform.jsx</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>图标登记(icons.js + icon-map.json)</h2>
|
||||
<p class="note">Lucide 线条 · 24 网格 · 2px 圆头 · currentColor。下方由 <code>icons.js</code> 实时渲染(需经 serve.mjs 访问;file:// 打开时仅显示映射表)。</p>
|
||||
<div id="icon-grid"></div>
|
||||
<div id="icon-map-table"></div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Guidelines 规范卡</h2>
|
||||
<div class="grid">
|
||||
<div class="entry"><b><a href="./guidelines/brand-logo.card.html">Logo</a></b><div class="sub">圆形嘴巴 + 声波线条 · 线性风格</div></div>
|
||||
<div class="entry"><b><a href="./guidelines/brand-wordmark.card.html">Wordmark</a></b><div class="sub">dudu 几何线条字标</div></div>
|
||||
<div class="entry"><b><a href="./guidelines/colors-brand.card.html">品牌蓝阶梯</a></b><div class="sub">blue-50 → 900</div></div>
|
||||
<div class="entry"><b><a href="./guidelines/colors-neutrals.card.html">中性阶梯</a></b><div class="sub">gray-0 → 950 冷灰</div></div>
|
||||
<div class="entry"><b><a href="./guidelines/colors-surfaces.card.html">表面与文字</a></b><div class="sub">bg → surface → text,Light/Dark</div></div>
|
||||
<div class="entry"><b><a href="./guidelines/colors-semantic.card.html">功能色</a></b><div class="sub">绿=录音/成功 · 琥珀=警告 · 红=错误</div></div>
|
||||
<div class="entry"><b><a href="./guidelines/colors-overlay.card.html">识别浮层色</a></b><div class="sub">双模式深色玻璃</div></div>
|
||||
<div class="entry"><b><a href="./guidelines/type-families.card.html">字体家族</a></b><div class="sub">Outfit(自托管)+ 系统中文栈</div></div>
|
||||
<div class="entry"><b><a href="./guidelines/type-scale.card.html">字号阶梯</a></b><div class="sub">12 → 44</div></div>
|
||||
<div class="entry"><b><a href="./guidelines/type-recognition.card.html">识别文本规范</a></b><div class="sub">final 实色 · partial 弱色</div></div>
|
||||
<div class="entry"><b><a href="./guidelines/spacing-grid.card.html">间距网格</a></b><div class="sub">4px 基准</div></div>
|
||||
<div class="entry"><b><a href="./guidelines/spacing-radius.card.html">圆角阶梯</a></b><div class="sub">xs 4 → full 999</div></div>
|
||||
<div class="entry"><b><a href="./guidelines/spacing-controls.card.html">控件高度</a></b><div class="sub">桌面 28/36 · 移动 44/56</div></div>
|
||||
<div class="entry"><b><a href="./guidelines/spacing-elevation.card.html">层级与阴影</a></b><div class="sub">card → menu → window → overlay</div></div>
|
||||
<div class="entry"><b><a href="./guidelines/motion.card.html">动效</a></b><div class="sub">快入慢出 · 120/200/320ms · 无弹跳</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>UI Kits(屏级原型)</h2>
|
||||
<div class="grid">
|
||||
<div class="entry"><b><a href="./ui_kits/desktop/index.html">桌面端 UI Kit</a></b><div class="sub">识别浮层 · 托盘菜单 · 设置 · 登录购买(可交互,Light/Dark)</div></div>
|
||||
<div class="entry"><b><a href="./ui_kits/mobile/index.html">移动端 UI Kit</a></b><div class="sub">语音键盘(按住说话)+ 主 App 四屏(可交互,Light/Dark)</div></div>
|
||||
</div>
|
||||
<p class="note">已知滞后:ui_kits 预览页仍经 unpkg CDN 加载 React/Babel,离线/无代理网络下不可用(治理项见 doc/frontend-overview.html)。</p>
|
||||
</section>
|
||||
|
||||
<script type="module">
|
||||
// 图标区:从单源渲染(serve.mjs 下可用;file:// 因模块 CORS 限制自动降级为仅映射表)
|
||||
try {
|
||||
const { icons } = await import('./icons.js');
|
||||
const map = await (await fetch('./icon-map.json')).json();
|
||||
const grid = document.getElementById('icon-grid');
|
||||
grid.innerHTML = Object.entries(icons).map(([k, body]) =>
|
||||
`<div class="ic"><div class="box"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${body}</svg></div>${k}</div>`
|
||||
).join('');
|
||||
const rows = Object.entries(map).filter(([k]) => k !== '$schema').map(([k, v]) =>
|
||||
`<tr><td><code>${k}</code></td><td>${v.web ?? '—'}</td><td>${v.ios ?? '—'}</td><td>${v.android ?? '—'}</td></tr>`
|
||||
).join('');
|
||||
document.getElementById('icon-map-table').innerHTML =
|
||||
`<table><tr><th>语义名</th><th>web(icons.js)</th><th>iOS(SF Symbol)</th><th>Android</th></tr>${rows}</table>`;
|
||||
} catch {
|
||||
document.getElementById('icon-map-table').innerHTML =
|
||||
'<p class="note">file:// 模式无法加载 icons.js / icon-map.json,请 <code>node design/serve.mjs</code> 后访问。</p>';
|
||||
}
|
||||
</script>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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<script>
|
||||
(function(){ try{
|
||||
var es=new EventSource('/__reload');
|
||||
es.onmessage=function(){ location.reload(); };
|
||||
}catch(e){} })();
|
||||
</script>\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('<h1>404</h1>'); 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('</body>', RELOAD_SNIPPET + '</body>');
|
||||
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();
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+12
-4
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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)',
|
||||
}}>
|
||||
<span style={{ width: 11, height: 11, borderRadius: '50%', background: '#FF5F57' }}></span>
|
||||
<span style={{ width: 11, height: 11, borderRadius: '50%', background: '#FEBC2E' }}></span>
|
||||
<span style={{ width: 11, height: 11, borderRadius: '50%', background: '#28C840' }}></span>
|
||||
<span style={{ width: 11, height: 11, borderRadius: '50%', background: '#FF5F57' /* ds-ignore: mac 红绿灯窗饰,系统固定色 */ }}></span>
|
||||
<span style={{ width: 11, height: 11, borderRadius: '50%', background: '#FEBC2E' /* ds-ignore: mac 红绿灯窗饰,系统固定色 */ }}></span>
|
||||
<span style={{ width: 11, height: 11, borderRadius: '50%', background: '#28C840' /* ds-ignore: mac 红绿灯窗饰,系统固定色 */ }}></span>
|
||||
<span style={{ marginLeft: 10, fontSize: 'var(--text-xs)', color: 'var(--text-2)', whiteSpace: 'nowrap' }}>{title}</span>
|
||||
</div>
|
||||
{children}
|
||||
@@ -109,7 +109,7 @@ function FakeQr({ size = 148, seed = 7 }) {
|
||||
} else {
|
||||
on = rnd() > 0.52;
|
||||
}
|
||||
if (on) cells.push(<span key={x + '-' + y} style={{ gridColumn: x + 1, gridRow: y + 1, background: '#1B1E26', borderRadius: 1 }}></span>);
|
||||
if (on) cells.push(<span key={x + '-' + y} style={{ gridColumn: x + 1, gridRow: y + 1, background: 'var(--gray-900)', borderRadius: 1 }}></span>);
|
||||
}
|
||||
}
|
||||
return (
|
||||
|
||||
@@ -15,7 +15,7 @@ function TrayMenu() {
|
||||
transition: 'background var(--dur-fast) var(--ease-out)',
|
||||
}}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, whiteSpace: 'nowrap' }}>{children}</span>
|
||||
{right && <span style={{ fontSize: 'var(--text-xs)', color: hover ? 'rgba(255,255,255,0.8)' : 'var(--text-3)', whiteSpace: 'nowrap' }}>{right}</span>}
|
||||
{right && <span style={{ fontSize: 'var(--text-xs)', color: hover ? 'var(--text-on-accent)' : 'var(--text-3)', opacity: hover ? 0.8 : 1, whiteSpace: 'nowrap' }}>{right}</span>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -69,7 +69,7 @@ function LoginScreen() {
|
||||
</div>
|
||||
<button type="button" style={{
|
||||
height: 'var(--control-xl)', borderRadius: 'var(--radius-full)', border: 'none', cursor: 'pointer',
|
||||
background: '#07C160', color: '#fff', fontSize: 'var(--text-md)', fontWeight: 'var(--weight-semibold)',
|
||||
background: 'var(--brand-wechat)', color: 'var(--text-on-accent)', fontSize: 'var(--text-md)', fontWeight: 'var(--weight-semibold)',
|
||||
fontFamily: 'var(--font-sans)', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, width: '100%',
|
||||
}}>
|
||||
<DuMIcons.Wechat size={19} /> 微信一键登录
|
||||
|
||||
@@ -83,7 +83,7 @@ function DuduMLogo({ size = 20, sw = 3.5 }) {
|
||||
function PhoneFrame({ width = 312, height = 596, children }) {
|
||||
return (
|
||||
<div style={{
|
||||
width, height, boxSizing: 'border-box', background: '#0B0C10', borderRadius: 38,
|
||||
width, height, boxSizing: 'border-box', background: '#0B0C10' /* ds-ignore: 手机外壳装饰,非 UI 色 */, borderRadius: 38,
|
||||
padding: 9, boxShadow: 'var(--shadow-window)', flex: 'none',
|
||||
}}>
|
||||
<div style={{
|
||||
@@ -92,7 +92,7 @@ function PhoneFrame({ width = 312, height = 596, children }) {
|
||||
}}>
|
||||
{/* 状态栏 */}
|
||||
<div style={{ height: 30, flex: 'none', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<div style={{ width: 84, height: 18, background: '#0B0C10', borderRadius: 10 }}></div>
|
||||
<div style={{ width: 84, height: 18, background: '#0B0C10' /* ds-ignore: 手机刘海装饰,非 UI 色 */, borderRadius: 10 }}></div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import '@dudu/design/styles.css';
|
||||
import { Button } from '@dudu/design/components/core/Button';
|
||||
import { Icon } from '@dudu/design/components/core/Icon';
|
||||
import { Switch } from '@dudu/design/components/forms/Switch';
|
||||
import { SettingRow } from '@dudu/design/components/forms/SettingRow';
|
||||
import { invoke } from '../shared/tauri';
|
||||
@@ -45,7 +46,7 @@ function Thumb({ img, onRemove }) {
|
||||
background: 'var(--text-1)', color: 'var(--bg-app)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0,
|
||||
}}>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>
|
||||
<Icon name="x" size={10} sw={3} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -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 */}
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M16 5h6"/><path d="M19 2v6"/><path d="M21 11.5V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7.5"/>
|
||||
<circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>
|
||||
</svg>
|
||||
<Icon name="imagePlus" size={20} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
}}>
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
|
||||
<Icon name="check" size={28} sw={2.5} />
|
||||
</span>
|
||||
<div style={{ fontSize: 17, fontWeight: 'var(--weight-semibold)', color: 'var(--text-1)' }}>已收到 · 谢谢你</div>
|
||||
<Button variant="ghost" size="sm" onClick={onAgain}>继续反馈</Button>
|
||||
|
||||
@@ -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,
|
||||
}}>
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
|
||||
<Icon name="check" size={28} sw={2.5} />
|
||||
</span>
|
||||
<div style={{ fontSize: 'var(--text-lg)', fontWeight: 'var(--weight-semibold)', color: 'var(--text-1)' }}>已到账 {pack.minutes} 分钟</div>
|
||||
<div style={{ fontSize: 'var(--text-sm)', color: 'var(--text-2)', textAlign: 'center' }}>
|
||||
|
||||
@@ -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 = (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 19v3"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><rect x="9" y="2" width="6" height="13" rx="3"/>
|
||||
</svg>
|
||||
);
|
||||
const MicIcon = <Icon name="mic" size={18} />;
|
||||
|
||||
const KeyIcon = (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m15.5 7.5 3 3L22 7l-3-3"/><path d="m21 2-9.6 9.6"/><circle cx="7.5" cy="15.5" r="5.5"/>
|
||||
</svg>
|
||||
);
|
||||
const KeyIcon = <Icon name="key" size={18} />;
|
||||
|
||||
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',
|
||||
}}>
|
||||
<svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 19v3"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><rect x="9" y="2" width="6" height="13" rx="3"/>
|
||||
</svg>
|
||||
<Icon name="mic" size={30} />
|
||||
</span>
|
||||
<Title>dudu 语音输入</Title>
|
||||
<Desc>按住快捷键说话,松开文字就出现在光标处<br />全程比打字快三倍</Desc>
|
||||
|
||||
@@ -18,7 +18,7 @@ function Item({ children, right, onClick }) {
|
||||
transition: 'background var(--dur-fast) var(--ease-out)',
|
||||
}}>
|
||||
<span style={{ whiteSpace: 'nowrap' }}>{children}</span>
|
||||
{right && <span style={{ fontSize: 'var(--text-xs)', color: hover ? 'rgba(255,255,255,.8)' : 'var(--text-3)' }}>{right}</span>}
|
||||
{right && <span style={{ fontSize: 'var(--text-xs)', color: hover ? 'var(--text-on-accent)' : 'var(--text-3)', opacity: hover ? 0.8 : 1 }}>{right}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>dudu 前端全景:设计真相源与防漂移体系</title>
|
||||
<style>
|
||||
:root {
|
||||
--accent: #4F6EF7;
|
||||
--accent-text: #3D58DB;
|
||||
--accent-soft: #EEF1FE;
|
||||
--bg: #F6F7FA;
|
||||
--card: #FFFFFF;
|
||||
--text-1: #1B1E26;
|
||||
--text-2: #5A6072;
|
||||
--text-3: #A6ABB8;
|
||||
--border-1: #E4E6EB;
|
||||
--positive: #16A34A;
|
||||
--warning: #E8890C;
|
||||
--danger: #DC2626;
|
||||
--code-bg: #1B1E26;
|
||||
--code-text: #D6DCEA;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", "Segoe UI", sans-serif;
|
||||
background: var(--bg); color: var(--text-1); line-height: 1.75;
|
||||
}
|
||||
.container { max-width: 980px; margin: 0 auto; padding: 0 24px 80px; }
|
||||
header.hero { background: var(--accent); color: #fff; padding: 48px 24px 40px; text-align: center; }
|
||||
header.hero h1 { margin: 0 0 6px; font-size: 30px; }
|
||||
header.hero p { margin: 0; opacity: .85; font-size: 15px; }
|
||||
.chips { margin-top: 16px; }
|
||||
.chip { display: inline-block; padding: 3px 14px; margin: 3px; background: rgba(255,255,255,.16); border-radius: 999px; font-size: 13px; }
|
||||
section.card {
|
||||
background: var(--card); border: 1px solid var(--border-1); border-radius: 14px;
|
||||
padding: 28px 32px; margin-top: 24px; box-shadow: 0 1px 3px rgba(17,19,26,.05);
|
||||
}
|
||||
h2 { font-size: 21px; margin: 0 0 14px; padding-bottom: 8px; border-bottom: 2px solid var(--accent); display: inline-block; }
|
||||
h3 { font-size: 16px; margin: 22px 0 8px; color: var(--accent-text); }
|
||||
table { width: 100%; border-collapse: collapse; margin: 10px 0; font-size: 13.5px; }
|
||||
th, td { border: 1px solid var(--border-1); padding: 7px 11px; text-align: left; vertical-align: top; }
|
||||
th { background: #F0F1F4; font-weight: 600; }
|
||||
tr:nth-child(even) td { background: #FCFCFD; }
|
||||
pre.code, pre.diagram {
|
||||
background: var(--code-bg); color: var(--code-text); padding: 16px 18px; border-radius: 10px;
|
||||
overflow-x: auto; font-family: "SF Mono", Menlo, Consolas, monospace; font-size: 12.5px; line-height: 1.55;
|
||||
}
|
||||
code.inline { background: var(--accent-soft); color: var(--accent-text); padding: 1px 6px; border-radius: 4px; font-family: "SF Mono", Menlo, monospace; font-size: 12.5px; }
|
||||
.callout { border-left: 4px solid var(--accent); background: var(--accent-soft); padding: 11px 16px; border-radius: 0 8px 8px 0; margin: 12px 0; font-size: 13.5px; }
|
||||
.callout.warn { border-color: var(--warning); background: #FCEFD7; }
|
||||
.tag { display: inline-block; font-size: 12px; padding: 1px 10px; border-radius: 999px; margin-right: 6px; font-weight: 600; }
|
||||
.tag.green { background: #DCFCE7; color: var(--positive); }
|
||||
.tag.orange { background: #FCEFD7; color: var(--warning); }
|
||||
.tag.blue { background: var(--accent-soft); color: var(--accent-text); }
|
||||
ul, ol { padding-left: 22px; }
|
||||
li { margin: 3px 0; }
|
||||
footer { text-align: center; color: var(--text-3); font-size: 13px; margin-top: 44px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="hero">
|
||||
<h1>dudu 前端全景</h1>
|
||||
<p>设计真相源(design/)· 五端消费 · 防漂移闸体系 —— 参考 jiu ds-flow 思想的非 Flutter 落地</p>
|
||||
<div class="chips">
|
||||
<span class="chip">2026-07-11</span>
|
||||
<span class="chip">登记簿:design/index.html</span>
|
||||
<span class="chip">闸:design-pipeline/check-ds · check-code · export-tokens --check</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="container">
|
||||
|
||||
<section class="card">
|
||||
<h2>一、一次 UI 改动的标准路径</h2>
|
||||
<pre class="diagram">改颜色/字号/间距 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 三道闸复验</pre>
|
||||
<div class="callout"><b>核心原则(ds-flow):</b>设计决策只有一个出生地(design/),代码永远是镜像;绕过单源的行为由闸在提交/CI 时拦截。<b>规则写了没上闸 = 没有规则。</b></div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>二、真相源结构(design/)</h2>
|
||||
<table>
|
||||
<tr><th>路径</th><th>角色</th></tr>
|
||||
<tr><td><code class="inline">index.html</code></td><td><b>登记簿</b>(活文档主页):token 色板 / 组件卡 / 图标映射 / guidelines / ui_kits 单一入口。新元素必须在此登记(check-ds ④ 强制)。评审:<code class="inline">node design/serve.mjs</code></td></tr>
|
||||
<tr><td><code class="inline">tokens/*.css</code></td><td>令牌单源:colors(含 --brand-wechat / --overlay-wave)/ typography / spacing / effects / fonts(Outfit 自托管,禁 CDN)</td></tr>
|
||||
<tr><td><code class="inline">icons.js + icon-map.json</code></td><td>图标单源(Lucide 线条 24 网格)+ 语义名→三端实现映射表;业务代码禁内联 <path></td></tr>
|
||||
<tr><td><code class="inline">components/</code>(core/forms/voice)</td><td>参考组件(React),桌面端直接运行时消费;Icon.jsx 渲染 icons.js</td></tr>
|
||||
<tr><td><code class="inline">ui_kits/</code>(desktop/mobile)</td><td>屏级交互原型(新屏 design-first 的出生地)</td></tr>
|
||||
<tr><td><code class="inline">guidelines/*.card.html</code></td><td>规范卡(品牌/色板/字体/间距/动效)</td></tr>
|
||||
<tr><td><code class="inline">readme.md / SKILL.md</code></td><td>设计基调、文案基调、视觉硬规则;agent 使用入口</td></tr>
|
||||
<tr><td><code class="inline">_ds_manifest.json</code></td><td>Claude 设计导出的机读清单——<b>非权威、可能滞后</b>,真相以 tokens/ 与登记簿为准</td></tr>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>三、五端消费方式对照</h2>
|
||||
<table>
|
||||
<tr><th>端</th><th>技术栈</th><th>令牌</th><th>组件/图标</th></tr>
|
||||
<tr><td>桌面 Mac/Win</td><td>Tauri 2 + React</td><td>vite alias <code class="inline">@dudu/design</code> 直引 tokens CSS(无生成层)</td><td>直接 import design/components;Icon 组件渲染 icons.js</td></tr>
|
||||
<tr><td>iOS</td><td>SwiftUI</td><td><code class="inline">ios/dudu/Shared/DuduTheme.swift</code>(codegen 直写)</td><td>SF Symbols,须 ∈ icon-map ios 列(check-code 同集闸)</td></tr>
|
||||
<tr><td>Android</td><td>Compose</td><td><code class="inline">DuduTheme.kt</code>(codegen 直写)→ <code class="inline">DuduPalette</code> CompositionLocal</td><td>暂无图标使用;启用后同集闸生效</td></tr>
|
||||
<tr><td>官网</td><td>静态单页</td><td><code class="inline">web/tokens.css</code>(codegen:custom props + Outfit data URI)</td><td>页面只允许 var(--*);深色板块挂 <code class="inline">data-theme="dark"</code> 复用 dark 令牌</td></tr>
|
||||
<tr><td>原型自身</td><td>React(浏览器内 Babel)</td><td>styles.css @import tokens</td><td>与桌面同一套组件源码</td></tr>
|
||||
</table>
|
||||
<div class="callout"><b>codegen 直写消费位置</b>(2026-07-11 改):此前生成到 design-pipeline/generated/ 再手工拷贝进 app,<code class="inline">--check</code> 守不到真实文件、拷贝已漂移。现直写 + CI 校验真实消费文件,漂移类型整个消除。</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>四、各道闸全景</h2>
|
||||
<table>
|
||||
<tr><th>闸</th><th>拦什么</th><th>何时跑</th></tr>
|
||||
<tr><td><code class="inline">check-ds.mjs</code><br>真相源自检(7 道)</td><td>① dark 孤儿 token ② var() 断链/@import 缺失 ③ design/ 内裸色 ∉ tokens 值集 ④ 组件未登记登记簿 ⑤ icons.js 重复 key/填充色 ⑥ icon-map ↔ icons.js 双向不同集 ⑦ 界面文案 emoji</td><td>pre-commit(动 design/)+ CI</td></tr>
|
||||
<tr><td><code class="inline">export-tokens.mjs --check</code></td><td>四端令牌产物与源不一致(改了 tokens 忘 regen / 手改生成文件)</td><td>pre-commit(动 design/)+ CI</td></tr>
|
||||
<tr><td><code class="inline">check-code.mjs</code><br>代码侧单源(四端)</td><td>desktop 字面色/内联 <path>;iOS 字面色/未登记 SF Symbol;Android Color(0x;web 字面色</td><td>pre-commit(动端代码)+ CI</td></tr>
|
||||
</table>
|
||||
<h3>豁免机制</h3>
|
||||
<ul>
|
||||
<li>行级 <code class="inline">ds-ignore: 理由</code> 注释——必须写理由(mac 红绿灯窗饰、手机外壳装饰、alpha 派生色等)</li>
|
||||
<li>codegen 产物(DuduTheme.*、web/tokens.css)自动豁免</li>
|
||||
</ul>
|
||||
<h3>启用</h3>
|
||||
<pre class="code"># 一次性(每个 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</pre>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>五、三层分治规则(L1 / L2 / L3)</h2>
|
||||
<table>
|
||||
<tr><th>层</th><th>规则</th></tr>
|
||||
<tr><td><b>L1 设计系统元素</b><br>颜色/字号/间距/组件/图标</td><td>先登记 design/(tokens / components+登记簿 / icons+map)再写端代码,<b>无例外</b>。闸强制。</td></tr>
|
||||
<tr><td><b>L2 屏级</b></td><td>屏以 ui_kits 原型为基准;小迭代直改代码但不得引入 L1 违规(闸兜底);整屏改版原型先行。</td></tr>
|
||||
<tr><td><b>L3 新屏</b></td><td>design-first:ui_kits 加屏(serve.mjs 起服务给 URL 评审,不截图)→ 各端实现 → 登记。</td></tr>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>六、已知滞后与后续项</h2>
|
||||
<ul>
|
||||
<li><span class="tag orange">todo #26</span>桌面 fidelity 像素闸(Playwright 截 ui_kits 原型 vs Tauri 页面 pixelmatch)——静态闸先行,像素级验收后续补</li>
|
||||
<li><span class="tag orange">待治理</span>ui_kits 预览页经 unpkg CDN 加载 React/Babel,离线/无代理网络不可用(评审时需网络;可选改为 vendor 本地化)</li>
|
||||
<li><span class="tag blue">说明</span>_ds_manifest.json 为 Claude 设计导出产物,未纳入闸;以 tokens/ 与登记簿为权威</li>
|
||||
<li><span class="tag blue">说明</span>移动端不做跨渲染器像素比对(字体度量漂移大,jiu 教训);iOS/Android 一致性由 token codegen + 同集闸保障</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<footer>dudu · 前端全景 · 2026-07-11 · 变更走 feat/design-truth-source</footer>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -53,6 +53,7 @@
|
||||
<ul>
|
||||
<li><a href="frontend-design.html">前端交互与界面设计</a> <span class="desc">— 五端交互流、界面规格与设计系统对接(v1.1)</span></li>
|
||||
<li><a href="backend-architecture.html">后端与数据架构</a> <span class="desc">— API、WS 流式协议、时长账本计费、Redis/PG 设计(v1.1)</span></li>
|
||||
<li><a href="frontend-overview.html">前端全景:设计真相源与防漂移体系</a> <span class="desc">— 2026-07-11 · design/ 单源、五端消费对照、三道闸、L1/L2/L3 分治规则</span></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
+28
-58
@@ -5,49 +5,21 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>dudu — 按住说话,文字上屏</title>
|
||||
<meta name="description" content="dudu 语音输入法:大模型识别,流式实时上屏。Mac / Windows / iPhone / iPad / Android。每天免费 3 分钟。">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="tokens.css">
|
||||
<style>
|
||||
/* ────────────────────────────────────────────────────────────────
|
||||
令牌来源 design/tokens/(宣传页独立部署,按值内联;改动需同步)
|
||||
令牌层由 tokens.css 提供(design/tokens/ 经 export-tokens.mjs 生成,
|
||||
含 Outfit 字体内嵌,禁外部 CDN)。本页只允许 var(--*),禁字面色值
|
||||
(check-code 闸拦截)。深色板块用 data-theme="dark" 复用 dark 令牌。
|
||||
品牌硬规则:单一品牌蓝 · 无渐变 · 无 emoji · 波形动画仅录音中
|
||||
──────────────────────────────────────────────────────────────── */
|
||||
:root {
|
||||
--bg: #F6F7FA;
|
||||
--card: #FFFFFF;
|
||||
--surface-2: #F0F1F4;
|
||||
--surface-3: #E4E6EB;
|
||||
--border-1: #E4E6EB;
|
||||
--border-2: #D2D5DD;
|
||||
--text-1: #1B1E26;
|
||||
--text-2: #5A6072;
|
||||
--text-3: #A6ABB8;
|
||||
--accent: #4F6EF7;
|
||||
--accent-hover: #3D58DB;
|
||||
--accent-press: #2F44B3;
|
||||
--accent-soft: #EEF1FE;
|
||||
--accent-text: #3D58DB;
|
||||
--positive: #16A34A;
|
||||
--positive-soft: #DCFCE7;
|
||||
--warning: #E8890C;
|
||||
--warning-soft: #FCEFD7;
|
||||
--ink: #11131A;
|
||||
--ink-card: #171A22;
|
||||
--ink-border: #272C38;
|
||||
--ink-text-2: #9AA1B2;
|
||||
--overlay-bg: rgba(24, 26, 34, 0.95);
|
||||
--overlay-text: #F2F4FA;
|
||||
--overlay-text-2: #9AA3BD;
|
||||
--overlay-text-3: #646E8C;
|
||||
--shadow-overlay: 0 12px 36px rgba(10, 12, 18, 0.38);
|
||||
--shadow-card: 0 1px 3px rgba(17, 19, 26, 0.05);
|
||||
--shadow-window: 0 8px 28px rgba(17, 19, 26, 0.14);
|
||||
--ease-out: cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
--sans: -apple-system, BlinkMacSystemFont, "PingFang SC", "MiSans",
|
||||
"HarmonyOS Sans SC", "Microsoft YaHei", sans-serif;
|
||||
--latin: "Outfit", -apple-system, sans-serif;
|
||||
--mono: ui-monospace, "SF Mono", Menlo, monospace;
|
||||
/* 页面级别名 → 设计系统 token(仅别名,禁止写字面色值) */
|
||||
--bg: var(--bg-app);
|
||||
--card: var(--surface-card);
|
||||
--sans: var(--font-sans);
|
||||
--latin: var(--font-sans);
|
||||
--mono: var(--font-mono);
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; }
|
||||
html { scroll-behavior: smooth; }
|
||||
@@ -90,7 +62,7 @@ nav {
|
||||
}
|
||||
.btn-sm { height: 32px; padding: 0 14px; font-size: 13px; }
|
||||
.btn-lg { height: 48px; padding: 0 26px; font-size: 16px; border-radius: 999px; }
|
||||
.btn-primary { background: var(--accent); color: #fff; }
|
||||
.btn-primary { background: var(--accent); color: var(--text-on-accent); }
|
||||
.btn-primary:hover { background: var(--accent-hover); }
|
||||
.btn-primary:active { background: var(--accent-press); }
|
||||
.btn-secondary { background: var(--card); border-color: var(--border-2); color: var(--text-1); }
|
||||
@@ -154,7 +126,7 @@ nav {
|
||||
pointer-events: none;
|
||||
}
|
||||
.overlay.show { opacity: 1; translate: 0 0; }
|
||||
.wave { display: inline-flex; align-items: center; gap: 3px; height: 24px; color: #7C9BFF; }
|
||||
.wave { display: inline-flex; align-items: center; gap: 3px; height: 24px; color: var(--overlay-wave); }
|
||||
.wave i { display: block; width: 3px; border-radius: 2px; background: currentColor; }
|
||||
.recording .wave i { animation: wavebounce 0.9s cubic-bezier(0.45, 0, 0.2, 1) infinite alternate; }
|
||||
@keyframes wavebounce { from { transform: scaleY(0.35); } to { transform: scaleY(1); } }
|
||||
@@ -168,7 +140,7 @@ nav {
|
||||
|
||||
.micbar {
|
||||
margin-top: 64px; width: 100%; height: 56px; border: none; cursor: pointer;
|
||||
border-radius: 999px; background: var(--accent); color: #fff;
|
||||
border-radius: 999px; background: var(--accent); color: var(--text-on-accent);
|
||||
font-family: var(--sans); font-size: 16px; font-weight: 600;
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 9px;
|
||||
user-select: none; -webkit-user-select: none; touch-action: none;
|
||||
@@ -184,22 +156,22 @@ kbd {
|
||||
}
|
||||
|
||||
/* ── 指标带(墨色) ── */
|
||||
.metrics { background: var(--ink); color: #ECEEF4; border-bottom: 1px solid var(--ink-border); }
|
||||
.metrics { background: var(--gray-950); color: var(--text-1); border-bottom: 1px solid var(--border-1); }
|
||||
.metrics-grid { display: grid; grid-template-columns: repeat(3, 1fr); }
|
||||
.metric {
|
||||
padding: 44px 32px; text-align: center;
|
||||
border-left: 1px solid var(--ink-border);
|
||||
border-left: 1px solid var(--border-1);
|
||||
}
|
||||
.metric:first-child { border-left: none; }
|
||||
.metric .num {
|
||||
font-family: var(--latin); font-weight: 700;
|
||||
font-size: clamp(34px, 4.4vw, 52px); line-height: 1.1;
|
||||
}
|
||||
.metric .num small { font-size: 0.45em; font-weight: 600; color: var(--ink-text-2); margin-left: 2px; }
|
||||
.metric .label { margin-top: 8px; font-size: 14px; color: var(--ink-text-2); }
|
||||
.metric .num small { font-size: 0.45em; font-weight: 600; color: var(--text-2); margin-left: 2px; }
|
||||
.metric .label { margin-top: 8px; font-size: 14px; color: var(--text-2); }
|
||||
@media (max-width: 720px) {
|
||||
.metrics-grid { grid-template-columns: 1fr; }
|
||||
.metric { border-left: none; border-top: 1px solid var(--ink-border); padding: 28px 20px; }
|
||||
.metric { border-left: none; border-top: 1px solid var(--border-1); padding: 28px 20px; }
|
||||
.metric:first-child { border-top: none; }
|
||||
}
|
||||
|
||||
@@ -282,25 +254,23 @@ section { border-bottom: 1px solid var(--border-1); }
|
||||
@media (max-width: 820px) { .pricing { grid-template-columns: 1fr; } }
|
||||
|
||||
/* ── 尾部 CTA(墨色) ── */
|
||||
.cta { background: var(--ink); color: #ECEEF4; border-bottom: 1px solid var(--ink-border); }
|
||||
.cta { background: var(--gray-950); color: var(--text-1); border-bottom: 1px solid var(--border-1); }
|
||||
.cta-inner { padding: 110px 0; text-align: center; }
|
||||
.cta .logo-big { color: #5C77E8; }
|
||||
.cta .logo-big { color: var(--accent); }
|
||||
.cta h2 { margin-top: 26px; font-size: clamp(34px, 5vw, 56px); font-weight: 700; letter-spacing: -0.01em; }
|
||||
.cta p { margin-top: 14px; color: var(--ink-text-2); font-size: 16px; }
|
||||
.cta p { margin-top: 14px; color: var(--text-2); font-size: 16px; }
|
||||
.cta .btn { margin-top: 36px; }
|
||||
.cta .btn-primary { background: #5C77E8; }
|
||||
.cta .btn-primary:hover { background: #6E89F9; }
|
||||
|
||||
/* ── 页脚 ── */
|
||||
footer { background: var(--ink); color: var(--ink-text-2); }
|
||||
footer { background: var(--gray-950); color: var(--text-2); }
|
||||
.footer-inner {
|
||||
display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 16px;
|
||||
padding: 28px 0; font-size: 13px;
|
||||
}
|
||||
.footer-inner .brand { color: #ECEEF4; }
|
||||
.footer-inner .brand svg { color: #5C77E8; }
|
||||
.footer-inner .brand { color: var(--text-1); }
|
||||
.footer-inner .brand svg { color: var(--accent); }
|
||||
.footer-links { display: flex; gap: 22px; }
|
||||
.footer-links a:hover { color: #ECEEF4; }
|
||||
.footer-links a:hover { color: var(--text-1); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -367,7 +337,7 @@ footer { background: var(--ink); color: var(--ink-text-2); }
|
||||
</header>
|
||||
|
||||
<!-- ════════ 指标带 ════════ -->
|
||||
<div class="metrics">
|
||||
<div class="metrics" data-theme="dark">
|
||||
<div class="frame metrics-grid">
|
||||
<div class="metric reveal">
|
||||
<div class="num"><500<small>ms</small></div>
|
||||
@@ -489,7 +459,7 @@ footer { background: var(--ink); color: var(--ink-text-2); }
|
||||
</section>
|
||||
|
||||
<!-- ════════ 尾部 CTA ════════ -->
|
||||
<div class="cta">
|
||||
<div class="cta" data-theme="dark">
|
||||
<div class="frame cta-inner reveal">
|
||||
<svg class="logo-big" width="64" height="64" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round"><circle cx="24" cy="24" r="18"/><path d="M17 20.5v7"/><path d="M24 16.5v15"/><path d="M31 20.5v7"/></svg>
|
||||
<h2>从下一句话开始<br>用说的</h2>
|
||||
@@ -499,7 +469,7 @@ footer { background: var(--ink); color: var(--ink-text-2); }
|
||||
</div>
|
||||
|
||||
<!-- ════════ 页脚 ════════ -->
|
||||
<footer>
|
||||
<footer data-theme="dark">
|
||||
<div class="frame footer-inner">
|
||||
<a class="brand" href="#">
|
||||
<svg width="20" height="20" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round"><circle cx="24" cy="24" r="18"/><path d="M17 20.5v7"/><path d="M24 16.5v15"/><path d="M31 20.5v7"/></svg>
|
||||
|
||||
+159
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user