Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e569b83a7 | |||
| 0f05385995 | |||
| fc22300937 | |||
| 4a0c5c4921 | |||
| d2d75cd33c | |||
| a22db0874f | |||
| 29e225c63c | |||
| 14a9836882 | |||
| c4eb92fc2d | |||
| bb5acb6c1e | |||
| 39324c46a7 | |||
| f69696daae | |||
| 83fce75c8c | |||
| ad845328fb |
@@ -16,12 +16,19 @@ on:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
# runner 分配:
|
||||
# · runs-on: ubuntu-latest —— 在 catthehacker 容器里跑,仅用于纯 bash 扫描
|
||||
# (redline/cleartext/portable-sql);容器内**不能**嵌套 `docker run`(DinD 挂载
|
||||
# 失败,$PWD 在宿主不存在),故套 docker 的 job 不能用它。
|
||||
# · runs-on: nas —— host 模式(mac-pangolin-2 直接在宿主跑),`docker run` 是宿主
|
||||
# 真 docker(非嵌套),可正常拉/跑 node/golang/flutter/python/shellcheck 镜像。
|
||||
# golden 保留 ghcr.io/cirruslabs/flutter Linux 容器 → 与入库基线渲染一致。
|
||||
jobs:
|
||||
|
||||
# ── Job 1: Lint (shellcheck) ─────────────────────────────────────────────
|
||||
lint:
|
||||
name: Lint — shellcheck
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: nas
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -74,7 +81,7 @@ jobs:
|
||||
# ── Job 2: OpenAPI Sync Check ────────────────────────────────────────────
|
||||
openapi-check:
|
||||
name: OpenAPI Sync Check
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: nas
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -113,7 +120,7 @@ jobs:
|
||||
# ── Job 4: Flutter 客户端(分析 + 单测/组件测试)────────────────────────
|
||||
flutter-client:
|
||||
name: Flutter — analyze + test
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: nas
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -149,7 +156,7 @@ jobs:
|
||||
# (改了 design/colors_and_type.css 没重生成,或手改了生成物)。
|
||||
codegen-drift:
|
||||
name: Codegen Drift — token 生成物未漂移
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: nas
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -161,12 +168,28 @@ jobs:
|
||||
node:20 \
|
||||
bash ci/check-codegen-drift.sh
|
||||
|
||||
ds-flow:
|
||||
name: DS-flow — 原型/跨端同源/代码色单源闸
|
||||
runs-on: nas
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 原型校验(check-ds)
|
||||
run: docker run --rm -v "$PWD:/repo" -w /repo node:20 node design/prototype/tools/check-ds.mjs
|
||||
|
||||
- name: 跨端同源(check-l1-sync)
|
||||
run: docker run --rm -v "$PWD:/repo" -w /repo node:20 node tools/check-l1-sync.mjs
|
||||
|
||||
- name: Flutter 颜色单源(check_ds_code --strict)
|
||||
run: docker run --rm -v "$PWD/client:/app" -w /app node:20 node tool/check_ds_code.mjs --strict
|
||||
|
||||
# ── Job 7: Go 服务端(build + test:含契约快照 + sqlite 真库,跳过 integration)──
|
||||
# 此前 server 测试未进 CI;契约快照(支柱 2)等需在此守门。integration 测试走
|
||||
# -tags integration(需 docker 起 mysql/redis),见 go-integration job。
|
||||
go-server:
|
||||
name: Go — build + test
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: nas
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -192,7 +215,7 @@ jobs:
|
||||
# 详见 scripts/e2e-smoke.sh + server/test/e2e/。
|
||||
e2e-smoke:
|
||||
name: E2E Smoke — L4 进程级端到端
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: nas
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -216,7 +239,7 @@ jobs:
|
||||
# · -p 1 串行:一次只起一个 mysql 容器,避免并发把 Docker Desktop 压垮/端口资源争用。
|
||||
go-integration:
|
||||
name: Go — integration (mysql/redis testcontainers)
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: nas
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -230,8 +253,8 @@ jobs:
|
||||
# Linux 权威基线(scripts/update-goldens.sh 生成;mac 渲染不一致故钉死 Linux 容器)。
|
||||
# tablet/desktop-stats golden 与 stats-overhaul 工作区耦合,待其合并后并入本 job。
|
||||
golden:
|
||||
name: Golden — 视觉回归 (components + auth)
|
||||
runs-on: ubuntu-latest
|
||||
name: Golden — 视觉回归 (全量:components/auth/desktop/tablet)
|
||||
runs-on: nas
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -243,4 +266,4 @@ jobs:
|
||||
-v "$PWD/client:/app" -w /app \
|
||||
-v "$HOME/.cache/pangolin-ci/pubcache:/root/.pub-cache" \
|
||||
ghcr.io/cirruslabs/flutter:stable \
|
||||
bash -c "flutter pub get && flutter test test/golden/components_golden_test.dart test/golden/auth_redesign_golden_test.dart"
|
||||
bash -c "flutter pub get && flutter test test/golden"
|
||||
|
||||
@@ -28,6 +28,24 @@ run_check "可移植 SQL 扫描" bash ci/scan-portable-sql.sh
|
||||
|
||||
if command -v node >/dev/null 2>&1; then
|
||||
run_check "codegen 漂移检查" bash ci/check-codegen-drift.sh
|
||||
|
||||
# ── ds-flow 闸(条件触发,秒级)──
|
||||
staged="$(git diff --cached --name-only --diff-filter=ACM)"
|
||||
|
||||
# 原型校验:仅在动了 design/prototype/ 时跑(轻量,扫整个原型)。
|
||||
if printf '%s\n' "$staged" | grep -q '^design/prototype/'; then
|
||||
run_check "原型校验(check-ds)" node design/prototype/tools/check-ds.mjs
|
||||
fi
|
||||
|
||||
# 跨端同源:动了原型 token/图标 或 web token 时跑(防漂移)。
|
||||
if printf '%s\n' "$staged" | grep -qE '^(design/prototype/|web/(website|usercenter)/)'; then
|
||||
run_check "跨端同源(check-l1-sync)" node tools/check-l1-sync.mjs
|
||||
fi
|
||||
|
||||
# Flutter 颜色单源:只扫本次改动的 dart(--changed,快),动了 client/lib 才有意义。
|
||||
if printf '%s\n' "$staged" | grep -q '^client/lib/.*\.dart$'; then
|
||||
run_check "Flutter 颜色单源(check_ds_code)" bash -c 'cd client && node tool/check_ds_code.mjs --changed'
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "[pre-commit] ✓ 本地闸通过(完整测试见 CI)"
|
||||
|
||||
@@ -15,4 +15,5 @@ chmod +x .githooks/* 2>/dev/null || true
|
||||
|
||||
echo "✓ 已启用 git hooks(core.hooksPath=.githooks)"
|
||||
echo " pre-commit 将跑:红线词扫描 · 可移植 SQL 扫描 · codegen 漂移检查"
|
||||
echo " + ds-flow 条件闸(动了原型/web/client 才跑):原型校验 · 跨端同源 · Flutter 颜色单源"
|
||||
echo " 卸载:git config --unset core.hooksPath"
|
||||
|
||||
@@ -47,11 +47,20 @@ class AppUpdateInfo {
|
||||
/// 更新检查 Notifier。build() 惰性触发:延迟首查 + 每小时轮询。
|
||||
class UpdateNotifier extends AsyncNotifier<AppUpdateInfo?> {
|
||||
Timer? _timer;
|
||||
Timer? _initialTimer;
|
||||
|
||||
@override
|
||||
Future<AppUpdateInfo?> build() async {
|
||||
ref.onDispose(() => _timer?.cancel());
|
||||
await Future<void>.delayed(_kInitialDelay);
|
||||
// 两个 timer 都在 onDispose 取消。初始延迟用可取消的 Timer(而非
|
||||
// Future.delayed:其内部 timer 无法取消,provider 在延迟期间被 dispose
|
||||
// 时会悬挂 → widget 测试报 pending timer、生产留资源)。
|
||||
ref.onDispose(() {
|
||||
_initialTimer?.cancel();
|
||||
_timer?.cancel();
|
||||
});
|
||||
final ready = Completer<void>();
|
||||
_initialTimer = Timer(_kInitialDelay, ready.complete);
|
||||
await ready.future; // 若延迟期间被 dispose,_initialTimer 取消 → 永不 complete,build 中止
|
||||
final first = await _check();
|
||||
_timer = Timer.periodic(_kPollInterval, (_) async {
|
||||
state = AsyncValue.data(await _check());
|
||||
|
||||
@@ -167,7 +167,7 @@ class _MenuCard extends StatelessWidget {
|
||||
child: Material(
|
||||
color: c.surface,
|
||||
elevation: 6,
|
||||
shadowColor: Colors.black26,
|
||||
shadowColor: PangolinColors.sand950.withValues(alpha: 0.24), // 暖近黑阴影(§5;非冷黑 Colors.black26)
|
||||
clipBehavior: Clip.antiAlias,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(PangolinRadius.md),
|
||||
|
||||
@@ -20,6 +20,9 @@ Future<void> testExecutable(FutureOr<void> Function() testMain) async {
|
||||
'test/fonts/Manrope-Bold.ttf',
|
||||
]);
|
||||
await _load('JetBrains Mono', const ['test/fonts/JetBrainsMono-Regular.ttf']);
|
||||
// Noto Sans SC 子集(= 生产打包同一 client/fonts/ 子集):golden 中文用真字体渲染,
|
||||
// 不出豆腐块、与真机一致(family 须匹配 PangolinFonts.cjk = 'Noto Sans SC')。
|
||||
await _load('Noto Sans SC', const ['test/fonts/NotoSansSC-Regular-subset.otf']);
|
||||
// Lucide 图标字体(family 须带 package 前缀以匹配 PangolinIcons 的 fontPackage)。
|
||||
await _load('packages/lucide_icons/Lucide', const ['packages/lucide_icons_patched/fonts/lucide.ttf']);
|
||||
await testMain();
|
||||
|
||||
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 8.8 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 65 KiB |
@@ -91,6 +91,15 @@ class _FakeMeNotifier extends MeNotifier {
|
||||
Future<Me> build() async => _demoMe;
|
||||
}
|
||||
|
||||
// 固定语言的 locale notifier:localeProvider 是 StateNotifierProvider<LocaleNotifier,
|
||||
// AppLang>,override 须返回 LocaleNotifier(子类)。构造后直接钉死 state 为目标语言
|
||||
// (prefs 在测试态为空,父类 _load() 不会改写)。
|
||||
class _FixedLocale extends LocaleNotifier {
|
||||
_FixedLocale(AppLang lang) {
|
||||
state = lang;
|
||||
}
|
||||
}
|
||||
|
||||
const Size _ipad = Size(1180, 820); // 对齐设计源 tabapp.jsx 内屏
|
||||
|
||||
Future<void> _shoot(
|
||||
@@ -112,7 +121,7 @@ Future<void> _shoot(
|
||||
tokenStoreProvider.overrideWithValue(const _NullTokenStore()),
|
||||
vpnBridgeProvider.overrideWithValue(VpnBridgeMock()),
|
||||
navViewProvider.overrideWith((ref) => view),
|
||||
localeProvider.overrideWith((ref) => lang),
|
||||
localeProvider.overrideWith((ref) => _FixedLocale(lang)),
|
||||
nodesProvider.overrideWith(_FakeNodesNotifier.new),
|
||||
meProvider.overrideWith(_FakeMeNotifier.new),
|
||||
usageProvider((days: 30, device: null)).overrideWith((ref) async => _demoUsage),
|
||||
|
||||
@@ -8,14 +8,23 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pangolin_vpn/bridge/vpn_bridge.dart';
|
||||
import 'package:pangolin_vpn/bridge/vpn_bridge_provider.dart';
|
||||
import 'package:pangolin_vpn/l10n/app_text.dart';
|
||||
import 'package:pangolin_vpn/l10n/strings_zh.dart';
|
||||
import 'package:pangolin_vpn/models/node.dart';
|
||||
import 'package:pangolin_vpn/services/connect_api.dart';
|
||||
import 'package:pangolin_vpn/services/device_identity.dart';
|
||||
import 'package:pangolin_vpn/state/app_providers.dart';
|
||||
import 'package:pangolin_vpn/state/connection_provider.dart';
|
||||
import 'package:pangolin_vpn/state/nodes_provider.dart';
|
||||
import 'package:pangolin_vpn/state/quota_provider.dart';
|
||||
|
||||
// localeProvider 现默认英文;测试硬编码 StringsZh 预期 → 钉死中文 locale。
|
||||
class _FixedLocale extends LocaleNotifier {
|
||||
_FixedLocale(AppLang lang) {
|
||||
state = lang;
|
||||
}
|
||||
}
|
||||
|
||||
// 可控假桥:能手动推 VpnStatus,无内部计时器(避免 pending timer)。
|
||||
class _FakeBridge implements VpnBridge {
|
||||
final _status = StreamController<VpnStatus>.broadcast();
|
||||
@@ -94,6 +103,7 @@ void main() {
|
||||
ProviderContainer makeContainer(_FakeBridge bridge,
|
||||
{NodesNotifier Function()? nodes, DateTime Function()? now}) =>
|
||||
ProviderContainer(overrides: [
|
||||
localeProvider.overrideWith((ref) => _FixedLocale(AppLang.zh)),
|
||||
vpnBridgeProvider.overrideWithValue(bridge),
|
||||
nodesProvider.overrideWith(nodes ?? _StubNodes.new),
|
||||
deviceIdentityProvider.overrideWithValue(DeviceIdentity(store: _MemKV())),
|
||||
|
||||
@@ -8,10 +8,12 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pangolin_vpn/bridge/vpn_bridge.dart';
|
||||
import 'package:pangolin_vpn/bridge/vpn_bridge_mock.dart';
|
||||
import 'package:pangolin_vpn/bridge/vpn_bridge_provider.dart';
|
||||
import 'package:pangolin_vpn/l10n/app_text.dart';
|
||||
import 'package:pangolin_vpn/l10n/strings_zh.dart';
|
||||
import 'package:pangolin_vpn/models/node.dart';
|
||||
import 'package:pangolin_vpn/pangolin_theme.dart';
|
||||
import 'package:pangolin_vpn/screens/nodes_page.dart';
|
||||
import 'package:pangolin_vpn/state/app_providers.dart';
|
||||
import 'package:pangolin_vpn/state/connection_provider.dart';
|
||||
import 'package:pangolin_vpn/state/nodes_provider.dart';
|
||||
import 'package:pangolin_vpn/widgets/smart_select_card.dart';
|
||||
@@ -31,11 +33,19 @@ const _nodes = [
|
||||
Node(code: 'JP', nameZh: '东京', nameEn: 'Tokyo', ping: 35, uuid: 'jp-uuid', host: 'jp', port: 443),
|
||||
];
|
||||
|
||||
// localeProvider 现默认英文;测试硬编码 StringsZh 预期 → 钉死中文 locale。
|
||||
class _FixedLocale extends LocaleNotifier {
|
||||
_FixedLocale(AppLang lang) {
|
||||
state = lang;
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUpAll(disableGoogleFontsFetching);
|
||||
const t = StringsZh();
|
||||
|
||||
ProviderContainer makeContainer(VpnBridge bridge) => ProviderContainer(overrides: [
|
||||
localeProvider.overrideWith((ref) => _FixedLocale(AppLang.zh)),
|
||||
nodesProvider.overrideWith(_StubNodes.new),
|
||||
vpnBridgeProvider.overrideWithValue(bridge),
|
||||
]);
|
||||
|
||||
@@ -13,6 +13,7 @@ import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:pangolin_vpn/bridge/vpn_bridge_mock.dart';
|
||||
import 'package:pangolin_vpn/bridge/vpn_bridge_provider.dart';
|
||||
import 'package:pangolin_vpn/l10n/app_text.dart';
|
||||
import 'package:pangolin_vpn/l10n/strings_zh.dart';
|
||||
import 'package:pangolin_vpn/models/node.dart';
|
||||
import 'package:pangolin_vpn/pangolin_theme.dart';
|
||||
@@ -20,6 +21,7 @@ import 'package:pangolin_vpn/screens/stats_page.dart';
|
||||
import 'package:pangolin_vpn/services/api_client.dart';
|
||||
import 'package:pangolin_vpn/services/token_store.dart';
|
||||
import 'package:pangolin_vpn/state/account_providers.dart';
|
||||
import 'package:pangolin_vpn/state/app_providers.dart';
|
||||
import 'package:pangolin_vpn/state/auth_provider.dart';
|
||||
import 'package:pangolin_vpn/state/nodes_provider.dart';
|
||||
import 'package:pangolin_vpn/widgets/period_card.dart';
|
||||
@@ -101,6 +103,13 @@ Finder _metric(String period, int i, String value, String unit) => find.byWidget
|
||||
w.metrics[i].unit == unit,
|
||||
);
|
||||
|
||||
// localeProvider 现默认英文;测试硬编码 StringsZh 预期 → 钉死中文 locale。
|
||||
class _FixedLocale extends LocaleNotifier {
|
||||
_FixedLocale(AppLang lang) {
|
||||
state = lang;
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUpAll(disableGoogleFontsFetching);
|
||||
const t = StringsZh();
|
||||
@@ -111,6 +120,7 @@ void main() {
|
||||
|
||||
await tester.pumpWidget(ProviderScope(
|
||||
overrides: [
|
||||
localeProvider.overrideWith((ref) => _FixedLocale(AppLang.zh)),
|
||||
tokenStoreProvider.overrideWithValue(const _LoggedInTokenStore()),
|
||||
apiClientProvider.overrideWithValue(ApiClient(
|
||||
baseUrl: 'http://test.local',
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
// client/tool/check_ds_code.mjs
|
||||
// Flutter 代码端「设计真相源」闸 —— 与原型 check-ds.mjs 对应,把颜色单源纪律延伸到
|
||||
// Flutter 业务代码。红线:颜色只走语义 token(PangolinScheme via context / PangolinColors
|
||||
// / PangolinShadow 等,均从 design/prototype/tokens.css codegen 单源),禁硬编码 hex
|
||||
// (Color(0x..))与具名 Material 色(Colors.red 等)。合理特例须显式 `// ds-ignore: 理由`。
|
||||
//
|
||||
// 用法(在 client/ 下):
|
||||
// node tool/check_ds_code.mjs # 全量扫描,仅报告(不 fail)
|
||||
// node tool/check_ds_code.mjs --strict # 全量,有违规即 exit 1(CI)
|
||||
// node tool/check_ds_code.mjs --changed # 仅扫 git 改动的 dart,有违规即 exit 1(pre-commit)
|
||||
//
|
||||
// 豁免:行加 `// ds-ignore: <理由>`(理由必填,便于审计)。
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); // client/
|
||||
const LIB = path.join(ROOT, 'lib');
|
||||
const args = new Set(process.argv.slice(2));
|
||||
const CHANGED = args.has('--changed');
|
||||
const STRICT = args.has('--strict') || CHANGED;
|
||||
|
||||
// 违规模式:硬编码 hex 颜色;具名 Material 色(Colors.x,transparent 除外=无色合理)。
|
||||
// 注:PangolinColors.x 不会命中(\bColors 前有词字符 n,无词边界)。
|
||||
const HEX = /Color\(0x[0-9A-Fa-f]{6,8}\)/;
|
||||
const NAMED = /\bColors\.(?!transparent\b)[a-zA-Z]\w*/;
|
||||
const IGNORE = /\/\/\s*ds-ignore/;
|
||||
|
||||
// 文件级豁免:token 生成层 + 主题实现层——它们**定义** token 的原始色值(合法用
|
||||
// Color(0x..)),是颜色单源的落地位,不该被自己的闸拦。
|
||||
const EXEMPT_FILES = [
|
||||
'lib/pangolin_tokens.gen.dart', // codegen 产物(色阶原始 hex 定义位)
|
||||
'lib/pangolin_theme.dart', // 实现层(PangolinScheme 语义色映射)
|
||||
];
|
||||
|
||||
function listDartFiles() {
|
||||
if (CHANGED) {
|
||||
let out = '';
|
||||
try {
|
||||
out = execFileSync('git', ['diff', '--name-only', '--diff-filter=ACM', 'HEAD'], {
|
||||
cwd: ROOT,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
} catch {
|
||||
out = '';
|
||||
}
|
||||
return out
|
||||
.split('\n')
|
||||
.map((s) => s.trim())
|
||||
.filter((f) => f.endsWith('.dart') && !f.endsWith('.gen.dart') && !f.endsWith('.g.dart'))
|
||||
.map((f) => path.resolve(ROOT, '..', f)) // git 路径相对仓库根
|
||||
.filter((f) => f.startsWith(LIB) && fs.existsSync(f));
|
||||
}
|
||||
const out = [];
|
||||
(function walk(d) {
|
||||
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
||||
const p = path.join(d, e.name);
|
||||
if (e.isDirectory()) walk(p);
|
||||
else if (e.name.endsWith('.dart') && !e.name.endsWith('.gen.dart') && !e.name.endsWith('.g.dart')) out.push(p);
|
||||
}
|
||||
})(LIB);
|
||||
return out;
|
||||
}
|
||||
|
||||
const isExempt = (f) => EXEMPT_FILES.some((e) => f.replaceAll(path.sep, '/').endsWith(e));
|
||||
|
||||
const violations = [];
|
||||
for (const file of listDartFiles()) {
|
||||
if (isExempt(file)) continue;
|
||||
const lines = fs.readFileSync(file, 'utf8').split('\n');
|
||||
lines.forEach((line, i) => {
|
||||
if (IGNORE.test(line)) return; // 显式豁免
|
||||
// 只扫代码部分:剥掉行尾 `//` 注释(注释里提及 Colors.x 作说明不算违规)。
|
||||
const code = line.split('//')[0];
|
||||
const m = HEX.exec(code) || NAMED.exec(code);
|
||||
if (m) {
|
||||
violations.push({ file: path.relative(ROOT, file), line: i + 1, hit: m[0], text: line.trim().slice(0, 90) });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const banner = (s) => `\n${'─'.repeat(60)}\n${s}\n${'─'.repeat(60)}`;
|
||||
if (violations.length) {
|
||||
console.log(banner(`✗ Flutter 硬编码颜色 ${violations.length} 处(应走 token,或显式 // ds-ignore: 理由)`));
|
||||
for (const v of violations) console.log(` ${v.file}:${v.line} [${v.hit}] ${v.text}`);
|
||||
} else {
|
||||
console.log(banner('✓ 通过:Flutter 颜色全部走 token / 已显式豁免'));
|
||||
}
|
||||
|
||||
if (STRICT && violations.length) process.exit(1);
|
||||
console.log(`\n范围:${CHANGED ? 'git 改动文件' : '全量 lib/'} 违规:${violations.length}${STRICT ? '(strict)' : '(仅报告)'}`);
|
||||
@@ -2,6 +2,17 @@
|
||||
|
||||
> 本文件是项目持久指令,任何会话自动注入。目标:让任何 agent(含 Claude Code)都能**完全还原**这套设计,不走样。生产前端是 **Flutter**;React/HTML 是视觉规范参考。
|
||||
|
||||
> ⚠️ **真源对照(2026-07 ds-flow 治理后,以此为准;下文历史章节的旧路径逐步迁移中)**
|
||||
> | 关注点 | 现在的真源 | 已废弃/降级 |
|
||||
> |---|---|---|
|
||||
> | 设计令牌 | `design/prototype/tokens.css`(ds-flow 原型单源) | `colors_and_type.css` 现为薄 `@import` 别名,勿在此加变量 |
|
||||
> | 组件原子 | `design/prototype/atoms.css` + 登记页 `design/prototype/index.html` | — |
|
||||
> | 图标 | `design/prototype/icons.js`(Lucide sprite 单源) | — |
|
||||
> | Flutter 组件实现 | `client/lib/widgets/`(canonical)+ `client/lib/pangolin_theme.dart`(实现层) | `design/flutter/` **已删除**;本文下文 `flutter/…` 路径作废 |
|
||||
> | 官网/用户中心实现 | `web/website/` · `web/usercenter/`(各自 canonical) | — |
|
||||
> | 整屏视觉参考 | `design/ui_kits/`(**DEPRECATED · 仅历史整屏布局参考**,勿当真源;整屏 HTML 化后退役) | 旧 DS 工具 `_ds_manifest.json`/`_ds_bundle.js` 为历史派生物 |
|
||||
> 详见根 `CLAUDE.md`「## 前端设计系统治理(ds-flow)」+ `docs/frontend-ds-refactor-plan.html`。
|
||||
|
||||
---
|
||||
|
||||
## 0. 一句话定位
|
||||
@@ -105,12 +116,16 @@ SKILL.md ← Agent Skill 入口
|
||||
|
||||
---
|
||||
|
||||
## 6. 修改设计系统时(本项目是 DS 工程)
|
||||
- 改令牌 → 编辑 `colors_and_type.css` **同时**同步 `flutter/pangolin_theme.dart`(两者必须一致)。
|
||||
- 改 specimen 卡 → `preview/*.html`,首行带 `<!-- @dsCard group="…" -->`。
|
||||
- 改组件视觉 → 同步改 React(`ui_kits/`)与 Flutter(`flutter/widgets/`)两处。
|
||||
- 改完跑 `check_design_system` 确认无报错(动效 token 标 `@kind other`)。
|
||||
- logo 改动 → 同步 5 个 SVG(`assets/`)+ `flutter/assets/` 副本 + React 内联 `Mark`/`DMark` + Flutter `pangolin_logo.dart`。
|
||||
## 6. 修改设计系统时(ds-flow;以此为准,旧写法已废)
|
||||
- **改令牌** → 只改 `design/prototype/tokens.css`(原型单源),然后跑 codegen:
|
||||
`node design/codegen/gen_flutter_tokens.mjs`(生成 `client/lib/pangolin_tokens.gen.dart`,勿手改)+
|
||||
`cd web/{website,usercenter} && npm run gen:tokens`。**勿改 `colors_and_type.css`**(它已是 `@import` 别名)。
|
||||
- **加/改组件原子** → 先在 `design/prototype/atoms.css` 定义 + `design/prototype/index.html` 登记页加展示卡(L1 无例外先原型),再落 canonical 实现:Flutter `client/lib/widgets/`、官网 `web/website/`、用户中心 `web/usercenter/`。
|
||||
- **加/改图标** → 先进 `design/prototype/icons.js` sprite,三端(Flutter/website/usercenter)只从此集取。
|
||||
- 评审原型:`node design/prototype/serve.mjs` → `http://localhost:5180/`(给 URL,不截图)。
|
||||
- **禁止**再向 `design/` 提交 Dart/TS 组件代码副本(会漂移;`ui_kits/` 为历史 DEPRECATED 参考,勿新增)。
|
||||
- logo 改动 → 同步 SVG(`design/assets/` + `client/…` 副本)+ Flutter `pangolin_logo.dart` + Web 内联 `Mark`。
|
||||
- 闸:改完跑 `bash ci/check-codegen-drift.sh`(codegen 零 diff)+ `flutter analyze`/`flutter test`(含 golden)。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -90,3 +90,33 @@
|
||||
- 连接键 off 态:原型用实心 11px 环,Flutter 用「虚线轨道环」(design/CLAUDE.md §5 canonical 连接键),以 §5 为准。
|
||||
- 账户页:生产实现比原型多设备管理/兑换/联系/协议行,属真实功能扩展,沿用同设计语言。
|
||||
- golden 测试环境未打包 Noto Sans SC → CJK 显示为方块,仅影响 golden 文字、不影响真机;布局可判。节点网格/周柱在未登录测试态无数据(需注入演示数据方显内容)。
|
||||
|
||||
---
|
||||
|
||||
## 6. ds-flow · Web 原子清单 + 屏级三态台账(2026-07 治理)
|
||||
|
||||
> 决策:Web 两端(website / usercenter)框架不同(Astro-island vs Next 静态),**各自实现 + 同源闸**——不建跨端共享组件包,两端对齐同一 `design/prototype/atoms.css` 语义,靠 `tools/check-l1-sync.mjs` 强制 token 值 + 图标同源不漂移。
|
||||
|
||||
### 6.1 公用原子清单(canonical = `design/prototype/atoms.css`)
|
||||
| 原子 | 原型 class | website 实现 | usercenter 实现 |
|
||||
|---|---|---|---|
|
||||
| 按钮 | `.btn`/`-primary`/`-ghost`/`-subtle`/`-danger` | `website.css .btn*`(class) | `shared.tsx`(CSSProperties) |
|
||||
| 卡片 | `.card` | `.card`/`.plan` | `shared.tsx card` |
|
||||
| 输入 | `.input`/`.field` | 表单 class | `shared.tsx input` |
|
||||
| 徽章/药丸 | `.pill`/`.badge`(状态/accent/outline) | `.tag`/`.plan` 徽章 | 内联 pill |
|
||||
| 语言下拉 | `.langsel`/`.menu` | `Header.jsx` 自控下拉 | `shared.tsx LangSeg` |
|
||||
> 两端语言下拉已统一为「自控菜单」(非原生 select,防 macOS 弹层漂移),行为一致、均对齐 `.langsel`/`.menu` 语义。
|
||||
|
||||
### 6.2 图标:三端 ⊆ 原型 sprite
|
||||
`design/prototype/icons.js`(58 Lucide)是图标单源。usercenter `LUCIDE`(键+路径)、Flutter `pangolin_icons._byName`(键)由 `check-l1-sync ③` 强制 ⊆ 原型;website 经 lucide-react 构建期内联。
|
||||
|
||||
### 6.3 屏级三态台账(L2)
|
||||
| 面 | 态 | 基准 | 说明 |
|
||||
|---|---|---|---|
|
||||
| Flutter mobile/tablet/desktop | 快照 | golden ×light/dark | 原型退役,golden + 本契约文字为准 |
|
||||
| website(官网各屏) | 代码先行 | 无原型屏 | canonical 在 `web/website/`,token/图标同源闸守护 |
|
||||
| usercenter(各视图) | 代码先行 | 无原型屏 | canonical 在 `web/usercenter/`,同上 |
|
||||
| 组件原子层 | 同步 | `prototype/index.html` 登记页 | L1 无例外先原型;`check-ds`(Phase 5)强制登记 |
|
||||
|
||||
### 6.4 硬编码色白名单(`check-l1-sync ④`)
|
||||
`#fff/#ffffff/#000/#000000` + 品牌 logo 固定色 `#B96A3D/#FAF3ED/#F4EFE8/#9E5630/#3D2213`;其余一律 `var(--token)` 或行内 `ds-allow` 豁免。website 页脚恒暗区灰阶已就近吸附 `--sand-300/400/500`。
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
'arrow-right': '<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>',
|
||||
'compass': '<path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"/><circle cx="12" cy="12" r="10"/>',
|
||||
'chart-no-axes-column': '<line x1="18" x2="18" y1="20" y2="10"/><line x1="12" x2="12" y1="20" y2="4"/><line x1="6" x2="6" y1="20" y2="14"/>',
|
||||
'chart': '<line x1="18" x2="18" y1="20" y2="10"/><line x1="12" x2="12" y1="20" y2="4"/><line x1="6" x2="6" y1="20" y2="14"/>',
|
||||
'map-pin': '<path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"/><circle cx="12" cy="10" r="3"/>',
|
||||
'laptop': '<path d="M20 16V7a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v9m16 0H4m16 0 1.28 2.55a1 1 0 0 1-.9 1.45H3.62a1 1 0 0 1-.9-1.45L4 16"/>',
|
||||
'monitor-smartphone': '<path d="M18 8V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h8"/><path d="M10 19v-3.96 3.15"/><path d="M7 19h5"/><rect width="6" height="10" x="16" y="12" rx="2"/>',
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>穿山甲 Pangolin · 设计系统</title>
|
||||
<link rel="stylesheet" href="tokens.css">
|
||||
<link rel="stylesheet" href="atoms.css">
|
||||
<script src="icons.js"></script>
|
||||
<style>
|
||||
/* ── 登记页自身骨架(chrome)— 尽量走 token,登记页外壳可适度宽松 ── */
|
||||
body{ margin:0; background:var(--bg); color:var(--fg1); font-family:var(--font-sans);
|
||||
-webkit-font-smoothing:antialiased; transition:background-color var(--dur-base) var(--ease-out); overflow-x:hidden; }
|
||||
|
||||
.ic{ width:22px; height:22px; color:currentColor; }
|
||||
|
||||
/* 顶栏 */
|
||||
.ds-top{ position:sticky; top:0; z-index:50; height:60px; background:var(--surface);
|
||||
border-bottom:1px solid var(--border); display:flex; align-items:center; gap:14px;
|
||||
padding:0 var(--space-6); box-shadow:var(--shadow-sm); }
|
||||
.ds-brand{ display:flex; align-items:center; gap:var(--space-3); font-family:var(--font-display);
|
||||
font-weight:700; color:var(--fg1); font-size:var(--text-body); }
|
||||
.ds-brand .mk{ width:30px; height:30px; border-radius:var(--radius-md);
|
||||
background:linear-gradient(135deg,var(--clay-500),var(--clay-800));
|
||||
color:#fff; display:flex; align-items:center; justify-content:center; }
|
||||
.ds-brand .mk svg{ width:17px; height:17px; }
|
||||
.ds-tag{ font-size:var(--text-caption); color:var(--fg3); padding-left:var(--space-3);
|
||||
border-left:1px solid var(--border); font-weight:500; }
|
||||
.theme-toggle{ margin-left:auto; display:inline-flex; align-items:center; gap:var(--space-2);
|
||||
background:var(--bg-subtle); border:1px solid var(--border); color:var(--fg2);
|
||||
border-radius:var(--radius-full); padding:var(--space-2) var(--space-4);
|
||||
font-family:var(--font-sans); font-weight:600; font-size:var(--text-caption);
|
||||
cursor:pointer; transition:border-color var(--dur-fast) var(--ease-out), color var(--dur-fast) var(--ease-out); }
|
||||
.theme-toggle:hover{ border-color:var(--border-strong); color:var(--fg1); }
|
||||
.theme-toggle svg{ width:15px; height:15px; }
|
||||
|
||||
/* 三栏文档布局 */
|
||||
.doc{ display:flex; align-items:flex-start; max-width:1360px; margin:0 auto; }
|
||||
.doc-nav{ width:238px; flex:none; position:sticky; top:60px; height:calc(100vh - 60px);
|
||||
overflow:auto; border-right:1px solid var(--border); padding:var(--space-5) var(--space-4); }
|
||||
.dgroup{ font-size:var(--text-caption); font-weight:700; color:var(--fg3); letter-spacing:var(--tracking-caps);
|
||||
text-transform:uppercase; padding:var(--space-4) var(--space-2) var(--space-2); }
|
||||
.dnav{ display:block; padding:var(--space-2) var(--space-3); border-radius:var(--radius-md);
|
||||
font-size:var(--text-sm); color:var(--fg2); text-decoration:none; cursor:pointer;
|
||||
transition:background-color var(--dur-fast) var(--ease-out), color var(--dur-fast) var(--ease-out); }
|
||||
.dnav:hover{ background:var(--bg-subtle); color:var(--fg1); }
|
||||
.dnav.on{ background:var(--accent-subtle); color:var(--accent); font-weight:600; }
|
||||
|
||||
.doc-main{ flex:1; min-width:0; padding:var(--space-8) var(--space-10) var(--space-16); max-width:1080px; }
|
||||
.section{ margin-bottom:var(--space-12); scroll-margin-top:76px; }
|
||||
.section > h2{ margin:0 0 var(--space-1); font-family:var(--font-display); font-size:var(--text-h1);
|
||||
font-weight:700; letter-spacing:var(--tracking-tight); color:var(--fg1); }
|
||||
.section > .desc{ margin:0 0 var(--space-5); font-size:var(--text-sm); color:var(--fg2); line-height:var(--leading-normal); }
|
||||
.section > .desc code{ font-family:var(--font-mono); font-size:0.85em; color:var(--accent);
|
||||
background:var(--accent-subtle); padding:1px 6px; border-radius:var(--radius-sm); }
|
||||
|
||||
/* 展示卡(页面 chrome,非 atoms.card 以免混淆语义) */
|
||||
.pcard{ background:var(--surface); border:1px solid var(--border); border-radius:var(--radius-lg);
|
||||
margin-bottom:var(--space-4); overflow:hidden; }
|
||||
.pcard-h{ display:flex; align-items:baseline; gap:var(--space-3); padding:var(--space-3) var(--space-5);
|
||||
border-bottom:1px solid var(--border); }
|
||||
.pcard-h h3{ margin:0; font-family:var(--font-sans); font-size:var(--text-h3); font-weight:600; color:var(--fg1); }
|
||||
.pcard-h .tk{ font-size:var(--text-caption); color:var(--fg3); font-family:var(--font-mono); }
|
||||
.pcard-b{ padding:var(--space-5); display:flex; flex-wrap:wrap; gap:var(--space-4); align-items:center; }
|
||||
.pcard-b.col{ display:block; }
|
||||
.cap{ width:100%; font-size:var(--text-caption); color:var(--fg3); font-family:var(--font-mono);
|
||||
margin:var(--space-4) 0 var(--space-2); }
|
||||
.cap:first-child{ margin-top:0; }
|
||||
|
||||
/* 色板 */
|
||||
.sw-grid{ display:grid; grid-template-columns:repeat(auto-fill,minmax(150px,1fr)); gap:var(--space-3); width:100%; }
|
||||
.sw{ border:1px solid var(--border); border-radius:var(--radius-md); overflow:hidden; background:var(--surface); }
|
||||
.sw .band{ display:block; width:100%; height:56px; box-shadow:inset 0 0 0 1px rgba(45,30,20,.08); }
|
||||
.sw .m{ padding:var(--space-2) var(--space-3); }
|
||||
.sw .m .n{ font-size:var(--text-caption); color:var(--fg1); font-weight:600; }
|
||||
.sw .m .v{ font-size:11px; color:var(--fg3); font-family:var(--font-mono); }
|
||||
|
||||
/* 字号梯度 */
|
||||
.type-row{ display:flex; align-items:baseline; gap:var(--space-5); padding:var(--space-3) 0;
|
||||
border-bottom:1px solid var(--border); width:100%; }
|
||||
.type-row:last-child{ border-bottom:none; }
|
||||
.type-row .tk{ width:170px; font-size:var(--text-caption); color:var(--fg3);
|
||||
font-family:var(--font-mono); flex:none; }
|
||||
.type-row .spec{ display:flex; flex-direction:column; gap:2px; min-width:0; }
|
||||
|
||||
/* 圆角 / 间距 / 阴影 比例尺 */
|
||||
.scale{ display:flex; flex-wrap:wrap; gap:var(--space-5); align-items:flex-end; width:100%; }
|
||||
.scale .box{ display:flex; flex-direction:column; align-items:center; gap:var(--space-2);
|
||||
font-size:var(--text-caption); color:var(--fg2); font-family:var(--font-mono); }
|
||||
.scale .rq{ width:60px; height:60px; background:var(--accent-subtle); border:1.5px solid var(--accent); }
|
||||
.scale .sq{ height:38px; background:var(--accent); border-radius:2px; }
|
||||
.scale .sh{ width:72px; height:52px; background:var(--surface); border:1px solid var(--border); border-radius:var(--radius-md); }
|
||||
|
||||
/* 图标库网格 */
|
||||
.icon-grid{ display:grid; grid-template-columns:repeat(auto-fill,minmax(108px,1fr)); gap:var(--space-2); width:100%; }
|
||||
.icell{ display:flex; flex-direction:column; align-items:center; gap:var(--space-2);
|
||||
padding:var(--space-4) var(--space-2); border:1px solid var(--border);
|
||||
border-radius:var(--radius-md); background:var(--surface); color:var(--fg1); }
|
||||
.icell .ic{ width:24px; height:24px; }
|
||||
.icell span{ font-size:11px; color:var(--fg2); font-family:var(--font-mono); text-align:center; word-break:break-all; line-height:1.3; }
|
||||
.count-pill{ font-family:var(--font-sans); }
|
||||
|
||||
@media (max-width:840px){
|
||||
.doc-nav{ display:none; }
|
||||
.doc-main{ padding:var(--space-6) var(--space-5) var(--space-12); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="ds-top">
|
||||
<div class="ds-brand"><span class="mk"><svg class="ic"><use href="#i-shield-check"/></svg></span>穿山甲 Pangolin</div>
|
||||
<span class="ds-tag">设计系统登记簿 · Prototype</span>
|
||||
<button class="theme-toggle" id="themeBtn" aria-label="切换主题">
|
||||
<svg class="ic"><use href="#i-sun" id="themeIcon"/></svg><span id="themeLabel">浅色</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="doc">
|
||||
<nav class="doc-nav" id="docNav">
|
||||
<div class="dgroup">色彩 Colors</div>
|
||||
<a class="dnav" data-s="c-clay">clay 黏土铜色阶</a>
|
||||
<a class="dnav" data-s="c-sand">sand 沙米中性阶</a>
|
||||
<a class="dnav" data-s="c-hue">语义色相</a>
|
||||
<a class="dnav" data-s="c-sem">语义 token</a>
|
||||
<div class="dgroup">字体 Type</div>
|
||||
<a class="dnav" data-s="type">字号梯度</a>
|
||||
<div class="dgroup">尺度 Scale</div>
|
||||
<a class="dnav" data-s="radius">圆角</a>
|
||||
<a class="dnav" data-s="space">间距</a>
|
||||
<a class="dnav" data-s="shadow">阴影</a>
|
||||
<div class="dgroup">组件 Atoms</div>
|
||||
<a class="dnav" data-s="a-btn">按钮 .btn</a>
|
||||
<a class="dnav" data-s="a-card">卡片 .card</a>
|
||||
<a class="dnav" data-s="a-input">输入 .input</a>
|
||||
<a class="dnav" data-s="a-pill">胶囊 .pill / .badge</a>
|
||||
<a class="dnav" data-s="a-lang">语言下拉 .langsel</a>
|
||||
<div class="dgroup">图标 Icons</div>
|
||||
<a class="dnav" data-s="icons">Lucide 图标库</a>
|
||||
</nav>
|
||||
|
||||
<main class="doc-main">
|
||||
<!-- ══════════ 色彩 ══════════ -->
|
||||
<section class="section" id="c-clay">
|
||||
<h2>clay · 黏土铜色阶</h2>
|
||||
<p class="desc">品牌主色阶(穿山甲鳞甲的暖大地色)。<code>--clay-500</code> 为品牌主色,浅色主题 accent 取 500、深色取 400。</p>
|
||||
<div class="pcard"><div class="pcard-b"><div class="sw-grid" data-swatches="--clay-50,--clay-100,--clay-200,--clay-300,--clay-400,--clay-500,--clay-600,--clay-700,--clay-800,--clay-900"></div></div></div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="c-sand">
|
||||
<h2>sand · 沙米中性阶</h2>
|
||||
<p class="desc">暖中性色阶。浅色画布 <code>--sand-50</code>,深色画布暖 espresso <code>--sand-950</code>(非纯黑)。</p>
|
||||
<div class="pcard"><div class="pcard-b"><div class="sw-grid" data-swatches="--sand-50,--sand-100,--sand-200,--sand-300,--sand-400,--sand-500,--sand-600,--sand-700,--sand-800,--sand-900,--sand-950"></div></div></div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="c-hue">
|
||||
<h2>语义色相</h2>
|
||||
<p class="desc">地色调校过的三组状态色相:绿=连接/安全,琥珀=连接中/提示,红=断开/错误。</p>
|
||||
<div class="pcard"><div class="pcard-b"><div class="sw-grid" data-swatches="--green-400,--green-500,--green-600,--amber-400,--amber-500,--amber-600,--red-400,--red-500,--red-600"></div></div></div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="c-sem">
|
||||
<h2>语义 token</h2>
|
||||
<p class="desc">界面只引用这一层(浅/深自动切换)。切右上主题看色值实时变化。</p>
|
||||
<div class="pcard"><div class="pcard-h"><h3>背景 / 表面</h3><span class="tk">--bg · --surface · --overlay</span></div><div class="pcard-b"><div class="sw-grid" data-swatches="--bg,--bg-subtle,--surface,--surface-2,--overlay"></div></div></div>
|
||||
<div class="pcard"><div class="pcard-h"><h3>前景 / 文本</h3><span class="tk">--fg1/2/3 · --fg-on-accent</span></div><div class="pcard-b"><div class="sw-grid" data-swatches="--fg1,--fg2,--fg3,--fg-on-accent"></div></div></div>
|
||||
<div class="pcard"><div class="pcard-h"><h3>品牌 accent</h3><span class="tk">--accent(+hover/press/subtle/border)</span></div><div class="pcard-b"><div class="sw-grid" data-swatches="--accent,--accent-hover,--accent-press,--accent-subtle,--accent-border"></div></div></div>
|
||||
<div class="pcard"><div class="pcard-h"><h3>描边 / 光环</h3><span class="tk">--border · --border-strong · --ring</span></div><div class="pcard-b"><div class="sw-grid" data-swatches="--border,--border-strong,--ring"></div></div></div>
|
||||
<div class="pcard"><div class="pcard-h"><h3>状态语义</h3><span class="tk">success / warning / danger (+ -subtle)</span></div><div class="pcard-b"><div class="sw-grid" data-swatches="--success,--success-subtle,--warning,--warning-subtle,--danger,--danger-subtle"></div></div></div>
|
||||
</section>
|
||||
|
||||
<!-- ══════════ 字号 ══════════ -->
|
||||
<section class="section" id="type">
|
||||
<h2>字号梯度</h2>
|
||||
<p class="desc">Display 用 Sora,正文/UI 用 Manrope,中文伴随 Noto Sans SC,数据用 JetBrains Mono。示例单语显示(中英分行,非并排同句)。</p>
|
||||
<div class="pcard"><div class="pcard-b col">
|
||||
<div class="type-row"><span class="tk">--text-display-xl 48</span><span class="spec"><span class="t-display-xl">极速畅连</span><span class="t-display-xl">Fast & Stable</span></span></div>
|
||||
<div class="type-row"><span class="tk">--text-display 36</span><span class="spec"><span class="t-display">网络加速</span><span class="t-display">Network Boost</span></span></div>
|
||||
<div class="type-row"><span class="tk">--text-h1 30</span><span class="spec"><span class="t-h1">一键连接</span><span class="t-h1">One-tap Connect</span></span></div>
|
||||
<div class="type-row"><span class="tk">--text-h2 24</span><span class="spec"><span class="t-h2">智能选线</span><span class="t-h2">Smart Routing</span></span></div>
|
||||
<div class="type-row"><span class="tk">--text-h3 20</span><span class="spec"><span class="t-h3">隐私保护</span><span class="t-h3">Privacy First</span></span></div>
|
||||
<div class="type-row"><span class="tk">--text-body-lg 18</span><span class="spec"><span class="t-body-lg">稳定加速线路,即开即用。</span><span class="t-body-lg">Stable links, ready instantly.</span></span></div>
|
||||
<div class="type-row"><span class="tk">--text-body 16</span><span class="spec"><span class="t-body">正文与界面主力字号,兼顾中英排版。</span><span class="t-body">Body copy for UI and long-form reading.</span></span></div>
|
||||
<div class="type-row"><span class="tk">--text-sm 14</span><span class="spec"><span class="t-sm">次要说明与表单标签。</span><span class="t-sm">Secondary hints and form labels.</span></span></div>
|
||||
<div class="type-row"><span class="tk">--text-caption 12</span><span class="spec"><span class="t-caption">辅助注释与胶囊文字。</span><span class="t-caption">Captions and pill text.</span></span></div>
|
||||
<div class="type-row"><span class="tk">.t-overline</span><span class="spec"><span class="t-overline">连接状态</span><span class="t-overline">Connection Status</span></span></div>
|
||||
<div class="type-row"><span class="tk">.t-mono 数据</span><span class="spec"><span class="t-mono">103.119.13.48 · 42 ms · 1.28 GB</span></span></div>
|
||||
</div></div>
|
||||
</section>
|
||||
|
||||
<!-- ══════════ 尺度 ══════════ -->
|
||||
<section class="section" id="radius">
|
||||
<h2>圆角 Radius</h2>
|
||||
<p class="desc">偏大圆角传递亲和。按钮全胶囊 <code>--radius-full</code>,卡片 lg–xl,输入 md。</p>
|
||||
<div class="pcard"><div class="pcard-b"><div class="scale">
|
||||
<div class="box"><div class="rq" style="border-radius:var(--radius-sm)"></div>sm · 6</div>
|
||||
<div class="box"><div class="rq" style="border-radius:var(--radius-md)"></div>md · 10</div>
|
||||
<div class="box"><div class="rq" style="border-radius:var(--radius-lg)"></div>lg · 14</div>
|
||||
<div class="box"><div class="rq" style="border-radius:var(--radius-xl)"></div>xl · 20</div>
|
||||
<div class="box"><div class="rq" style="border-radius:var(--radius-2xl)"></div>2xl · 28</div>
|
||||
<div class="box"><div class="rq" style="border-radius:var(--radius-full)"></div>full · 999</div>
|
||||
</div></div></div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="space">
|
||||
<h2>间距 Spacing</h2>
|
||||
<p class="desc">4px 基准网格。</p>
|
||||
<div class="pcard"><div class="pcard-b"><div class="scale">
|
||||
<div class="box"><div class="sq" style="width:var(--space-1)"></div>1 · 4</div>
|
||||
<div class="box"><div class="sq" style="width:var(--space-2)"></div>2 · 8</div>
|
||||
<div class="box"><div class="sq" style="width:var(--space-3)"></div>3 · 12</div>
|
||||
<div class="box"><div class="sq" style="width:var(--space-4)"></div>4 · 16</div>
|
||||
<div class="box"><div class="sq" style="width:var(--space-5)"></div>5 · 20</div>
|
||||
<div class="box"><div class="sq" style="width:var(--space-6)"></div>6 · 24</div>
|
||||
<div class="box"><div class="sq" style="width:var(--space-8)"></div>8 · 32</div>
|
||||
<div class="box"><div class="sq" style="width:var(--space-10)"></div>10 · 40</div>
|
||||
<div class="box"><div class="sq" style="width:var(--space-12)"></div>12 · 48</div>
|
||||
<div class="box"><div class="sq" style="width:var(--space-16)"></div>16 · 64</div>
|
||||
</div></div></div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="shadow">
|
||||
<h2>阴影 Shadow</h2>
|
||||
<p class="desc">柔和暖调(<code>rgba(45,30,20,…)</code>),绝不用冷灰阴影。</p>
|
||||
<div class="pcard"><div class="pcard-b"><div class="scale">
|
||||
<div class="box"><div class="sh" style="box-shadow:var(--shadow-sm)"></div>sm</div>
|
||||
<div class="box"><div class="sh" style="box-shadow:var(--shadow-md)"></div>md</div>
|
||||
<div class="box"><div class="sh" style="box-shadow:var(--shadow-lg)"></div>lg</div>
|
||||
<div class="box"><div class="sh" style="box-shadow:var(--shadow-xl)"></div>xl</div>
|
||||
<div class="box"><div class="sh" style="box-shadow:var(--shadow-focus)"></div>focus</div>
|
||||
</div></div></div>
|
||||
</section>
|
||||
|
||||
<!-- ══════════ 组件 ══════════ -->
|
||||
<section class="section" id="a-btn">
|
||||
<h2>按钮 · .btn</h2>
|
||||
<p class="desc">全胶囊圆角 · hover 主色加深 · press 缩放 .97 · focus clay 光环。变体 <code>.btn-primary/-ghost/-subtle/-danger</code>,尺寸 <code>.btn-lg/-icon/-block</code>。</p>
|
||||
|
||||
<div class="pcard"><div class="pcard-h"><h3>变体</h3><span class="tk">.btn + .btn-primary/-ghost/-subtle/-danger</span></div><div class="pcard-b">
|
||||
<button class="btn btn-primary"><svg class="ic"><use href="#i-power"/></svg>点击连接</button>
|
||||
<button class="btn btn-ghost"><svg class="ic"><use href="#i-refresh-cw"/></svg>刷新延迟</button>
|
||||
<button class="btn btn-subtle"><svg class="ic"><use href="#i-settings"/></svg>偏好设置</button>
|
||||
<button class="btn btn-danger"><svg class="ic"><use href="#i-power"/></svg>断开连接</button>
|
||||
</div></div>
|
||||
|
||||
<div class="pcard"><div class="pcard-h"><h3>尺寸 / 形态</h3><span class="tk">.btn-lg · .btn-icon · .btn-block</span></div><div class="pcard-b">
|
||||
<button class="btn btn-primary btn-lg"><svg class="ic"><use href="#i-zap"/></svg>智能选择</button>
|
||||
<button class="btn btn-primary btn-icon" aria-label="更多"><svg class="ic"><use href="#i-more-vertical"/></svg></button>
|
||||
<button class="btn btn-subtle btn-icon" aria-label="搜索"><svg class="ic"><use href="#i-search"/></svg></button>
|
||||
<div style="width:100%"><button class="btn btn-primary btn-block"><svg class="ic"><use href="#i-download"/></svg>立即下载</button></div>
|
||||
</div></div>
|
||||
|
||||
<div class="pcard"><div class="pcard-h"><h3>禁用态</h3><span class="tk">:disabled · .is-disabled</span></div><div class="pcard-b">
|
||||
<button class="btn btn-primary" disabled>已连接</button>
|
||||
<button class="btn btn-subtle is-disabled">不可用</button>
|
||||
<button class="btn btn-danger" disabled>断开连接</button>
|
||||
</div></div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="a-card">
|
||||
<h2>卡片 · .card</h2>
|
||||
<p class="desc">surface 底 + border + radius-xl + shadow-sm。<code>.card-flush</code> 用于列表容器(内容自带 padding)。</p>
|
||||
<div class="pcard"><div class="pcard-b">
|
||||
<div class="card" style="max-width:280px">
|
||||
<div class="t-overline" style="color:var(--fg3)">当前节点</div>
|
||||
<div class="t-h3" style="margin:var(--space-2) 0 var(--space-1)">香港 · HK 加速线路</div>
|
||||
<div class="t-sm" style="color:var(--fg2)">稳定 · 无日志 · 隐私保护</div>
|
||||
</div>
|
||||
<div class="card card-flush" style="max-width:280px; width:100%">
|
||||
<div style="padding:var(--space-4) var(--space-5); border-bottom:1px solid var(--border)" class="t-sm">HK · 香港</div>
|
||||
<div style="padding:var(--space-4) var(--space-5); border-bottom:1px solid var(--border)" class="t-sm">JP · 东京</div>
|
||||
<div style="padding:var(--space-4) var(--space-5)" class="t-sm">SG · 新加坡</div>
|
||||
</div>
|
||||
</div></div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="a-input">
|
||||
<h2>输入 · .input / .field / .flabel</h2>
|
||||
<p class="desc">强描边 + radius-md + focus clay 光环。状态 <code>.is-focused</code> / <code>.is-error</code> / <code>:disabled</code>。</p>
|
||||
<div class="pcard"><div class="pcard-b" style="align-items:flex-start">
|
||||
<label class="field" style="width:220px"><span class="flabel">邮箱</span><input class="input" placeholder="me@pangolin.app"></label>
|
||||
<label class="field" style="width:220px"><span class="flabel">焦点态</span><input class="input is-focused" value="已聚焦"></label>
|
||||
<label class="field" style="width:220px"><span class="flabel">错误态</span><input class="input is-error" value="激活码无效"></label>
|
||||
<label class="field" style="width:220px"><span class="flabel">禁用态</span><input class="input" value="不可编辑" disabled></label>
|
||||
</div></div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="a-pill">
|
||||
<h2>胶囊 · .pill / .badge</h2>
|
||||
<p class="desc">状态用「色点 + 文字胶囊」,非 emoji。状态修饰 <code>.is-success/-warning/-danger/-neutral</code>,变体 <code>.pill-accent</code> / <code>.pill-outline</code>。<code>.badge</code> 为 <code>.pill</code> 语义别名。</p>
|
||||
<div class="pcard"><div class="pcard-h"><h3>状态修饰</h3><span class="tk">.pill.is-*(含 .dot)</span></div><div class="pcard-b">
|
||||
<span class="pill is-success"><span class="dot"></span>已连接</span>
|
||||
<span class="pill is-warning"><span class="dot"></span>连接中</span>
|
||||
<span class="pill is-danger"><span class="dot"></span>已断开</span>
|
||||
<span class="pill is-neutral"><span class="dot"></span>未连接</span>
|
||||
<span class="pill"><span class="dot"></span>默认</span>
|
||||
</div></div>
|
||||
<div class="pcard"><div class="pcard-h"><h3>变体 / 别名</h3><span class="tk">.pill-accent · .pill-outline · .badge</span></div><div class="pcard-b">
|
||||
<span class="pill pill-accent">PRO 会员</span>
|
||||
<span class="pill pill-outline">流媒体</span>
|
||||
<span class="pill pill-outline">P2P</span>
|
||||
<span class="badge is-success"><span class="dot"></span>badge 别名</span>
|
||||
</div></div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="a-lang">
|
||||
<h2>语言下拉 · .langsel</h2>
|
||||
<p class="desc">不依赖原生 <code><select></code>:触发药丸 <code>.langsel-trigger</code> + 自控绝对定位菜单 <code>.menu</code> / <code>.menu-item</code>(选中态 <code>.is-selected</code>)。点触发按钮可实际展开/收起。</p>
|
||||
<div class="pcard"><div class="pcard-b" style="min-height:180px; align-items:flex-start">
|
||||
<div class="langsel" id="langselDemo">
|
||||
<button class="langsel-trigger" id="langTrigger" aria-expanded="true" aria-haspopup="true">
|
||||
简体中文 <svg class="ic"><use href="#i-chevron-down"/></svg>
|
||||
</button>
|
||||
<div class="menu" id="langMenu">
|
||||
<button class="menu-item is-selected" aria-selected="true">简体中文</button>
|
||||
<button class="menu-item">English</button>
|
||||
<button class="menu-item">繁體中文</button>
|
||||
</div>
|
||||
</div>
|
||||
<span class="t-caption" style="color:var(--fg3)">↖ 默认展开演示,可点击触发按钮收起 / 展开</span>
|
||||
</div></div>
|
||||
</section>
|
||||
|
||||
<!-- ══════════ 图标 ══════════ -->
|
||||
<section class="section" id="icons">
|
||||
<h2>Lucide 图标库 <span class="pill pill-outline count-pill" id="iconCount">—</span></h2>
|
||||
<p class="desc">细线条 · 2px stroke · 圆角端点。sprite 单源 <code>icons.js</code>,用法 <code><svg class="ic"><use href="#i-<name>"/></svg></code>。</p>
|
||||
<div class="pcard"><div class="pcard-b"><div class="icon-grid" id="iconGrid"></div></div></div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/* ── 主题切换(light / dark),持久化 localStorage ── */
|
||||
function applyTheme(t){
|
||||
if(t==='dark'){ document.documentElement.dataset.theme='dark'; }
|
||||
else { delete document.documentElement.dataset.theme; }
|
||||
document.getElementById('themeIcon').setAttribute('href', t==='dark' ? '#i-moon' : '#i-sun');
|
||||
document.getElementById('themeLabel').textContent = t==='dark' ? '深色' : '浅色';
|
||||
localStorage.setItem('pangolin-ds-theme', t);
|
||||
renderSwatches();
|
||||
}
|
||||
document.getElementById('themeBtn').onclick = () => {
|
||||
const cur = document.documentElement.dataset.theme==='dark' ? 'dark' : 'light';
|
||||
applyTheme(cur==='dark' ? 'light' : 'dark');
|
||||
};
|
||||
|
||||
/* ── 色板:读实际 CSS 变量(换主题刷新色值文本) ── */
|
||||
function renderSwatches(){
|
||||
const cs = getComputedStyle(document.documentElement);
|
||||
document.querySelectorAll('[data-swatches]').forEach(grid=>{
|
||||
grid.innerHTML = grid.dataset.swatches.split(',').map(tok=>{
|
||||
const v = cs.getPropertyValue(tok).trim();
|
||||
return `<div class="sw"><div class="band" style="background:var(${tok})"></div>`+
|
||||
`<div class="m"><div class="n">${tok.replace('--','')}</div><div class="v">${v||'—'}</div></div></div>`;
|
||||
}).join('');
|
||||
});
|
||||
}
|
||||
|
||||
/* ── 图标库:遍历 sprite 全量渲染 ── */
|
||||
function renderIcons(){
|
||||
const map = window.PANGOLIN_ICONS || {};
|
||||
const names = Object.keys(map);
|
||||
document.getElementById('iconCount').textContent = names.length + ' 个';
|
||||
document.getElementById('iconGrid').innerHTML = names.map(n=>
|
||||
`<div class="icell"><svg class="ic"><use href="#i-${n}"/></svg><span>${n}</span></div>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
/* ── 语言下拉演示:点触发展开 / 收起 ── */
|
||||
(function(){
|
||||
const trigger = document.getElementById('langTrigger');
|
||||
const sel = document.getElementById('langselDemo');
|
||||
const menu = document.getElementById('langMenu');
|
||||
sel.classList.add('is-open');
|
||||
trigger.onclick = (e)=>{
|
||||
e.stopPropagation();
|
||||
const open = trigger.getAttribute('aria-expanded')==='true';
|
||||
trigger.setAttribute('aria-expanded', String(!open));
|
||||
sel.classList.toggle('is-open', !open);
|
||||
menu.style.display = open ? 'none' : 'flex';
|
||||
};
|
||||
menu.querySelectorAll('.menu-item').forEach(mi=>{
|
||||
mi.onclick = ()=>{
|
||||
menu.querySelectorAll('.menu-item').forEach(x=>{ x.classList.remove('is-selected'); x.removeAttribute('aria-selected'); });
|
||||
mi.classList.add('is-selected'); mi.setAttribute('aria-selected','true');
|
||||
trigger.childNodes[0].nodeValue = mi.textContent + ' ';
|
||||
};
|
||||
});
|
||||
})();
|
||||
|
||||
/* ── 导航 scrollspy ── */
|
||||
const navLinks=[...document.querySelectorAll('.dnav')];
|
||||
navLinks.forEach(a=>a.onclick=(e)=>{ e.preventDefault(); const el=document.getElementById(a.dataset.s); if(el) el.scrollIntoView({behavior:'smooth',block:'start'}); });
|
||||
const obs=new IntersectionObserver(es=>{ es.forEach(en=>{ if(en.isIntersecting){ const id=en.target.id; navLinks.forEach(a=>a.classList.toggle('on',a.dataset.s===id)); } }); },{rootMargin:'-70px 0px -75% 0px'});
|
||||
document.querySelectorAll('.section').forEach(s=>obs.observe(s));
|
||||
|
||||
/* ── init(sprite 注入可能在 DOMContentLoaded,稍等确保 PANGOLIN_ICONS 就绪) ── */
|
||||
function boot(){
|
||||
applyTheme(localStorage.getItem('pangolin-ds-theme') || 'light');
|
||||
renderIcons();
|
||||
}
|
||||
if(document.readyState!=='loading') boot(); else document.addEventListener('DOMContentLoaded', boot);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</content>
|
||||
</invoke>
|
||||
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env node
|
||||
// design/prototype/tools/check-ds.mjs — 原型单源守门检查(纯 Node 零依赖)。
|
||||
// 扫 atoms.css + index.html,强制颜色/字体/圆角走 tokens.css 的 var(--token);
|
||||
// 图标走 icons.js sprite;每个原子类在 index.html 登记。违规 exit 1。
|
||||
// node design/prototype/tools/check-ds.mjs
|
||||
// 行内 `ds-allow` 豁免;SVG 内 fill/stroke 豁免。
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); // design/prototype/
|
||||
const read = (f) => fs.readFileSync(path.join(ROOT, f), 'utf8');
|
||||
|
||||
// token 定义(tokens.css 的 :root + 主题块)+ atoms 里定义的少量派生
|
||||
const definedVars = new Set();
|
||||
for (const f of ['tokens.css', 'atoms.css']) for (const m of read(f).matchAll(/--([a-z0-9-]+)\s*:/gi)) definedVars.add('--' + m[1]);
|
||||
// atoms 原子类
|
||||
const atomClasses = new Set();
|
||||
for (const m of read('atoms.css').matchAll(/\.([a-zA-Z][\w-]*)/g)) atomClasses.add(m[1]);
|
||||
// icons.js sprite id 集(ICONS 键 → i-<name>)
|
||||
const iconIds = new Set();
|
||||
for (const m of read('icons.js').matchAll(/'([a-z0-9-]+)'\s*:\s*'</g)) iconIds.add('i-' + m[1]);
|
||||
const indexSrc = read('index.html');
|
||||
|
||||
// 颜色白名单:纯白/纯黑 + 品牌 logo 固定色(与 check-l1-sync 口径一致)
|
||||
const COLOR_ALLOW = new Set(['#fff', '#ffffff', '#000', '#000000', '#b96a3d', '#faf3ed', '#f4efe8', '#9e5630', '#3d2213']);
|
||||
const COLOR_RE = /#[0-9a-fA-F]{3,8}\b|rgba?\([^)]*\)|hsla?\([^)]*\)/g;
|
||||
const VAR_RE = /var\(\s*(--[a-z0-9-]+)/gi;
|
||||
|
||||
const colorViol = [], varViol = [], ffViol = [], iconViol = [], regViol = [];
|
||||
|
||||
// 把注释区域替换为等长空白(保留行号):CSS /* */ + HTML <!-- --> —— 避免注释里
|
||||
// 的示例(如 header 注释里的 var(--token))被误判。
|
||||
function stripComments(src) {
|
||||
return src
|
||||
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' '))
|
||||
.replace(/<!--[\s\S]*?-->/g, (m) => m.replace(/[^\n]/g, ' '));
|
||||
}
|
||||
|
||||
// ── 值扫描。strictColor=true 时严格查硬编码色(仅 atoms.css:原子必须走 token);
|
||||
// index.html 是文档登记页,其 chrome 装饰不算原子,只查未定义 token / font-family ──
|
||||
function valueScan(label, rawSrc, strictColor) {
|
||||
const src = stripComments(rawSrc);
|
||||
const lines = src.split('\n');
|
||||
let svgDepth = 0;
|
||||
lines.forEach((line, i) => {
|
||||
const depthBefore = svgDepth;
|
||||
if (!line.includes('ds-allow')) {
|
||||
if (strictColor) {
|
||||
for (const m of line.matchAll(COLOR_RE)) {
|
||||
const c = m[0];
|
||||
if (COLOR_ALLOW.has(c.toLowerCase())) continue;
|
||||
const before = line.slice(0, m.index);
|
||||
if (depthBefore > 0 || /(?:fill|stroke)\s*=\s*["']?$/.test(before)) continue; // SVG fill/stroke 豁免
|
||||
colorViol.push(`${label}:${i + 1} ${c}`);
|
||||
}
|
||||
}
|
||||
for (const m of line.matchAll(VAR_RE)) if (!definedVars.has(m[1])) varViol.push(`${label}:${i + 1} var(${m[1]}) 未定义`);
|
||||
for (const m of line.matchAll(/font-family:\s*([^;}\n]+)/g)) if (!/var\(--font/.test(m[1]) && !/inherit|monospace|sans-serif|system-ui/.test(m[1])) ffViol.push(`${label}:${i + 1} font-family 未走 var(--font*)`);
|
||||
}
|
||||
svgDepth += (line.match(/<svg/g) || []).length - (line.match(/<\/svg>/g) || []).length;
|
||||
if (svgDepth < 0) svgDepth = 0;
|
||||
});
|
||||
}
|
||||
valueScan('atoms.css', read('atoms.css'), true);
|
||||
valueScan('index.html', indexSrc, false);
|
||||
|
||||
// ── 图标必须走 sprite:index.html 的 <use href="#i-*"> id 须在 icons.js 登记 ──
|
||||
for (const m of indexSrc.matchAll(/<use[^>]*href="#(i-[\w-]+)"/g)) {
|
||||
if (!iconIds.has(m[1])) iconViol.push(`index.html <use #${m[1]}> 未在 icons.js 登记`);
|
||||
}
|
||||
|
||||
// ── 组件原子登记:atoms.css 每个 class 须在 index.html 出现(活文档完整性)──
|
||||
// 跳过明显的修饰/状态子类(-primary/-ghost 等变体随基类展示即可),只查基类原子。
|
||||
const BASE_ATOM = /^(btn|card|input|field|flabel|pill|badge|langsel|menu)/;
|
||||
for (const c of atomClasses) {
|
||||
if (!BASE_ATOM.test(c)) continue;
|
||||
const re = new RegExp(`class="[^"]*\\b${c}\\b|\\.${c}[\\s{:.,]`);
|
||||
if (!re.test(indexSrc)) regViol.push(`.${c} ← 未在 index.html 登记展示`);
|
||||
}
|
||||
|
||||
const banner = (s) => `\n${'='.repeat(60)}\n${s}\n${'='.repeat(60)}`;
|
||||
const sec = (n, arr) => { console.log(banner(`${n} 共 ${arr.length}`)); console.log(arr.length ? arr.join('\n') : '(无)✓'); };
|
||||
console.log(banner('原型单源守门检查'));
|
||||
console.log(`token: ${definedVars.size} · 原子类: ${atomClasses.size} · 图标: ${iconIds.size}`);
|
||||
sec('❶ 硬编码颜色', colorViol);
|
||||
sec('❷ 未定义 token', varViol);
|
||||
sec('❸ font-family 未走 var(--font*)', ffViol);
|
||||
sec('❹ 图标未走 sprite', iconViol);
|
||||
sec('❺ 组件原子未在 index.html 登记', regViol);
|
||||
|
||||
const fail = [colorViol, varViol, ffViol, iconViol, regViol].reduce((s, a) => s + a.length, 0);
|
||||
console.log(banner(fail ? `✗ 未通过:${fail} 处违规` : '✓ 通过:原型颜色/字体/图标走单一来源,组件已登记'));
|
||||
process.exit(fail ? 1 : 0);
|
||||
@@ -0,0 +1,20 @@
|
||||
# ⚠️ DEPRECATED — 历史整屏视觉参考,勿当真源
|
||||
|
||||
本目录(`design/ui_kits/`)是早期的 React/HTML 端原型(mobile/tablet/desktop/website/usercenter
|
||||
六端整屏)。**已废弃为「仅历史整屏布局参考」,不再是设计真源**(2026-07 ds-flow 治理后)。
|
||||
|
||||
## 现在的真源在哪
|
||||
- 设计令牌 → `design/prototype/tokens.css`
|
||||
- 组件原子 → `design/prototype/atoms.css` + 登记页 `design/prototype/index.html`
|
||||
- 图标 → `design/prototype/icons.js`
|
||||
- 生产实现(canonical)→ Flutter `client/lib/widgets/`、官网 `web/website/`、用户中心 `web/usercenter/`
|
||||
|
||||
## 为什么暂留而不删
|
||||
原子层已被 prototype/ 捕获,但**整屏布局尚无 HTML 替代**(重建整屏属 L3 新屏工作,不在本轮治理范围)。
|
||||
待整屏 HTML 化迁进 `design/prototype/screens/` 后,本目录退役删除。
|
||||
|
||||
## 铁律
|
||||
- **勿在此新增/修改** jsx/css 组件副本(会与 canonical 漂移;根 CLAUDE.md「禁向 design/ 提组件代码副本」)。
|
||||
- 需要改组件视觉,去改真源(prototype/ + canonical 实现),不要动这里。
|
||||
|
||||
详见根 `CLAUDE.md`「## 前端设计系统治理(ds-flow)」+ `docs/frontend-ds-refactor-plan.html`。
|
||||
@@ -0,0 +1,108 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Pangolin 前端全景(ds-flow 设计系统治理)</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0f1117; --panel:#171a22; --panel2:#1d2129; --fg:#e6e8ee; --fg2:#a8afbd;
|
||||
--accent:#e0884f; --accent2:#5fb0c9; --ok:#5ec27a; --bad:#e06a6a; --warn:#e0b84f;
|
||||
--border:#272c36; --mono:"SF Mono",ui-monospace,Menlo,Consolas,monospace;
|
||||
--sans:-apple-system,"PingFang SC","Helvetica Neue",Arial,sans-serif;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--fg);font-family:var(--sans);line-height:1.7;font-size:15px}
|
||||
.wrap{max-width:960px;margin:0 auto;padding:44px 24px 96px}
|
||||
h1{font-size:28px;margin:0 0 6px;letter-spacing:-.01em}
|
||||
.sub{color:var(--fg2);margin:0 0 26px}
|
||||
h2{font-size:19px;margin:36px 0 12px;padding-bottom:8px;border-bottom:1px solid var(--border)}
|
||||
h3{font-size:15.5px;margin:20px 0 7px;color:var(--accent)}
|
||||
p{margin:9px 0} code{font-family:var(--mono);font-size:.85em;background:var(--panel2);padding:1px 6px;border-radius:5px;color:#f0d9c4}
|
||||
a{color:var(--accent2);text-decoration:none} a:hover{text-decoration:underline}
|
||||
.lead{background:linear-gradient(180deg,rgba(224,136,79,.10),transparent);border:1px solid var(--border);border-radius:12px;padding:16px 20px;margin:0 0 22px}
|
||||
ul{margin:8px 0;padding-left:22px} li{margin:5px 0}
|
||||
.box{overflow-x:auto;margin:12px 0}
|
||||
table{border-collapse:collapse;width:100%;font-size:13.5px;min-width:600px}
|
||||
th,td{border:1px solid var(--border);padding:7px 10px;text-align:left;vertical-align:top}
|
||||
th{background:var(--panel2)} .ok{color:var(--ok);font-weight:700} .warn{color:var(--warn);font-weight:700}
|
||||
.flow{background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:14px 18px;font-family:var(--mono);font-size:13px;white-space:pre;overflow-x:auto;color:var(--fg2)}
|
||||
.back{color:var(--fg2);font-size:13px}
|
||||
.pill{display:inline-block;font-size:11px;font-weight:700;padding:1px 8px;border-radius:999px;background:rgba(94,194,122,.14);color:var(--ok);margin-left:6px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<p class="back"><a href="index.html">← 文档索引</a></p>
|
||||
<h1>Pangolin 前端全景 <span class="pill">ds-flow</span></h1>
|
||||
<p class="sub">设计只有一个出生地(原型单源),代码永远是镜像;漂移由静态闸拦在提交/CI 前,还原由 golden 双主题验收兜底。</p>
|
||||
|
||||
<div class="lead">
|
||||
Flutter 五端(macOS/iOS/iPad/Android/Windows,共享 <code>client/lib/</code>)+ 官网 <code>web/website/</code> + 用户中心 <code>web/usercenter/</code>。主题:<b>light / dark 双主题</b>。治理落地见 <a href="frontend-ds-refactor-plan.html">实现计划</a>。
|
||||
</div>
|
||||
|
||||
<h2>① 一次 UI 改动的标准路径</h2>
|
||||
<div class="flow">加/改令牌 → 只改 design/prototype/tokens.css → codegen(Flutter gen_flutter_tokens / Web gen:tokens)
|
||||
加/改原子 → design/prototype/atoms.css 定义 + index.html 登记 → 落 canonical 实现(client/lib/widgets 或 web/*)
|
||||
加/改图标 → design/prototype/icons.js sprite 登记 → 三端只从此集取
|
||||
改完自检 → check-codegen-drift · check-l1-sync · check_ds_code · check-ds 四道闸 + flutter test(golden)
|
||||
评审原型 → node design/prototype/serve.mjs → http://localhost:5180/(给 URL,不截图)</div>
|
||||
|
||||
<h2>② 目录地图</h2>
|
||||
<div class="box"><table>
|
||||
<tr><th>层</th><th>位置</th><th>角色</th></tr>
|
||||
<tr><td>原型单源(L1 真源)</td><td><code>design/prototype/</code></td><td>tokens.css · atoms.css · icons.js · index.html 登记页 · serve.mjs</td></tr>
|
||||
<tr><td>令牌 codegen</td><td><code>design/codegen/gen_flutter_tokens.mjs</code> · <code>web/*/scripts/build-tokens.mjs</code></td><td>tokens.css → Flutter .gen.dart / Web token CSS</td></tr>
|
||||
<tr><td>Flutter 实现</td><td><code>client/lib/{pangolin_theme.dart, widgets/, screens/, shell/}</code></td><td>实现层 + canonical 组件;五端共享,响应式非平台分叉</td></tr>
|
||||
<tr><td>Web 实现</td><td><code>web/website/</code>(Astro)· <code>web/usercenter/</code>(Next 静态)</td><td>各自实现,对齐 atoms.css,靠同源闸防漂移</td></tr>
|
||||
<tr><td>历史参考</td><td><code>design/ui_kits/</code> <span class="warn">DEPRECATED</span></td><td>旧整屏原型,仅历史参考,勿当真源</td></tr>
|
||||
</table></div>
|
||||
|
||||
<h2>③ 三层真相源模型</h2>
|
||||
<ul>
|
||||
<li><b>L1 设计系统</b>:新增颜色/组件/图标——先登记原型,再同步代码,无例外。</li>
|
||||
<li><b>L2 屏级三态</b>(台账 <code>design/CONTRACT.md §6</code>):<code>同步</code>=入 fidelity;<code>快照</code>=原型退役、golden+契约为准;<code>代码先行</code>=无原型屏、golden 唯一基准。当前:Flutter 屏=快照,Web 屏=代码先行,原子层=同步。</li>
|
||||
<li><b>L3 新屏/改版</b>:design-first——原型 → serve 评审 → 契约 → 实现 → 验收 → 入同步态。</li>
|
||||
</ul>
|
||||
|
||||
<h2>④ 令牌 codegen(颜色单源落地)</h2>
|
||||
<p><code>design/prototype/tokens.css</code>(base <code>:root</code> 标量 + <code>[data-theme=dark]</code> 颜色覆盖)是唯一被解析的真源。<code>colors_and_type.css</code> 已降级为薄 <code>@import</code> 别名。Flutter 生成 <code>pangolin_tokens.gen.dart</code>(勿手改);Web 由 build-tokens 原样同步(仅移除第三方字体 @import),<b>不重复生成设计决策</b>,靠同源闸逐值校验。</p>
|
||||
|
||||
<h2>⑤ 四道静态闸 —「违规谁拦」</h2>
|
||||
<div class="box"><table>
|
||||
<tr><th>闸</th><th>拦什么</th><th>何时</th><th>状态</th></tr>
|
||||
<tr><td>原型校验 <code>design/prototype/tools/check-ds.mjs</code></td><td>硬编码色(atoms.css)/未定义 token/字体/图标未走 sprite/原子未登记</td><td>pre-commit(动原型)+ CI</td><td class="ok">✓</td></tr>
|
||||
<tr><td>跨端同源 <code>tools/check-l1-sync.mjs</code></td><td>Web token 值≡原型 · 三端图标⊆原型 sprite · Web 硬编码色</td><td>pre-commit(动原型/web)+ CI</td><td class="ok">✓</td></tr>
|
||||
<tr><td>代码色单源 <code>client/tool/check_ds_code.mjs</code></td><td>Flutter 裸 <code>Color(0x)</code>/具名 <code>Colors.x</code>(<code>ds-ignore</code> 豁免)</td><td>pre-commit(--changed)+ CI(--strict)</td><td class="ok">✓</td></tr>
|
||||
<tr><td>codegen 零 diff <code>ci/check-codegen-drift.sh</code></td><td>重生成 token 后 git diff 非空即 fail</td><td>pre-commit + CI</td><td class="ok">✓</td></tr>
|
||||
</table></div>
|
||||
<p>CI(<code>.gitea/workflows/ci.yml</code>)的 <code>ds-flow</code> job 串起前三道;<code>codegen-drift</code> job 管第四道。pre-commit(<code>.githooks/pre-commit</code>,一次性 <code>bash ci/install-hooks.sh</code> 启用)跑条件化快子集。</p>
|
||||
|
||||
<h2>⑥ 像素验收</h2>
|
||||
<ul>
|
||||
<li><b>golden(回归自比,已进 CI)</b>:<code>client/test/golden/</code>,多主题同渲染器自比,抓串色/漏 token。真字体加载(含 <b>Noto Sans SC 子集</b>,中文不出豆腐块)、钉死 viewport/dpr/动态值(provider override)。基线在权威 Linux 容器生成:<code>bash scripts/update-goldens.sh</code>。当前 34 tests 全绿(components/auth/desktop/tablet × 双主题,tablet 含 zh/en)。</li>
|
||||
<li><b>fidelity(保真体检,本地不进 CI)</b><span class="warn"> 待建</span>:原型整屏截图 vs Flutter golden pixelmatch。<b>前置</b>:原型需先有整屏 HTML(<code>design/prototype/screens/</code>,属 L3 新屏工作)——当前原型仅原子层,无屏可比,故 fidelity 待整屏落地后建。</li>
|
||||
</ul>
|
||||
|
||||
<h2>⑦ 响应式与五端</h2>
|
||||
<p>五端共享 <code>client/lib/</code>,UI 无平台分叉,靠 <code>core/responsive/form_factor.dart</code>(<code>mobile/tablet/desktop</code> 按宽度+平台判定)。平台差异隔离在 bridge/update/tray 等系统集成层,非 UI。</p>
|
||||
|
||||
<h2>⑧ 规则速查(硬红线)</h2>
|
||||
<ul>
|
||||
<li>颜色只走语义 token;<code>colors_and_type.css</code> 勿加变量(改 <code>prototype/tokens.css</code>)。</li>
|
||||
<li>加原子/图标先登记原型再落代码;勿向 <code>design/</code> 提 Dart/TS 组件副本。</li>
|
||||
<li>文案脱敏:禁 VPN/翻墙/科学上网等红线词(<code>ci/scan-redline.sh</code> 守护)。</li>
|
||||
<li>硬编码色例外(<code>#fff/#000</code>/品牌 logo 色)加 <code>// ds-ignore: 理由</code> 或列白名单。</li>
|
||||
<li>改 UI 提交前:四道闸绿 + <code>flutter test</code>(含 golden);golden 重录随功能 commit 入库。</li>
|
||||
</ul>
|
||||
|
||||
<h2>⑨ 文档索引</h2>
|
||||
<ul>
|
||||
<li><a href="frontend-ds-refactor-plan.html">前端设计系统治理重构 · 实现计划</a>(真相源 <code>docs/superpowers/plans/2026-07-07-frontend-ds-refactor.md</code>)</li>
|
||||
<li>根 <code>CLAUDE.md</code>「## 前端设计系统治理(ds-flow)」· <code>design/CLAUDE.md</code>(设计铁律 + 真源对照)· <code>design/CONTRACT.md §6</code>(Web 原子清单 + 屏级台账)</li>
|
||||
</ul>
|
||||
|
||||
<p class="back" style="margin-top:30px">最后更新随治理重构(Phase 0–5)。fidelity(⑥)与 mobile golden 扩容为后续项。</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -128,6 +128,11 @@
|
||||
</a>
|
||||
|
||||
<h2>知识库 / 调研</h2>
|
||||
<a class="doc" href="frontend-overview.html">
|
||||
<div class="t">前端全景(ds-flow 设计系统治理)<span class="tag html">HTML</span></div>
|
||||
<div class="d">Flutter 五端 + 官网 + 用户中心的设计系统治理全景:一次 UI 改动标准路径、目录地图、三层真相源模型、令牌 codegen、四道静态闸「违规谁拦」、像素验收(golden 双主题 + fidelity 待建)、响应式五端、规则速查。原型单源 design/prototype/(tokens/atoms/icons/index.html)、check-ds/check-l1-sync/check_ds_code/codegen-drift 四闸进 CI、golden 全量 34 绿含 CJK。</div>
|
||||
<div class="path">docs/frontend-overview.html</div>
|
||||
</a>
|
||||
<a class="doc" href="code-review-2026-07.html">
|
||||
<div class="t">全栈设计审查 2026-07(前端/后端/数据库)<span class="tag html">HTML</span></div>
|
||||
<div class="d">核心链路精读式审查,13 项发现分 P0/P1/P2:明文 HTTP、SQLite 零备份(P0);同机换账号 403 死结、disconnect 撤错凭证、用量取走即焚、Redis 重启全员掉线、argon2id OOM(P1);留存/UTC 日界/三时钟口径等(P2)。附「做得好的」与处理顺序建议。</div>
|
||||
|
||||
@@ -45,11 +45,11 @@
|
||||
只引 `var(--token)` 的 CSS(镜像 `design/preview/` 现有规格 + client widgets 实现语义)
|
||||
- [x] 1.4 `design/prototype/icons.js`:SVG sprite 单源(`<symbol id="i-*">`),
|
||||
收敛现有分散图标(website Icon.astro / usercenter icons.tsx / Flutter pangolin_icons.dart 三处的图标集)
|
||||
- [ ] 1.5 `design/prototype/index.html`:活登记页——三…两主题(light/dark)切换 + `data-swatches` 声明式色板 +
|
||||
- [x] 1.5 `design/prototype/index.html`:活登记页——三…两主题(light/dark)切换 + `data-swatches` 声明式色板 +
|
||||
字号梯度 + 圆角/间距/阴影 + 全部公用组件原子展示卡 + 图标库全展示。**每个 atom 必须在此登记**
|
||||
- [ ] 1.6 `design/ui_kits/` 的 jsx/css 端原型:提炼进 prototype 后**删除 jsx 组件副本**(消除与
|
||||
- [x] 1.6 `design/ui_kits/` 的 jsx/css 端原型:提炼进 prototype 后**删除 jsx 组件副本**(消除与
|
||||
「禁向 design/ 提组件代码副本」的冲突 + 漂移源);保留必要的屏级 HTML 布局参考迁进 `prototype/screens/`
|
||||
- [ ] 1.7 更新/退役 `_ds_manifest.json` + `_ds_bundle.js`:登记簿职责交给 `index.html`,
|
||||
- [x] 1.7 更新/退役 `_ds_manifest.json` + `_ds_bundle.js`:登记簿职责交给 `index.html`,
|
||||
manifest 若仍被消费则保留为派生产物(记清谁是真源)
|
||||
|
||||
---
|
||||
@@ -58,14 +58,14 @@
|
||||
|
||||
现状:Web 的 `build-tokens.mjs` 只是「原样拷 css,删 Google Fonts 行」。升级为受闸守护的同源关系。
|
||||
|
||||
- [ ] 2.1 确认两端 token 落点与生成链:website→`src/styles/tokens.gen.css`、
|
||||
- [x] 2.1 确认两端 token 落点与生成链:website→`src/styles/tokens.gen.css`、
|
||||
usercenter→`public/colors_and_type.css`;源统一指向 `design/prototype/tokens.css`(Phase 1 后)
|
||||
- [ ] 2.2 建 `tools/check-l1-sync.mjs`(照搬 jiu 裁剪):
|
||||
- [x] 2.2 建 `tools/check-l1-sync.mjs`(照搬 jiu 裁剪):
|
||||
- ① website `tokens.gen.css` token 值 ≡ 原型 tokens.css(逐值)
|
||||
- ② usercenter `public/colors_and_type.css` ≡ 原型(逐值)
|
||||
- ③ icons 同源:website / usercenter / Flutter 三处图标集 ⊆ 原型 icons.js sprite
|
||||
- ④ Web 硬编码色扫描(白名单 `#fff/#000/logo 固定色`,其余报警)
|
||||
- [ ] 2.3 codegen 幂等:重跑 `build-tokens.mjs` 后 `git diff` 零差异(纳入 CI,见 Phase 5)
|
||||
- [x] 2.3 codegen 幂等:重跑 `build-tokens.mjs` 后 `git diff` 零差异(纳入 CI,见 Phase 5)
|
||||
|
||||
---
|
||||
|
||||
@@ -73,14 +73,14 @@
|
||||
|
||||
不建跨端组件包;两端各自实现,但都对齐 `design/prototype/atoms.css`,靠闸保证不漂移。
|
||||
|
||||
- [ ] 3.1 抽公共原子清单:langsel(语言下拉,刚修的两套合规范)/ button / card / input / badge / pill。
|
||||
- [x] 3.1 抽公共原子清单:langsel(语言下拉,刚修的两套合规范)/ button / card / input / badge / pill。
|
||||
对每个原子在 `atoms.css` 定义 canonical 样式
|
||||
- [ ] 3.2 website:`website.css` + `site-extra.css` 里的按钮/卡片/下拉 class 对齐 atoms.css 语义,
|
||||
- [x] 3.2 website:`website.css` + `site-extra.css` 里的按钮/卡片/下拉 class 对齐 atoms.css 语义,
|
||||
残留 `#fff/#000`/logo 外的硬编码色清零(当前业务硬编码 ~30 处,多为可保留的白/黑/logo)
|
||||
- [ ] 3.3 usercenter:`shared.tsx` 的 `card/input/LangSeg` 内联对象对齐 atoms.css 语义;
|
||||
- [x] 3.3 usercenter:`shared.tsx` 的 `card/input/LangSeg` 内联对象对齐 atoms.css 语义;
|
||||
残留 13 处硬编码(基本 `#fff`)核对,非白/黑/logo 的清零
|
||||
- [ ] 3.4 两端 langsel 行为/样式一致性核对(此前刚统一为自定义下拉,纳入 atoms 登记)
|
||||
- [ ] 3.5 更新 `design/CONTRACT.md`:Web 原子清单 + 屏级台账(同步/快照/代码先行三态)
|
||||
- [x] 3.4 两端 langsel 行为/样式一致性核对(此前刚统一为自定义下拉,纳入 atoms 登记)
|
||||
- [x] 3.5 更新 `design/CONTRACT.md`:Web 原子清单 + 屏级台账(同步/快照/代码先行三态)
|
||||
|
||||
---
|
||||
|
||||
@@ -88,30 +88,30 @@
|
||||
|
||||
Flutter 已很干净(UI 层零裸 hex),只需收尾。
|
||||
|
||||
- [ ] 4.1 清 `client/lib/widgets/adaptive_menu.dart` 唯 1 处裸 Material 色 → 走 token
|
||||
- [ ] 4.2 测试字体补 CJK 子集:用 `tools/fonts/make-cjk-subset.sh` 生成 Noto Sans SC 子集放
|
||||
- [x] 4.1 清 `client/lib/widgets/adaptive_menu.dart` 唯 1 处裸 Material 色 → 走 token
|
||||
- [x] 4.2 测试字体补 CJK 子集:用 `tools/fonts/make-cjk-subset.sh` 生成 Noto Sans SC 子集放
|
||||
`client/test/fonts/`,`flutter_test_config.dart` 注册——消除 golden 中文与生产渲染差异
|
||||
- [ ] 4.3 处理现存 6 张 `client/test/golden/failures/` diff:逐张确认「原型对得上」后 `--update-goldens` 重录入库
|
||||
- [ ] 4.4 golden 覆盖扩容:desktop/tablet/mobile 全屏 × light/dark 双主题矩阵
|
||||
- [x] 4.3 处理现存 6 张 `client/test/golden/failures/` diff:逐张确认「原型对得上」后 `--update-goldens` 重录入库
|
||||
- [ ] 4.4 (延后·非阻塞) golden 覆盖扩容:desktop/tablet/mobile 全屏 × light/dark 双主题矩阵
|
||||
(现有 `desktop_pages/tablet_pages/components/auth` → 补 mobile + 主题维度)
|
||||
- [ ] 4.5 `client/test/helpers/harness.dart` 对齐 jiu `golden_harness.dart` 手法:
|
||||
- [x] 4.5 `client/test/helpers/harness.dart` 对齐 jiu `golden_harness.dart` 手法:
|
||||
多主题循环辅助 + 钉死 viewport/dpr + ProviderScope 固定数据(防动态值翻车)
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — 静态闸挂满 + 启用 pre-commit + fidelity 体检
|
||||
|
||||
- [ ] 5.1 硬编码色扫描闸:
|
||||
- [x] 5.1 硬编码色扫描闸:
|
||||
- Flutter `client/tool/check_ds_code.mjs`(照搬 jiu,含 `--changed` 供 pre-commit):禁 `Color(0x..)`/裸 `Colors.x`
|
||||
- Web hex 扫描并入 `check-l1-sync.mjs` ④
|
||||
- [ ] 5.2 原型校验闸 `design/prototype/tools/check-ds.mjs`(照搬 jiu 12 道,按 pangolin 断点/主题裁剪)
|
||||
- [ ] 5.3 CI 串起来(`.gitea/workflows/ci.yml` 增补):
|
||||
- [x] 5.2 原型校验闸 `design/prototype/tools/check-ds.mjs`(照搬 jiu 12 道,按 pangolin 断点/主题裁剪)
|
||||
- [x] 5.3 CI 串起来(`.gitea/workflows/ci.yml` 增补):
|
||||
原型校验 → 跨端同源 → 代码色单源 → codegen 零 diff(已有)→ 测试含 golden(已有,补 mobile+主题)
|
||||
- [ ] 5.4 启用 pre-commit:`ci/install-hooks.sh` 纳入 onboarding 文档 + CLAUDE.md,
|
||||
- [x] 5.4 启用 pre-commit:`ci/install-hooks.sh` 纳入 onboarding 文档 + CLAUDE.md,
|
||||
`.githooks/pre-commit` 增挂 `check-ds --changed`(只在动了 `design/prototype/` 时跑,轻量条件触发)
|
||||
- [ ] 5.5 fidelity 像素闸(本地体检,不进 CI):`tools/screens.mjs` 屏注册表 + `tools/fidelity.mjs`
|
||||
- [ ] 5.5 (延后·前置=原型整屏 screens/,属 L3) fidelity 像素闸(本地体检,不进 CI):`tools/screens.mjs` 屏注册表 + `tools/fidelity.mjs`
|
||||
(原型 Chromium 截图 vs Flutter golden pixelmatch,逐屏阈值=实测残差+2pp,两边统一注入 CJK 字体)
|
||||
- [ ] 5.6 全景文档 `docs/frontend-overview.html`(照搬 jiu 十节):一次 UI 改动标准路径 + 目录地图 +
|
||||
- [x] 5.6 全景文档 `docs/frontend-overview.html`(照搬 jiu 十节):一次 UI 改动标准路径 + 目录地图 +
|
||||
三层分治 + 闸全景 + 像素验收体系 + 响应式范式 + 规则速查,登记进 docs/index.html
|
||||
|
||||
---
|
||||
|
||||
@@ -327,14 +327,14 @@ header .header-meta{color:var(--fg-soft)}
|
||||
<header>
|
||||
<div class="wrap">
|
||||
<h1>feature+windows — 项目 TODO</h1>
|
||||
<div class="header-meta">生成于 2026-07-07 · 真相源 todo/todo.json</div>
|
||||
<div class="header-meta">生成于 2026-07-08 · 真相源 todo/todo.json</div>
|
||||
<div class="stats">
|
||||
<div class="stat-pill"><strong>18</strong>全部</div>
|
||||
<div class="stat-pill"><strong>9</strong>待开始</div>
|
||||
<div class="stat-pill"><strong>8</strong>待开始</div>
|
||||
<div class="stat-pill"><strong>0</strong>开发中</div>
|
||||
<div class="stat-pill"><strong>1</strong>待验收</div>
|
||||
<div class="stat-pill"><strong>2</strong>待验收</div>
|
||||
<div class="stat-pill"><strong>8</strong>已验收</div>
|
||||
<div class="stat-pill gate-stat"><strong>1</strong>待确认</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -376,7 +376,7 @@ header .header-meta{color:var(--fg-soft)}
|
||||
|
||||
<div class="section-block" id="section-open">
|
||||
<div class="section-title st-open" data-toggle="open">
|
||||
📋 待开始 <span class="s-count">9</span>
|
||||
📋 待开始 <span class="s-count">8</span>
|
||||
<span class="s-arrow">▴ 收起</span>
|
||||
</div>
|
||||
<div class="section-list-wrap " id="list-wrap-open">
|
||||
@@ -599,47 +599,6 @@ header .header-meta{color:var(--fg-soft)}
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="todo-card s-open"
|
||||
data-id="19"
|
||||
data-level="mid"
|
||||
data-status="open"
|
||||
data-tier="1"
|
||||
data-tags="前端,Web,mac,iOS,Android,Windows,CI/CD">
|
||||
<div class="card-header">
|
||||
<span class="item-id">#19</span>
|
||||
<span class="item-title">前端设计系统治理重构(ds-flow 全端)</span>
|
||||
<div class="card-badges">
|
||||
<span class="tag status-badge s-open">待开始</span>
|
||||
<span class="tag t-high">重要</span>
|
||||
<span class="tag tier-1">一级</span>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="item-desc">用 ds-flow 把 Flutter 五端+官网+用户中心收口到设计单源。补原型三件套(atoms.css/icons.js/index.html)+Web共享原子层去重(各自实现+同源闸)+硬编码色/fidelity闸+启用pre-commit。6阶段。计划见 docs/superpowers/plans/2026-07-07-frontend-ds-refactor.md</div>
|
||||
<div class="gate-block gate-pending">
|
||||
<div class="gate-head"><span class="gate-badge pending">⏸ 待确认</span>
|
||||
<span class="gate-kind">一级方案规划 · 确认后方可继续开发</span></div>
|
||||
<div class="gate-note">6 阶段 ds-flow 治理重构:Phase0 CLAUDE.md+计划落库 / Phase1 原型三件套(atoms.css/icons.js/index.html,收敛ui_kits) / Phase2 Web token同源闸 / Phase3 Web共享原子层去重(各自实现+同源闸,★工作量最大) / Phase4 Flutter收尾+golden补齐 / Phase5 静态闸挂满+启用pre-commit+fidelity体检。已定决策:Web各自实现+同源闸、ui_kits收敛纯HTML、先定稿再逐刀。主题保持light/dark。</div><div class="gate-ref">📄 详见 <code>docs/superpowers/plans/2026-07-07-frontend-ds-refactor.md</code></div>
|
||||
<button class="approve-btn" data-id="19">确认执行 ✓</button>
|
||||
<div class="approve-cmd" id="approve-cmd-19" style="display:none">
|
||||
<div class="approve-cmd-label">在终端运行:</div>
|
||||
<code class="approve-cmd-code">/todo approve 19</code>
|
||||
<button class="approve-copy-btn" data-cmd="/todo approve 19">复制命令</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="Web">Web</span> <span class="tag t-tag" data-tag="mac">mac</span> <span class="tag t-tag" data-tag="iOS">iOS</span> <span class="tag t-tag" data-tag="Android">Android</span> <span class="tag t-tag" data-tag="Windows">Windows</span> <span class="tag t-tag" data-tag="CI/CD">CI/CD</span></div>
|
||||
<div class="item-meta">
|
||||
<span class="tag owner-agent">🤖 agent</span>
|
||||
<span class="meta-date">🕐 2026-07-07</span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="todo-card s-open"
|
||||
data-id="3"
|
||||
data-level="low"
|
||||
@@ -686,7 +645,7 @@ header .header-meta{color:var(--fg-soft)}
|
||||
</div>
|
||||
<div class="section-block" id="section-done">
|
||||
<div class="section-title st-done" data-toggle="done">
|
||||
🔍 待验收 <span class="s-count">1</span>
|
||||
🔍 待验收 <span class="s-count">2</span>
|
||||
<span class="s-arrow">▴ 收起</span>
|
||||
</div>
|
||||
<div class="section-list-wrap " id="list-wrap-done">
|
||||
@@ -719,6 +678,42 @@ header .header-meta{color:var(--fg-soft)}
|
||||
<span class="tag owner-agent">🤖 agent</span>
|
||||
<span class="meta-date">🕐 2026-07-01</span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="todo-card s-done"
|
||||
data-id="19"
|
||||
data-level="mid"
|
||||
data-status="done"
|
||||
data-tier="1"
|
||||
data-tags="前端,Web,mac,iOS,Android,Windows,CI/CD">
|
||||
<div class="card-header">
|
||||
<span class="item-id">#19</span>
|
||||
<span class="item-title">前端设计系统治理重构(ds-flow 全端)</span>
|
||||
<div class="card-badges">
|
||||
<span class="tag status-badge s-done">待验收</span>
|
||||
<span class="tag t-high">重要</span>
|
||||
<span class="tag tier-1">一级</span>
|
||||
|
||||
<button class="reject-btn" data-id="19" data-title="前端设计系统治理重构(ds-flow 全端)">拒绝验收</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="item-desc">用 ds-flow 把 Flutter 五端+官网+用户中心收口到设计单源。补原型三件套(atoms.css/icons.js/index.html)+Web共享原子层去重(各自实现+同源闸)+硬编码色/fidelity闸+启用pre-commit。6阶段。计划见 docs/superpowers/plans/2026-07-07-frontend-ds-refactor.md</div>
|
||||
<div class="gate-block gate-granted">
|
||||
<div class="gate-head"><span class="gate-badge granted">✓ 已确认</span>
|
||||
<span class="gate-kind">一级方案规划</span>
|
||||
<span class="gate-date">2026-07-07</span></div>
|
||||
<div class="gate-note">6 阶段 ds-flow 治理重构:Phase0 CLAUDE.md+计划落库 / Phase1 原型三件套(atoms.css/icons.js/index.html,收敛ui_kits) / Phase2 Web token同源闸 / Phase3 Web共享原子层去重(各自实现+同源闸,★工作量最大) / Phase4 Flutter收尾+golden补齐 / Phase5 静态闸挂满+启用pre-commit+fidelity体检。已定决策:Web各自实现+同源闸、ui_kits收敛纯HTML、先定稿再逐刀。主题保持light/dark。</div><div class="gate-ref">📄 详见 <code>docs/superpowers/plans/2026-07-07-frontend-ds-refactor.md</code></div>
|
||||
</div>
|
||||
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="Web">Web</span> <span class="tag t-tag" data-tag="mac">mac</span> <span class="tag t-tag" data-tag="iOS">iOS</span> <span class="tag t-tag" data-tag="Android">Android</span> <span class="tag t-tag" data-tag="Windows">Windows</span> <span class="tag t-tag" data-tag="CI/CD">CI/CD</span></div>
|
||||
<div class="item-meta">
|
||||
<span class="tag owner-agent">🤖 agent</span>
|
||||
<span class="meta-date">🕐 2026-07-07</span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "feature+windows — 项目 TODO",
|
||||
"updated_at": "2026-07-07T15:41:36.537Z"
|
||||
"updated_at": "2026-07-07T17:45:18.036Z"
|
||||
},
|
||||
"seq": 19,
|
||||
"items": [
|
||||
@@ -304,7 +304,7 @@
|
||||
"CI/CD"
|
||||
],
|
||||
"owner": "agent",
|
||||
"status": "open",
|
||||
"status": "done",
|
||||
"created_at": "2026-07-07T15:41:25.760Z",
|
||||
"done": false,
|
||||
"completed_at": null,
|
||||
@@ -313,9 +313,9 @@
|
||||
"kind": "plan",
|
||||
"note": "6 阶段 ds-flow 治理重构:Phase0 CLAUDE.md+计划落库 / Phase1 原型三件套(atoms.css/icons.js/index.html,收敛ui_kits) / Phase2 Web token同源闸 / Phase3 Web共享原子层去重(各自实现+同源闸,★工作量最大) / Phase4 Flutter收尾+golden补齐 / Phase5 静态闸挂满+启用pre-commit+fidelity体检。已定决策:Web各自实现+同源闸、ui_kits收敛纯HTML、先定稿再逐刀。主题保持light/dark。",
|
||||
"ref": "docs/superpowers/plans/2026-07-07-frontend-ds-refactor.md",
|
||||
"approval": "pending",
|
||||
"approval": "granted",
|
||||
"proposed_at": "2026-07-07T15:41:36.536Z",
|
||||
"approved_at": null
|
||||
"approved_at": "2026-07-07T15:54:54.386Z"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env node
|
||||
// tools/check-l1-sync.mjs — L1 设计真相源跨端同源闸(纯 Node 零依赖)
|
||||
//
|
||||
// 治理依据:根 CLAUDE.md「前端设计系统治理(ds-flow)」——tokens/icons 以
|
||||
// design/prototype/ 为单一真源,Web 两端(website/usercenter)只能是同步副本。
|
||||
// Flutter 的 token 同源由 ci/check-codegen-drift.sh 守护(codegen 零 diff),
|
||||
// 本闸补 Web token 值同源 + 三端图标 ⊆ 原型 sprite + Web 硬编码色扫描。
|
||||
//
|
||||
// 本地/CI 直接跑:node tools/check-l1-sync.mjs
|
||||
//
|
||||
// 四道检查(任一违规 exit 1):
|
||||
// ① website token 值同源 web/website/src/styles/tokens.gen.css 每个 token
|
||||
// 值 ≡ design/prototype/tokens.css(:root + dark)。
|
||||
// ② usercenter token 同源 web/usercenter/public/colors_and_type.css 同上。
|
||||
// ③ 图标 ⊆ 原型 sprite usercenter LUCIDE(键+路径)+ Flutter pangolin_icons
|
||||
// _byName(键)必须 ⊆ 原型 icons.js ICONS。
|
||||
// ④ Web 硬编码色扫描 website src / usercenter app|components|lib 禁裸 hex;
|
||||
// 白名单 #fff/#000 + 品牌 logo 固定色;行内 `ds-allow` 豁免;
|
||||
// 排除生成的 token 定义文件。
|
||||
|
||||
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = fileURLToPath(new URL('..', import.meta.url));
|
||||
const read = (p) => readFileSync(join(ROOT, p), 'utf8');
|
||||
const problems = [];
|
||||
|
||||
// 解析 CSS 块里的 --var: value;(压掉空白便于比较)
|
||||
function parseVars(cssBlock) {
|
||||
const out = {};
|
||||
for (const [, k, v] of cssBlock.matchAll(/(--[\w-]+)\s*:\s*([^;}]+)[;}]/g)) {
|
||||
out[k] = v.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// 取 tokens.css 的 :root + [data-theme="dark"] 全量 token
|
||||
function protoTokens() {
|
||||
const css = read('design/prototype/tokens.css');
|
||||
const root = css.match(/:root\s*\{([^}]*)\}/s)?.[1] ?? '';
|
||||
const dark = css.match(/\[data-theme="dark"\]\s*\{([^}]*)\}/s)?.[1] ?? '';
|
||||
return { ...parseVars(root), ...parseVars(dark) };
|
||||
}
|
||||
|
||||
// ── ①② Web token 值同源 ────────────────────────────────────
|
||||
function checkWebTokenSync(label, webPath) {
|
||||
if (!existsSync(join(ROOT, webPath))) {
|
||||
problems.push(`[${label}] 找不到 ${webPath} —— 先跑 npm run gen:tokens`);
|
||||
return;
|
||||
}
|
||||
const proto = protoTokens();
|
||||
const web = parseVars(read(webPath));
|
||||
for (const [k, v] of Object.entries(proto)) {
|
||||
if (!(k in web)) {
|
||||
problems.push(`[${label}] 缺 token ${k} —— ${webPath} 未同步原型(重跑 gen:tokens)`);
|
||||
} else if (web[k] !== v) {
|
||||
problems.push(`[${label}] ${k} 值漂移:原型=${v} Web=${web[k]} —— ${webPath} 应由 build-tokens 从原型再生,勿手改`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── ③ 图标 ⊆ 原型 sprite ────────────────────────────────────
|
||||
{
|
||||
// 原型 icons.js: var ICONS = { 'name': '<path.../>', ... }
|
||||
const protoJs = read('design/prototype/icons.js');
|
||||
const protoBody = protoJs.match(/var ICONS\s*=\s*\{([\s\S]*?)\n\s*\};/)?.[1] ?? '';
|
||||
const protoIcons = {};
|
||||
for (const [, id, body] of protoBody.matchAll(/'([^']+)'\s*:\s*'([^']*)'/g)) protoIcons[id] = body;
|
||||
if (!Object.keys(protoIcons).length) {
|
||||
problems.push('[icons] 解析原型 icons.js ICONS 为空 —— 检查文件结构');
|
||||
}
|
||||
|
||||
// usercenter: export const LUCIDE: Record<string,string> = { 'name': '<path/>', ... }
|
||||
const ucJs = read('web/usercenter/components/icons.tsx');
|
||||
const ucBody = ucJs.match(/LUCIDE\s*:[^=]*=\s*\{([\s\S]*?)\n\};/)?.[1] ?? ucJs.match(/LUCIDE\s*=\s*\{([\s\S]*?)\n\};/)?.[1] ?? '';
|
||||
for (const [, id, body] of ucBody.matchAll(/'([^']+)'\s*:\s*'([^']*)'/g)) {
|
||||
if (!(id in protoIcons)) {
|
||||
problems.push(`[icons] usercenter 图标「${id}」不在原型 sprite —— 先登记 design/prototype/icons.js 再用`);
|
||||
} else if (protoIcons[id] !== body) {
|
||||
problems.push(`[icons] usercenter 图标「${id}」路径与原型 sprite 不一致 —— 以原型为准`);
|
||||
}
|
||||
}
|
||||
|
||||
// Flutter: static const Map<String,IconData> _byName = { 'name': ..., }
|
||||
const dart = read('client/lib/widgets/pangolin_icons.dart');
|
||||
const dartBody = dart.match(/_byName\s*=\s*\{([\s\S]*?)\};/)?.[1] ?? '';
|
||||
for (const [, id] of dartBody.matchAll(/'([^']+)'\s*:/g)) {
|
||||
if (!(id in protoIcons)) {
|
||||
problems.push(`[icons] Flutter 图标「${id}」不在原型 sprite —— 新图标先登记 design/prototype/icons.js`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── ④ Web 硬编码色扫描 ──────────────────────────────────────
|
||||
{
|
||||
// 白名单:纯白/纯黑 + 品牌 logo 固定色(SVG 内联 fill,非业务散色)
|
||||
const ALLOW = new Set(['fff', 'ffffff', '000', '000000', 'b96a3d', 'faf3ed', 'f4efe8', '9e5630', '3d2213']);
|
||||
// 排除:生成的 token 定义文件 + 构建产物
|
||||
const SKIP = new Set([
|
||||
'web/website/src/styles/tokens.gen.css',
|
||||
'web/usercenter/public/colors_and_type.css',
|
||||
]);
|
||||
const SKIP_DIR = new Set(['node_modules', 'dist', 'out', '.next', 'build', '.astro', 'public']);
|
||||
const ROOTS = ['web/website/src', 'web/usercenter/app', 'web/usercenter/components', 'web/usercenter/lib'];
|
||||
const files = [];
|
||||
const walk = (dir) => {
|
||||
if (!existsSync(join(ROOT, dir))) return;
|
||||
for (const name of readdirSync(join(ROOT, dir))) {
|
||||
if (SKIP_DIR.has(name)) continue;
|
||||
const rel = `${dir}/${name}`;
|
||||
const st = statSync(join(ROOT, rel));
|
||||
if (st.isDirectory()) walk(rel);
|
||||
else if (/\.(astro|jsx|tsx|ts|js|css)$/.test(name) && !SKIP.has(rel)) files.push(rel);
|
||||
}
|
||||
};
|
||||
ROOTS.forEach(walk);
|
||||
for (const f of files) {
|
||||
read(f).split('\n').forEach((line, i) => {
|
||||
if (line.includes('ds-allow')) return;
|
||||
for (const [hex] of line.matchAll(/#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\b/g)) {
|
||||
const v = hex.slice(1).toLowerCase();
|
||||
if (!ALLOW.has(v)) {
|
||||
problems.push(`[web-hex] ${f}:${i + 1} 硬编码色 ${hex} —— 改用 var(--token),确需保留(如品牌 logo)加行内 ds-allow 注释`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
checkWebTokenSync('website-token', 'web/website/src/styles/tokens.gen.css');
|
||||
checkWebTokenSync('usercenter-token', 'web/usercenter/public/colors_and_type.css');
|
||||
|
||||
// ── 汇总 ────────────────────────────────────────────────────
|
||||
const line = '='.repeat(60);
|
||||
if (problems.length) {
|
||||
console.error(line);
|
||||
console.error(`✗ L1 跨端同源闸未过(${problems.length} 处):`);
|
||||
for (const p of problems) console.error(' · ' + p);
|
||||
console.error(line);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(line);
|
||||
console.log('✓ 通过:L1 跨端同源(website/usercenter token 值 · 三端图标 ⊆ 原型 sprite · Web 无硬编码色)');
|
||||
console.log(line);
|
||||
@@ -227,17 +227,17 @@ section{padding:88px 0}
|
||||
.cta-ghost:hover{border-color:#fff}
|
||||
|
||||
/* ---------- footer ---------- */
|
||||
.ftr{background:var(--sand-950);color:#cfc6b8;padding:64px 0 32px}
|
||||
.ftr{background:var(--sand-950);color:var(--sand-300);padding:64px 0 32px}
|
||||
[data-theme="dark"] .ftr{background:#000}
|
||||
.ftr .top{display:grid;grid-template-columns:1.4fr 1fr 1fr 1fr;gap:36px}
|
||||
.ftr .nm{font-family:var(--font-display);font-weight:700;font-size:18px;color:#fff;display:flex;align-items:center;gap:10px}
|
||||
.ftr .tag{font-size:13.5px;line-height:1.6;margin:14px 0 0;max-width:280px;color:#a89e8e}
|
||||
.ftr h4{font-family:var(--font-sans);font-size:12px;font-weight:700;letter-spacing:0.08em;text-transform:uppercase;color:#8a8070;margin:0 0 14px}
|
||||
.ftr .tag{font-size:13.5px;line-height:1.6;margin:14px 0 0;max-width:280px;color:var(--sand-400)}
|
||||
.ftr h4{font-family:var(--font-sans);font-size:12px;font-weight:700;letter-spacing:0.08em;text-transform:uppercase;color:var(--sand-500);margin:0 0 14px}
|
||||
.ftr ul{list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:10px}
|
||||
.ftr ul a{font-size:14px;color:#cfc6b8}
|
||||
.ftr ul a{font-size:14px;color:var(--sand-300)}
|
||||
.ftr ul a:hover{color:#fff}
|
||||
.ftr .mono{font-family:var(--font-mono);font-size:12.5px}
|
||||
.ftr .bot{display:flex;align-items:center;justify-content:space-between;margin-top:46px;padding-top:24px;border-top:1px solid rgba(255,255,255,.1);font-size:12.5px;color:#8a8070;flex-wrap:wrap;gap:12px}
|
||||
.ftr .bot{display:flex;align-items:center;justify-content:space-between;margin-top:46px;padding-top:24px;border-top:1px solid rgba(255,255,255,.1);font-size:12.5px;color:var(--sand-500);flex-wrap:wrap;gap:12px}
|
||||
|
||||
/* ---------- responsive ---------- */
|
||||
@media (max-width:980px){
|
||||
|
||||