805bf8e2ca
- 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>
249 lines
11 KiB
JavaScript
249 lines
11 KiB
JavaScript
#!/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 → 0xFFRRGGBB(Dart 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 double(root 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} 字节)`);
|