Files
jiu/client/tool/gen_tokens.mjs
wangjia 23a583918d feat(tools): 代码端真相源闸 check_ds_code + 外壳硬编码颜色清零
把原型 check-ds 的「单一真源」纪律延伸到 Flutter 代码:
- client/tool/check_ds_code.mjs:扫 lib/(排除.g.dart) 硬编码 Color(0x..)/Colors.具名色,
  --strict/--changed 报错挡提交,合理特例须显式 // ds-ignore: 理由
- tokens.css(单源,client+原型同步) 加 chrome 前景色 --brand-dot/--on-accent/--on-accent-faint,
  codegen 进 AppChrome;外壳 14 处硬编码(白字/品牌点/grace橙)全部改走 token
- 外壳+3 新 widget 现 0 违规;全量存量 172→158(其余屏后续逐步清)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSKEiHsvauyxYUW2itzUXX
2026-06-25 19:14:47 +08:00

158 lines
6.8 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// client/tool/gen_tokens.mjs
// 解析 token_source/tokens.css 的三套主题块 → 生成 app_tokens.g.dart。
// 用法:node tool/gen_tokens.mjs (在 client/ 下运行)
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const css = fs.readFileSync(path.join(ROOT, 'lib/core/theme/token_source/tokens.css'), 'utf8');
// CSS 变量名(kebab) → dart 字段(camel)。仅这些进 AppTokens。
const FIELDS = {
'page':'page','bg':'bg','surface':'surface','border':'border','border-subtle':'borderSubtle',
'text':'text','heading':'heading','title':'title','muted':'muted','faint':'faint',
'primary':'primary','primary-dark':'primaryDark','on-primary':'onPrimary','brand400':'brand400',
'brand50':'brand50','accent':'accent','success':'success','danger':'danger','warn':'warn',
'info-soft':'infoSoft','ok-soft':'okSoft','accent-soft':'accentSoft','success-bg':'successBg',
'danger-bg':'dangerBg','warn-bg':'warnBg','th-bg':'thBg','row-hover':'rowHover','shadow':'shadow',
'top-bg':'topBg','top-fg':'topFg','top-faint':'topFaint','top-border':'topBorder','top-control':'topControl',
'top-mark-bg':'topMarkBg','top-mark-fg':'topMarkFg','side-bg':'sideBg','side-fg':'sideFg',
'side-muted':'sideMuted','side-active-bg':'sideActiveBg','side-active-fg':'sideActiveFg',
'side-border':'sideBorder','side-hover':'sideHover','menu-user-bg':'menuUserBg',
'menu-user-fg':'menuUserFg','menu-user-hover':'menuUserHover',
};
const DARK = { a:false, b:true, c:false };
function block(key){
const m = css.match(new RegExp(`\\[data-theme="${key}"\\]\\s*\\{([^}]*)\\}`));
if(!m) throw new Error('missing theme block: '+key);
const out = {};
for(const mm of m[1].matchAll(/--([a-z0-9-]+)\s*:\s*([^;]+);/g)) out[mm[1]] = mm[2].trim();
return out;
}
function toColor(raw){
let v = raw.trim();
if(v.startsWith('linear-gradient')){ const h = v.match(/#([0-9a-fA-F]{6})/); v = '#'+h[1]; } // 取首个 hex
let m = v.match(/^#([0-9a-fA-F]{6})$/);
if(m) return `Color(0xFF${m[1].toUpperCase()})`;
m = v.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+))?\s*\)$/);
if(m){ const a = Math.round((m[4]===undefined?1:parseFloat(m[4]))*255).toString(16).padStart(2,'0');
const hex = [m[1],m[2],m[3]].map(n=>(+n).toString(16).padStart(2,'0')).join(''); return `Color(0x${a}${hex.toUpperCase()})`; }
throw new Error('unsupported color value: '+raw);
}
function emit(key){
const b = block(key);
const lines = Object.entries(FIELDS).map(([css,dart])=>{
if(b[css]===undefined) throw new Error(`theme ${key} missing --${css}`);
return ` ${dart}: ${toColor(b[css])},`;
});
lines.push(` isDark: ${DARK[key]},`);
return `const AppTokens kTokens${key.toUpperCase()} = AppTokens(\n${lines.join('\n')}\n);`;
}
const body = `// GENERATED by tool/gen_tokens.mjs — DO NOT EDIT.
// 源:lib/core/theme/token_source/tokens.css(从原型同步)。改色请改源后重跑脚本。
import 'package:flutter/material.dart';
import 'app_tokens.dart';
${emit('a')}
${emit('b')}
${emit('c')}
AppTokens appTokensOf(String key) {
switch (key) {
case 'b': return kTokensB;
case 'c': return kTokensC;
default: return kTokensA;
}
}
`;
fs.writeFileSync(path.join(ROOT, 'lib/core/theme/app_tokens.g.dart'), body);
console.log('generated app_tokens.g.dart');
// ── 主题无关标量(间距/圆角/字号):解析 :root{} 共享块 → AppDims ──
function rootBlock(){
// 匹配 ":root{" 而非 ":root[data-theme=...]"(前者 { 紧跟)
const m = css.match(/:root\s*\{([^}]*)\}/);
if(!m) throw new Error('missing :root shared block');
const out = {};
for(const mm of m[1].matchAll(/--([a-z0-9-]+)\s*:\s*([^;]+);/g)) out[mm[1]] = mm[2].trim();
return out;
}
// kebab → camelsp-1→sp1 · r-sm→rSm · fs-h1→fsH1 · fs-display→fsDisplay
function camel(name){
const [h, ...rest] = name.split('-');
return h + rest.map(s => s.charAt(0).toUpperCase() + s.slice(1)).join('');
}
function px(raw){
const m = raw.trim().match(/^([\d.]+)px$/);
if(!m) throw new Error('unsupported dim value: ' + raw);
return m[1];
}
function emitDims(){
const r = rootBlock();
const groups = [
['间距', k => k.startsWith('sp-')],
['圆角', k => k.startsWith('r-')],
['字号', k => k.startsWith('fs-')],
];
const lines = [];
for(const [label, pick] of groups){
const keys = Object.keys(r).filter(pick);
if(!keys.length) continue;
lines.push(` // ${label}`);
for(const k of keys) lines.push(` static const double ${camel(k)} = ${px(r[k])};`);
}
return lines.join('\n');
}
const dimsBody = `// GENERATED by tool/gen_tokens.mjs — DO NOT EDIT.
// 源:lib/core/theme/token_source/tokens.css:root 共享块,主题无关)。改尺寸请改源后重跑脚本。
// 间距/圆角/字号三套主题统一,故为 const 标量,不进 per-theme AppTokens。
class AppDims {
AppDims._();
${emitDims()}
}
`;
fs.writeFileSync(path.join(ROOT, 'lib/core/theme/app_dims.g.dart'), dimsBody);
console.log('generated app_dims.g.dart');
// ── 主题无关「chrome 色」::root 里固定不随主题切换的颜色 → AppChrome 常量类 ──
// 底部状态栏(深石板灰绿条)、品牌标识色(logo/字标)、遮罩/玻璃叠加、Toast 深底。
// 外壳(状态栏/品牌)要用,但不属任何主题,故不进 per-theme AppTokens。
const CHROME = {
// 状态栏(底部 chrome 条)
'statusbar-bg':'statusbarBg','statusbar-fg':'statusbarFg','statusbar-muted':'statusbarMuted',
'statusbar-strong':'statusbarStrong','statusbar-div':'statusbarDiv','statusbar-ok':'statusbarOk',
// 品牌标识色(固定不随主题)
'brand-deep':'brandDeep','brand-mid':'brandMid','brand-wine':'brandWine','brand-dot':'brandDot',
// 彩色底上的固定前景白(横幅/徽章/品牌弹窗 chrome 前景)
'on-accent':'onAccent','on-accent-faint':'onAccentFaint',
// 遮罩 / 玻璃叠加
'scrim':'scrim','hero-glass':'heroGlass','hero-glass-strong':'heroGlassStrong',
// Toast 深底
'toast-bg':'toastBg',
};
function emitChrome(){
const r = rootBlock();
return Object.entries(CHROME).map(([css,dart])=>{
if(r[css]===undefined) throw new Error(`:root missing --${css}`);
return ` static const Color ${dart} = ${toColor(r[css])};`;
}).join('\n');
}
const chromeBody = `// GENERATED by tool/gen_tokens.mjs — DO NOT EDIT.
// 源:lib/core/theme/token_source/tokens.css:root 共享块的主题无关颜色)。改色请改源后重跑脚本。
// 外壳 chrome 色:状态栏/品牌/遮罩/Toast,固定不随 A/B/C 主题切换。
import 'package:flutter/material.dart';
class AppChrome {
AppChrome._();
${emitChrome()}
}
`;
fs.writeFileSync(path.join(ROOT, 'lib/core/theme/app_chrome.g.dart'), chromeBody);
console.log('generated app_chrome.g.dart');