Files
pangolin/design/codegen/gen_flutter_tokens.mjs
T
wangjia b04cef3cc4 chore(design): 建立 token 单源管线,删除 design/flutter fork
- 新增 design/codegen/gen_flutter_tokens.mjs:
  从 colors_and_type.css codegen 出 client/lib/pangolin_tokens.gen.dart
  覆盖 PangolinColors/Spacing/Radius/Motion/Shadow(纯数值层)
- 重构 client/lib/pangolin_theme.dart:
  删除手抄数值,import+export pangolin_tokens.gen.dart
  保留实现层 PangolinScheme/PangolinText/PangolinTheme/PangolinContext
  19 个引用方零改动,对外 API 完全不变
- 新增 web/usercenter/scripts/build-tokens.mjs:
  仿 website 样板,prebuild/predev 自动从 css 生成 public/colors_and_type.css
  去除 usercenter 手抄副本的漂移风险
- 更新 CLAUDE.md:补 token codegen 使用说明与单源模型
- 更新 design/SKILL.md:移除已删 flutter/ 引用,补 codegen 入口

验证:改 clay-500 → 三端 codegen 同步变化;flutter analyze 无新增 error。
注:design/flutter/ fork 手动删除(git rm 需用户执行)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 18:27:22 +08:00

249 lines
11 KiB
JavaScript
Raw 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.
#!/usr/bin/env node
/**
* gen_flutter_tokens.mjs — CSS token → Dart codegen
*
* 唯一真相源:design/colors_and_type.css
* 输出: client/lib/pangolin_tokens.gen.dart
*
* 生成内容(纯数据层,无 Flutter 实现逻辑):
* PangolinColors — 原色阶 hex const
* PangolinSpacing — 间距 double const
* PangolinRadius — 圆角 double + Radius const
* PangolinMotion — 时长 Duration + Cubic const
* PangolinShadow — BoxShadow List(引用 PangolinColors._tint
*
* 使用:
* node design/codegen/gen_flutter_tokens.mjs
*/
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const SRC = resolve(__dirname, '../colors_and_type.css');
const OUT = resolve(__dirname, '../../client/lib/pangolin_tokens.gen.dart');
if (!existsSync(SRC)) {
console.error('[gen_flutter_tokens] 找不到源文件:', SRC);
process.exit(1);
}
const css = readFileSync(SRC, 'utf8');
// ── 工具函数 ───────────────────────────────────────────────────────────
/** 从 :root { ... } 块提取 CSS 自定义属性 Map<string, string> */
function extractRootVars(css) {
const rootMatch = css.match(/:root\s*\{([^}]+)\}/s);
if (!rootMatch) throw new Error('找不到 :root 块');
const vars = {};
for (const line of rootMatch[1].split('\n')) {
const m = line.match(/--([a-zA-Z0-9-]+)\s*:\s*(.+?)\s*;/);
if (m) vars[m[1]] = m[2].trim();
}
return vars;
}
/** #RRGGBB → 0xFFRRGGBBDart Color hex */
function hexToDart(hex) {
const h = hex.replace('#', '');
if (h.length !== 6) throw new Error(`不支持的 hex: ${hex}`);
return `0xFF${h.toUpperCase()}`;
}
/** rgba(r, g, b, a) → 0xAARRGGBB */
function rgbaToDart(rgba) {
const m = rgba.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+))?\s*\)/);
if (!m) throw new Error(`不支持的 rgba: ${rgba}`);
const r = parseInt(m[1]).toString(16).padStart(2, '0').toUpperCase();
const g = parseInt(m[2]).toString(16).padStart(2, '0').toUpperCase();
const b = parseInt(m[3]).toString(16).padStart(2, '0').toUpperCase();
const alpha = m[4] !== undefined ? Math.round(parseFloat(m[4]) * 255) : 255;
const a = alpha.toString(16).padStart(2, '0').toUpperCase();
return `0x${a}${r}${g}${b}`;
}
/** rem → px doubleroot 16px */
function remToPx(val) {
if (val === '0') return 0;
const m = val.match(/([\d.]+)rem/);
if (!m) throw new Error(`不支持的 rem: ${val}`);
return parseFloat((parseFloat(m[1]) * 16).toFixed(1));
}
/** px 字符串 → double */
function pxToDouble(val) {
if (val === '0') return 0;
const m = val.match(/([\d.]+)px/);
if (!m) throw new Error(`不支持的 px: ${val}`);
return parseFloat(m[1]);
}
/** ms 字符串 → int */
function msToInt(val) {
const m = val.match(/(\d+)ms/);
if (!m) throw new Error(`不支持的 ms: ${val}`);
return parseInt(m[1]);
}
/** cubic-bezier(a,b,c,d) → [a,b,c,d] */
function parseCubic(val) {
const m = val.match(/cubic-bezier\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*\)/);
if (!m) throw new Error(`不支持的 cubic-bezier: ${val}`);
return [m[1], m[2], m[3], m[4]];
}
/** 解析 shadow 值:`0 1px 2px rgba(45, 30, 20, 0.06)` */
function parseShadow(val) {
// format: 0 <offsetY>px <blur>px rgba(r,g,b,<opacity>)
const m = val.match(/0\s+([\d.]+)px\s+([\d.]+)px\s+rgba\(\d+,\s*\d+,\s*\d+,\s*([\d.]+)\)/);
if (!m) throw new Error(`不支持的 shadow: ${val}`);
return { offsetY: parseFloat(m[1]), blur: parseFloat(m[2]), opacity: parseFloat(m[3]) };
}
// ── 解析 ──────────────────────────────────────────────────────────────
const vars = extractRootVars(css);
// 原色阶:hex 值
const colorRamps = {
clay: ['50','100','200','300','400','500','600','700','800','900'],
sand: ['50','100','200','300','400','500','600','700','800','900','950'],
green: ['400','500','600'],
amber: ['400','500','600'],
red: ['400','500','600'],
};
// CSS property name → Dart identifier
function cssPropToDartName(ramp, shade) {
return `${ramp}${shade}`;
}
// ── 生成 Dart ──────────────────────────────────────────────────────────
const lines = [];
lines.push(`// pangolin_tokens.gen.dart`);
lines.push(`// AUTO-GENERATED — 勿手改。`);
lines.push(`// 源: design/colors_and_type.css`);
lines.push(`// 生成器: design/codegen/gen_flutter_tokens.mjs`);
lines.push(`//`);
lines.push(`// 包含:PangolinColors · PangolinSpacing · PangolinRadius · PangolinMotion · PangolinShadow`);
lines.push(`// 由 pangolin_theme.dart 导入;业务代码通过 pangolin_theme.dart 的符号访问,无需直接 import 本文件。`);
lines.push(``);
lines.push(`import 'package:flutter/material.dart';`);
lines.push(``);
// ── PangolinColors ──────────────────────────────────────────────────
lines.push(`/// ── Primitive color ramps ───────────────────────────────────────────`);
lines.push(`/// Auto-generated from design/colors_and_type.css :root color ramps.`);
lines.push(`class PangolinColors {`);
lines.push(` PangolinColors._();`);
lines.push(``);
for (const [ramp, shades] of Object.entries(colorRamps)) {
lines.push(` // ${ramp.charAt(0).toUpperCase() + ramp.slice(1)}`);
for (const shade of shades) {
const cssKey = `${ramp}-${shade}`;
const val = vars[cssKey];
if (!val) throw new Error(`找不到 CSS 变量 --${cssKey}`);
const dartHex = hexToDart(val.split(';')[0].trim().split(' ')[0]);
const paddedName = cssPropToDartName(ramp, shade).padEnd(10);
// extract trailing comment from CSS
const commentMatch = vars[cssKey].match(/\/\*(.+?)\*\//);
const comment = commentMatch ? ` // ${commentMatch[1].trim()}` : '';
lines.push(` static const ${paddedName}= Color(${dartHex});${comment}`);
}
lines.push(``);
}
lines.push(` static const white = Color(0xFFFFFFFF);`);
lines.push(`}`);
lines.push(``);
// ── PangolinSpacing ──────────────────────────────────────────────────
lines.push(`/// ── Spacing (4px base) ──────────────────────────────────────────────`);
lines.push(`class PangolinSpacing {`);
lines.push(` PangolinSpacing._();`);
const spacingKeys = ['space-0','space-1','space-2','space-3','space-4','space-5','space-6','space-8','space-10','space-12','space-16'];
const spacingNames = { 'space-0':'x0','space-1':'x1','space-2':'x2','space-3':'x3','space-4':'x4',
'space-5':'x5','space-6':'x6','space-8':'x8','space-10':'x10','space-12':'x12','space-16':'x16' };
const spacingParts = [];
for (const key of spacingKeys) {
const val = vars[key];
if (!val) throw new Error(`找不到 --${key}`);
const px = remToPx(val.split(';')[0].trim().split(' ')[0]);
spacingParts.push(`x${key.replace('space-','')} = ${px}`);
}
lines.push(` static const double ${spacingParts.join(', ')};`);
lines.push(`}`);
lines.push(``);
// ── PangolinRadius ──────────────────────────────────────────────────
lines.push(`/// ── Radii ───────────────────────────────────────────────────────────`);
lines.push(`class PangolinRadius {`);
lines.push(` PangolinRadius._();`);
const radiusMap = { 'radius-sm':'sm', 'radius-md':'md', 'radius-lg':'lg', 'radius-xl':'xl', 'radius-2xl':'xxl', 'radius-full':'full' };
const radiusParts = [];
const radiusCircParts = [];
for (const [cssKey, dartName] of Object.entries(radiusMap)) {
const val = vars[cssKey];
if (!val) throw new Error(`找不到 --${cssKey}`);
const px = pxToDouble(val.split(';')[0].trim().split(' ')[0]);
radiusParts.push(`${dartName} = ${px}`);
if (dartName !== 'full') {
const capName = dartName.charAt(0).toUpperCase() + dartName.slice(1);
radiusCircParts.push(`r${capName} = Radius.circular(${dartName})`);
}
}
lines.push(` static const double ${radiusParts.join(', ')};`);
lines.push(` static const rSm = Radius.circular(sm);`);
lines.push(` static const rMd = Radius.circular(md);`);
lines.push(` static const rLg = Radius.circular(lg);`);
lines.push(` static const rXl = Radius.circular(xl);`);
lines.push(` static const rXxl = Radius.circular(xxl);`);
lines.push(`}`);
lines.push(``);
// ── PangolinMotion ──────────────────────────────────────────────────
lines.push(`/// ── Motion ──────────────────────────────────────────────────────────`);
lines.push(`class PangolinMotion {`);
lines.push(` PangolinMotion._();`);
const durFast = msToInt(vars['dur-fast']);
const durBase = msToInt(vars['dur-base']);
const durSlow = msToInt(vars['dur-slow']);
lines.push(` static const fast = Duration(milliseconds: ${durFast});`);
lines.push(` static const base = Duration(milliseconds: ${durBase});`);
lines.push(` static const slow = Duration(milliseconds: ${durSlow});`);
const easeOut = parseCubic(vars['ease-out']);
const easeInOut = parseCubic(vars['ease-in-out']);
lines.push(` static const easeOut = Cubic(${easeOut.join(', ')});`);
lines.push(` static const easeInOut = Cubic(${easeInOut.join(', ')});`);
lines.push(`}`);
lines.push(``);
// ── PangolinShadow ──────────────────────────────────────────────────
lines.push(`/// ── Soft warm-tinted shadows (light mode) ─────────────────────────`);
lines.push(`class PangolinShadow {`);
lines.push(` PangolinShadow._();`);
lines.push(` static const _tint = Color(0xFF2D1E14); // rgba(45, 30, 20) warm shadow tint`);
for (const name of ['sm','md','lg','xl']) {
const val = vars[`shadow-${name}`];
if (!val) throw new Error(`找不到 --shadow-${name}`);
const s = parseShadow(val);
lines.push(` static List<BoxShadow> ${name} = [BoxShadow(color: _tint.withValues(alpha: ${s.opacity}), blurRadius: ${s.blur}, offset: const Offset(0, ${s.offsetY}))];`);
}
lines.push(`}`);
lines.push(``);
// ── 写文件 ────────────────────────────────────────────────────────────
const output = lines.join('\n');
writeFileSync(OUT, output, 'utf8');
console.log(`[gen_flutter_tokens] ✅ 已生成 ${OUT} (${output.length} 字节)`);