feat(client): 字体可注入 + 启用 golden 视觉回归闸
- pangolin_theme: PangolinFonts 加 useBundled 开关 + sora/manrope/jetBrainsMono/ manropeTextTheme 封装(生产走 google_fonts / 测试走 bundled family); PangolinText 与 _build 改调封装,移除散落的 GoogleFonts 直接调用 - test/flutter_test_config.dart: 全测试前置,FontLoader 注册 test/fonts 真实 Sora/Manrope/JetBrainsMono + Lucide 图标字体,置 useBundled=true - 启用 test/golden(去 @Skip):连接键三态+推荐卡+额度卡×明暗 12 基准图 - tool/visual-diff.md: 标注 golden 严格闸已落地 测试: flutter test 76 通过(原 64+1skip → 76 无 skip), analyze 0 error Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@@ -143,15 +143,45 @@ extension PangolinContext on BuildContext {
|
||||
PangolinScheme get pangolin => Theme.of(this).extension<PangolinScheme>()!;
|
||||
}
|
||||
|
||||
/// ── Font families (via google_fonts at runtime) ─────────────────────
|
||||
/// ── Font families ───────────────────────────────────────────────────
|
||||
/// 生产用 google_fonts 运行时加载;测试态设 [useBundled]=true,改用本地
|
||||
/// FontLoader 注册的同名 family(避免 google_fonts 在无网/禁拉取时抛异常,
|
||||
/// 让 golden 截图回归可跑)。封装函数是 PangolinText/_build 的唯一字体入口。
|
||||
class PangolinFonts {
|
||||
PangolinFonts._();
|
||||
// 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';
|
||||
static const cjk = 'Noto Sans SC';
|
||||
static const mono = 'JetBrains Mono';
|
||||
|
||||
/// 测试钩子:true 时用 bundled family(test/fonts/ 经 FontLoader 注册),不走 google_fonts。
|
||||
static bool useBundled = false;
|
||||
|
||||
static TextStyle sora({double? fontSize, FontWeight? fontWeight, double? height, double? letterSpacing}) =>
|
||||
useBundled
|
||||
? TextStyle(fontFamily: display, fontSize: fontSize, fontWeight: fontWeight, height: height, letterSpacing: letterSpacing)
|
||||
: GoogleFonts.sora(fontSize: fontSize, fontWeight: fontWeight, height: height, letterSpacing: letterSpacing);
|
||||
|
||||
static TextStyle manrope(
|
||||
{double? fontSize, FontWeight? fontWeight, double? height, double? letterSpacing, List<FontFeature>? fontFeatures}) =>
|
||||
useBundled
|
||||
? TextStyle(
|
||||
fontFamily: sans,
|
||||
fontSize: fontSize,
|
||||
fontWeight: fontWeight,
|
||||
height: height,
|
||||
letterSpacing: letterSpacing,
|
||||
fontFeatures: fontFeatures)
|
||||
: GoogleFonts.manrope(
|
||||
fontSize: fontSize, fontWeight: fontWeight, height: height, letterSpacing: letterSpacing, fontFeatures: fontFeatures);
|
||||
|
||||
static TextStyle jetBrainsMono({double? fontSize, FontWeight? fontWeight, List<FontFeature>? fontFeatures}) =>
|
||||
useBundled
|
||||
? TextStyle(fontFamily: mono, fontSize: fontSize, fontWeight: fontWeight, fontFeatures: fontFeatures)
|
||||
: GoogleFonts.jetBrainsMono(fontSize: fontSize, fontWeight: fontWeight, fontFeatures: fontFeatures);
|
||||
|
||||
static TextTheme manropeTextTheme(TextTheme base) =>
|
||||
useBundled ? base.apply(fontFamily: sans) : GoogleFonts.manropeTextTheme(base);
|
||||
}
|
||||
|
||||
/// ── Type scale ──────────────────────────────────────────────────────
|
||||
@@ -161,27 +191,27 @@ class PangolinFonts {
|
||||
class PangolinText {
|
||||
PangolinText._();
|
||||
|
||||
static TextStyle get displayXl => GoogleFonts.sora(
|
||||
static TextStyle get displayXl => PangolinFonts.sora(
|
||||
fontSize: 48, fontWeight: FontWeight.w700, height: 1.15, letterSpacing: -0.96);
|
||||
static TextStyle get display => GoogleFonts.sora(
|
||||
static TextStyle get display => PangolinFonts.sora(
|
||||
fontSize: 36, fontWeight: FontWeight.w700, height: 1.15, letterSpacing: -0.72);
|
||||
static TextStyle get h1 => GoogleFonts.sora(
|
||||
static TextStyle get h1 => PangolinFonts.sora(
|
||||
fontSize: 30, fontWeight: FontWeight.w600, height: 1.3, letterSpacing: -0.3);
|
||||
static TextStyle get h2 => GoogleFonts.sora(
|
||||
static TextStyle get h2 => PangolinFonts.sora(
|
||||
fontSize: 24, fontWeight: FontWeight.w600, height: 1.3, letterSpacing: -0.24);
|
||||
static TextStyle get h3 => GoogleFonts.manrope(
|
||||
static TextStyle get h3 => PangolinFonts.manrope(
|
||||
fontSize: 20, fontWeight: FontWeight.w600, height: 1.3);
|
||||
static TextStyle get bodyLg => GoogleFonts.manrope(
|
||||
static TextStyle get bodyLg => PangolinFonts.manrope(
|
||||
fontSize: 18, fontWeight: FontWeight.w400, height: 1.65);
|
||||
static TextStyle get body => GoogleFonts.manrope(
|
||||
static TextStyle get body => PangolinFonts.manrope(
|
||||
fontSize: 16, fontWeight: FontWeight.w400, height: 1.5);
|
||||
static TextStyle get sm => GoogleFonts.manrope(
|
||||
static TextStyle get sm => PangolinFonts.manrope(
|
||||
fontSize: 14, fontWeight: FontWeight.w400, height: 1.5);
|
||||
static TextStyle get caption => GoogleFonts.manrope(
|
||||
static TextStyle get caption => PangolinFonts.manrope(
|
||||
fontSize: 12, fontWeight: FontWeight.w500, height: 1.5);
|
||||
static TextStyle get overline => GoogleFonts.manrope(
|
||||
static TextStyle get overline => PangolinFonts.manrope(
|
||||
fontSize: 12, fontWeight: FontWeight.w600, letterSpacing: 0.96);
|
||||
static TextStyle get mono => GoogleFonts.jetBrainsMono(
|
||||
static TextStyle get mono => PangolinFonts.jetBrainsMono(
|
||||
fontSize: 14, fontWeight: FontWeight.w400,
|
||||
fontFeatures: const [FontFeature.tabularFigures()]);
|
||||
}
|
||||
@@ -210,8 +240,8 @@ class PangolinTheme {
|
||||
brightness: b,
|
||||
colorScheme: scheme,
|
||||
scaffoldBackgroundColor: s.bg,
|
||||
// Use google_fonts Manrope as app-wide base font
|
||||
textTheme: GoogleFonts.manropeTextTheme(
|
||||
// Manrope app-wide base font(生产 google_fonts / 测试 bundled)
|
||||
textTheme: PangolinFonts.manropeTextTheme(
|
||||
ThemeData(brightness: b).textTheme,
|
||||
),
|
||||
extensions: [s],
|
||||
@@ -219,7 +249,7 @@ class PangolinTheme {
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: s.accent,
|
||||
foregroundColor: s.fgOnAccent,
|
||||
textStyle: GoogleFonts.manrope(fontWeight: FontWeight.w600, fontSize: 15),
|
||||
textStyle: PangolinFonts.manrope(fontWeight: FontWeight.w600, fontSize: 15),
|
||||
shape: const StadiumBorder(),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 22, vertical: 14),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// flutter_test_config.dart — 全测试前置(Flutter 自动应用于 test/ 下所有测试)。
|
||||
//
|
||||
// 启用 PangolinFonts.useBundled,并用 FontLoader 注册 test/fonts/ 的真实字体,
|
||||
// 让 widget/golden 测试用真字体渲染、且不触发 google_fonts 运行时拉取(避免抛异常)。
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pangolin_vpn/pangolin_theme.dart';
|
||||
|
||||
Future<void> testExecutable(FutureOr<void> Function() testMain) async {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
PangolinFonts.useBundled = true;
|
||||
await _load('Sora', const ['test/fonts/Sora-Regular.ttf', 'test/fonts/Sora-SemiBold.ttf', 'test/fonts/Sora-Bold.ttf']);
|
||||
await _load('Manrope', const [
|
||||
'test/fonts/Manrope-Regular.ttf',
|
||||
'test/fonts/Manrope-Medium.ttf',
|
||||
'test/fonts/Manrope-SemiBold.ttf',
|
||||
'test/fonts/Manrope-Bold.ttf',
|
||||
]);
|
||||
await _load('JetBrains Mono', const ['test/fonts/JetBrainsMono-Regular.ttf']);
|
||||
// Lucide 图标字体(family 须带 package 前缀以匹配 PangolinIcons 的 fontPackage)。
|
||||
await _load('packages/lucide_icons/Lucide', const ['packages/lucide_icons_patched/fonts/lucide.ttf']);
|
||||
await testMain();
|
||||
}
|
||||
|
||||
Future<void> _load(String family, List<String> paths) async {
|
||||
final loader = FontLoader(family);
|
||||
for (final p in paths) {
|
||||
final file = File(p);
|
||||
if (file.existsSync()) {
|
||||
final bytes = file.readAsBytesSync();
|
||||
loader.addFont(Future.value(ByteData.view(bytes.buffer, bytes.offsetInBytes, bytes.lengthInBytes)));
|
||||
}
|
||||
}
|
||||
await loader.load();
|
||||
}
|
||||
@@ -1,21 +1,9 @@
|
||||
// components_golden_test.dart — 关键组件 golden(明/暗两主题)
|
||||
//
|
||||
// 覆盖:连接键三态、智能选择推荐卡、免费额度卡。
|
||||
// 首次生成基准图:`flutter test --update-goldens test/golden`。
|
||||
//
|
||||
// TODO: 字体依赖待解决。
|
||||
// google_fonts 在测试环境中使用内部变体字体族名(如 `Manrope_400italic0`),
|
||||
// 难以通过 FontLoader 预加载。需要:
|
||||
// 1. 确认 google_fonts v6.3.3 各变体的实际族名
|
||||
// 2. 在 setUpAll 中用 FontLoader 加载 test/fonts/*.ttf
|
||||
// 3. 调用 `flutter test --update-goldens test/golden` 生成基准图
|
||||
// 在此之前,跳过 golden 测试以避免 CI 失败;
|
||||
// 组件行为已由 test/widget/ 覆盖。
|
||||
// ignore_for_file: directives_ordering
|
||||
@Skip('Golden tests need bundled fonts + baseline generation. '
|
||||
'See TODO above for setup steps.')
|
||||
library;
|
||||
|
||||
// 重新生成基准图:`flutter test --update-goldens test/golden`。
|
||||
// 字体由 test/flutter_test_config.dart 经 FontLoader 注册 + PangolinFonts.useBundled
|
||||
// 提供真实 Sora/Manrope/JetBrainsMono,无需 google_fonts 运行时拉取。
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pangolin_vpn/l10n/strings_zh.dart';
|
||||
|
||||
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 8.0 KiB After Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 9.9 KiB |
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 9.2 KiB |
@@ -26,10 +26,13 @@ node ~/.claude/skills/design-distill/tools/diff.mjs <baseline.png> <impl.png> <d
|
||||
|
||||
## 实现侧截图:两条路径与现状
|
||||
|
||||
### A. Flutter golden(推荐,CI 友好)— 当前被 google_fonts 阻塞
|
||||
`flutter test --update-goldens` 截 `RepaintBoundary`,无 chrome/窗口问题,可入 CI。
|
||||
**阻塞**:组件用 `GoogleFonts.*()`,测试环境 `allowRuntimeFetching=false` 会抛异常(`test/golden/components_golden_test.dart` 因此 `@Skip`)。
|
||||
**解法**:让字体可注入——把 `pangolin_theme.dart` 的 `PangolinText` / `GoogleFonts.*TextTheme` 改为可由测试用 `FontLoader` 注册的 family 覆盖(test/fonts/ 已备 Manrope/Sora/JetBrainsMono ttf),或测试态走 bundled 字体。改完即可启用 golden 做严格回归闸。
|
||||
### A. Flutter golden(已启用 ✅,CI 严格回归闸)
|
||||
`flutter test --update-goldens test/golden` 截 `RepaintBoundary`,无 chrome/窗口问题,入 CI。
|
||||
**字体方案**(已落地):`PangolinFonts.useBundled` 开关 + `test/flutter_test_config.dart` 用 `FontLoader`
|
||||
注册 `test/fonts/` 真实 Sora/Manrope/JetBrainsMono + Lucide 图标字体,测试态绕过 google_fonts。
|
||||
- 改组件视觉后:`flutter test test/golden` 失败即回归;确认是预期变更则 `--update-goldens` 重生成基准。
|
||||
- 现覆盖:连接键三态 + 推荐卡 + 额度卡 × 明暗(`test/golden/components_golden_test.dart`)。
|
||||
- 注:CJK 文案用 fallback(Noto Sans SC 未打包,体积大),基准自洽不影响回归检测;新增屏/组件按此模式扩展。
|
||||
|
||||
### B. screencapture 真实 app(已可截,但受限)
|
||||
真实渲染、字体真实,但:macOS 原生标题栏(红绿灯) vs 原型自绘 chrome 结构不同;窗口精确定位需「辅助功能」权限。
|
||||
|
||||