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:
wangjia
2026-07-11 00:05:22 +08:00
parent 4aec0ef6e9
commit 208d0cda12
36 changed files with 1014 additions and 494 deletions
Binary file not shown.
+13
View File
@@ -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;
+25
View File
@@ -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}
/>
);
}
+1 -1
View File
@@ -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); }
+3 -9
View File
@@ -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>
) : (
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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>
+16
View File
@@ -0,0 +1,16 @@
{
"$schema": "语义图标 → 各端实现的映射表(真相源)。web = design/icons.js 的 keyios = 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 }
}
+36
View File
@@ -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"/>',
};
/** 品牌 mark48 网格、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>`
);
}
+213
View File
@@ -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/&lt;&gt;/X.jsx</code>(+ .d.ts)并在本页「组件登记」加卡,再到各端实现。</div>
<div class="rule">新增<b>图标</b>:先登记 <code>icons.js</code>Lucide 24 网格 2px 圆头)+ <code>icon-map.json</code> 跨端映射,业务代码禁止内联 &lt;path&gt;</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>iOSSwiftUI</td><td><code>DuduTheme.*</code> 常量(codegen 直写)</td><td><code>ios/dudu/Shared/DuduTheme.swift</code></td></tr>
<tr><td>AndroidCompose</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>&lt;link href="tokens.css"&gt;</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 → textLight/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>webicons.js</th><th>iOSSF 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>
+96
View File
@@ -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();
});
+9
View File
@@ -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
View File
@@ -1,6 +1,14 @@
/* dudu · 品牌字体
OutfitGoogle Fonts CDN)— 圆润几何无衬线,用于品牌拉丁字
"dudu"、数字与价格;中文 UI 文本走系统字体栈(PingFang SC /
Outfit(圆润几何无衬线,可变字重 400-700latin 子集)——用于品牌
拉丁字 "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.woff240KB),禁外部 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');
}
+4 -4
View File
@@ -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 (
+1 -1
View File
@@ -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>
);
};
+1 -1
View File
@@ -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} /> 微信一键登录
+2 -2
View File
@@ -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>