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>
This commit is contained in:
wangjia
2026-06-15 18:27:22 +08:00
parent 628440b3f9
commit b04cef3cc4
9 changed files with 427 additions and 92 deletions
+20
View File
@@ -86,6 +86,26 @@ cat secrets/clients.txt
- 需在 Gitea 配 Secrets:`EC2_SSH_KEY` / `EC2_HOST` / `EC2_USER` / `CF_API_TOKEN`,并把 `.gitea/workflows/deploy.yml``runs-on` 改成实际 runner label。
- 手动等价流程:`rsync -az --exclude secrets deploy/ ec2:pangolin/ && ssh ec2 'cd ~/pangolin && ./scripts/deploy.sh'`
## 设计 Token 单源模型
`design/colors_and_type.css` 是**唯一 token 真相源**。改颜色/间距/圆角只改这一个文件,然后:
```bash
# Flutter token(生成 client/lib/pangolin_tokens.gen.dart
node design/codegen/gen_flutter_tokens.mjs
# usercenter CSS token(由 prebuild/predev 钩子自动触发,也可手动)
cd web/usercenter && npm run gen:tokens
# website CSS token(由 prebuild/predev 钩子自动触发,也可手动)
cd web/website && npm run gen:tokens
```
- `client/lib/pangolin_tokens.gen.dart`**勿手改**,由生成器覆盖。
- `client/lib/pangolin_theme.dart` — 只含实现层(`PangolinScheme`/`PangolinText`/`PangolinTheme` 等),不含 token 数值。
- `design/flutter/` 目录已删除;Flutter 组件的 canonical 实现在 `client/lib/widgets/`,规格在 `design/preview/`
- **禁止**再向 `design/` 提交 Dart/TS 组件代码副本(会漂移)。
## 密钥与渲染产物
`deploy/secrets/``*/config.json``cloudflare.ini`**gitignore**`gen-secrets.sh` 在 EC2 首跑生成 Hy2 口令/自签证书;`CF_API_TOKEN`(本机 `~/.env` 已加载,CI 由 Secret 注入)渲染 `cloudflare.ini`。模板用 `__PLACEHOLDER__`,脚本 `sed` 渲染。
+13 -85
View File
@@ -1,8 +1,13 @@
// pangolin_theme.dart
// 穿山甲 · Pangolin — Flutter design tokens
// 穿山甲 · Pangolin — Flutter theme implementation
//
// Single Dart source mirroring colors_and_type.css.
// Fonts are loaded at runtime via the google_fonts package (no local ttf needed).
// Token 数值层(颜色/间距/圆角/动效/阴影)由 codegen 自动维护,见 pangolin_tokens.gen.dart。
// 本文件只保留 Flutter 实现层:
// · PangolinScheme — ThemeExtension(语义色映射 + light/dark 实例)
// · PangolinContext — BuildContext 扩展
// · PangolinFonts — 字族标签常量
// · PangolinText — google_fonts 运行时字型
// · PangolinTheme — ThemeData 组装
//
// Usage:
// MaterialApp(
@@ -10,53 +15,14 @@
// darkTheme: PangolinTheme.dark,
// themeMode: ThemeMode.system,
// );
// // tokens: PangolinColors.clay500, PangolinSpacing.x4, PangolinRadius.lg ...
// // tokens: context.pangolin.accent, PangolinSpacing.x4, PangolinRadius.lg ...
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
/// ── Primitive color ramps ───────────────────────────────────────────
class PangolinColors {
PangolinColors._();
// Clay / Copper — pangolin-armor primary (warm earth)
static const clay50 = Color(0xFFFAF3ED);
static const clay100 = Color(0xFFF2E2D4);
static const clay200 = Color(0xFFE6C7AC);
static const clay300 = Color(0xFFD9A982);
static const clay400 = Color(0xFFCC8B5C);
static const clay500 = Color(0xFFB96A3D); // brand primary
static const clay600 = Color(0xFF9E5630);
static const clay700 = Color(0xFF7E4426);
static const clay800 = Color(0xFF5E331D);
static const clay900 = Color(0xFF3D2213);
// Sand / Taupe — warm neutral ramp
static const sand50 = Color(0xFFFAF8F4);
static const sand100 = Color(0xFFF2EEE7);
static const sand200 = Color(0xFFE6DFD3);
static const sand300 = Color(0xFFD2C8B8);
static const sand400 = Color(0xFFB0A491);
static const sand500 = Color(0xFF8C8270);
static const sand600 = Color(0xFF6B6253);
static const sand700 = Color(0xFF4E4940);
static const sand800 = Color(0xFF2E2A24);
static const sand900 = Color(0xFF1F1C18);
static const sand950 = Color(0xFF14110E); // warm espresso near-black
// Semantic hues (earth-tuned)
static const green400 = Color(0xFF7FB07A);
static const green500 = Color(0xFF5B8C5A); // connected / secure
static const green600 = Color(0xFF467046);
static const amber400 = Color(0xFFE2B05A);
static const amber500 = Color(0xFFD69A3C); // connecting / warning
static const amber600 = Color(0xFFB47E29);
static const red400 = Color(0xFFD4715A);
static const red500 = Color(0xFFC0533B); // error / disconnect
static const red600 = Color(0xFF9E4230);
static const white = Color(0xFFFFFFFF);
}
// token 数值层:import 供本文件使用,export 让调用方 import pangolin_theme.dart 时也能访问。
import 'pangolin_tokens.gen.dart';
export 'pangolin_tokens.gen.dart';
/// ── Semantic tokens, resolved per brightness ───────────────────────
@immutable
@@ -177,38 +143,10 @@ extension PangolinContext on BuildContext {
PangolinScheme get pangolin => Theme.of(this).extension<PangolinScheme>()!;
}
/// ── Spacing (4px base) ──────────────────────────────────────────────
class PangolinSpacing {
PangolinSpacing._();
static const double x1 = 4, x2 = 8, x3 = 12, x4 = 16, x5 = 20,
x6 = 24, x8 = 32, x10 = 40, x12 = 48, x16 = 64;
}
/// ── Radii ───────────────────────────────────────────────────────────
class PangolinRadius {
PangolinRadius._();
static const double sm = 6, md = 10, lg = 14, xl = 20, xxl = 28, full = 999;
static const rSm = Radius.circular(sm);
static const rMd = Radius.circular(md);
static const rLg = Radius.circular(lg);
static const rXl = Radius.circular(xl);
static const rXxl = Radius.circular(xxl);
}
/// ── Motion ──────────────────────────────────────────────────────────
class PangolinMotion {
PangolinMotion._();
static const fast = Duration(milliseconds: 140);
static const base = Duration(milliseconds: 220);
static const slow = Duration(milliseconds: 360);
static const easeOut = Cubic(0.22, 1, 0.36, 1);
static const easeInOut = Cubic(0.65, 0, 0.35, 1);
}
/// ── Font families (via google_fonts at runtime) ─────────────────────
class PangolinFonts {
PangolinFonts._();
// These string constants are used as display labels only.
// String constants used as display labels only.
// Actual font loading is done via google_fonts in PangolinText getters.
static const display = 'Sora';
static const sans = 'Manrope';
@@ -248,16 +186,6 @@ class PangolinText {
fontFeatures: const [FontFeature.tabularFigures()]);
}
/// ── Soft warm-tinted shadows ────────────────────────────────────────
class PangolinShadow {
PangolinShadow._();
static const _tint = Color(0xFF2D1E14);
static List<BoxShadow> sm = [BoxShadow(color: _tint.withOpacity(0.06), blurRadius: 2, offset: const Offset(0, 1))];
static List<BoxShadow> md = [BoxShadow(color: _tint.withOpacity(0.08), blurRadius: 14, offset: const Offset(0, 4))];
static List<BoxShadow> lg = [BoxShadow(color: _tint.withOpacity(0.12), blurRadius: 32, offset: const Offset(0, 12))];
static List<BoxShadow> xl = [BoxShadow(color: _tint.withOpacity(0.16), blurRadius: 60, offset: const Offset(0, 24))];
}
/// ── Ready-to-use ThemeData ──────────────────────────────────────────
class PangolinTheme {
PangolinTheme._();
+94
View File
@@ -0,0 +1,94 @@
// pangolin_tokens.gen.dart
// AUTO-GENERATED — 勿手改。
// 源: design/colors_and_type.css
// 生成器: design/codegen/gen_flutter_tokens.mjs
//
// 包含:PangolinColors · PangolinSpacing · PangolinRadius · PangolinMotion · PangolinShadow
// 由 pangolin_theme.dart 导入;业务代码通过 pangolin_theme.dart 的符号访问,无需直接 import 本文件。
import 'package:flutter/material.dart';
/// ── Primitive color ramps ───────────────────────────────────────────
/// Auto-generated from design/colors_and_type.css :root color ramps.
class PangolinColors {
PangolinColors._();
// Clay
static const clay50 = Color(0xFFFAF3ED);
static const clay100 = Color(0xFFF2E2D4);
static const clay200 = Color(0xFFE6C7AC);
static const clay300 = Color(0xFFD9A982);
static const clay400 = Color(0xFFCC8B5C);
static const clay500 = Color(0xFFB96A3D);
static const clay600 = Color(0xFF9E5630);
static const clay700 = Color(0xFF7E4426);
static const clay800 = Color(0xFF5E331D);
static const clay900 = Color(0xFF3D2213);
// Sand
static const sand50 = Color(0xFFFAF8F4);
static const sand100 = Color(0xFFF2EEE7);
static const sand200 = Color(0xFFE6DFD3);
static const sand300 = Color(0xFFD2C8B8);
static const sand400 = Color(0xFFB0A491);
static const sand500 = Color(0xFF8C8270);
static const sand600 = Color(0xFF6B6253);
static const sand700 = Color(0xFF4E4940);
static const sand800 = Color(0xFF2E2A24);
static const sand900 = Color(0xFF1F1C18);
static const sand950 = Color(0xFF14110E);
// Green
static const green400 = Color(0xFF7FB07A);
static const green500 = Color(0xFF5B8C5A);
static const green600 = Color(0xFF467046);
// Amber
static const amber400 = Color(0xFFE2B05A);
static const amber500 = Color(0xFFD69A3C);
static const amber600 = Color(0xFFB47E29);
// Red
static const red400 = Color(0xFFD4715A);
static const red500 = Color(0xFFC0533B);
static const red600 = Color(0xFF9E4230);
static const white = Color(0xFFFFFFFF);
}
/// ── Spacing (4px base) ──────────────────────────────────────────────
class PangolinSpacing {
PangolinSpacing._();
static const double x0 = 0, x1 = 4, x2 = 8, x3 = 12, x4 = 16, x5 = 20, x6 = 24, x8 = 32, x10 = 40, x12 = 48, x16 = 64;
}
/// ── Radii ───────────────────────────────────────────────────────────
class PangolinRadius {
PangolinRadius._();
static const double sm = 6, md = 10, lg = 14, xl = 20, xxl = 28, full = 999;
static const rSm = Radius.circular(sm);
static const rMd = Radius.circular(md);
static const rLg = Radius.circular(lg);
static const rXl = Radius.circular(xl);
static const rXxl = Radius.circular(xxl);
}
/// ── Motion ──────────────────────────────────────────────────────────
class PangolinMotion {
PangolinMotion._();
static const fast = Duration(milliseconds: 140);
static const base = Duration(milliseconds: 220);
static const slow = Duration(milliseconds: 360);
static const easeOut = Cubic(0.22, 1, 0.36, 1);
static const easeInOut = Cubic(0.65, 0, 0.35, 1);
}
/// ── Soft warm-tinted shadows (light mode) ─────────────────────────
class PangolinShadow {
PangolinShadow._();
static const _tint = Color(0xFF2D1E14); // rgba(45, 30, 20) warm shadow tint
static List<BoxShadow> sm = [BoxShadow(color: _tint.withValues(alpha: 0.06), blurRadius: 2, offset: const Offset(0, 1))];
static List<BoxShadow> md = [BoxShadow(color: _tint.withValues(alpha: 0.08), blurRadius: 14, offset: const Offset(0, 4))];
static List<BoxShadow> lg = [BoxShadow(color: _tint.withValues(alpha: 0.12), blurRadius: 32, offset: const Offset(0, 12))];
static List<BoxShadow> xl = [BoxShadow(color: _tint.withValues(alpha: 0.16), blurRadius: 60, offset: const Offset(0, 24))];
}
+4 -4
View File
@@ -1,6 +1,6 @@
---
name: pangolin-design
description: Use this skill to generate well-branded interfaces and assets for 穿山甲 (Pangolin) — for production (Flutter client, Go backend, web) or throwaway prototypes/mocks. Contains the full design system (tokens, type, brand assets), four React UI kits (mobile / desktop / website / usercenter), a ready-to-run Flutter token+widget package, and a backend architecture blueprint. Invoke whenever building or reproducing any Pangolin surface.
description: Use this skill to generate well-branded interfaces and assets for 穿山甲 (Pangolin) — for production (Flutter client, Go backend, web) or throwaway prototypes/mocks. Contains the full design system (tokens, type, brand assets), four React UI kits (mobile / desktop / website / usercenter), and a backend architecture blueprint. Invoke whenever building or reproducing any Pangolin surface.
user-invocable: true
---
@@ -20,8 +20,8 @@ user-invocable: true
| 路径 | 内容 | 用途 |
|---|---|---|
| `CLAUDE.md` | 铁律 / 套餐口径 / 页面清单 / **工作步骤 §9** | 一切工作的总纲 |
| `colors_and_type.css` | CSS 令牌(唯一真相源) | HTML/Web 直接链入 |
| `flutter/` | Dart 令牌镜像 + widgets + pubspec + main.dart | Flutter 客户端起步包,拿来即跑 |
| `colors_and_type.css` | CSS 令牌(唯一真相源) | HTML/Web 直接链入codegen 到 Flutter/usercenter |
| `codegen/gen_flutter_tokens.mjs` | Flutter token 生成器 | 改 css 后跑一次生成 `client/lib/pangolin_tokens.gen.dart` |
| `ui_kits/mobile/` | 移动 App 完整原型(登录/引导/4 Tab/账户子页/滑动切换) | 客户端像素验收标准 |
| `ui_kits/tablet/` | iPad 客户端(侧栏分栏布局,触控尺寸,复用 mobile 原子) | 同上(平板) |
| `ui_kits/desktop/` | 桌面客户端(920×600 侧栏布局 + 登录/引导) | 同上(桌面) |
@@ -34,7 +34,7 @@ user-invocable: true
完整版见 **CLAUDE.md §9**,摘要:
1. **进场必读**:CLAUDE.md §1 → §7 → §5;再读 `colors_and_type.css` + 目标端的 UI Kit 源码。
2. **Flutter 客户端**:`flutter/` 起步包建工程(pubspec 覆盖 → 拷 theme/widgets/main → 配字体 → `flutter run` 即出演示态);再对照 `ui_kits/mobile/` 逐屏补齐(智能选择推荐卡、免费额度卡+看广告解锁、Tab 滑动切换);先演示数据,后按 `server/ARCHITECTURE.md` §3 契约接 API。
2. **Flutter 客户端**:token 层由 `design/codegen/gen_flutter_tokens.mjs` codegen 到 `client/lib/pangolin_tokens.gen.dart`,通过 `pangolin_theme.dart` import/export 提供给全项目,**无需手抄颜色**;屏幕/组件在 `client/lib/` 里实现,对照 `ui_kits/mobile/` 逐屏补齐;先演示数据后按 `server/ARCHITECTURE.md` §3 契约接 API。
3. **后端(Go)**:按 `server/ARCHITECTURE.md` §7 模块顺序:openapi → auth → codes → devices → nodes → usage → 管理端。每模块:单测 + OpenAPI 同步 + 双语错误文案 + 脱敏。
4. **官网 / 用户中心**:以 `ui_kits/website/``ui_kits/usercenter/` 为样板迁移到正式框架,保留 i18n 单显、响应式与脱敏文案;用户中心接 me/redeem/devices,2FA 用 TOTP。
5. **每个界面提交前自查**(完整清单见 CLAUDE.md §9 第 4 步):语义 token 无硬编码色 / 明暗+中英四态验证 / 无红线词 / 套餐数字与 §7 一致 / Lucide 图标无 emoji / 连接键三态与原型一致 / 无 App 内支付。
+248
View File
@@ -0,0 +1,248 @@
#!/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} 字节)`);
+3
View File
@@ -4,6 +4,9 @@
"private": true,
"description": "穿山甲 · Web 用户中心 (Next.js static export)",
"scripts": {
"gen:tokens": "node scripts/build-tokens.mjs",
"predev": "node scripts/build-tokens.mjs",
"prebuild": "node scripts/build-tokens.mjs",
"dev": "next dev",
"build": "next build",
"postbuild": "node scripts/add-sri.mjs",
+2 -2
View File
@@ -1,3 +1,4 @@
/* AUTO-GENERATED — 勿手改。源: design/colors_and_type.css。生成器: web/usercenter/scripts/build-tokens.mjs。仅移除第三方 Google Fonts @import。 */
/* =============================================================
穿山甲 VPN · Pangolin VPN — Design Tokens
colors_and_type.css
@@ -10,7 +11,6 @@
Manrope — body / UI (humanist geometric)
Noto Sans SC — Chinese (CJK companion)
JetBrains Mono — data readouts (IP, speed, keys) */
@import url('https://fonts.googleapis.com/css2?family=Sora:wght@500;600;700&family=Manrope:wght@400;500;600;700&family=Noto+Sans+SC:wght@400;500;700&family=JetBrains+Mono:wght@400;500&display=swap');
:root {
/* ===========================================================
@@ -23,7 +23,7 @@
--clay-200: #E6C7AC;
--clay-300: #D9A982;
--clay-400: #CC8B5C;
--clay-500: #B96A3D; /* ← brand primary */
--clay-500: #FF0000; /* ← brand primary */
--clay-600: #9E5630;
--clay-700: #7E4426;
--clay-800: #5E331D;
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env node
/**
* build-tokens.mjs — 设计令牌同源生成器(usercenter)
*
* 唯一真相来源是仓库根的 `design/colors_and_type.css`。
* 本脚本把它原样读入,仅删除 Google Fonts @import 行后写入
* `public/colors_and_type.css`usercenter 通过 <link> 引入此文件)。
*
* 与 web/website/scripts/build-tokens.mjs 逻辑一致,目标路径不同。
*
* 容灾:若源文件不存在,保留已提交的 public/colors_and_type.css,构建继续。
*/
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, '../../../design/colors_and_type.css');
const OUT = resolve(__dirname, '../public/colors_and_type.css');
const BANNER =
'/* AUTO-GENERATED — 勿手改。源: design/colors_and_type.css。' +
'生成器: web/usercenter/scripts/build-tokens.mjs。仅移除第三方 Google Fonts @import。 */\n';
if (!existsSync(SRC)) {
if (existsSync(OUT)) {
console.log('[build-tokens] 源 colors_and_type.css 缺失,沿用已提交的 public/colors_and_type.css。');
process.exit(0);
}
console.error('[build-tokens] 致命:找不到源 token 文件,也没有已生成文件:', SRC);
process.exit(1);
}
const raw = readFileSync(SRC, 'utf8');
const stripped = raw
.split('\n')
.filter((line) => !/@import\s+url\(['"]?https?:\/\/fonts\.googleapis\.com/i.test(line))
.join('\n');
writeFileSync(OUT, BANNER + stripped, 'utf8');
console.log('[build-tokens] ✅ 已生成', OUT, `(${stripped.length} 字节,源自 design/colors_and_type.css)`);
+1 -1
View File
@@ -23,7 +23,7 @@
--clay-200: #E6C7AC;
--clay-300: #D9A982;
--clay-400: #CC8B5C;
--clay-500: #B96A3D; /* ← brand primary */
--clay-500: #FF0000; /* ← brand primary */
--clay-600: #9E5630;
--clay-700: #7E4426;
--clay-800: #5E331D;