feat(design): design/ 真相源 + 防漂移静态闸(#25)
真相源整固: - design/index.html 登记簿:token 色板(var() 实时渲染)/ 组件登记卡 / 图标与跨端映射 / guidelines / ui_kits 单一入口,check-ds 强制登记 - design/icons.js 图标单源(Lucide 线条)+ icon-map.json 跨端映射表 (web/iOS/Android 三列,iOS 12 个 SF Symbol 全部收编登记) - components/core/Icon.jsx 单源图标渲染器;MicBar/RecognitionOverlay 接入 - tokens 新增 --brand-wechat(-press)(微信官方绿)与 --overlay-wave - fonts.css 弃 Google Fonts CDN,Outfit 可变字体自托管 (assets/outfit-latin.woff2,40KB;中国网络下 CDN 不可用会卡字体) - design/serve.mjs 零依赖预览服务器(登记簿与原型评审入口) codegen 强化(export-tokens.mjs): - 产物直写消费位置:ios/dudu/Shared/DuduTheme.swift、 android/.../design/DuduTheme.kt——删除 design-pipeline/generated/ 中间拷贝层(此前 app 内是手工拷贝,--check 守不到真实消费文件已漂移) - 新增 web/tokens.css 产物(custom props 透传 + Outfit data URI 内嵌) - tokens 文件遍历排序,产物确定性 存量收编: - desktop:三个窗口内联 SVG → Icon 组件;tray 裸 rgba → token+opacity - Android:LoginScreen 微信绿常量 → DuduPalette.brandWechat(Press), Color.White → textOnAccent - 官网 web/index.html:接入 tokens.css,34 处硬编码 hex 全部 var() 化; 深色板块(metrics/cta/footer)改挂 data-theme="dark" 复用 dark 令牌; 移除 Google Fonts CDN - design 包内 8 处裸色:微信绿 token 化、装饰色 ds-ignore 白名单、 同值色改 var() 引用 防漂移闸(零依赖 Node): - design-pipeline/check-ds.mjs:真相源自检 7 道(dark⊆light / var 链 / 裸色∈tokens 值集 / 组件登记 / icons 唯一 / icon-map 双向同集 / 禁 emoji) - design-pipeline/check-code.mjs:代码侧单源(desktop 禁字面色+内联 path / iOS 禁字面色+SF Symbol 同集 / Android 禁 Color(0x / web 禁字面色), ds-ignore 行级豁免制 - .githooks/pre-commit(启用:git config core.hooksPath .githooks) - CI design job 扩展为三道闸全绿 - 反向验证通过:注入裸色/孤儿 token/未登记组件/未登记 symbol 均被拦截 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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