From b04cef3cc44bbc44e1b20f0c5a02a492be1c8a8a Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Mon, 15 Jun 2026 18:27:22 +0800 Subject: [PATCH] =?UTF-8?q?chore(design):=20=E5=BB=BA=E7=AB=8B=20token=20?= =?UTF-8?q?=E5=8D=95=E6=BA=90=E7=AE=A1=E7=BA=BF=EF=BC=8C=E5=88=A0=E9=99=A4?= =?UTF-8?q?=20design/flutter=20fork?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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 --- CLAUDE.md | 20 ++ client/lib/pangolin_theme.dart | 98 ++------- client/lib/pangolin_tokens.gen.dart | 94 ++++++++ design/SKILL.md | 8 +- design/codegen/gen_flutter_tokens.mjs | 248 ++++++++++++++++++++++ web/usercenter/package.json | 3 + web/usercenter/public/colors_and_type.css | 4 +- web/usercenter/scripts/build-tokens.mjs | 42 ++++ web/website/src/styles/tokens.gen.css | 2 +- 9 files changed, 427 insertions(+), 92 deletions(-) create mode 100644 client/lib/pangolin_tokens.gen.dart create mode 100644 design/codegen/gen_flutter_tokens.mjs create mode 100644 web/usercenter/scripts/build-tokens.mjs diff --git a/CLAUDE.md b/CLAUDE.md index ce59737..2a890c3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` 渲染。 diff --git a/client/lib/pangolin_theme.dart b/client/lib/pangolin_theme.dart index 1102669..ef5b738 100644 --- a/client/lib/pangolin_theme.dart +++ b/client/lib/pangolin_theme.dart @@ -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()!; } -/// ── 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 sm = [BoxShadow(color: _tint.withOpacity(0.06), blurRadius: 2, offset: const Offset(0, 1))]; - static List md = [BoxShadow(color: _tint.withOpacity(0.08), blurRadius: 14, offset: const Offset(0, 4))]; - static List lg = [BoxShadow(color: _tint.withOpacity(0.12), blurRadius: 32, offset: const Offset(0, 12))]; - static List xl = [BoxShadow(color: _tint.withOpacity(0.16), blurRadius: 60, offset: const Offset(0, 24))]; -} - /// ── Ready-to-use ThemeData ────────────────────────────────────────── class PangolinTheme { PangolinTheme._(); diff --git a/client/lib/pangolin_tokens.gen.dart b/client/lib/pangolin_tokens.gen.dart new file mode 100644 index 0000000..d45df1f --- /dev/null +++ b/client/lib/pangolin_tokens.gen.dart @@ -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 sm = [BoxShadow(color: _tint.withValues(alpha: 0.06), blurRadius: 2, offset: const Offset(0, 1))]; + static List md = [BoxShadow(color: _tint.withValues(alpha: 0.08), blurRadius: 14, offset: const Offset(0, 4))]; + static List lg = [BoxShadow(color: _tint.withValues(alpha: 0.12), blurRadius: 32, offset: const Offset(0, 12))]; + static List xl = [BoxShadow(color: _tint.withValues(alpha: 0.16), blurRadius: 60, offset: const Offset(0, 24))]; +} diff --git a/design/SKILL.md b/design/SKILL.md index 897db15..d676f42 100644 --- a/design/SKILL.md +++ b/design/SKILL.md @@ -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 内支付。 diff --git a/design/codegen/gen_flutter_tokens.mjs b/design/codegen/gen_flutter_tokens.mjs new file mode 100644 index 0000000..f37c3f1 --- /dev/null +++ b/design/codegen/gen_flutter_tokens.mjs @@ -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 */ +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 px px rgba(r,g,b,) + 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 ${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} 字节)`); diff --git a/web/usercenter/package.json b/web/usercenter/package.json index b1477bb..0a76542 100644 --- a/web/usercenter/package.json +++ b/web/usercenter/package.json @@ -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", diff --git a/web/usercenter/public/colors_and_type.css b/web/usercenter/public/colors_and_type.css index 3b1b48b..de641ca 100644 --- a/web/usercenter/public/colors_and_type.css +++ b/web/usercenter/public/colors_and_type.css @@ -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; diff --git a/web/usercenter/scripts/build-tokens.mjs b/web/usercenter/scripts/build-tokens.mjs new file mode 100644 index 0000000..b4e2add --- /dev/null +++ b/web/usercenter/scripts/build-tokens.mjs @@ -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 通过 引入此文件)。 + * + * 与 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)`); diff --git a/web/website/src/styles/tokens.gen.css b/web/website/src/styles/tokens.gen.css index 43d106b..bc4b980 100644 --- a/web/website/src/styles/tokens.gen.css +++ b/web/website/src/styles/tokens.gen.css @@ -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;