Files
pangolin/design/codegen/gen_flutter_tokens.mjs
wangjia 805bf8e2ca feat(ds-flow): Phase 1.1-1.2 — 原型单源目录 + tokens.css 迁为真源
- design/prototype/serve.mjs:零依赖热重载预览服务器(照搬 jiu,check-ds
  存在性守护,Phase 5 前静默跳过)
- design/prototype/tokens.css:token 真源(现结构已满足 ds-flow 的
  base :root + [data-theme=dark],字节迁移保证 codegen 零 diff)
- design/colors_and_type.css:退化为薄 @import 别名(供历史 preview/*.html
  浏览器内引用;codegen 不再读它)
- 三个 codegen(Flutter gen_flutter_tokens / website+usercenter build-tokens)
  + drift 检查全部重指向 prototype/tokens.css
- 生成产物 diff 仅头注释源路径行,token 数值零变化(已验证)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 00:00:38 +08:00

249 lines
11 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.
#!/usr/bin/env node
/**
* gen_flutter_tokens.mjs — CSS token → Dart codegen
*
* 唯一真相源:design/prototype/tokens.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, '../prototype/tokens.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/prototype/tokens.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/prototype/tokens.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} 字节)`);