Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b89de656e | |||
| f37f1f3b59 | |||
| c9e266b89a | |||
| 0f05385995 | |||
| fc22300937 | |||
| 4a0c5c4921 | |||
| d2d75cd33c | |||
| a22db0874f | |||
| 29e225c63c | |||
| 14a9836882 | |||
| c4eb92fc2d | |||
| bb5acb6c1e | |||
| 39324c46a7 | |||
| f69696daae | |||
| 83fce75c8c | |||
| ad845328fb | |||
| 6def3edaed | |||
| 56f673791e | |||
| 805bf8e2ca | |||
| 3311430db6 | |||
| 4d274aad7a |
@@ -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
|
||||
@@ -205,7 +228,9 @@ jobs:
|
||||
-v "$HOME/.cache/pangolin-ci/gomod:/go/pkg/mod" \
|
||||
-v "$HOME/.cache/pangolin-ci/gobuild:/root/.cache/go-build" \
|
||||
golang:1.25 \
|
||||
bash -c "apt-get update -qq && apt-get install -y -qq openssl curl python3 >/dev/null 2>&1 && bash scripts/e2e-smoke.sh"
|
||||
bash -c "bash scripts/e2e-smoke.sh"
|
||||
# 注:openssl/curl/python3 已在 golang:1.25 镜像内,无需 apt 安装
|
||||
# (原 apt-get 会走 Docker Desktop 代理→本机 clash 死口,徒增网络脆性)。
|
||||
|
||||
# ── Job 10: Go 集成测试 (L2:真 mysql8/redis 经 testcontainers)──────────
|
||||
# 跨库可移植(支柱 3)+ 按租户流量记账(usage)+ 配额(devices)+ 兑换(codes)+
|
||||
@@ -216,7 +241,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 +255,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 +268,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)"
|
||||
|
||||
@@ -90,6 +90,50 @@ cd web/website && npm run gen:tokens
|
||||
- `design/flutter/` 已删除;Flutter 组件 canonical 实现在 `client/lib/widgets/`,规格在 `design/preview/`。
|
||||
- **禁止**再向 `design/` 提交 Dart/TS 组件代码副本(会漂移)。
|
||||
|
||||
## 前端设计系统治理(ds-flow)
|
||||
|
||||
> **落地中**(分阶段收口,见计划 `docs/superpowers/plans/2026-07-07-frontend-ds-refactor.md`
|
||||
> / 阅读版 `docs/frontend-ds-refactor-plan.html` / todo #19)。以下是**目标模型与硬规则**;
|
||||
> 标注「⏳」的部件正在建,未标注的已生效。参考样板 `~/code/jiu`。
|
||||
|
||||
**心智模型**:设计只有一个出生地(**原型单源**),代码永远是镜像;跨端副本是否走样由
|
||||
**静态闸**在提交/CI 前拦截,像素是否还原由 **golden/fidelity 双级验收**兜底。主题:**light / dark 双主题**。
|
||||
|
||||
**原型单源** `design/prototype/`(⏳ 建设中,Phase 1):
|
||||
- `tokens.css` — 令牌真源:基础 `:root`(主题无关标量:间距/圆角/字号/字体/阴影/动效)+ `[data-theme=dark]` 颜色覆盖块。
|
||||
(当前真源仍是 `design/colors_and_type.css`,Phase 1 迁移后数值不变、结构规整)
|
||||
- `atoms.css` — 公用组件原子(按钮/卡片/输入/语言下拉/徽章/状态药丸),**只引 `var(--token)`,禁硬编码**。
|
||||
- `icons.js` — SVG sprite 单源(`<symbol id="i-*">`);website/usercenter/Flutter 三处图标集须 ⊆ 此集。
|
||||
- `index.html` — 活登记页:主题切换 + 声明式色板 + 全组件/图标展示卡。**每个 atom 必须在此登记**。
|
||||
- `serve.mjs` — 零依赖热重载预览;评审给 URL,**不截图**。
|
||||
|
||||
**三层治理**:
|
||||
- **L1 设计系统**:新增颜色/组件/图标——**先登记原型,再同步代码**,无例外。跨端映射(状态词→图标)两端同集要有闸。
|
||||
- **L2 屏级三态**(台账记 `design/CONTRACT.md`):`同步`=入 fidelity;`快照`=原型退役、golden+契约为准;`代码先行`=无原型屏、golden 唯一基准。
|
||||
- **L3 新屏/改版**:design-first——原型 → serve 评审 → 契约 → 实现 → 验收 → 入同步态。
|
||||
|
||||
**codegen**:
|
||||
- Flutter:`node design/codegen/gen_flutter_tokens.mjs` → `pangolin_tokens.gen.dart`(**勿手改**)。
|
||||
- Web:`build-tokens.mjs`(website→`src/styles/tokens.gen.css` / usercenter→`public/colors_and_type.css`),
|
||||
**只同源不重复生成设计决策**;靠同源闸逐值校验(不建跨端共享组件包,两端各自实现)。
|
||||
|
||||
**四道静态闸 —「违规谁拦」**(规则没上闸 = 没有规则):
|
||||
|
||||
| 闸 | 拦什么 | 何时 |
|
||||
|---|---|---|
|
||||
| 原型校验 `check-ds.mjs`(⏳ Phase 5) | 硬编码色/未定义 token/未登记组件/魔法数断点… 12 道 | pre-commit(动了原型)+ CI |
|
||||
| 跨端同源 `check-l1-sync.mjs`(⏳ Phase 2) | tokens 逐值/icons 同集/Web hex 白名单 | CI |
|
||||
| 代码色单源 `check_ds_code.mjs`(⏳ Phase 5) | Flutter 裸 `Color(0x)`/具名 `Colors.x`(`// ds-ignore: 理由` 豁免) | pre-commit(`--changed`)+ CI |
|
||||
| codegen 零 diff `ci/check-codegen-drift.sh`(**已生效**) | 重生成 token 后 `git diff` 非空即 fail | pre-commit + CI |
|
||||
|
||||
**双级像素验收**:
|
||||
- **golden**(**已有**,`client/test/golden/`):多主题回归自比(同渲染器),抓串色/漏 token;真字体加载防豆腐块、钉死 viewport/dpr/动态值。重录 `flutter test --update-goldens`,随功能 commit 入库。
|
||||
- **fidelity**(⏳ Phase 5,本地体检**不进 CI**):原型 Chromium 截图 vs Flutter golden pixelmatch,逐屏阈值=实测残差+2pp;跨渲染器噪声大,故不入 CI。
|
||||
|
||||
**pre-commit**:`.githooks/pre-commit` 已写,**每台机需 `bash ci/install-hooks.sh` 启用一次**(设 `core.hooksPath=.githooks`)。
|
||||
|
||||
**发现硬编码色**:换 token;确属例外(`#fff/#000`/品牌 logo 固定色)加 `// ds-ignore: 理由` 或列白名单。
|
||||
|
||||
## client/ macOS 原生隧道(PacketTunnel 系统扩展 + 内嵌 libbox)
|
||||
|
||||
内嵌 sing-box(`Libbox.xcframework`)的 `NEPacketTunnelProvider` **系统扩展**(站外 Developer ID
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# check-codegen-drift.sh — codegen 漂移检查(支柱 2:契约单源 / token 单源)。
|
||||
#
|
||||
# design/colors_and_type.css 是唯一 token 真相源,client/lib/pangolin_tokens.gen.dart
|
||||
# design/prototype/tokens.css 是唯一 token 真相源,client/lib/pangolin_tokens.gen.dart
|
||||
# 由 design/codegen/gen_flutter_tokens.mjs 生成、勿手改(CLAUDE.md 铁律)。本检查重新
|
||||
# 生成一遍,若生成物与已提交版本不一致 → 漂移:要么改了 CSS 没重生成,要么手改了
|
||||
# 生成物。两者都会让"单源"失效、契约悄悄分叉。
|
||||
@@ -22,7 +22,7 @@ echo "→ 重新生成 Flutter token (design/codegen/gen_flutter_tokens.mjs) ...
|
||||
node design/codegen/gen_flutter_tokens.mjs
|
||||
|
||||
if ! git diff --quiet -- "$GEN"; then
|
||||
echo "❌ codegen 漂移:$GEN 与 design/colors_and_type.css 不一致。" >&2
|
||||
echo "❌ codegen 漂移:$GEN 与 design/prototype/tokens.css 不一致。" >&2
|
||||
echo " 原因:改了 CSS 真相源却没重生成,或手改了生成物(铁律:勿手改 *.gen.dart)。" >&2
|
||||
echo " 修复:node design/codegen/gen_flutter_tokens.mjs 后提交生成物。" >&2
|
||||
echo " ── diff(前 40 行)──" >&2
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// pangolin_tokens.gen.dart
|
||||
// AUTO-GENERATED — 勿手改。
|
||||
// 源: design/colors_and_type.css
|
||||
// 源: design/prototype/tokens.css
|
||||
// 生成器: design/codegen/gen_flutter_tokens.mjs
|
||||
//
|
||||
// 包含:PangolinColors · PangolinSpacing · PangolinRadius · PangolinMotion · PangolinShadow
|
||||
@@ -9,7 +9,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// ── Primitive color ramps ───────────────────────────────────────────
|
||||
/// Auto-generated from design/colors_and_type.css :root color ramps.
|
||||
/// Auto-generated from design/prototype/tokens.css :root color ramps.
|
||||
class PangolinColors {
|
||||
PangolinColors._();
|
||||
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
/**
|
||||
* gen_flutter_tokens.mjs — CSS token → Dart codegen
|
||||
*
|
||||
* 唯一真相源:design/colors_and_type.css
|
||||
* 唯一真相源:design/prototype/tokens.css
|
||||
* 输出: client/lib/pangolin_tokens.gen.dart
|
||||
*
|
||||
* 生成内容(纯数据层,无 Flutter 实现逻辑):
|
||||
@@ -20,7 +20,7 @@ import { fileURLToPath } from 'node:url';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const SRC = resolve(__dirname, '../colors_and_type.css');
|
||||
const SRC = resolve(__dirname, '../prototype/tokens.css');
|
||||
const OUT = resolve(__dirname, '../../client/lib/pangolin_tokens.gen.dart');
|
||||
|
||||
if (!existsSync(SRC)) {
|
||||
@@ -125,7 +125,7 @@ const lines = [];
|
||||
|
||||
lines.push(`// pangolin_tokens.gen.dart`);
|
||||
lines.push(`// AUTO-GENERATED — 勿手改。`);
|
||||
lines.push(`// 源: design/colors_and_type.css`);
|
||||
lines.push(`// 源: design/prototype/tokens.css`);
|
||||
lines.push(`// 生成器: design/codegen/gen_flutter_tokens.mjs`);
|
||||
lines.push(`//`);
|
||||
lines.push(`// 包含:PangolinColors · PangolinSpacing · PangolinRadius · PangolinMotion · PangolinShadow`);
|
||||
@@ -136,7 +136,7 @@ lines.push(``);
|
||||
|
||||
// ── PangolinColors ──────────────────────────────────────────────────
|
||||
lines.push(`/// ── Primitive color ramps ───────────────────────────────────────────`);
|
||||
lines.push(`/// Auto-generated from design/colors_and_type.css :root color ramps.`);
|
||||
lines.push(`/// Auto-generated from design/prototype/tokens.css :root color ramps.`);
|
||||
lines.push(`class PangolinColors {`);
|
||||
lines.push(` PangolinColors._();`);
|
||||
lines.push(``);
|
||||
|
||||
@@ -1,218 +1,8 @@
|
||||
/* =============================================================
|
||||
穿山甲 VPN · Pangolin VPN — Design Tokens
|
||||
colors_and_type.css
|
||||
Single source of truth: color ramps, semantic colors (light + dark),
|
||||
typography, spacing, radii, shadows, motion.
|
||||
穿山甲 VPN · Pangolin VPN — Design Tokens (兼容别名)
|
||||
⚠️ 真源已迁至 design/prototype/tokens.css(ds-flow 原型单源)。
|
||||
本文件仅为浏览器内 @import 转发,供仍 <link> 此路径的历史 preview/*.html 使用。
|
||||
codegen(Flutter gen_flutter_tokens.mjs / Web build-tokens.mjs)已改读 prototype/tokens.css。
|
||||
改 token 只改 design/prototype/tokens.css,勿在此加变量(不会被 codegen 解析)。
|
||||
============================================================= */
|
||||
|
||||
/* ---- Webfonts (open-source; documented as the brand's chosen faces) ----
|
||||
Sora — display / headings (geometric, friendly)
|
||||
Manrope — body / UI (humanist geometric)
|
||||
Noto Sans SC — Chinese (CJK companion)
|
||||
JetBrains Mono — data readouts (IP, speed, keys) */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Sora:wght@500;600;700&family=Manrope:wght@400;500;600;700&family=Noto+Sans+SC:wght@400;500;700&family=JetBrains+Mono:wght@400;500&display=swap');
|
||||
|
||||
:root {
|
||||
/* ===========================================================
|
||||
1. PRIMITIVE COLOR RAMPS
|
||||
=========================================================== */
|
||||
|
||||
/* Clay / Copper — the pangolin-armor primary (warm earth) */
|
||||
--clay-50: #FAF3ED;
|
||||
--clay-100: #F2E2D4;
|
||||
--clay-200: #E6C7AC;
|
||||
--clay-300: #D9A982;
|
||||
--clay-400: #CC8B5C;
|
||||
--clay-500: #B96A3D; /* ← brand primary */
|
||||
--clay-600: #9E5630;
|
||||
--clay-700: #7E4426;
|
||||
--clay-800: #5E331D;
|
||||
--clay-900: #3D2213;
|
||||
|
||||
/* Sand / Taupe — warm neutral ramp */
|
||||
--sand-50: #FAF8F4;
|
||||
--sand-100: #F2EEE7;
|
||||
--sand-200: #E6DFD3;
|
||||
--sand-300: #D2C8B8;
|
||||
--sand-400: #B0A491;
|
||||
--sand-500: #8C8270;
|
||||
--sand-600: #6B6253;
|
||||
--sand-700: #4E4940;
|
||||
--sand-800: #2E2A24;
|
||||
--sand-900: #1F1C18;
|
||||
--sand-950: #14110E; /* warm espresso near-black */
|
||||
|
||||
/* Semantic hues (earth-tuned) */
|
||||
--green-400: #7FB07A;
|
||||
--green-500: #5B8C5A; /* connected / secure */
|
||||
--green-600: #467046;
|
||||
--amber-400: #E2B05A;
|
||||
--amber-500: #D69A3C; /* connecting / warning */
|
||||
--amber-600: #B47E29;
|
||||
--red-400: #D4715A;
|
||||
--red-500: #C0533B; /* error / disconnect */
|
||||
--red-600: #9E4230;
|
||||
|
||||
/* ===========================================================
|
||||
2. SEMANTIC TOKENS — LIGHT THEME (default)
|
||||
=========================================================== */
|
||||
|
||||
/* Backgrounds & surfaces */
|
||||
--bg: var(--sand-50); /* app canvas */
|
||||
--bg-subtle: var(--sand-100); /* striped / inset regions */
|
||||
--surface: #FFFFFF; /* cards, sheets */
|
||||
--surface-2: var(--sand-50); /* nested surface */
|
||||
--overlay: rgba(31, 28, 24, 0.45);
|
||||
|
||||
/* Foreground / text */
|
||||
--fg1: var(--sand-900); /* primary text */
|
||||
--fg2: var(--sand-600); /* secondary text */
|
||||
--fg3: var(--sand-500); /* tertiary / captions */
|
||||
--fg-on-accent: #FFFFFF; /* text on clay fills */
|
||||
|
||||
/* Brand / accent */
|
||||
--accent: var(--clay-500);
|
||||
--accent-hover: var(--clay-600);
|
||||
--accent-press: var(--clay-700);
|
||||
--accent-subtle: var(--clay-50);
|
||||
--accent-border: var(--clay-200);
|
||||
|
||||
/* Borders & lines */
|
||||
--border: var(--sand-200);
|
||||
--border-strong: var(--sand-300);
|
||||
--ring: rgba(185, 106, 61, 0.35); /* focus ring (clay) */
|
||||
|
||||
/* Status */
|
||||
--success: var(--green-500);
|
||||
--success-subtle: #E9F0E6;
|
||||
--warning: var(--amber-500);
|
||||
--warning-subtle: #F8EED6;
|
||||
--danger: var(--red-500);
|
||||
--danger-subtle: #F6E1DA;
|
||||
|
||||
/* ===========================================================
|
||||
3. TYPOGRAPHY
|
||||
=========================================================== */
|
||||
--font-display: 'Sora', 'Noto Sans SC', system-ui, sans-serif;
|
||||
--font-sans: 'Manrope', 'Noto Sans SC', system-ui, sans-serif;
|
||||
--font-cjk: 'Noto Sans SC', 'Manrope', system-ui, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', ui-monospace, 'SFMono-Regular', monospace;
|
||||
|
||||
/* Type scale (root 16px) */
|
||||
--text-display-xl: 3rem; /* 48 */
|
||||
--text-display: 2.25rem; /* 36 */
|
||||
--text-h1: 1.875rem; /* 30 */
|
||||
--text-h2: 1.5rem; /* 24 */
|
||||
--text-h3: 1.25rem; /* 20 */
|
||||
--text-body-lg: 1.125rem; /* 18 */
|
||||
--text-body: 1rem; /* 16 */
|
||||
--text-sm: 0.875rem; /* 14 */
|
||||
--text-caption: 0.75rem; /* 12 */
|
||||
|
||||
--leading-tight: 1.15;
|
||||
--leading-snug: 1.3;
|
||||
--leading-normal:1.5;
|
||||
--leading-relaxed:1.65;
|
||||
|
||||
--tracking-tight: -0.02em;
|
||||
--tracking-snug: -0.01em;
|
||||
--tracking-wide: 0.04em;
|
||||
--tracking-caps: 0.08em;
|
||||
|
||||
/* ===========================================================
|
||||
4. SPACING (4px base)
|
||||
=========================================================== */
|
||||
--space-0: 0;
|
||||
--space-1: 0.25rem; /* 4 */
|
||||
--space-2: 0.5rem; /* 8 */
|
||||
--space-3: 0.75rem; /* 12 */
|
||||
--space-4: 1rem; /* 16 */
|
||||
--space-5: 1.25rem; /* 20 */
|
||||
--space-6: 1.5rem; /* 24 */
|
||||
--space-8: 2rem; /* 32 */
|
||||
--space-10: 2.5rem; /* 40 */
|
||||
--space-12: 3rem; /* 48 */
|
||||
--space-16: 4rem; /* 64 */
|
||||
|
||||
/* ===========================================================
|
||||
5. RADII (generous = friendly)
|
||||
=========================================================== */
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 14px;
|
||||
--radius-xl: 20px;
|
||||
--radius-2xl: 28px;
|
||||
--radius-full: 999px;
|
||||
|
||||
/* ===========================================================
|
||||
6. SHADOWS (warm-tinted, soft)
|
||||
=========================================================== */
|
||||
--shadow-sm: 0 1px 2px rgba(45, 30, 20, 0.06);
|
||||
--shadow-md: 0 4px 14px rgba(45, 30, 20, 0.08);
|
||||
--shadow-lg: 0 12px 32px rgba(45, 30, 20, 0.12);
|
||||
--shadow-xl: 0 24px 60px rgba(45, 30, 20, 0.16);
|
||||
--shadow-focus: 0 0 0 4px var(--ring);
|
||||
|
||||
/* ===========================================================
|
||||
7. MOTION
|
||||
=========================================================== */
|
||||
--ease-out: cubic-bezier(0.22, 1, 0.36, 1); /* @kind other */
|
||||
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1); /* @kind other */
|
||||
--dur-fast: 140ms; /* @kind other */
|
||||
--dur-base: 220ms; /* @kind other */
|
||||
--dur-slow: 360ms; /* @kind other */
|
||||
}
|
||||
|
||||
/* ===========================================================
|
||||
DARK THEME — warm espresso
|
||||
Apply via [data-theme="dark"] on <html> or any container.
|
||||
=========================================================== */
|
||||
[data-theme="dark"] {
|
||||
--bg: var(--sand-950);
|
||||
--bg-subtle: var(--sand-900);
|
||||
--surface: #221E19;
|
||||
--surface-2: #2A251F;
|
||||
--overlay: rgba(0, 0, 0, 0.6);
|
||||
|
||||
--fg1: #F4EFE8;
|
||||
--fg2: #B6AC9C;
|
||||
--fg3: #897F6F;
|
||||
--fg-on-accent: #1F1C18;
|
||||
|
||||
--accent: var(--clay-400);
|
||||
--accent-hover: var(--clay-300);
|
||||
--accent-press: var(--clay-500);
|
||||
--accent-subtle: rgba(204, 139, 92, 0.14);
|
||||
--accent-border: rgba(204, 139, 92, 0.30);
|
||||
|
||||
--border: rgba(242, 238, 231, 0.10);
|
||||
--border-strong: rgba(242, 238, 231, 0.18);
|
||||
--ring: rgba(204, 139, 92, 0.45);
|
||||
|
||||
--success: var(--green-400);
|
||||
--success-subtle: rgba(127, 176, 122, 0.16);
|
||||
--warning: var(--amber-400);
|
||||
--warning-subtle: rgba(226, 176, 90, 0.16);
|
||||
--danger: var(--red-400);
|
||||
--danger-subtle: rgba(212, 113, 90, 0.16);
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||
--shadow-md: 0 4px 14px rgba(0, 0, 0, 0.45);
|
||||
--shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.5);
|
||||
--shadow-xl: 0 24px 60px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
/* ===========================================================
|
||||
SEMANTIC TYPE CLASSES (use directly in markup)
|
||||
=========================================================== */
|
||||
.t-display-xl { font-family: var(--font-display); font-size: var(--text-display-xl); font-weight: 700; line-height: var(--leading-tight); letter-spacing: var(--tracking-tight); }
|
||||
.t-display { font-family: var(--font-display); font-size: var(--text-display); font-weight: 700; line-height: var(--leading-tight); letter-spacing: var(--tracking-tight); }
|
||||
.t-h1 { font-family: var(--font-display); font-size: var(--text-h1); font-weight: 600; line-height: var(--leading-snug); letter-spacing: var(--tracking-snug); }
|
||||
.t-h2 { font-family: var(--font-display); font-size: var(--text-h2); font-weight: 600; line-height: var(--leading-snug); letter-spacing: var(--tracking-snug); }
|
||||
.t-h3 { font-family: var(--font-sans); font-size: var(--text-h3); font-weight: 600; line-height: var(--leading-snug); }
|
||||
.t-body-lg { font-family: var(--font-sans); font-size: var(--text-body-lg); font-weight: 400; line-height: var(--leading-relaxed); }
|
||||
.t-body { font-family: var(--font-sans); font-size: var(--text-body); font-weight: 400; line-height: var(--leading-normal); }
|
||||
.t-sm { font-family: var(--font-sans); font-size: var(--text-sm); font-weight: 400; line-height: var(--leading-normal); }
|
||||
.t-caption { font-family: var(--font-sans); font-size: var(--text-caption); font-weight: 500; line-height: var(--leading-normal); }
|
||||
.t-overline { font-family: var(--font-sans); font-size: var(--text-caption); font-weight: 600; text-transform: uppercase; letter-spacing: var(--tracking-caps); }
|
||||
.t-mono { font-family: var(--font-mono); font-size: var(--text-sm); font-weight: 400; font-feature-settings: 'tnum' 1; }
|
||||
@import url('./prototype/tokens.css');
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
/* =============================================================
|
||||
穿山甲 VPN · Pangolin VPN — Prototype Atoms
|
||||
design/prototype/atoms.css
|
||||
-------------------------------------------------------------
|
||||
本文件是「原型公用组件原子层」的唯一真相源(single source)。
|
||||
canonical CSS 原子类,沉淀自 design/preview/*.html 组件规格、
|
||||
web/usercenter/components/shared.tsx、web/website/src/styles/website.css
|
||||
与 client/lib/widgets/*.dart 的交互态语义。
|
||||
|
||||
铁律(写/改本文件必守,对齐 design/CLAUDE.md §1):
|
||||
1. 颜色 / 圆角 / 间距 / 字号 / 阴影 / 字体 一律走 var(--token)
|
||||
(token 定义在 tokens.css)。禁任何硬编码 hex / rgb / 魔法数。
|
||||
2. 明暗双主题「自动」适配:原子类只引语义 token(--surface / --fg1 …),
|
||||
主题切换由 tokens.css 的 [data-theme="dark"] 负责——
|
||||
本文件里不写任何 [data-theme] 分支。
|
||||
3. 暖大地色 · 大圆角(按钮全胶囊 radius-full / 卡片 lg–xl / 输入 md)·
|
||||
柔和暖阴影 · 状态用「色点 + 文字胶囊」非 emoji。
|
||||
4. 交互态语义:hover 主色加深一档 · press 缩放 .97 · focus clay 光环
|
||||
(shadow-focus)· disabled sand-200 底 + sand-400 字。
|
||||
5. 每新增一个原子,须在 design/prototype/index.html 登记(登记簿单源)。
|
||||
|
||||
使用前先在页面 <head> 引入:tokens.css(令牌)+ 本文件(原子)。
|
||||
============================================================= */
|
||||
|
||||
|
||||
/* =============================================================
|
||||
BUTTONS · .btn + variants
|
||||
全胶囊圆角 · hover 加深 · press 收缩 .97 · focus clay 光环
|
||||
变体:.btn-primary(实心 clay) · .btn-ghost(描边/幽灵) ·
|
||||
.btn-subtle(次要描边) · .btn-danger · 尺寸 .btn-lg · .btn-icon
|
||||
============================================================= */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 600;
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1;
|
||||
border: none;
|
||||
border-radius: var(--radius-full);
|
||||
padding: var(--space-3) var(--space-5);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background-color var(--dur-fast) var(--ease-out),
|
||||
color var(--dur-fast) var(--ease-out),
|
||||
border-color var(--dur-fast) var(--ease-out),
|
||||
transform var(--dur-fast) var(--ease-out),
|
||||
box-shadow var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
.btn > svg { width: 16px; height: 16px; flex-shrink: 0; }
|
||||
.btn:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--shadow-focus);
|
||||
}
|
||||
.btn:active { transform: scale(0.97); }
|
||||
.btn:disabled,
|
||||
.btn.is-disabled {
|
||||
background: var(--sand-200);
|
||||
color: var(--sand-400);
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
/* Primary — 实心黏土铜 */
|
||||
.btn-primary { background: var(--accent); color: var(--fg-on-accent); }
|
||||
.btn-primary:hover { background: var(--accent-hover); }
|
||||
.btn-primary:active { background: var(--accent-press); }
|
||||
|
||||
/* Ghost — 透明底、clay 字,hover 上 accent-subtle */
|
||||
.btn-ghost { background: transparent; color: var(--accent); }
|
||||
.btn-ghost:hover { background: var(--accent-subtle); }
|
||||
|
||||
/* Subtle — surface 底 + 强描边(次要动作) */
|
||||
.btn-subtle {
|
||||
background: var(--surface);
|
||||
color: var(--fg1);
|
||||
border: 1.5px solid var(--border-strong);
|
||||
}
|
||||
.btn-subtle:hover { border-color: var(--accent); color: var(--accent); }
|
||||
|
||||
/* Danger — 危险动作(断开 / 移除设备) */
|
||||
.btn-danger { background: var(--danger-subtle); color: var(--red-600); }
|
||||
.btn-danger:hover { background: var(--danger); color: var(--fg-on-accent); }
|
||||
|
||||
/* 尺寸 / 形态修饰 */
|
||||
.btn-lg { padding: var(--space-4) var(--space-6); font-size: var(--text-body); }
|
||||
.btn-icon { padding: var(--space-3); } /* 正方形图标按钮(配 radius-full 成圆) */
|
||||
.btn-block { width: 100%; }
|
||||
|
||||
|
||||
/* =============================================================
|
||||
CARD · .card
|
||||
surface 底 + border + radius-xl + shadow-sm
|
||||
============================================================= */
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: var(--space-5);
|
||||
}
|
||||
.card-flush { padding: 0; } /* 列表容器(server-row 组)用,内容自带 padding */
|
||||
|
||||
|
||||
/* =============================================================
|
||||
INPUT · .input (+ .field / .flabel)
|
||||
强描边 + radius-md + focus clay 光环
|
||||
============================================================= */
|
||||
.field { display: block; }
|
||||
.flabel {
|
||||
display: block;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-caption);
|
||||
font-weight: 600;
|
||||
color: var(--fg2);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
.input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--fg1);
|
||||
background: var(--surface);
|
||||
border: 1.5px solid var(--border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
transition: border-color var(--dur-fast) var(--ease-out),
|
||||
box-shadow var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
.input::placeholder { color: var(--fg3); }
|
||||
.input:focus,
|
||||
.input.is-focused {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: var(--shadow-focus);
|
||||
}
|
||||
.input:disabled {
|
||||
background: var(--bg-subtle);
|
||||
color: var(--fg3);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.input.is-error { border-color: var(--danger); }
|
||||
.input.is-error:focus { box-shadow: 0 0 0 4px var(--danger-subtle); }
|
||||
|
||||
|
||||
/* =============================================================
|
||||
BADGE / PILL · .pill (+ status modifiers)
|
||||
状态胶囊:色点 + 文字(非 emoji)。全胶囊圆角。
|
||||
状态:.is-success / .is-warning / .is-danger / .is-neutral
|
||||
变体:.pill-accent(实心 clay,如 PRO) · .pill-outline(accent 描边,如节点属性)
|
||||
============================================================= */
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-caption);
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--sand-100);
|
||||
color: var(--fg2);
|
||||
}
|
||||
.pill .dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--sand-400);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
/* alias:.badge === .pill(语义等价,命名习惯不同) */
|
||||
.badge { }
|
||||
|
||||
.pill.is-success { background: var(--success-subtle); color: var(--green-600); }
|
||||
.pill.is-success .dot { background: var(--success); }
|
||||
.pill.is-warning { background: var(--warning-subtle); color: var(--amber-600); }
|
||||
.pill.is-warning .dot { background: var(--warning); }
|
||||
.pill.is-danger { background: var(--danger-subtle); color: var(--red-600); }
|
||||
.pill.is-danger .dot { background: var(--danger); }
|
||||
.pill.is-neutral { background: var(--sand-100); color: var(--fg2); }
|
||||
.pill.is-neutral .dot { background: var(--sand-400); }
|
||||
|
||||
/* 实心 accent(PRO 会员等强调标签) */
|
||||
.pill-accent { background: var(--accent); color: var(--fg-on-accent); }
|
||||
/* accent 描边(节点属性:流媒体 / P2P 等) */
|
||||
.pill-outline {
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent);
|
||||
border: 1px solid var(--accent-border);
|
||||
}
|
||||
|
||||
|
||||
/* =============================================================
|
||||
LANGUAGE SELECT · .langsel (触发药丸) + .menu (绝对定位菜单)
|
||||
对齐 usercenter shared.tsx::LangSeg / website 的自定义下拉:
|
||||
不依赖原生 <select>(会漂移),按钮 + 自控绝对定位菜单。
|
||||
============================================================= */
|
||||
.langsel {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
/* 触发按钮:药丸形,bg-subtle 底 + 细描边 */
|
||||
.langsel-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-caption);
|
||||
font-weight: 700;
|
||||
color: var(--fg2);
|
||||
background: var(--bg-subtle);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
cursor: pointer;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: border-color var(--dur-fast) var(--ease-out),
|
||||
color var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
.langsel-trigger:hover { border-color: var(--border-strong); color: var(--fg1); }
|
||||
.langsel-trigger:focus-visible { outline: none; box-shadow: var(--shadow-focus); }
|
||||
.langsel-trigger > svg { width: 11px; height: 11px; }
|
||||
/* 展开态:caret 翻转(配合 .is-open 或 [aria-expanded="true"]) */
|
||||
.langsel-trigger[aria-expanded="true"] > svg,
|
||||
.langsel.is-open .langsel-trigger > svg { transform: rotate(180deg); }
|
||||
|
||||
/* 绝对定位菜单(下拉列表容器) */
|
||||
.menu {
|
||||
position: absolute;
|
||||
top: calc(100% + var(--space-2));
|
||||
right: 0;
|
||||
min-width: 132px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: var(--space-1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
z-index: 60;
|
||||
}
|
||||
.menu-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
color: var(--fg1);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-sm);
|
||||
white-space: nowrap;
|
||||
transition: background-color var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
.menu-item:hover { background: var(--bg-subtle); }
|
||||
/* 选中项:accent-subtle 底 + clay 字 */
|
||||
.menu-item.is-selected,
|
||||
.menu-item[aria-selected="true"] {
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// icons.js — SVG sprite 单源(Lucide 细线条,viewBox 0 0 24 24 / stroke-width 2 / 圆角端点,ISC 许可)。
|
||||
// 用法:<svg class="ic"><use href="#i-power"/></svg>;.ic{ width/height/color } 即可控制尺寸与颜色。
|
||||
// 收敛三处分散的 Lucide 图标为一份权威 sprite:
|
||||
// · usercenter —— web/usercenter/components/icons.tsx 的内联路径(权威副本,逐字照抄)。
|
||||
// · lucide-react —— web/website/node_modules/lucide-react(usercenter 没有的从包源提取)。
|
||||
// 新增图标先在此登记 symbol,再用 <use> 引用;勿手写/瞎猜路径。
|
||||
(function () {
|
||||
// key = kebab-name,value = 该图标的 lucide 子元素串(照搬权威来源)。
|
||||
var ICONS = {
|
||||
// ── 源自 usercenter(web/usercenter/components/icons.tsx 内联,逐字照抄)──
|
||||
'layout-dashboard': '<rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/>',
|
||||
'link': '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
|
||||
'ticket': '<path d="M2 9a3 3 0 0 1 0 6v2a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-2a3 3 0 0 1 0-6V7a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2Z"/><path d="M13 5v2"/><path d="M13 17v2"/><path d="M13 11v2"/>',
|
||||
'users': '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
|
||||
'copy': '<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>',
|
||||
'refresh-cw': '<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M3 21v-5h5"/>',
|
||||
'qr-code': '<rect width="5" height="5" x="3" y="3" rx="1"/><rect width="5" height="5" x="16" y="3" rx="1"/><rect width="5" height="5" x="3" y="16" rx="1"/><path d="M21 16h-3a2 2 0 0 0-2 2v3"/><path d="M21 21v.01"/><path d="M12 7v3a2 2 0 0 1-2 2H7"/><path d="M3 12h.01"/><path d="M12 3h.01"/><path d="M12 16v.01"/><path d="M16 12h1"/><path d="M21 12v.01"/><path d="M12 21v-1"/>',
|
||||
'download': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/>',
|
||||
'check': '<path d="M20 6 9 17l-5-5"/>',
|
||||
'check-circle': '<path d="M21.801 10A10 10 0 1 1 17 3.335"/><path d="m9 11 3 3L22 4"/>',
|
||||
'zap': '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>',
|
||||
'clock': '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
|
||||
'shield-check': '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/>',
|
||||
'shield': '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/>',
|
||||
'crown': '<path d="M11.562 3.266a.5.5 0 0 1 .876 0L15.39 8.87a1 1 0 0 0 1.516.294L21.183 5.5a.5.5 0 0 1 .798.519l-2.834 10.246a1 1 0 0 1-.956.734H5.81a1 1 0 0 1-.957-.734L2.02 6.02a.5.5 0 0 1 .798-.519l4.276 3.664a1 1 0 0 0 1.516-.294z"/><path d="M5 21h14"/>',
|
||||
'credit-card': '<rect width="20" height="14" x="2" y="5" rx="2"/><line x1="2" x2="22" y1="10" y2="10"/>',
|
||||
'send': '<path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"/><path d="m21.854 2.147-10.94 10.939"/>',
|
||||
'message-circle': '<path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"/>',
|
||||
'mail': '<rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>',
|
||||
'shopping-bag': '<path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z"/><path d="M3 6h18"/><path d="M16 10a4 4 0 0 1-8 0"/>',
|
||||
'log-out': '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" x2="9" y1="12" y2="12"/>',
|
||||
'external-link': '<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>',
|
||||
'gift': '<rect x="3" y="8" width="18" height="4" rx="1"/><path d="M12 8v13"/><path d="M19 12v7a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2v-7"/><path d="M7.5 8a2.5 2.5 0 0 1 0-5A4.8 8 0 0 1 12 8a4.8 8 0 0 1 4.5-5 2.5 2.5 0 0 1 0 5"/>',
|
||||
'smartphone': '<rect width="14" height="20" x="5" y="2" rx="2" ry="2"/><path d="M12 18h.01"/>',
|
||||
'lock': '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
|
||||
'chevron-right': '<path d="m9 18 6-6-6-6"/>',
|
||||
'arrow-down': '<path d="M12 5v14"/><path d="m19 12-7 7-7-7"/>',
|
||||
'settings': '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
|
||||
'trash-2': '<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/>',
|
||||
'monitor': '<rect width="20" height="14" x="2" y="3" rx="2"/><line x1="8" x2="16" y1="21" y2="21"/><line x1="12" x2="12" y1="17" y2="21"/>',
|
||||
'key': '<path d="m15.5 7.5 2.3 2.3a1 1 0 0 0 1.4 0l2.1-2.1a1 1 0 0 0 0-1.4L19 4"/><path d="m21 2-9.6 9.6"/><circle cx="7.5" cy="15.5" r="5.5"/>',
|
||||
'sun': '<circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/>',
|
||||
'moon': '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>',
|
||||
'alert-triangle': '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
|
||||
'x': '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
|
||||
|
||||
// ── 源自 lucide-react 包(web/website/node_modules/lucide-react v0.469.0,usercenter 未含)──
|
||||
'power': '<path d="M12 2v10"/><path d="M18.4 6.6a9 9 0 1 1-12.77.04"/>',
|
||||
'loader-circle': '<path d="M21 12a9 9 0 1 1-6.219-8.56"/>',
|
||||
'globe': '<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>',
|
||||
'user': '<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
|
||||
'search': '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
|
||||
'chevron-down': '<path d="m6 9 6 6 6-6"/>',
|
||||
'arrow-left': '<path d="m12 19-7-7 7-7"/><path d="M19 12H5"/>',
|
||||
'arrow-up': '<path d="m5 12 7-7 7 7"/><path d="M12 19V5"/>',
|
||||
'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"/>',
|
||||
'home': '<path d="M3 9.5 12 3l9 6.5"/><path d="M5 10v10a1 1 0 0 0 1 1h3v-6h6v6h3a1 1 0 0 0 1-1V10"/>',
|
||||
'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"/>',
|
||||
'play-circle': '<circle cx="12" cy="12" r="10"/><polygon points="10 8 16 12 10 16 10 8"/>',
|
||||
'wifi': '<path d="M12 20h.01"/><path d="M2 8.82a15 15 0 0 1 20 0"/><path d="M5 12.859a10 10 0 0 1 14 0"/><path d="M8.5 16.429a5 5 0 0 1 7 0"/>',
|
||||
'eye': '<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/>',
|
||||
'eye-off': '<path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49"/><path d="M14.084 14.158a3 3 0 0 1-4.242-4.242"/><path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143"/><path d="m2 2 20 20"/>',
|
||||
'more-vertical': '<circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/>',
|
||||
'pencil': '<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/>',
|
||||
'terminal': '<polyline points="4 17 10 11 4 5"/><line x1="12" x2="20" y1="19" y2="19"/>',
|
||||
'menu': '<line x1="4" x2="20" y1="12" y2="12"/><line x1="4" x2="20" y1="6" y2="6"/><line x1="4" x2="20" y1="18" y2="18"/>'
|
||||
};
|
||||
|
||||
// 组装隐藏 sprite:每个图标一个 <symbol>,符号上带 lucide 标准描边属性,故 .ic 只需管 width/height/color。
|
||||
function buildSprite() {
|
||||
var parts = ['<svg xmlns="http://www.w3.org/2000/svg" style="position:absolute;width:0;height:0;overflow:hidden" aria-hidden="true">'];
|
||||
for (var name in ICONS) {
|
||||
if (!Object.prototype.hasOwnProperty.call(ICONS, name)) continue;
|
||||
parts.push(
|
||||
'<symbol id="i-' + name + '" viewBox="0 0 24 24" fill="none" stroke="currentColor" ' +
|
||||
'stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' + ICONS[name] + '</symbol>'
|
||||
);
|
||||
}
|
||||
parts.push('</svg>');
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
function inject() {
|
||||
if (document.getElementById('pangolin-icon-sprite')) return;
|
||||
var d = document.createElement('div');
|
||||
d.id = 'pangolin-icon-sprite';
|
||||
d.style.cssText = 'position:absolute;width:0;height:0';
|
||||
d.innerHTML = buildSprite();
|
||||
document.body.insertBefore(d, document.body.firstChild);
|
||||
}
|
||||
|
||||
if (document.readyState !== 'loading') inject();
|
||||
else document.addEventListener('DOMContentLoaded', inject);
|
||||
|
||||
// 便于调试/校验:暴露原始映射。
|
||||
if (typeof window !== 'undefined') window.PANGOLIN_ICONS = ICONS;
|
||||
})();
|
||||
@@ -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,97 @@
|
||||
// 零依赖本地热重载静态服务器 — pangolin 原型单源预览
|
||||
// 用法: node design/prototype/serve.mjs [port] 默认 5180
|
||||
// 评审给 URL,不截图(ds-flow L3)。
|
||||
import http from 'node:http';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
const ROOT = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PORT = Number(process.argv[2]) || 5180;
|
||||
|
||||
const MIME = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.mjs': 'text/javascript; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.woff2': 'font/woff2',
|
||||
'.otf': 'font/otf',
|
||||
'.ttf': 'font/ttf',
|
||||
};
|
||||
|
||||
const clients = new Set();
|
||||
const RELOAD_SNIPPET = `\n<script>
|
||||
(function(){ try{
|
||||
var es=new EventSource('/__reload');
|
||||
es.onmessage=function(){ location.reload(); };
|
||||
}catch(e){} })();
|
||||
</script>\n`;
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.url === '/__reload') {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
});
|
||||
res.write('retry: 500\n\n');
|
||||
clients.add(res);
|
||||
req.on('close', () => clients.delete(res));
|
||||
return;
|
||||
}
|
||||
|
||||
let urlPath = decodeURIComponent(req.url.split('?')[0]);
|
||||
if (urlPath === '/') urlPath = '/index.html';
|
||||
const filePath = path.join(ROOT, urlPath);
|
||||
if (!filePath.startsWith(ROOT)) { res.writeHead(403); res.end('forbidden'); return; }
|
||||
|
||||
fs.readFile(filePath, (err, data) => {
|
||||
if (err) { res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' }); res.end('<h1>404</h1>'); return; }
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const mime = MIME[ext] || 'application/octet-stream';
|
||||
const noCache = { 'Cache-Control': 'no-store, no-cache, must-revalidate', 'Pragma': 'no-cache', 'Expires': '0' };
|
||||
if (ext === '.html') {
|
||||
const html = data.toString('utf8').replace('</body>', RELOAD_SNIPPET + '</body>');
|
||||
res.writeHead(200, { 'Content-Type': mime, ...noCache });
|
||||
res.end(html);
|
||||
} else {
|
||||
res.writeHead(200, { 'Content-Type': mime, ...noCache });
|
||||
res.end(data);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 设计系统守门:启动 + 每次改动自动跑 check-ds.mjs(Phase 5 建立后生效)。
|
||||
const CHECK_DS = path.join(ROOT, 'tools', 'check-ds.mjs');
|
||||
function runDsCheck() {
|
||||
if (!fs.existsSync(CHECK_DS)) return; // Phase 5 前无闸,静默跳过
|
||||
const p = spawn(process.execPath, [CHECK_DS], { cwd: ROOT });
|
||||
let out = '';
|
||||
p.stdout.on('data', d => out += d);
|
||||
p.stderr.on('data', d => out += d);
|
||||
p.on('close', code => {
|
||||
const t = new Date().toLocaleTimeString();
|
||||
if (code === 0) console.log(`\x1b[32m[设计系统 ✓ ${t}] 颜色全部走 token、变量均已定义\x1b[0m`);
|
||||
else console.log(`\x1b[31m[设计系统 ✗ ${t}] 存在违规 —— 运行 node tools/check-ds.mjs 看详情\x1b[0m`);
|
||||
});
|
||||
}
|
||||
|
||||
let debounce;
|
||||
fs.watch(ROOT, { recursive: true }, (_ev, file) => {
|
||||
if (file && file.endsWith('serve.mjs')) return;
|
||||
clearTimeout(debounce);
|
||||
debounce = setTimeout(() => {
|
||||
for (const c of clients) c.write('data: reload\n\n');
|
||||
if (file && /\.(html|css|js)$/.test(file)) runDsCheck();
|
||||
}, 80);
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`prototype live at http://localhost:${PORT}/ (watching ${ROOT})`);
|
||||
runDsCheck();
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
/* =============================================================
|
||||
穿山甲 VPN · Pangolin VPN — Design Tokens
|
||||
colors_and_type.css
|
||||
Single source of truth: color ramps, semantic colors (light + dark),
|
||||
typography, spacing, radii, shadows, motion.
|
||||
============================================================= */
|
||||
|
||||
/* ---- Webfonts (open-source; documented as the brand's chosen faces) ----
|
||||
Sora — display / headings (geometric, friendly)
|
||||
Manrope — body / UI (humanist geometric)
|
||||
Noto Sans SC — Chinese (CJK companion)
|
||||
JetBrains Mono — data readouts (IP, speed, keys) */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Sora:wght@500;600;700&family=Manrope:wght@400;500;600;700&family=Noto+Sans+SC:wght@400;500;700&family=JetBrains+Mono:wght@400;500&display=swap');
|
||||
|
||||
:root {
|
||||
/* ===========================================================
|
||||
1. PRIMITIVE COLOR RAMPS
|
||||
=========================================================== */
|
||||
|
||||
/* Clay / Copper — the pangolin-armor primary (warm earth) */
|
||||
--clay-50: #FAF3ED;
|
||||
--clay-100: #F2E2D4;
|
||||
--clay-200: #E6C7AC;
|
||||
--clay-300: #D9A982;
|
||||
--clay-400: #CC8B5C;
|
||||
--clay-500: #B96A3D; /* ← brand primary */
|
||||
--clay-600: #9E5630;
|
||||
--clay-700: #7E4426;
|
||||
--clay-800: #5E331D;
|
||||
--clay-900: #3D2213;
|
||||
|
||||
/* Sand / Taupe — warm neutral ramp */
|
||||
--sand-50: #FAF8F4;
|
||||
--sand-100: #F2EEE7;
|
||||
--sand-200: #E6DFD3;
|
||||
--sand-300: #D2C8B8;
|
||||
--sand-400: #B0A491;
|
||||
--sand-500: #8C8270;
|
||||
--sand-600: #6B6253;
|
||||
--sand-700: #4E4940;
|
||||
--sand-800: #2E2A24;
|
||||
--sand-900: #1F1C18;
|
||||
--sand-950: #14110E; /* warm espresso near-black */
|
||||
|
||||
/* Semantic hues (earth-tuned) */
|
||||
--green-400: #7FB07A;
|
||||
--green-500: #5B8C5A; /* connected / secure */
|
||||
--green-600: #467046;
|
||||
--amber-400: #E2B05A;
|
||||
--amber-500: #D69A3C; /* connecting / warning */
|
||||
--amber-600: #B47E29;
|
||||
--red-400: #D4715A;
|
||||
--red-500: #C0533B; /* error / disconnect */
|
||||
--red-600: #9E4230;
|
||||
|
||||
/* ===========================================================
|
||||
2. SEMANTIC TOKENS — LIGHT THEME (default)
|
||||
=========================================================== */
|
||||
|
||||
/* Backgrounds & surfaces */
|
||||
--bg: var(--sand-50); /* app canvas */
|
||||
--bg-subtle: var(--sand-100); /* striped / inset regions */
|
||||
--surface: #FFFFFF; /* cards, sheets */
|
||||
--surface-2: var(--sand-50); /* nested surface */
|
||||
--overlay: rgba(31, 28, 24, 0.45);
|
||||
|
||||
/* Foreground / text */
|
||||
--fg1: var(--sand-900); /* primary text */
|
||||
--fg2: var(--sand-600); /* secondary text */
|
||||
--fg3: var(--sand-500); /* tertiary / captions */
|
||||
--fg-on-accent: #FFFFFF; /* text on clay fills */
|
||||
|
||||
/* Brand / accent */
|
||||
--accent: var(--clay-500);
|
||||
--accent-hover: var(--clay-600);
|
||||
--accent-press: var(--clay-700);
|
||||
--accent-subtle: var(--clay-50);
|
||||
--accent-border: var(--clay-200);
|
||||
|
||||
/* Borders & lines */
|
||||
--border: var(--sand-200);
|
||||
--border-strong: var(--sand-300);
|
||||
--ring: rgba(185, 106, 61, 0.35); /* focus ring (clay) */
|
||||
|
||||
/* Status */
|
||||
--success: var(--green-500);
|
||||
--success-subtle: #E9F0E6;
|
||||
--warning: var(--amber-500);
|
||||
--warning-subtle: #F8EED6;
|
||||
--danger: var(--red-500);
|
||||
--danger-subtle: #F6E1DA;
|
||||
|
||||
/* ===========================================================
|
||||
3. TYPOGRAPHY
|
||||
=========================================================== */
|
||||
--font-display: 'Sora', 'Noto Sans SC', system-ui, sans-serif;
|
||||
--font-sans: 'Manrope', 'Noto Sans SC', system-ui, sans-serif;
|
||||
--font-cjk: 'Noto Sans SC', 'Manrope', system-ui, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', ui-monospace, 'SFMono-Regular', monospace;
|
||||
|
||||
/* Type scale (root 16px) */
|
||||
--text-display-xl: 3rem; /* 48 */
|
||||
--text-display: 2.25rem; /* 36 */
|
||||
--text-h1: 1.875rem; /* 30 */
|
||||
--text-h2: 1.5rem; /* 24 */
|
||||
--text-h3: 1.25rem; /* 20 */
|
||||
--text-body-lg: 1.125rem; /* 18 */
|
||||
--text-body: 1rem; /* 16 */
|
||||
--text-sm: 0.875rem; /* 14 */
|
||||
--text-caption: 0.75rem; /* 12 */
|
||||
|
||||
--leading-tight: 1.15;
|
||||
--leading-snug: 1.3;
|
||||
--leading-normal:1.5;
|
||||
--leading-relaxed:1.65;
|
||||
|
||||
--tracking-tight: -0.02em;
|
||||
--tracking-snug: -0.01em;
|
||||
--tracking-wide: 0.04em;
|
||||
--tracking-caps: 0.08em;
|
||||
|
||||
/* ===========================================================
|
||||
4. SPACING (4px base)
|
||||
=========================================================== */
|
||||
--space-0: 0;
|
||||
--space-1: 0.25rem; /* 4 */
|
||||
--space-2: 0.5rem; /* 8 */
|
||||
--space-3: 0.75rem; /* 12 */
|
||||
--space-4: 1rem; /* 16 */
|
||||
--space-5: 1.25rem; /* 20 */
|
||||
--space-6: 1.5rem; /* 24 */
|
||||
--space-8: 2rem; /* 32 */
|
||||
--space-10: 2.5rem; /* 40 */
|
||||
--space-12: 3rem; /* 48 */
|
||||
--space-16: 4rem; /* 64 */
|
||||
|
||||
/* ===========================================================
|
||||
5. RADII (generous = friendly)
|
||||
=========================================================== */
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 14px;
|
||||
--radius-xl: 20px;
|
||||
--radius-2xl: 28px;
|
||||
--radius-full: 999px;
|
||||
|
||||
/* ===========================================================
|
||||
6. SHADOWS (warm-tinted, soft)
|
||||
=========================================================== */
|
||||
--shadow-sm: 0 1px 2px rgba(45, 30, 20, 0.06);
|
||||
--shadow-md: 0 4px 14px rgba(45, 30, 20, 0.08);
|
||||
--shadow-lg: 0 12px 32px rgba(45, 30, 20, 0.12);
|
||||
--shadow-xl: 0 24px 60px rgba(45, 30, 20, 0.16);
|
||||
--shadow-focus: 0 0 0 4px var(--ring);
|
||||
|
||||
/* ===========================================================
|
||||
7. MOTION
|
||||
=========================================================== */
|
||||
--ease-out: cubic-bezier(0.22, 1, 0.36, 1); /* @kind other */
|
||||
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1); /* @kind other */
|
||||
--dur-fast: 140ms; /* @kind other */
|
||||
--dur-base: 220ms; /* @kind other */
|
||||
--dur-slow: 360ms; /* @kind other */
|
||||
}
|
||||
|
||||
/* ===========================================================
|
||||
DARK THEME — warm espresso
|
||||
Apply via [data-theme="dark"] on <html> or any container.
|
||||
=========================================================== */
|
||||
[data-theme="dark"] {
|
||||
--bg: var(--sand-950);
|
||||
--bg-subtle: var(--sand-900);
|
||||
--surface: #221E19;
|
||||
--surface-2: #2A251F;
|
||||
--overlay: rgba(0, 0, 0, 0.6);
|
||||
|
||||
--fg1: #F4EFE8;
|
||||
--fg2: #B6AC9C;
|
||||
--fg3: #897F6F;
|
||||
--fg-on-accent: #1F1C18;
|
||||
|
||||
--accent: var(--clay-400);
|
||||
--accent-hover: var(--clay-300);
|
||||
--accent-press: var(--clay-500);
|
||||
--accent-subtle: rgba(204, 139, 92, 0.14);
|
||||
--accent-border: rgba(204, 139, 92, 0.30);
|
||||
|
||||
--border: rgba(242, 238, 231, 0.10);
|
||||
--border-strong: rgba(242, 238, 231, 0.18);
|
||||
--ring: rgba(204, 139, 92, 0.45);
|
||||
|
||||
--success: var(--green-400);
|
||||
--success-subtle: rgba(127, 176, 122, 0.16);
|
||||
--warning: var(--amber-400);
|
||||
--warning-subtle: rgba(226, 176, 90, 0.16);
|
||||
--danger: var(--red-400);
|
||||
--danger-subtle: rgba(212, 113, 90, 0.16);
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||
--shadow-md: 0 4px 14px rgba(0, 0, 0, 0.45);
|
||||
--shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.5);
|
||||
--shadow-xl: 0 24px 60px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
/* ===========================================================
|
||||
SEMANTIC TYPE CLASSES (use directly in markup)
|
||||
=========================================================== */
|
||||
.t-display-xl { font-family: var(--font-display); font-size: var(--text-display-xl); font-weight: 700; line-height: var(--leading-tight); letter-spacing: var(--tracking-tight); }
|
||||
.t-display { font-family: var(--font-display); font-size: var(--text-display); font-weight: 700; line-height: var(--leading-tight); letter-spacing: var(--tracking-tight); }
|
||||
.t-h1 { font-family: var(--font-display); font-size: var(--text-h1); font-weight: 600; line-height: var(--leading-snug); letter-spacing: var(--tracking-snug); }
|
||||
.t-h2 { font-family: var(--font-display); font-size: var(--text-h2); font-weight: 600; line-height: var(--leading-snug); letter-spacing: var(--tracking-snug); }
|
||||
.t-h3 { font-family: var(--font-sans); font-size: var(--text-h3); font-weight: 600; line-height: var(--leading-snug); }
|
||||
.t-body-lg { font-family: var(--font-sans); font-size: var(--text-body-lg); font-weight: 400; line-height: var(--leading-relaxed); }
|
||||
.t-body { font-family: var(--font-sans); font-size: var(--text-body); font-weight: 400; line-height: var(--leading-normal); }
|
||||
.t-sm { font-family: var(--font-sans); font-size: var(--text-sm); font-weight: 400; line-height: var(--leading-normal); }
|
||||
.t-caption { font-family: var(--font-sans); font-size: var(--text-caption); font-weight: 500; line-height: var(--leading-normal); }
|
||||
.t-overline { font-family: var(--font-sans); font-size: var(--text-caption); font-weight: 600; text-transform: uppercase; letter-spacing: var(--tracking-caps); }
|
||||
.t-mono { font-family: var(--font-mono); font-size: var(--text-sm); font-weight: 400; font-feature-settings: 'tnum' 1; }
|
||||
@@ -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,163 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>前端设计系统治理重构(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:920px;margin:0 auto;padding:48px 24px 96px}
|
||||
h1{font-size:29px;line-height:1.3;margin:0 0 8px;letter-spacing:-.01em}
|
||||
.sub{color:var(--fg2);font-size:15px;margin:0 0 28px}
|
||||
h2{font-size:20px;margin:38px 0 12px;padding-bottom:8px;border-bottom:1px solid var(--border)}
|
||||
h3{font-size:16px;margin:22px 0 8px;color:var(--accent)}
|
||||
p{margin:10px 0}
|
||||
code{font-family:var(--mono);font-size:.86em;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:18px 20px;margin:0 0 20px}
|
||||
.small{color:var(--fg2);font-size:13px}
|
||||
.phase{background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:16px 20px;margin:14px 0}
|
||||
.phase h2{margin-top:4px;border:none;padding:0;font-size:18px}
|
||||
ul{margin:8px 0;padding-left:22px} li{margin:5px 0}
|
||||
.box{overflow-x:auto;margin:14px 0}
|
||||
table{border-collapse:collapse;width:100%;font-size:13.5px;min-width:560px}
|
||||
th,td{border:1px solid var(--border);padding:7px 10px;text-align:left;vertical-align:top}
|
||||
th{background:var(--panel2);color:var(--fg)}
|
||||
.ok{color:var(--ok);font-weight:700} .bad{color:var(--bad);font-weight:700} .warn{color:var(--warn);font-weight:700}
|
||||
.pill{display:inline-block;font-size:11px;font-weight:700;padding:1px 8px;border-radius:999px;margin-left:6px}
|
||||
.pill.big{background:rgba(224,106,106,.16);color:var(--bad)}
|
||||
.tag{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:8px;vertical-align:middle}
|
||||
.back{color:var(--fg2);font-size:13px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
|
||||
<p class="back"><a href="index.html">← 文档索引</a></p>
|
||||
<h1>前端设计系统治理重构 <span class="tag">ds-flow</span></h1>
|
||||
<p class="sub">用 ds-flow 把 Flutter 五端 + 官网 + 用户中心收口到「设计单源 · 代码镜像 · 静态闸拦漂移 · 双级像素验收兜底」</p>
|
||||
|
||||
<div class="lead">
|
||||
<strong>阅读版</strong>;执行真相源 <code>docs/superpowers/plans/2026-07-07-frontend-ds-refactor.md</code>(含 checkbox)。<br>
|
||||
<strong>关键前提</strong>:pangolin <b>不是从零 bootstrap,已约 65% 达标</b>——token 单源(含暗色)、Flutter codegen + drift 闸、golden + CI 闸、pre-commit(写好未启用)都在。本计划是<b>补缺口 + Web 共享原子层去重</b>,非推倒重来。主题保持 <b>light / dark 双主题</b>。
|
||||
</div>
|
||||
|
||||
<h2>现状盘点</h2>
|
||||
<div class="box">
|
||||
<table>
|
||||
<tr><th>维度</th><th>现状</th><th>缺口</th></tr>
|
||||
<tr><td>Token 单源</td><td class="ok">✓ colors_and_type.css(含 [data-theme=dark])</td><td>迁为 ds-flow 原型结构</td></tr>
|
||||
<tr><td>Flutter codegen / 主题层</td><td class="ok">✓ gen 层/实现层分离 + drift 闸</td><td>—</td></tr>
|
||||
<tr><td>Flutter UI 硬编码</td><td class="ok">✓ 零裸 hex(唯 1 处裸色 adaptive_menu)</td><td>清 1 处</td></tr>
|
||||
<tr><td>Flutter golden</td><td class="ok">✓ 36 张 + harness + 真字体 + CI</td><td class="warn">6 张 failure;缺 CJK 测试字体;覆盖不全</td></tr>
|
||||
<tr><td>website / usercenter</td><td class="ok">✓ 179 / 171 处 var(--token)</td><td class="bad">零前端测试</td></tr>
|
||||
<tr><td>跨端共享组件</td><td class="bad">✗ 下拉/按钮/卡片两端各写一遍</td><td>抽 atoms 对齐</td></tr>
|
||||
<tr><td>原型三件套</td><td class="warn">⚠ 有 _ds_manifest/preview/ui_kits</td><td>缺 atoms.css / icons.js / index.html</td></tr>
|
||||
<tr><td>静态闸</td><td class="ok">✓ redline / analyze+test / codegen-drift / golden</td><td class="bad">✗ 硬编码色扫描 / fidelity / Web 同源</td></tr>
|
||||
<tr><td>pre-commit</td><td class="warn">⚠ .githooks 写好</td><td class="bad">默认未启用</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2>已定决策</h2>
|
||||
<ul>
|
||||
<li><b>Web 去重</b>:两端<b>各自实现 + 同源闸</b>(不建跨端组件包;成本低、风险小,符合 jiu 取舍)</li>
|
||||
<li><b>ui_kits</b>:jsx/css 端原型<b>收敛为纯 HTML 原型</b>并删副本(消除与「禁向 design/ 提组件代码副本」的冲突)</li>
|
||||
<li><b>节奏</b>:<b>先定稿本计划,再逐刀执行</b>(每刀 commit,tier-1 大改走确认闸)</li>
|
||||
</ul>
|
||||
|
||||
<h2>执行阶段(6 阶段)</h2>
|
||||
|
||||
<div class="phase">
|
||||
<h2>Phase 0 — 更新 CLAUDE.md + 计划落库</h2>
|
||||
<ul>
|
||||
<li>CLAUDE.md 补「前端设计系统治理(ds-flow)」章节:原型单源位置、codegen 命令、L1/L2/L3 三层规则、四道静态闸 +「违规谁拦」对照表、golden/fidelity 双闸定位</li>
|
||||
<li>本计划 .md 定稿 + HTML 阅读版 + 登记 docs/index.html</li>
|
||||
<li>/todo 建 tier-1 条目 + 6 子任务</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="phase">
|
||||
<h2>Phase 1 — 原型单源三件套(design/prototype/)</h2>
|
||||
<p class="small">把散在 ui_kits / preview / _ds_manifest.json 的东西收敛为 ds-flow 标准三件套。</p>
|
||||
<ul>
|
||||
<li><code>serve.mjs</code> 照搬 jiu(零依赖热重载)</li>
|
||||
<li><code>tokens.css</code>:现有 token <b>数值不变</b>,重排为「基础 :root 标量 + [data-theme=dark] 颜色覆盖」结构</li>
|
||||
<li><code>atoms.css</code>:按钮/卡片/输入/<b>语言下拉</b>/徽章/状态药丸公用原子(只引 var(--token))</li>
|
||||
<li><code>icons.js</code>:SVG sprite 单源,收敛 website / usercenter / Flutter 三处图标集</li>
|
||||
<li><code>index.html</code>:活登记页——light/dark 切换 + 声明式色板 + 全组件/图标展示卡(每 atom 必登记)</li>
|
||||
<li>ui_kits jsx 副本提炼后<b>删除</b>;屏级布局参考迁 <code>prototype/screens/</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="phase">
|
||||
<h2>Phase 2 — Web token 升为一等公民 + 同源闸</h2>
|
||||
<ul>
|
||||
<li>两端 token 落点统一指向原型 tokens.css(website→tokens.gen.css / usercenter→public/colors_and_type.css)</li>
|
||||
<li><code>tools/check-l1-sync.mjs</code>(照搬 jiu 裁剪):website / usercenter token 值逐值同源 + icons 同集 + Web hex 白名单扫描</li>
|
||||
<li>build-tokens 幂等:重跑零 diff(纳入 CI)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="phase">
|
||||
<h2>Phase 3 — Web 共享原子层对齐 <span class="pill big">工作量最大</span></h2>
|
||||
<p class="small">各自实现 + 同源闸:两端对齐同一 atoms.css,靠闸防漂移。</p>
|
||||
<ul>
|
||||
<li>抽公共原子:langsel / button / card / input / badge / pill → atoms.css canonical</li>
|
||||
<li>website:website.css/site-extra.css 对齐 atoms 语义,非白/黑/logo 硬编码清零</li>
|
||||
<li>usercenter:shared.tsx 的 card/input/LangSeg 对齐 atoms 语义,13 处硬编码核对</li>
|
||||
<li>两端 langsel 一致性纳入登记;更新 CONTRACT.md(Web 原子清单 + 屏级三态台账)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="phase">
|
||||
<h2>Phase 4 — Flutter 收尾 + golden 补齐</h2>
|
||||
<ul>
|
||||
<li>清 adaptive_menu.dart 唯 1 处裸色 → token</li>
|
||||
<li>测试字体补 CJK 子集(make-cjk-subset.sh → client/test/fonts + flutter_test_config 注册)</li>
|
||||
<li>处理现存 6 张 failure diff,逐张确认后 --update-goldens 重录</li>
|
||||
<li>golden 覆盖扩容:desktop/tablet/mobile 全屏 × light/dark 双主题矩阵</li>
|
||||
<li>harness 对齐 jiu:多主题循环 + 钉死 viewport/dpr + ProviderScope 固定数据</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="phase">
|
||||
<h2>Phase 5 — 静态闸挂满 + 启用 pre-commit + fidelity 体检</h2>
|
||||
<ul>
|
||||
<li>硬编码色扫描:Flutter <code>check_ds_code.mjs</code>(含 --changed)+ Web hex 并入 check-l1-sync</li>
|
||||
<li>原型校验 <code>check-ds.mjs</code>(照搬 jiu 12 道,按 pangolin 断点/主题裁剪)</li>
|
||||
<li>CI 串起来:原型校验 → 跨端同源 → 代码色单源 → codegen 零 diff(已有)→ 测试含 golden(补 mobile+主题)</li>
|
||||
<li>启用 pre-commit:install-hooks 纳入文档,增挂 check-ds --changed(条件触发)</li>
|
||||
<li>fidelity 像素闸(本地体检,不进 CI):screens.mjs + fidelity.mjs,逐屏阈值=实测残差+2pp</li>
|
||||
<li>全景文档 docs/frontend-overview.html(照搬 jiu 十节)+ 登记索引</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h2>Verification(端到端)</h2>
|
||||
<ul>
|
||||
<li><b>原型</b>:serve.mjs 逐屏目检 light/dark;check-ds 12 道全绿</li>
|
||||
<li><b>同源</b>:check-l1-sync 全绿(tokens 逐值 / icons 同集 / Web hex 白名单)</li>
|
||||
<li><b>Flutter</b>:analyze + test(golden ×双主题);check_ds_code 绿;codegen 重跑零 diff</li>
|
||||
<li><b>Web</b>:两端 build 通过;token 同源绿;langsel/button/card 对齐 atoms</li>
|
||||
<li><b>fidelity</b>:逐屏残差在阈内(首次校准记录实测值)</li>
|
||||
<li><b>闸生效</b>:install-hooks 后改一处硬编码色/未登记组件 → pre-commit 或 CI 拦下</li>
|
||||
</ul>
|
||||
|
||||
<h2>不在本轮</h2>
|
||||
<ul>
|
||||
<li>新功能 / 新屏开发(本轮是治理重构)</li>
|
||||
<li>iOS/iPad 专属布局深度优化(响应式已覆盖)</li>
|
||||
<li>三主题扩展(保持 light/dark)</li>
|
||||
</ul>
|
||||
|
||||
<p class="small" style="margin-top:32px">真相源(含 checkbox 执行跟踪):<code>docs/superpowers/plans/2026-07-07-frontend-ds-refactor.md</code></p>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</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>
|
||||
@@ -86,6 +86,11 @@
|
||||
</a>
|
||||
|
||||
<h2>实现计划 / Plans</h2>
|
||||
<a class="doc" href="frontend-ds-refactor-plan.html">
|
||||
<div class="t">前端设计系统治理重构(ds-flow 全端)<span class="tag html">HTML</span></div>
|
||||
<div class="d">阅读版;执行真相源 <code>docs/superpowers/plans/2026-07-07-frontend-ds-refactor.md</code>(含 checkbox)。用 ds-flow 把 Flutter 五端 + 官网 + 用户中心收口到「设计单源·代码镜像·静态闸拦漂移·golden/fidelity 双级像素验收兜底」。<b>非从零 bootstrap(已约 65% 达标)</b>:补原型三件套(atoms.css/icons.js/index.html 登记页)+ Web 共享原子层去重(各自实现+同源闸)+ 硬编码色/fidelity 闸 + 启用 pre-commit。6 阶段:CLAUDE.md → 原型单源 → Web token 同源 → Web 原子对齐 → Flutter golden 补齐 → 闸挂满。主题保持 light/dark。</div>
|
||||
<div class="path">docs/frontend-ds-refactor-plan.html · 真相源 docs/superpowers/plans/2026-07-07-frontend-ds-refactor.md</div>
|
||||
</a>
|
||||
<a class="doc" href="control-plane-tls-tunnel.html">
|
||||
<div class="t">控制面 TLS(Cloudflare Tunnel 前置)实现计划 <span class="tag html">HTML</span></div>
|
||||
<div class="d">阅读版;执行真相源 <code>docs/superpowers/plans/2026-07-06-control-plane-tls-tunnel.md</code>(含 checkbox)。把控制面 API 从明文 <code>http://103.119.13.48:8080</code> 迁到 <code>https://api.yanmeiai.com</code>(cloudflared 出站隧道前置,源站仅绑 127.0.0.1,数据面 sing-box REALITY :443 全程不动)。6 任务:CF Tunnel 供给 → 客户端切 https/Android 去明文 → CI 守护禁明文 → PANGOLIN_PUBLIC_URL 切 https → 退役明文口(收 loopback,带上线顺序闸)→ 文档。</div>
|
||||
@@ -123,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>
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
# 前端设计系统治理重构(ds-flow 落地全端)
|
||||
|
||||
> 用 ds-flow 方法论把 pangolin 全部前端(Flutter 五端 + 官网 website + 用户中心 usercenter)
|
||||
> 收口到「设计只有一个出生地(原型单源),代码永远是镜像;漂移由静态闸在提交/CI 前拦截,
|
||||
> 走样由 golden/fidelity 双级像素验收兜底」。
|
||||
>
|
||||
> **关键前提(摸底结论)**:pangolin 不是从零 bootstrap,已约 65% 达标——
|
||||
> token 单源(`design/colors_and_type.css` 含 `[data-theme=dark]`)、Flutter codegen + drift 闸、
|
||||
> golden + CI 闸、pre-commit(写好未启用)都在。本计划是**补缺口 + Web 共享原子层去重**,
|
||||
> 不是推倒重来。
|
||||
>
|
||||
> 主题模型:pangolin 用 **light / dark 两主题**(非 jiu 的 a/b/c 三主题),全程保持。
|
||||
>
|
||||
> 已定决策:① Web 两端**各自实现 + 同源闸**(不建跨端共享组件包);
|
||||
> ② `design/ui_kits/` 的 jsx/css 端原型**收敛为纯 HTML 原型**并删副本;
|
||||
> ③ **先定稿本计划,再逐刀执行**(每刀 commit)。
|
||||
>
|
||||
> 参考样板:`~/code/jiu`(`design/prototype/` + `tools/` + `client/lib/core/theme/` + `docs/frontend-overview.html`)。
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — 更新 CLAUDE.md + 计划落库
|
||||
|
||||
- [x] 0.1 CLAUDE.md 新增「## 前端设计系统治理(ds-flow)」章节:
|
||||
- 原型单源位置(`design/prototype/`:tokens/atoms/icons/index.html 登记簿)+ 只读约定
|
||||
- codegen 命令(Flutter `gen_flutter_tokens.mjs`;Web `build-tokens.mjs` 同源)
|
||||
- 三层治理 L1/L2/L3 规则速查
|
||||
- 四道静态闸清单 + 「违规谁拦」对照表(原型校验 / 跨端同源 / 代码色单源 / codegen 零 diff)
|
||||
- golden(多主题回归自比)/ fidelity(对原型 pixelmatch,本地体检不进 CI)双闸定位
|
||||
- [x] 0.2 本 `.md` 定稿 + 生成 HTML 阅读版 `docs/frontend-ds-refactor-plan.html`,登记进 `docs/index.html`「实现计划」
|
||||
- [x] 0.3 `/todo` 建 tier-1 条目跟踪本重构,拆 6 个子任务(对应 Phase 1-5 + 收尾)
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — 原型单源三件套(design/prototype/)
|
||||
|
||||
把散在 `ui_kits/`(6 端 jsx/css 原型)+ `preview/`(20 规格 HTML)+ `_ds_manifest.json`(登记簿)
|
||||
的东西收敛成 ds-flow 标准三件套。
|
||||
|
||||
- [x] 1.1 建 `design/prototype/` 目录;`serve.mjs` 照搬 jiu(零依赖热重载,默认端口按 jiu)
|
||||
- [x] 1.2 `design/prototype/tokens.css`:从现有 `colors_and_type.css` 迁移/规整为
|
||||
「基础 `:root`(主题无关标量:间距/圆角/字号/字体/阴影/动效)+ `[data-theme=dark]` 颜色覆盖块」结构。
|
||||
**保持数值不变**,只重排为 ds-flow 结构;`colors_and_type.css` 作为兼容别名或迁移为薄封装(不破坏现有 codegen)
|
||||
- [x] 1.3 `design/prototype/atoms.css`:把按钮/卡片/输入/**语言下拉**/徽章/状态药丸等公用原子类沉淀为
|
||||
只引 `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 三处的图标集)
|
||||
- [x] 1.5 `design/prototype/index.html`:活登记页——三…两主题(light/dark)切换 + `data-swatches` 声明式色板 +
|
||||
字号梯度 + 圆角/间距/阴影 + 全部公用组件原子展示卡 + 图标库全展示。**每个 atom 必须在此登记**
|
||||
- [x] 1.6 `design/ui_kits/` 的 jsx/css 端原型:提炼进 prototype 后**删除 jsx 组件副本**(消除与
|
||||
「禁向 design/ 提组件代码副本」的冲突 + 漂移源);保留必要的屏级 HTML 布局参考迁进 `prototype/screens/`
|
||||
- [x] 1.7 更新/退役 `_ds_manifest.json` + `_ds_bundle.js`:登记簿职责交给 `index.html`,
|
||||
manifest 若仍被消费则保留为派生产物(记清谁是真源)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Web token 升为一等公民 + 同源闸
|
||||
|
||||
现状:Web 的 `build-tokens.mjs` 只是「原样拷 css,删 Google Fonts 行」。升级为受闸守护的同源关系。
|
||||
|
||||
- [x] 2.1 确认两端 token 落点与生成链:website→`src/styles/tokens.gen.css`、
|
||||
usercenter→`public/colors_and_type.css`;源统一指向 `design/prototype/tokens.css`(Phase 1 后)
|
||||
- [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 固定色`,其余报警)
|
||||
- [x] 2.3 codegen 幂等:重跑 `build-tokens.mjs` 后 `git diff` 零差异(纳入 CI,见 Phase 5)
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Web 共享原子层对齐(各自实现 + 同源闸)★工作量最大
|
||||
|
||||
不建跨端组件包;两端各自实现,但都对齐 `design/prototype/atoms.css`,靠闸保证不漂移。
|
||||
|
||||
- [x] 3.1 抽公共原子清单:langsel(语言下拉,刚修的两套合规范)/ button / card / input / badge / pill。
|
||||
对每个原子在 `atoms.css` 定义 canonical 样式
|
||||
- [x] 3.2 website:`website.css` + `site-extra.css` 里的按钮/卡片/下拉 class 对齐 atoms.css 语义,
|
||||
残留 `#fff/#000`/logo 外的硬编码色清零(当前业务硬编码 ~30 处,多为可保留的白/黑/logo)
|
||||
- [x] 3.3 usercenter:`shared.tsx` 的 `card/input/LangSeg` 内联对象对齐 atoms.css 语义;
|
||||
残留 13 处硬编码(基本 `#fff`)核对,非白/黑/logo 的清零
|
||||
- [x] 3.4 两端 langsel 行为/样式一致性核对(此前刚统一为自定义下拉,纳入 atoms 登记)
|
||||
- [x] 3.5 更新 `design/CONTRACT.md`:Web 原子清单 + 屏级台账(同步/快照/代码先行三态)
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Flutter 收尾 + golden 补齐
|
||||
|
||||
Flutter 已很干净(UI 层零裸 hex),只需收尾。
|
||||
|
||||
- [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 中文与生产渲染差异
|
||||
- [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 + 主题维度)
|
||||
- [x] 4.5 `client/test/helpers/harness.dart` 对齐 jiu `golden_harness.dart` 手法:
|
||||
多主题循环辅助 + 钉死 viewport/dpr + ProviderScope 固定数据(防动态值翻车)
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — 静态闸挂满 + 启用 pre-commit + fidelity 体检
|
||||
|
||||
- [x] 5.1 硬编码色扫描闸:
|
||||
- Flutter `client/tool/check_ds_code.mjs`(照搬 jiu,含 `--changed` 供 pre-commit):禁 `Color(0x..)`/裸 `Colors.x`
|
||||
- Web hex 扫描并入 `check-l1-sync.mjs` ④
|
||||
- [x] 5.2 原型校验闸 `design/prototype/tools/check-ds.mjs`(照搬 jiu 12 道,按 pangolin 断点/主题裁剪)
|
||||
- [x] 5.3 CI 串起来(`.gitea/workflows/ci.yml` 增补):
|
||||
原型校验 → 跨端同源 → 代码色单源 → codegen 零 diff(已有)→ 测试含 golden(已有,补 mobile+主题)
|
||||
- [x] 5.4 启用 pre-commit:`ci/install-hooks.sh` 纳入 onboarding 文档 + CLAUDE.md,
|
||||
`.githooks/pre-commit` 增挂 `check-ds --changed`(只在动了 `design/prototype/` 时跑,轻量条件触发)
|
||||
- [ ] 5.5 (延后·前置=原型整屏 screens/,属 L3) fidelity 像素闸(本地体检,不进 CI):`tools/screens.mjs` 屏注册表 + `tools/fidelity.mjs`
|
||||
(原型 Chromium 截图 vs Flutter golden pixelmatch,逐屏阈值=实测残差+2pp,两边统一注入 CJK 字体)
|
||||
- [x] 5.6 全景文档 `docs/frontend-overview.html`(照搬 jiu 十节):一次 UI 改动标准路径 + 目录地图 +
|
||||
三层分治 + 闸全景 + 像素验收体系 + 响应式范式 + 规则速查,登记进 docs/index.html
|
||||
|
||||
---
|
||||
|
||||
## Verification(端到端)
|
||||
|
||||
- **原型**:`node design/prototype/serve.mjs` 起服务,浏览器逐屏目检 light/dark;`check-ds.mjs` 12 道全绿
|
||||
- **同源**:`node tools/check-l1-sync.mjs` 全绿(tokens 逐值 / icons 同集 / Web hex 白名单)
|
||||
- **Flutter**:`flutter analyze` + `flutter test`(含 golden ×双主题);`check_ds_code.mjs` 全绿;codegen 重跑零 diff
|
||||
- **Web**:两端 `npm run build` 通过;token 同源闸绿;langsel/button/card 对齐 atoms
|
||||
- **fidelity**:`node tools/fidelity.mjs` 逐屏残差在阈内(首次校准记录各屏实测值)
|
||||
- **闸生效**:`ci/install-hooks.sh` 后改一处硬编码色/未登记组件 → pre-commit 或 CI 拦下
|
||||
|
||||
## 不在本轮
|
||||
|
||||
- 新功能/新屏开发(本轮是治理重构,不加业务)
|
||||
- iOS/iPad 专属布局深度优化(响应式已覆盖,超阈再单独立项)
|
||||
- 三主题扩展(保持 light/dark 双主题)
|
||||
@@ -140,7 +140,7 @@ func TestIntegration_FullChain(t *testing.T) {
|
||||
}
|
||||
|
||||
// 2. Register → trial subscription must exist for 7 days.
|
||||
pair, apiErr := svc.Register(ctx, email, code, pw)
|
||||
pair, apiErr := svc.Register(ctx, email, code, pw, "203.0.113.10", DeviceMeta{})
|
||||
if apiErr != nil {
|
||||
t.Fatalf("Register: %v", apiErr)
|
||||
}
|
||||
@@ -170,12 +170,12 @@ func TestIntegration_FullChain(t *testing.T) {
|
||||
}
|
||||
// Force a fresh code regardless of rate limit.
|
||||
_ = rdb.Set(ctx, codeKey(email), code, 10*time.Minute).Err()
|
||||
if _, e := svc.Register(ctx, email, code, pw); e == nil || e.Code != ErrCodeInvalid.Code {
|
||||
if _, e := svc.Register(ctx, email, code, pw, "203.0.113.10", DeviceMeta{}); e == nil || e.Code != ErrCodeInvalid.Code {
|
||||
t.Fatalf("want code_invalid (anti-enumeration), got %v", e)
|
||||
}
|
||||
|
||||
// 4. Login.
|
||||
loginPair, _, apiErr := svc.Login(ctx, email, pw, "198.51.100.7")
|
||||
loginPair, _, apiErr := svc.Login(ctx, email, pw, "198.51.100.7", DeviceMeta{})
|
||||
if apiErr != nil {
|
||||
t.Fatalf("Login: %v", apiErr)
|
||||
}
|
||||
|
||||
@@ -3,17 +3,21 @@ package devices
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/wangjia/pangolin/server/internal/codes"
|
||||
)
|
||||
|
||||
// ctxKey is a private type for context keys to avoid collisions.
|
||||
type ctxKey string
|
||||
|
||||
// CtxKeyUserID is the context key under which the authenticated user's
|
||||
// internal int64 ID is stored by the JWT auth middleware (module #2).
|
||||
// internal int64 ID is stored by the JWT auth middleware (auth.RequireAuth).
|
||||
//
|
||||
// It mirrors the key used by the codes module so that, once the auth
|
||||
// middleware lands, a single canonical key can be reconciled across modules.
|
||||
const CtxKeyUserID ctxKey = "user_id"
|
||||
// It is the single canonical key shared with the codes and auth modules
|
||||
// (auth.UserIDFromContext reads codes.CtxKeyUserID). Aliasing it here — rather
|
||||
// than declaring a distinct devices.ctxKey("user_id") — ensures the devices
|
||||
// middleware/handlers resolve the same value the auth middleware injects.
|
||||
const CtxKeyUserID = codes.CtxKeyUserID
|
||||
|
||||
// ctxKeyPlan is the context key under which the resolved subscription Plan is
|
||||
// stored by SubscriptionMiddleware.
|
||||
|
||||
@@ -117,7 +117,7 @@ func applySchema(db *sql.DB) error {
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
|
||||
`INSERT IGNORE INTO plans (code, max_devices, daily_minutes, ad_gate)
|
||||
VALUES ('free', 1, 10, TRUE), ('pro', 5, NULL, FALSE), ('team', 10, NULL, FALSE)`,
|
||||
VALUES ('free', 1, 10, TRUE), ('pro', 3, NULL, FALSE), ('team', 10, NULL, FALSE)`,
|
||||
}
|
||||
for _, stmt := range stmts {
|
||||
if _, err := db.Exec(stmt); err != nil {
|
||||
|
||||
@@ -108,6 +108,7 @@ func applySchema(db *sql.DB) error {
|
||||
bytes_down BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
minutes_used INT NOT NULL DEFAULT 0,
|
||||
ad_unlocked_at DATETIME(6) NULL,
|
||||
ad_bonus_minutes INT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (user_id, date)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
`CREATE TABLE IF NOT EXISTS usage_hourly (
|
||||
@@ -433,11 +434,16 @@ func TestIntAdsUnlockAccumulates(t *testing.T) {
|
||||
t.Errorf("remaining after 2 ads=%d, want 30", rem)
|
||||
}
|
||||
|
||||
// Exactly one unlock timestamp.
|
||||
var n int
|
||||
db.QueryRow(`SELECT COUNT(*) FROM usage_daily WHERE user_id=? AND ad_unlocked_at IS NOT NULL`, uid).Scan(&n)
|
||||
if n != 1 {
|
||||
t.Errorf("expected 1 unlocked day, got %d", n)
|
||||
// Additive model: the two ads accumulate into a single day row's
|
||||
// ad_bonus_minutes (10 + 10 = 20). (ad_unlocked_at is legacy from the old
|
||||
// per-day boolean unlock and is no longer stamped by UnlockAd.)
|
||||
var rows, bonus int
|
||||
db.QueryRow(`SELECT COUNT(*), COALESCE(MAX(ad_bonus_minutes),0) FROM usage_daily WHERE user_id=?`, uid).Scan(&rows, &bonus)
|
||||
if rows != 1 {
|
||||
t.Errorf("expected 1 usage_daily row, got %d", rows)
|
||||
}
|
||||
if bonus != 20 {
|
||||
t.Errorf("expected ad_bonus_minutes=20, got %d", bonus)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -100,6 +100,8 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
.t-tag:hover { background: #d9e8fb; color: #1d4ed8; }
|
||||
.ver-badge { font-weight: 700; color: #1f7a44; }
|
||||
.sub-progress-badge { background: #f0f4ff; color: #3730a3; }
|
||||
.owner-user{background:rgba(232,192,122,.16);border:1px solid #e8c07a;color:#e8c07a}
|
||||
.owner-agent{background:rgba(126,224,162,.14);border:1px solid #7ee0a2;color:#7ee0a2}
|
||||
|
||||
/* 状态徽章 */
|
||||
.status-badge { font-size: 11.5px; }
|
||||
@@ -246,6 +248,78 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
.modal-cmd-code { font-family: "JetBrains Mono", "Fira Code", monospace; font-size: 13px; color: #86efac; word-break: break-all; display: block; }
|
||||
.modal-copy-btn { margin-top: 10px; padding: 5px 14px; border-radius: 6px; background: #334155; color: #e2e8f0; border: none; font-size: 12px; cursor: pointer; }
|
||||
.modal-copy-btn:hover { background: #475569; }
|
||||
/* ════ 家族深色主题覆盖层(2026-07-06 对齐 brain/docs 家族风格;后置覆盖上方浅色基础样式) ════ */
|
||||
:root{--bg:#0d1117;--card:#161b22;--card-2:#1c2330;--border:#283041;--fg:#e6edf3;--fg-soft:#aeb9c7;--muted:#7d8896;--accent:#58a6ff;--green:#3fb950;--amber:#d29922;--red:#f85149}
|
||||
body{color:var(--fg);background:radial-gradient(1200px 600px at 80% -10%,rgba(88,166,255,.08),transparent 60%),var(--bg)}
|
||||
header{background:linear-gradient(135deg,#101a2c 0%,#15294a 100%);border-bottom:1px solid var(--border)}
|
||||
header .header-meta{color:var(--fg-soft)}
|
||||
.stat-pill{background:rgba(88,166,255,.12);border:1px solid var(--border)}
|
||||
.filter-bar{background:rgba(13,17,23,.92);backdrop-filter:blur(6px);border-bottom:1px solid var(--border)}
|
||||
.filter-label{color:var(--muted)}
|
||||
.filter-chip,.filter-btn{background:var(--card);border-color:var(--border);color:var(--fg-soft)}
|
||||
.filter-chip:hover,.filter-btn:hover{border-color:var(--accent);color:var(--accent)}
|
||||
.filter-chip.active,.filter-btn.active{background:var(--accent);border-color:var(--accent);color:#0d1117}
|
||||
.filter-sep{background:var(--border)}
|
||||
.section-title .s-count{background:rgba(255,255,255,.08)}
|
||||
.st-open {background:rgba(88,166,255,.10);color:#79b8ff;border-left-color:var(--accent)}
|
||||
.st-doing {background:rgba(210,153,34,.10);color:#e3b341;border-left-color:var(--amber)}
|
||||
.st-done {background:rgba(63,185,80,.08);color:#7ee787;border-left-color:var(--green)}
|
||||
.st-accepted{background:rgba(63,185,80,.12);color:#7ee787;border-left-color:var(--green)}
|
||||
.todo-card{background:var(--card);border-color:var(--border)}
|
||||
.todo-card.s-doing{border-left-color:var(--amber)}
|
||||
.todo-card.s-done{border-left-color:var(--green)}
|
||||
.todo-card.s-accepted{background:rgba(63,185,80,.06);border-left-color:var(--green)}
|
||||
.item-id{color:var(--accent);background:#0b1020;border-color:var(--border)}
|
||||
.item-title{color:var(--fg)}
|
||||
.todo-card.s-accepted .item-title{color:var(--muted)}
|
||||
.item-desc{color:var(--fg-soft)}
|
||||
.item-desc code{background:#0b1020;border:1px solid var(--border);color:#c9d6e6}
|
||||
.item-meta{color:var(--muted)}
|
||||
.t-block{background:rgba(248,81,73,.15);color:#ff9492}
|
||||
.t-high{background:rgba(210,153,34,.15);color:#e3b341}
|
||||
.t-low{background:rgba(88,166,255,.12);color:#79b8ff}
|
||||
.t-tag{background:var(--card-2);color:var(--fg-soft)}
|
||||
.t-tag:hover{background:rgba(88,166,255,.15);color:var(--accent)}
|
||||
.ver-badge{color:var(--green)}
|
||||
.sub-progress-badge{background:rgba(88,166,255,.12);color:#79b8ff}
|
||||
.s-open{background:rgba(88,166,255,.15);color:#79b8ff}
|
||||
.s-doing{background:rgba(210,153,34,.18);color:#e3b341}
|
||||
.s-done{background:rgba(63,185,80,.15);color:#7ee787}
|
||||
.s-accepted{background:rgba(63,185,80,.18);color:#7ee787}
|
||||
.empty-tip{color:var(--muted)}
|
||||
.tier-1{background:rgba(163,113,247,.15);color:#d2a8ff}
|
||||
.tier-2{background:rgba(57,197,187,.12);color:#76e3db}
|
||||
.tier-3{background:var(--card-2);color:var(--fg-soft)}
|
||||
.reject-btn{background:transparent;border-color:var(--red);color:#ff9492}
|
||||
.reject-btn:hover{background:var(--red);color:#0d1117}
|
||||
.reject-note{background:rgba(248,81,73,.08);border-color:rgba(248,81,73,.4);color:#ff9492}
|
||||
.reject-date{color:rgba(248,81,73,.6)}
|
||||
.gate-pending{background:rgba(210,153,34,.08);border-color:rgba(210,153,34,.4)}
|
||||
.gate-granted{background:rgba(63,185,80,.08);border-color:rgba(63,185,80,.4)}
|
||||
.gate-info{background:var(--card-2);border-color:var(--border)}
|
||||
.gate-kind,.gate-note,.gate-ref{color:var(--fg-soft)}
|
||||
.gate-date{color:var(--muted)}
|
||||
.gate-note code,.gate-ref code{background:#0b1020;border:1px solid var(--border);color:#c9d6e6}
|
||||
.subtask-block{background:var(--card-2);border-color:var(--border)}
|
||||
.subtask-label{color:var(--muted)}
|
||||
.subtask-progress-text{color:var(--fg-soft)}
|
||||
.subtask-progress-bar{background:var(--border)}
|
||||
.subtask-item{background:var(--card);border-color:var(--border)}
|
||||
.subtask-item.s-done,.subtask-item.s-accepted{background:rgba(63,185,80,.06);border-color:rgba(63,185,80,.3)}
|
||||
.subtask-item.s-doing{background:rgba(210,153,34,.06);border-color:rgba(210,153,34,.3)}
|
||||
.sub-icon.s-open{color:var(--muted)}
|
||||
.sub-sid{color:var(--muted)}
|
||||
.subtask-item.s-done .sub-title,.subtask-item.s-accepted .sub-title{color:var(--muted)}
|
||||
.dep-label{color:var(--muted)}
|
||||
.dep-done{background:rgba(63,185,80,.15);color:#7ee787}
|
||||
.dep-pending{background:rgba(248,81,73,.15);color:#ff9492}
|
||||
.modal-overlay{background:rgba(0,0,0,.6)}
|
||||
.modal-box{background:var(--card);border:1px solid var(--border)}
|
||||
.modal-box h3{color:var(--fg)}
|
||||
.modal-subtitle{color:var(--fg-soft)}
|
||||
.modal-label{color:var(--fg-soft)}
|
||||
.modal-textarea{background:#0b1020;border-color:var(--border);color:var(--fg)}
|
||||
.modal-btn-cancel{background:var(--card-2);color:var(--fg-soft)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -253,12 +327,12 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
<header>
|
||||
<div class="wrap">
|
||||
<h1>feature+windows — 项目 TODO</h1>
|
||||
<div class="header-meta">生成于 2026-07-01 · 真相源 todo/todo.json</div>
|
||||
<div class="header-meta">生成于 2026-07-08 · 真相源 todo/todo.json</div>
|
||||
<div class="stats">
|
||||
<div class="stat-pill"><strong>17</strong>全部</div>
|
||||
<div class="stat-pill"><strong>18</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>
|
||||
@@ -293,7 +367,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
<div class="filter-sep"></div>
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">平台 / 标签</span>
|
||||
<button class="filter-chip" data-filter-tag="Android">Android</button><button class="filter-chip" data-filter-tag="Windows">Windows</button><button class="filter-chip" data-filter-tag="gRPC">gRPC</button><button class="filter-chip" data-filter-tag="iOS">iOS</button><button class="filter-chip" data-filter-tag="mac">mac</button><button class="filter-chip" data-filter-tag="前端">前端</button><button class="filter-chip" data-filter-tag="后端">后端</button><button class="filter-chip" data-filter-tag="运维">运维</button>
|
||||
<button class="filter-chip" data-filter-tag="Android">Android</button><button class="filter-chip" data-filter-tag="CI/CD">CI/CD</button><button class="filter-chip" data-filter-tag="Web">Web</button><button class="filter-chip" data-filter-tag="Windows">Windows</button><button class="filter-chip" data-filter-tag="gRPC">gRPC</button><button class="filter-chip" data-filter-tag="iOS">iOS</button><button class="filter-chip" data-filter-tag="mac">mac</button><button class="filter-chip" data-filter-tag="前端">前端</button><button class="filter-chip" data-filter-tag="后端">后端</button><button class="filter-chip" data-filter-tag="运维">运维</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -332,6 +406,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"><span class="tag t-tag" data-tag="mac">mac</span></div>
|
||||
<div class="item-meta">
|
||||
<span class="tag owner-agent">🤖 agent</span>
|
||||
<span class="meta-date">🕐 2026-06-22</span>
|
||||
|
||||
</div>
|
||||
@@ -362,6 +437,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"><span class="tag t-tag" data-tag="Android">Android</span></div>
|
||||
<div class="item-meta">
|
||||
<span class="tag owner-agent">🤖 agent</span>
|
||||
<span class="meta-date">🕐 2026-06-22</span>
|
||||
|
||||
</div>
|
||||
@@ -392,6 +468,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span> <span class="tag t-tag" data-tag="运维">运维</span></div>
|
||||
<div class="item-meta">
|
||||
<span class="tag owner-agent">🤖 agent</span>
|
||||
<span class="meta-date">🕐 2026-06-28</span>
|
||||
|
||||
</div>
|
||||
@@ -422,6 +499,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
|
||||
<div class="item-meta">
|
||||
<span class="tag owner-agent">🤖 agent</span>
|
||||
<span class="meta-date">🕐 2026-06-28</span>
|
||||
|
||||
</div>
|
||||
@@ -452,6 +530,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="后端">后端</span></div>
|
||||
<div class="item-meta">
|
||||
<span class="tag owner-agent">🤖 agent</span>
|
||||
<span class="meta-date">🕐 2026-06-28</span>
|
||||
|
||||
</div>
|
||||
@@ -482,6 +561,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"><span class="tag t-tag" data-tag="Android">Android</span> <span class="tag t-tag" data-tag="iOS">iOS</span> <span class="tag t-tag" data-tag="后端">后端</span></div>
|
||||
<div class="item-meta">
|
||||
<span class="tag owner-agent">🤖 agent</span>
|
||||
<span class="meta-date">🕐 2026-06-29</span>
|
||||
|
||||
</div>
|
||||
@@ -512,6 +592,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span> <span class="tag t-tag" data-tag="前端">前端</span></div>
|
||||
<div class="item-meta">
|
||||
<span class="tag owner-agent">🤖 agent</span>
|
||||
<span class="meta-date">🕐 2026-06-30</span>
|
||||
|
||||
</div>
|
||||
@@ -542,6 +623,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"><span class="tag t-tag" data-tag="Windows">Windows</span></div>
|
||||
<div class="item-meta">
|
||||
<span class="tag owner-agent">🤖 agent</span>
|
||||
<span class="meta-date">🕐 2026-06-22</span>
|
||||
|
||||
</div>
|
||||
@@ -563,7 +645,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
</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">
|
||||
@@ -593,8 +675,45 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"><span class="tag t-tag" data-tag="mac">mac</span> <span class="tag t-tag" data-tag="前端">前端</span></div>
|
||||
<div class="item-meta">
|
||||
<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>
|
||||
@@ -633,6 +752,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span></div>
|
||||
<div class="item-meta">
|
||||
<span class="tag owner-agent">🤖 agent</span>
|
||||
<span class="meta-date">🕐 2026-06-28</span>
|
||||
<span class="meta-date">✅ 验收 2026-06-30</span>
|
||||
</div>
|
||||
@@ -663,6 +783,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
|
||||
<div class="item-meta">
|
||||
<span class="tag owner-agent">🤖 agent</span>
|
||||
<span class="meta-date">🕐 2026-06-28</span>
|
||||
<span class="meta-date">✅ 验收 2026-06-30</span>
|
||||
</div>
|
||||
@@ -693,6 +814,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span></div>
|
||||
<div class="item-meta">
|
||||
<span class="tag owner-agent">🤖 agent</span>
|
||||
<span class="meta-date">🕐 2026-06-28</span>
|
||||
<span class="meta-date">✅ 验收 2026-06-30</span>
|
||||
</div>
|
||||
@@ -723,6 +845,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"><span class="tag t-tag" data-tag="iOS">iOS</span> <span class="tag t-tag" data-tag="前端">前端</span></div>
|
||||
<div class="item-meta">
|
||||
<span class="tag owner-agent">🤖 agent</span>
|
||||
<span class="meta-date">🕐 2026-06-30</span>
|
||||
<span class="meta-date">✅ 验收 2026-06-30</span>
|
||||
</div>
|
||||
@@ -753,6 +876,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="后端">后端</span></div>
|
||||
<div class="item-meta">
|
||||
<span class="tag owner-agent">🤖 agent</span>
|
||||
<span class="meta-date">🕐 2026-06-28</span>
|
||||
<span class="meta-date">✅ 验收 2026-06-30</span>
|
||||
</div>
|
||||
@@ -783,6 +907,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span> <span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="gRPC">gRPC</span></div>
|
||||
<div class="item-meta">
|
||||
<span class="tag owner-agent">🤖 agent</span>
|
||||
<span class="meta-date">🕐 2026-06-29</span>
|
||||
<span class="meta-date">✅ 验收 2026-06-30</span>
|
||||
</div>
|
||||
@@ -813,6 +938,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="后端">后端</span></div>
|
||||
<div class="item-meta">
|
||||
<span class="tag owner-agent">🤖 agent</span>
|
||||
<span class="meta-date">🕐 2026-06-28</span>
|
||||
<span class="meta-date">✅ 验收 2026-06-28</span>
|
||||
</div>
|
||||
@@ -843,6 +969,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
|
||||
<div class="item-meta">
|
||||
<span class="tag owner-agent">🤖 agent</span>
|
||||
<span class="meta-date">🕐 2026-06-28</span>
|
||||
<span class="meta-date">✅ 验收 2026-06-28</span>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "feature+windows — 项目 TODO",
|
||||
"updated_at": "2026-06-30T23:10:43.458Z"
|
||||
"updated_at": "2026-07-07T17:45:18.036Z"
|
||||
},
|
||||
"seq": 18,
|
||||
"seq": 19,
|
||||
"items": [
|
||||
{
|
||||
"id": 1,
|
||||
@@ -18,7 +18,8 @@
|
||||
"created_at": "2026-06-22T09:32:42.340Z",
|
||||
"done": false,
|
||||
"completed_at": null,
|
||||
"version": null
|
||||
"version": null,
|
||||
"owner": "agent"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
@@ -33,7 +34,8 @@
|
||||
"created_at": "2026-06-22T09:32:56.098Z",
|
||||
"done": false,
|
||||
"completed_at": null,
|
||||
"version": null
|
||||
"version": null,
|
||||
"owner": "agent"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
@@ -48,7 +50,8 @@
|
||||
"created_at": "2026-06-22T09:32:56.169Z",
|
||||
"done": false,
|
||||
"completed_at": null,
|
||||
"version": null
|
||||
"version": null,
|
||||
"owner": "agent"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
@@ -64,7 +67,8 @@
|
||||
"created_at": "2026-06-27T16:39:31.549Z",
|
||||
"done": false,
|
||||
"completed_at": null,
|
||||
"version": null
|
||||
"version": null,
|
||||
"owner": "agent"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
@@ -79,7 +83,8 @@
|
||||
"created_at": "2026-06-27T23:37:24.980Z",
|
||||
"done": true,
|
||||
"completed_at": "2026-06-28T13:19:24.207Z",
|
||||
"version": null
|
||||
"version": null,
|
||||
"owner": "agent"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
@@ -95,7 +100,8 @@
|
||||
"created_at": "2026-06-28T00:21:30.990Z",
|
||||
"done": true,
|
||||
"completed_at": "2026-06-28T13:19:24.299Z",
|
||||
"version": null
|
||||
"version": null,
|
||||
"owner": "agent"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
@@ -110,7 +116,8 @@
|
||||
"created_at": "2026-06-28T00:26:44.498Z",
|
||||
"done": true,
|
||||
"completed_at": "2026-06-30T14:44:35.082Z",
|
||||
"version": null
|
||||
"version": null,
|
||||
"owner": "agent"
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
@@ -125,7 +132,8 @@
|
||||
"created_at": "2026-06-28T00:38:22.413Z",
|
||||
"done": false,
|
||||
"completed_at": null,
|
||||
"version": null
|
||||
"version": null,
|
||||
"owner": "agent"
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
@@ -141,7 +149,8 @@
|
||||
"created_at": "2026-06-28T09:32:26.008Z",
|
||||
"done": true,
|
||||
"completed_at": "2026-06-30T14:44:34.810Z",
|
||||
"version": null
|
||||
"version": null,
|
||||
"owner": "agent"
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
@@ -156,7 +165,8 @@
|
||||
"created_at": "2026-06-28T09:32:26.080Z",
|
||||
"done": true,
|
||||
"completed_at": "2026-06-30T14:44:34.991Z",
|
||||
"version": null
|
||||
"version": null,
|
||||
"owner": "agent"
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
@@ -171,7 +181,8 @@
|
||||
"created_at": "2026-06-28T11:27:56.637Z",
|
||||
"done": true,
|
||||
"completed_at": "2026-06-30T14:47:39.343Z",
|
||||
"version": null
|
||||
"version": null,
|
||||
"owner": "agent"
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
@@ -187,7 +198,8 @@
|
||||
"created_at": "2026-06-28T11:27:56.708Z",
|
||||
"done": false,
|
||||
"completed_at": null,
|
||||
"version": null
|
||||
"version": null,
|
||||
"owner": "agent"
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
@@ -204,7 +216,8 @@
|
||||
"created_at": "2026-06-29T04:55:46.733Z",
|
||||
"done": true,
|
||||
"completed_at": "2026-06-29T16:59:39.926Z",
|
||||
"version": null
|
||||
"version": null,
|
||||
"owner": "agent"
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
@@ -221,7 +234,8 @@
|
||||
"created_at": "2026-06-29T09:36:14.004Z",
|
||||
"done": false,
|
||||
"completed_at": null,
|
||||
"version": null
|
||||
"version": null,
|
||||
"owner": "agent"
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
@@ -237,7 +251,8 @@
|
||||
"created_at": "2026-06-30T10:56:48.894Z",
|
||||
"done": true,
|
||||
"completed_at": "2026-06-30T14:44:34.900Z",
|
||||
"version": null
|
||||
"version": null,
|
||||
"owner": "agent"
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
@@ -253,7 +268,8 @@
|
||||
"created_at": "2026-06-30T14:33:48.996Z",
|
||||
"done": false,
|
||||
"completed_at": null,
|
||||
"version": null
|
||||
"version": null,
|
||||
"owner": "agent"
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
@@ -269,7 +285,38 @@
|
||||
"created_at": "2026-06-30T23:00:25.193Z",
|
||||
"done": false,
|
||||
"completed_at": null,
|
||||
"version": null
|
||||
"version": null,
|
||||
"owner": "agent"
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"title": "前端设计系统治理重构(ds-flow 全端)",
|
||||
"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",
|
||||
"level": "mid",
|
||||
"tier": 1,
|
||||
"tags": [
|
||||
"前端",
|
||||
"Web",
|
||||
"mac",
|
||||
"iOS",
|
||||
"Android",
|
||||
"Windows",
|
||||
"CI/CD"
|
||||
],
|
||||
"owner": "agent",
|
||||
"status": "done",
|
||||
"created_at": "2026-07-07T15:41:25.760Z",
|
||||
"done": false,
|
||||
"completed_at": null,
|
||||
"version": null,
|
||||
"gate": {
|
||||
"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": "granted",
|
||||
"proposed_at": "2026-07-07T15:41:36.536Z",
|
||||
"approved_at": "2026-07-07T15:54:54.386Z"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
#!/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] ?? '';
|
||||
// 键可能带引号('refresh-cw')或裸标识符(home)—— 两种都匹配(裸键此前被漏检)。
|
||||
for (const [, id, body] of ucBody.matchAll(/['"]?([\w-]+)['"]?\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);
|
||||
@@ -75,7 +75,7 @@ export default function Login({ onDone }: { onDone: () => void }) {
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<Mark size={32} />
|
||||
<div>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 17, color: 'var(--fg1)', lineHeight: 1 }}>穿山甲</div>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 17, color: 'var(--fg1)', lineHeight: 1 }}>{t('brandName')}</div>
|
||||
<div style={{ fontSize: 8.5, fontWeight: 600, letterSpacing: '0.2em', color: 'var(--accent)', marginTop: 3 }}>PANGOLIN</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,7 @@ export default function Subscription({ t, mobile }: { t: TFn; mobile: boolean })
|
||||
}, [api]);
|
||||
|
||||
const clients = [
|
||||
{ name: '穿山甲 App', sub: 'iOS / Android / 桌面', icon: 'shield-check', accent: true },
|
||||
{ name: `${t('brandName')} App`, sub: 'iOS / Android / 桌面', icon: 'shield-check', accent: true },
|
||||
{ name: 'Shadowrocket', sub: 'iOS', icon: 'external-link' },
|
||||
{ name: 'Clash Verge', sub: 'Windows / macOS', icon: 'external-link' },
|
||||
{ name: 'v2rayN', sub: 'Windows', icon: 'external-link' },
|
||||
|
||||
@@ -18,6 +18,14 @@ import type { Me } from '../lib/api/types';
|
||||
type View = 'overview' | 'sub' | 'redeem' | 'invite' | 'settings';
|
||||
const ORDER: View[] = ['overview', 'sub', 'redeem', 'invite', 'settings'];
|
||||
|
||||
/** redirect 白名单:仅接受单个 '/' 开头、且不以 '//' 或反斜杠开头的本站相对
|
||||
* 路径(防 open redirect,与 app/sso/page.tsx::safeRedirect 一致);否则返回 null。 */
|
||||
function safeRedirect(raw: string | null): string | null {
|
||||
if (!raw) return null;
|
||||
if (raw.charAt(0) !== '/' || raw.charAt(1) === '/' || raw.indexOf('\\') >= 0) return null;
|
||||
return raw;
|
||||
}
|
||||
|
||||
function useIsMobile() {
|
||||
const [m, setM] = useState(false);
|
||||
useEffect(() => {
|
||||
@@ -100,10 +108,22 @@ export default function UserCenter() {
|
||||
setView('overview');
|
||||
}
|
||||
|
||||
// 登录成功回调:若 URL 带合法 ?redirect=<本站相对路径>(如官网带 ?redirect=/ 过来),
|
||||
// 回跳来源页;否则进用户中心概览。
|
||||
function onLoginDone() {
|
||||
const redirect = safeRedirect(new URLSearchParams(window.location.search).get('redirect'));
|
||||
if (redirect) {
|
||||
window.location.replace(redirect);
|
||||
return;
|
||||
}
|
||||
setAuthed(true);
|
||||
setView('overview');
|
||||
}
|
||||
|
||||
// 静态导出无服务端会话:首屏(!ready)与未登录一律直接渲染登录页,避免出现空白
|
||||
// 背景(慢网络下用户会看到"空的")。已登录用户(有 refresh)会话续期完成后再切面板。
|
||||
if (!ready || !authed) {
|
||||
return <Login onDone={() => { setAuthed(true); setView('overview'); }} />;
|
||||
return <Login onDone={onLoginDone} />;
|
||||
}
|
||||
|
||||
const nav: [View, string, string][] = [
|
||||
@@ -148,10 +168,14 @@ export default function UserCenter() {
|
||||
<div style={{ maxWidth: 1000, margin: '0 auto', padding: mobile ? '0 16px' : '0 24px', height: mobile ? 54 : 60, display: 'flex', alignItems: 'center', gap: mobile ? 12 : 22 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 9 }}>
|
||||
<Mark size={26} />
|
||||
<span style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 16.5, color: 'var(--fg1)' }}>穿山甲</span>
|
||||
<span style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 16.5, color: 'var(--fg1)' }}>{t('brandName')}</span>
|
||||
</div>
|
||||
{!mobile && <nav style={{ display: 'flex', gap: 4, flex: 1 }}>{navBtns}</nav>}
|
||||
{mobile && <div style={{ flex: 1 }} />}
|
||||
<a href="/" title={t('backHome')} style={{ display: 'inline-flex', alignItems: 'center', gap: 7, textDecoration: 'none', color: 'var(--fg2)', fontSize: 13, fontWeight: 600, padding: 6 }}>
|
||||
<Icon name="home" size={15} color="var(--fg3)" />
|
||||
{!mobile && t('backHome')}
|
||||
</a>
|
||||
<button onClick={toggleTheme} aria-label="theme" title="theme" style={{ border: 'none', background: 'transparent', cursor: 'pointer', color: 'var(--fg2)', padding: 6, display: 'flex' }}>
|
||||
<Icon name={theme === 'dark' ? 'sun' : 'moon'} size={17} color="var(--fg3)" />
|
||||
</button>
|
||||
|
||||
@@ -25,6 +25,7 @@ export const LUCIDE: Record<string, string> = {
|
||||
'shopping-bag': '<path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z"/><path d="M3 6h18"/><path d="M16 10a4 4 0 0 1-8 0"/>',
|
||||
'log-out': '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" x2="9" y1="12" y2="12"/>',
|
||||
'external-link': '<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>',
|
||||
home: '<path d="M3 9.5 12 3l9 6.5"/><path d="M5 10v10a1 1 0 0 0 1 1h3v-6h6v6h3a1 1 0 0 0 1-1V10"/>',
|
||||
gift: '<rect x="3" y="8" width="18" height="4" rx="1"/><path d="M12 8v13"/><path d="M19 12v7a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2v-7"/><path d="M7.5 8a2.5 2.5 0 0 1 0-5A4.8 8 0 0 1 12 8a4.8 8 0 0 1 4.5-5 2.5 2.5 0 0 1 0 5"/>',
|
||||
smartphone: '<rect width="14" height="20" x="5" y="2" rx="2" ry="2"/><path d="M12 18h.01"/>',
|
||||
lock: '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
clearSession,
|
||||
getAccessToken,
|
||||
getRefreshToken,
|
||||
setEmail,
|
||||
setSession,
|
||||
} from './session';
|
||||
|
||||
@@ -171,7 +172,11 @@ export class HttpClient implements ApiClient {
|
||||
return session;
|
||||
}
|
||||
|
||||
getMe = async (): Promise<Me> => mapMe(await this.request<RawMe>('/v1/me'));
|
||||
getMe = async (): Promise<Me> => {
|
||||
const me = mapMe(await this.request<RawMe>('/v1/me'));
|
||||
setEmail(me.email); // 同源官网读取显示用户名;clearSession/logout 时删除
|
||||
return me;
|
||||
};
|
||||
getSubscription = () => this.request<SubscriptionInfo>('/v1/me/subscription');
|
||||
resetSubscription = () => this.request<SubscriptionInfo>('/v1/me/subscription/reset', { method: 'POST' });
|
||||
listDevices = async (): Promise<Device[]> => {
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
SubscriptionInfo,
|
||||
TotpSetup,
|
||||
} from './types';
|
||||
import { setSession, clearSession } from './session';
|
||||
import { setSession, clearSession, setEmail } from './session';
|
||||
|
||||
const delay = (ms = 420) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
@@ -88,6 +88,7 @@ export class MockClient implements ApiClient {
|
||||
|
||||
async getMe(): Promise<Me> {
|
||||
await delay(260);
|
||||
setEmail('me@pangolin.vpn'); // 同源官网读取显示用户名;clearSession/logout 时删除
|
||||
return {
|
||||
email: 'me@pangolin.vpn',
|
||||
plan: 'pro',
|
||||
|
||||
@@ -5,6 +5,17 @@
|
||||
import type { Session } from './types';
|
||||
|
||||
const REFRESH_KEY = 'pg_uc_refresh';
|
||||
// 登录用户邮箱:同源官网(pangolin website)读取以显示用户名。仅邮箱、非敏感凭证。
|
||||
const EMAIL_KEY = 'pg_uc_email';
|
||||
|
||||
export function setEmail(email: string): void {
|
||||
if (typeof window === 'undefined' || !email) return;
|
||||
try {
|
||||
window.localStorage.setItem(EMAIL_KEY, email);
|
||||
} catch {
|
||||
/* ignore quota / privacy mode */
|
||||
}
|
||||
}
|
||||
|
||||
let accessToken: string | null = null;
|
||||
let accessExpiresAt = 0;
|
||||
@@ -44,6 +55,7 @@ export function clearSession(): void {
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
window.localStorage.removeItem(REFRESH_KEY);
|
||||
window.localStorage.removeItem(EMAIL_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ export const STRINGS: Record<string, Entry> = {
|
||||
navInvite: { zh: '邀请返利', en: 'Referral', ja: '紹介', ko: '추천', ru: 'Рефералы', es: 'Referidos' },
|
||||
navSettings: { zh: '设置', en: 'Settings', ja: '設定', ko: '설정', ru: 'Настройки', es: 'Ajustes' },
|
||||
signOut: { zh: '退出', en: 'Sign out', ja: 'ログアウト', ko: '로그아웃', ru: 'Выйти', es: 'Cerrar sesión' },
|
||||
backHome: { zh: '返回主页', en: 'Home', ja: 'ホーム', ko: '홈', ru: 'На главную', es: 'Inicio' },
|
||||
brandName: { zh: '穿山甲', en: 'Pangolin', ja: 'Pangolin', ko: 'Pangolin', ru: 'Pangolin', es: 'Pangolin' },
|
||||
|
||||
/* login */
|
||||
loginTitle: { zh: '登录用户中心', en: 'Log in to your account', ja: 'アカウントにログイン', ko: '계정에 로그인', ru: 'Вход в аккаунт', es: 'Inicia sesión en tu cuenta' },
|
||||
|
||||
@@ -27,7 +27,9 @@ export function UIProvider({ children }: { children: React.ReactNode }) {
|
||||
const l = window.localStorage.getItem(LANG_KEY) as Lang | null;
|
||||
const t = window.localStorage.getItem(THEME_KEY) as Theme | null;
|
||||
if (l && (['zh', 'en', 'ja', 'ko', 'ru', 'es'] as Lang[]).includes(l)) setLangState(l);
|
||||
const initial: Theme = t === 'dark' || t === 'light' ? t : window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
// 默认恒浅色:只有用户手动切换过(localStorage 有显式偏好)才用保存值,
|
||||
// 不跟随系统 prefers-color-scheme(避免系统暗色把登录页/用户中心染黑)。
|
||||
const initial: Theme = t === 'dark' || t === 'light' ? t : 'light';
|
||||
setThemeState(initial);
|
||||
} catch {
|
||||
/* ignore */
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* AUTO-GENERATED — 勿手改。源: design/colors_and_type.css。生成器: web/usercenter/scripts/build-tokens.mjs。仅移除第三方 Google Fonts @import。 */
|
||||
/* AUTO-GENERATED — 勿手改。源: design/prototype/tokens.css。生成器: web/usercenter/scripts/build-tokens.mjs。仅移除第三方 Google Fonts @import。 */
|
||||
/* =============================================================
|
||||
穿山甲 VPN · Pangolin VPN — Design Tokens
|
||||
colors_and_type.css
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
/**
|
||||
* build-tokens.mjs — 设计令牌同源生成器(usercenter)
|
||||
*
|
||||
* 唯一真相来源是仓库根的 `design/colors_and_type.css`。
|
||||
* 唯一真相来源是仓库根的 `design/prototype/tokens.css`。
|
||||
* 本脚本把它原样读入,仅删除 Google Fonts @import 行后写入
|
||||
* `public/colors_and_type.css`(usercenter 通过 <link> 引入此文件)。
|
||||
*
|
||||
@@ -15,11 +15,11 @@ import { fileURLToPath } from 'node:url';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const SRC = resolve(__dirname, '../../../design/colors_and_type.css');
|
||||
const SRC = resolve(__dirname, '../../../design/prototype/tokens.css');
|
||||
const OUT = resolve(__dirname, '../public/colors_and_type.css');
|
||||
|
||||
const BANNER =
|
||||
'/* AUTO-GENERATED — 勿手改。源: design/colors_and_type.css。' +
|
||||
'/* AUTO-GENERATED — 勿手改。源: design/prototype/tokens.css。' +
|
||||
'生成器: web/usercenter/scripts/build-tokens.mjs。仅移除第三方 Google Fonts @import。 */\n';
|
||||
|
||||
if (!existsSync(SRC)) {
|
||||
@@ -39,4 +39,4 @@ const stripped = raw
|
||||
.join('\n');
|
||||
|
||||
writeFileSync(OUT, BANNER + stripped, 'utf8');
|
||||
console.log('[build-tokens] ✅ 已生成', OUT, `(${stripped.length} 字节,源自 design/colors_and_type.css)`);
|
||||
console.log('[build-tokens] ✅ 已生成', OUT, `(${stripped.length} 字节,源自 design/prototype/tokens.css)`);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
/**
|
||||
* build-tokens.mjs — 设计令牌同源生成器
|
||||
*
|
||||
* 唯一真相来源是仓库根的 `design/colors_and_type.css`(铁律 1:颜色只用语义 token)。
|
||||
* 唯一真相来源是仓库根的 `design/prototype/tokens.css`(铁律 1:颜色只用语义 token)。
|
||||
* 本脚本把它原样读入,仅做一处「必须的」改写后写入 `src/styles/tokens.gen.css`:
|
||||
*
|
||||
* 删除其中加载 Google Fonts 的 `@import url('https://fonts.googleapis.com/...')` 一行。
|
||||
@@ -20,11 +20,11 @@ import { fileURLToPath } from 'node:url';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const SRC = resolve(__dirname, '../../../design/colors_and_type.css');
|
||||
const SRC = resolve(__dirname, '../../../design/prototype/tokens.css');
|
||||
const OUT = resolve(__dirname, '../src/styles/tokens.gen.css');
|
||||
|
||||
const BANNER =
|
||||
'/* AUTO-GENERATED — 勿手改。源: design/colors_and_type.css。' +
|
||||
'/* AUTO-GENERATED — 勿手改。源: design/prototype/tokens.css。' +
|
||||
'生成器: web/website/scripts/build-tokens.mjs。仅移除第三方 Google Fonts @import。 */\n';
|
||||
|
||||
if (!existsSync(SRC)) {
|
||||
@@ -50,4 +50,4 @@ if (/fonts\.(googleapis|gstatic)\.com/i.test(stripped.replace(/^\s*(\/\/|\*|\/\*
|
||||
}
|
||||
|
||||
writeFileSync(OUT, BANNER + stripped, 'utf8');
|
||||
console.log('[build-tokens] 已生成', OUT, `(${stripped.length} 字节,源自 design/colors_and_type.css)`);
|
||||
console.log('[build-tokens] 已生成', OUT, `(${stripped.length} 字节,源自 design/prototype/tokens.css)`);
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
---
|
||||
import Icon from './Icon.astro';
|
||||
import type { T } from '../i18n/strings';
|
||||
import type { T, Lang } from '../i18n/strings';
|
||||
|
||||
interface Props { t: T }
|
||||
const { t } = Astro.props;
|
||||
interface Props { t: T; lang: Lang }
|
||||
const { t, lang } = Astro.props;
|
||||
|
||||
// 中文走 /zh/docs/*,其余语言暂共用英文文档(/docs/*)。
|
||||
const base = lang === 'zh' ? '/zh/docs' : '/docs';
|
||||
|
||||
const docs = [
|
||||
{ icon: 'rocket', t: 'docs.1t', d: 'docs.1d' },
|
||||
{ icon: 'circle-help', t: 'docs.2t', d: 'docs.2d' },
|
||||
{ icon: 'shield-check', t: 'docs.3t', d: 'docs.3d' },
|
||||
{ icon: 'lock', t: 'docs.4t', d: 'docs.4d' },
|
||||
{ icon: 'rocket', t: 'docs.1t', d: 'docs.1d', slug: 'quickstart' },
|
||||
{ icon: 'circle-help', t: 'docs.2t', d: 'docs.2d', slug: 'faq' },
|
||||
{ icon: 'shield-check', t: 'docs.3t', d: 'docs.3d', slug: 'protocol' },
|
||||
{ icon: 'lock', t: 'docs.4t', d: 'docs.4d', slug: 'privacy' },
|
||||
];
|
||||
---
|
||||
<section id="docs">
|
||||
@@ -20,13 +23,13 @@ const docs = [
|
||||
</div>
|
||||
<div class="wrap">
|
||||
<div class="docs-grid">
|
||||
{/* 文档页未就绪:卡片暂作信息展示(非链接),写好真实文档后改回 <a href> + 恢复「阅读」。 */}
|
||||
{docs.map((d) => (
|
||||
<div class="doc">
|
||||
<a class="doc" href={`${base}/${d.slug}/`}>
|
||||
<div class="ico"><Icon name={d.icon} /></div>
|
||||
<h3>{t(d.t)}</h3>
|
||||
<p>{t(d.d)}</p>
|
||||
</div>
|
||||
<span class="ln">{t('docs.read')}<Icon name="arrow-right" /></span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,7 @@ const { t } = Astro.props;
|
||||
<div>
|
||||
<div class="nm">
|
||||
<Brand variant="footer" size={28} />
|
||||
穿山甲
|
||||
{t('nav.brand')}
|
||||
</div>
|
||||
<p class="tag">{t('ft.tag')}</p>
|
||||
</div>
|
||||
|
||||
@@ -30,7 +30,36 @@ export default function Header({ lang = 'zh', t = {} }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [langOpen, setLangOpen] = useState(false);
|
||||
const [userOpen, setUserOpen] = useState(false);
|
||||
const [loggedIn, setLoggedIn] = useState(false);
|
||||
const [email, setEmail] = useState('');
|
||||
const langRef = useRef(null);
|
||||
const userRef = useRef(null);
|
||||
|
||||
// 登录后回跳主页:用户中心带 ?redirect=/(配合 usercenter 登录成功后回跳)。
|
||||
const loginHref = `${SITE.usercenter}?redirect=/`;
|
||||
// 用户名截断显示:优先邮箱 @ 前部分,缺失时回退通用词。
|
||||
const displayName = (email && email.split('@')[0]) || 'Account';
|
||||
|
||||
// 与用户中心同源:登录后 localStorage 存 pg_uc_refresh(+ pg_uc_email)→ 显示用户名下拉。
|
||||
useEffect(() => {
|
||||
try {
|
||||
setLoggedIn(!!localStorage.getItem('pg_uc_refresh'));
|
||||
setEmail(localStorage.getItem('pg_uc_email') || '');
|
||||
} catch {
|
||||
setLoggedIn(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 清登录态并跳转(切换用户 = 回登录页;退出 = 回主页)。
|
||||
const clearSession = () => {
|
||||
try {
|
||||
localStorage.removeItem('pg_uc_refresh');
|
||||
localStorage.removeItem('pg_uc_email');
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
const onSwitch = () => { clearSession(); window.location.href = loginHref; };
|
||||
const onLogout = () => { clearSession(); window.location.href = '/'; };
|
||||
|
||||
useEffect(() => {
|
||||
if (!langOpen) return;
|
||||
@@ -41,6 +70,15 @@ export default function Header({ lang = 'zh', t = {} }) {
|
||||
return () => { document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onKey); };
|
||||
}, [langOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userOpen) return;
|
||||
const onDoc = (e) => { if (userRef.current && !userRef.current.contains(e.target)) setUserOpen(false); };
|
||||
const onKey = (e) => { if (e.key === 'Escape') setUserOpen(false); };
|
||||
document.addEventListener('mousedown', onDoc);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => { document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onKey); };
|
||||
}, [userOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setScrolled(window.scrollY > 8);
|
||||
onScroll();
|
||||
@@ -67,7 +105,7 @@ export default function Header({ lang = 'zh', t = {} }) {
|
||||
<div class="wrap row">
|
||||
<a class="brand" href="#top">
|
||||
<Mark />
|
||||
<span class="nm">穿山甲</span>
|
||||
<span class="nm">{t.brand || 'Pangolin'}</span>
|
||||
</a>
|
||||
<nav class="nav">
|
||||
{nav.map(([href, label]) => (
|
||||
@@ -75,7 +113,7 @@ export default function Header({ lang = 'zh', t = {} }) {
|
||||
))}
|
||||
</nav>
|
||||
<div class="right">
|
||||
<div ref={langRef} style={{ position: 'relative', display: 'inline-block' }}>
|
||||
<div ref={langRef} class="langwrap">
|
||||
<button
|
||||
type="button"
|
||||
class="langsel"
|
||||
@@ -83,21 +121,18 @@ export default function Header({ lang = 'zh', t = {} }) {
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={langOpen}
|
||||
aria-label="Language"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}
|
||||
>
|
||||
<span>{(langs.find(([c]) => c === lang) || ['', 'English'])[1]}</span>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" aria-hidden="true"
|
||||
style={{ transform: langOpen ? 'rotate(180deg)' : 'none', transition: 'transform 140ms' }}>
|
||||
<svg class="caret" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M6 9l6 6 6-6" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
{langOpen && (
|
||||
<div role="listbox" style={{ position: 'absolute', top: 'calc(100% + 6px)', right: 0, minWidth: 132, background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 12, boxShadow: '0 10px 30px rgba(20, 12, 6, 0.22)', padding: 5, zIndex: 60, display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<div class="langmenu" role="listbox">
|
||||
{langs.map(([code, label]) => {
|
||||
const on = code === lang;
|
||||
return (
|
||||
<a key={code} role="option" aria-selected={on} href={langHref(code)}
|
||||
style={{ textAlign: 'left', textDecoration: 'none', background: on ? 'var(--accent-subtle, var(--bg-subtle))' : 'transparent', color: on ? 'var(--accent)' : 'var(--fg1)', fontWeight: on ? 700 : 500, fontFamily: 'var(--font-sans)', fontSize: 13, padding: '8px 11px', borderRadius: 8, whiteSpace: 'nowrap' }}>
|
||||
<a key={code} role="option" aria-selected={on} href={langHref(code)}>
|
||||
{label}
|
||||
</a>
|
||||
);
|
||||
@@ -105,7 +140,31 @@ export default function Header({ lang = 'zh', t = {} }) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<a class="linklogin" href={SITE.usercenter}>{t.login}</a>
|
||||
{loggedIn ? (
|
||||
<div ref={userRef} class="langwrap usermenu">
|
||||
<button
|
||||
type="button"
|
||||
class="langsel userbtn"
|
||||
onClick={() => setUserOpen((o) => !o)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={userOpen}
|
||||
>
|
||||
<span class="uname">{displayName}</span>
|
||||
<svg class="caret" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M6 9l6 6 6-6" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
{userOpen && (
|
||||
<div class="langmenu" role="menu">
|
||||
<a role="menuitem" href={SITE.usercenter}>{t.mcenter}</a>
|
||||
<button role="menuitem" type="button" onClick={onSwitch}>{t.mswitch}</button>
|
||||
<button role="menuitem" type="button" onClick={onLogout}>{t.mlogout}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<a class="linklogin" href={loginHref}>{t.login}</a>
|
||||
)}
|
||||
<a class="btn btn-primary" href="#download">
|
||||
<Download />
|
||||
<span>{t.get}</span>
|
||||
|
||||
@@ -38,7 +38,14 @@ export const STRINGS: Record<string, Record<Lang, string>> = {
|
||||
'nav.docs': { zh: '文档', en: 'Docs', ja: 'ドキュメント', ko: '문서', ru: 'Документация', es: 'Documentación' },
|
||||
'nav.blog': { zh: 'Blog', en: 'Blog', ja: 'ブログ', ko: '블로그', ru: 'Блог', es: 'Blog' },
|
||||
'nav.login': { zh: '登录', en: 'Log in', ja: 'ログイン', ko: '로그인', ru: 'Войти', es: 'Iniciar sesión' },
|
||||
'nav.center': { zh: '用户中心', en: 'Account', ja: 'アカウント', ko: '계정', ru: 'Личный кабинет', es: 'Mi cuenta' },
|
||||
'nav.get': { zh: '立即下载', en: 'Get the app', ja: 'アプリを入手', ko: '앱 받기', ru: 'Получить приложение', es: 'Obtener la app' },
|
||||
// 品牌字标:中文显「穿山甲」,其余语言统一显「Pangolin」(英文版 logo 本地化)。
|
||||
'nav.brand': { zh: '穿山甲', en: 'Pangolin', ja: 'Pangolin', ko: 'Pangolin', ru: 'Pangolin', es: 'Pangolin' },
|
||||
// 登录态用户下拉菜单项(3 项 × 6 语)。
|
||||
'menu.center': { zh: '进入用户中心', en: 'Open account center', ja: 'アカウントセンターへ', ko: '계정 센터 열기', ru: 'Личный кабинет', es: 'Ir a mi cuenta' },
|
||||
'menu.switch': { zh: '切换用户', en: 'Switch account', ja: 'アカウントを切替', ko: '계정 전환', ru: 'Сменить аккаунт', es: 'Cambiar de cuenta' },
|
||||
'menu.logout': { zh: '退出登录', en: 'Log out', ja: 'ログアウト', ko: '로그아웃', ru: 'Выйти', es: 'Cerrar sesión' },
|
||||
|
||||
'hero.eyebrow': { zh: '极速 · 稳定 · 省心', en: 'Fast · Stable · Effortless', ja: '高速 · 安定 · 快適', ko: '빠름 · 안정 · 간편', ru: 'Быстро · Стабильно · Без забот', es: 'Rápido · Estable · Sin complicaciones' },
|
||||
'hero.h1': { zh: '极速畅连,\n网络如丝顺滑', en: 'Faster, smoother,\neverywhere', ja: 'もっと速く、もっと滑らかに、\nどこでも', ko: '더 빠르고 더 매끄럽게,\n어디서나', ru: 'Быстрее, плавнее,\nвезде', es: 'Más rápido, más fluido,\nen todas partes' },
|
||||
@@ -139,7 +146,7 @@ export const STRINGS: Record<string, Record<Lang, string>> = {
|
||||
'docs.2t': { zh: '常见问题', en: 'FAQ', ja: 'よくある質問', ko: '자주 묻는 질문', ru: 'Вопросы и ответы', es: 'Preguntas frecuentes' },
|
||||
'docs.2d': { zh: '连接、计费、设备与兑换码的常见疑问。', en: 'Connection, billing, devices and redeem codes.', ja: '接続・請求・デバイス・引き換えコードのよくある疑問。', ko: '연결, 결제, 기기, 등록 코드에 대한 궁금증.', ru: 'Подключение, оплата, устройства и коды активации.', es: 'Conexión, facturación, dispositivos y códigos de canje.' },
|
||||
'docs.3t': { zh: '协议与安全', en: 'Protocol & security', ja: 'プロトコルとセキュリティ', ko: '프로토콜 & 보안', ru: 'Протокол и безопасность', es: 'Protocolo y seguridad' },
|
||||
'docs.3d': { zh: 'WireGuard、加密方式与无日志架构说明。', en: 'WireGuard, encryption and our no-logs architecture.', ja: 'WireGuard、暗号化方式、ノーログ設計の解説。', ko: 'WireGuard, 암호화 방식, 노로그 아키텍처 설명.', ru: 'WireGuard, шифрование и наша архитектура без логов.', es: 'WireGuard, cifrado y nuestra arquitectura sin registros.' },
|
||||
'docs.3d': { zh: 'sing-box + REALITY、加密方式与无日志架构说明。', en: 'sing-box + REALITY, encryption and our no-logs architecture.', ja: 'sing-box + REALITY、暗号化方式、ノーログ設計の解説。', ko: 'sing-box + REALITY, 암호화 방식, 노로그 아키텍처 설명.', ru: 'sing-box + REALITY, шифрование и наша архитектура без логов.', es: 'sing-box + REALITY, cifrado y nuestra arquitectura sin registros.' },
|
||||
'docs.4t': { zh: '隐私政策', en: 'Privacy policy', ja: 'プライバシーポリシー', ko: '개인정보 처리방침', ru: 'Политика конфиденциальности', es: 'Política de privacidad' },
|
||||
'docs.4d': { zh: '我们收集什么、不收集什么,一目了然。', en: 'Exactly what we collect — and what we never do.', ja: '収集するもの・しないものを明確に。', ko: '무엇을 수집하고 무엇을 수집하지 않는지 한눈에.', ru: 'Что мы собираем — и чего не собираем никогда.', es: 'Exactamente qué recopilamos y qué nunca hacemos.' },
|
||||
'docs.read': { zh: '阅读', en: 'Read', ja: '読む', ko: '읽기', ru: 'Читать', es: 'Leer' },
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
/**
|
||||
* Doc.astro — 文档正文页布局(/docs/* 与 /zh/docs/*)。
|
||||
* 复用官网 Header/Footer 与全站样式(tokens.gen / website / site-extra),
|
||||
* 中间是 .doc-page > .doc-article 可读正文容器。构建期单显一种语言。
|
||||
*/
|
||||
import '@fontsource/sora/500.css';
|
||||
import '@fontsource/sora/600.css';
|
||||
import '@fontsource/sora/700.css';
|
||||
import '@fontsource/manrope/400.css';
|
||||
import '@fontsource/manrope/500.css';
|
||||
import '@fontsource/manrope/600.css';
|
||||
import '@fontsource/manrope/700.css';
|
||||
import '@fontsource/noto-sans-sc/400.css';
|
||||
import '@fontsource/noto-sans-sc/500.css';
|
||||
import '@fontsource/noto-sans-sc/700.css';
|
||||
import '@fontsource/jetbrains-mono/400.css';
|
||||
import '@fontsource/jetbrains-mono/500.css';
|
||||
|
||||
import '../styles/tokens.gen.css';
|
||||
import '../styles/website.css';
|
||||
import '../styles/site-extra.css';
|
||||
|
||||
import { createT, type Lang } from '../i18n/strings';
|
||||
import Header from '../components/Header.jsx';
|
||||
import Footer from '../components/Footer.astro';
|
||||
|
||||
interface Props { lang: Lang; title: string; desc?: string }
|
||||
const { lang, title, desc } = Astro.props;
|
||||
const t = createT(lang);
|
||||
|
||||
const HTML_LANG: Record<Lang, string> = { zh: 'zh-CN', en: 'en', ja: 'ja', ko: 'ko', ru: 'ru', es: 'es' };
|
||||
|
||||
const headerT = {
|
||||
product: t('nav.product'),
|
||||
pricing: t('nav.pricing'),
|
||||
download: t('nav.download'),
|
||||
docs: t('nav.docs'),
|
||||
blog: t('nav.blog'),
|
||||
login: t('nav.login'),
|
||||
center: t('nav.center'),
|
||||
brand: t('nav.brand'),
|
||||
mcenter: t('menu.center'),
|
||||
mswitch: t('menu.switch'),
|
||||
mlogout: t('menu.logout'),
|
||||
get: t('nav.get'),
|
||||
suBtn: t('su.btn'),
|
||||
};
|
||||
|
||||
// 导航锚点回主页(文档页无同页锚点):把 #x 改为主页前缀。
|
||||
const home = lang === 'zh' ? '/zh/' : '/';
|
||||
const backHref = `${home}#docs`;
|
||||
const metaTitle = `${title} · ${t('nav.brand')}`;
|
||||
---
|
||||
<!doctype html>
|
||||
<html lang={HTML_LANG[lang]}>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{metaTitle}</title>
|
||||
{desc && <meta name="description" content={desc} />}
|
||||
<meta name="robots" content="index,follow" />
|
||||
<meta name="theme-color" content="#B96A3D" />
|
||||
</head>
|
||||
<body>
|
||||
<Header client:load lang={lang} t={headerT} />
|
||||
<main class="doc-page">
|
||||
<div class="wrap">
|
||||
<article class="doc-article">
|
||||
<slot />
|
||||
<a class="doc-back" href={backHref}>← {t('nav.docs')}</a>
|
||||
</article>
|
||||
</div>
|
||||
</main>
|
||||
<Footer t={t} />
|
||||
</body>
|
||||
</html>
|
||||
@@ -56,6 +56,11 @@ const headerT = {
|
||||
docs: t('nav.docs'),
|
||||
blog: t('nav.blog'),
|
||||
login: t('nav.login'),
|
||||
center: t('nav.center'),
|
||||
brand: t('nav.brand'),
|
||||
mcenter: t('menu.center'),
|
||||
mswitch: t('menu.switch'),
|
||||
mlogout: t('menu.logout'),
|
||||
get: t('nav.get'),
|
||||
suBtn: t('su.btn'),
|
||||
};
|
||||
@@ -90,7 +95,7 @@ const headerT = {
|
||||
<WhySignup t={t} />
|
||||
<Pricing t={t} lang={lang} />
|
||||
<Download t={t} />
|
||||
<Docs t={t} />
|
||||
<Docs t={t} lang={lang} />
|
||||
<CtaBand t={t} />
|
||||
<Footer t={t} />
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
import Doc from '../../layouts/Doc.astro';
|
||||
---
|
||||
<Doc lang="en" title="FAQ" desc="Common questions about accounts, plans, devices and activation codes.">
|
||||
<div class="doc-eyebrow">Docs</div>
|
||||
<h1>Frequently asked questions</h1>
|
||||
<p class="doc-lede">Short answers to the questions we hear most about accounts, plans, devices and codes.</p>
|
||||
|
||||
<h2>How do I redeem an activation code?</h2>
|
||||
<p>Open the app, go to the account or subscription screen, choose <strong>Redeem code</strong>, paste the code and confirm. Your plan upgrades immediately — no restart needed. Codes are obtained through external channels; there is no checkout on the website or in the app.</p>
|
||||
|
||||
<h2>Which platforms are supported?</h2>
|
||||
<p>Windows, macOS, Android and iOS. One account works across all of them, and your plan and settings follow you between devices.</p>
|
||||
|
||||
<h2>What are the free plan limits?</h2>
|
||||
<p>The free plan gives you 10 minutes of connection time per day on a single basic route, and asks you to watch a short ad before each session. Core encryption and our no-logs promise are included on every plan, free or paid. During the 7-day trial the time limit is lifted.</p>
|
||||
|
||||
<h2>How many devices can I use?</h2>
|
||||
<p>Up to 5 devices on one account at the same time on the Pro plan. Sign in with the same email on each device to keep everything in sync.</p>
|
||||
|
||||
<h2>I forgot my password — what now?</h2>
|
||||
<p>Accounts sign in by email verification code, so there is no fixed password to forget. Just request a fresh code at login and enter it to get back in.</p>
|
||||
|
||||
<h2>Why is there no payment button on the site?</h2>
|
||||
<p>For risk and privacy reasons Pangolin never processes payments in the web or app. You get an activation code through an external channel and redeem it in the client — that keeps the payment flow entirely off our platform.</p>
|
||||
</Doc>
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
import Doc from '../../layouts/Doc.astro';
|
||||
---
|
||||
<Doc lang="en" title="Privacy policy" desc="Exactly what we collect, what we never collect, and how it is stored.">
|
||||
<div class="doc-eyebrow">Docs</div>
|
||||
<h1>Privacy policy</h1>
|
||||
<p class="doc-lede">Privacy protection is the baseline of this product, not a marketing line. Here is exactly what we do and don't collect, in plain language.</p>
|
||||
|
||||
<h2>What we collect</h2>
|
||||
<p>We keep only the operational minimum needed to run your account and the service:</p>
|
||||
<ul>
|
||||
<li><strong>Account email</strong> — used to sign in, deliver verification codes and tie your plan to you.</li>
|
||||
<li><strong>Device identifier</strong> — an anonymous ID used to enforce the device limit and sync your plan across devices.</li>
|
||||
<li><strong>Usage statistics</strong> — coarse figures such as connection time and data volume, used for billing limits and capacity planning.</li>
|
||||
</ul>
|
||||
|
||||
<h2>What we never collect</h2>
|
||||
<p>We do not log the content of your traffic, the sites or apps you reach, DNS queries, or any browsing history. There is no per-request connection log tied to what you do online. Because we never gather this data, there is nothing of that kind to disclose or lose.</p>
|
||||
|
||||
<h2>How it is stored</h2>
|
||||
<p>The limited data above is stored on our own infrastructure, encrypted in transit, and retained only as long as it is needed to operate your account and the service. Payments happen entirely through external channels, so no payment card details ever touch our systems.</p>
|
||||
|
||||
<div class="doc-card">
|
||||
<h3>Questions?</h3>
|
||||
<p>Reach out through any of the channels listed in the footer and we'll help clarify how your data is handled.</p>
|
||||
</div>
|
||||
</Doc>
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
import Doc from '../../layouts/Doc.astro';
|
||||
---
|
||||
<Doc lang="en" title="Protocol & security" desc="Our data plane runs on sing-box with the REALITY transport, plus a strict no-logs architecture.">
|
||||
<div class="doc-eyebrow">Docs</div>
|
||||
<h1>Protocol & security</h1>
|
||||
<p class="doc-lede">How we move your traffic quickly while keeping it private — the transport, the encryption, and the no-logs architecture behind it.</p>
|
||||
|
||||
<h2>Data plane: sing-box + REALITY</h2>
|
||||
<p>Our data plane is built on <strong>sing-box</strong> and uses the <strong>REALITY</strong> transport. REALITY performs a genuine TLS handshake against a real destination, so accelerated traffic blends in with ordinary encrypted web traffic instead of standing out. The result is a connection that stays fast and reliable on demanding networks.</p>
|
||||
|
||||
<h2>End-to-end encryption</h2>
|
||||
<p>Every session is encrypted from your device to the node. Encryption is the baseline for all traffic on every plan — it is not an add-on. Keys are negotiated per session, and the client configuration is rendered and delivered by our control plane rather than assembled on the device.</p>
|
||||
|
||||
<h2>No-logs architecture</h2>
|
||||
<p>We do not record what you browse. Nodes forward traffic without keeping content or connection logs, and the system is designed so there is simply nothing sensitive to hand over. What we do keep is the operational minimum needed to run the service — see the <a href="/docs/privacy/">Privacy policy</a> for the exact list.</p>
|
||||
|
||||
<div class="doc-card">
|
||||
<h3>Kill switch</h3>
|
||||
<p>If the tunnel ever drops, the client blocks traffic instantly so your real IP is never exposed while the connection re-establishes.</p>
|
||||
</div>
|
||||
</Doc>
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
import Doc from '../../layouts/Doc.astro';
|
||||
---
|
||||
<Doc lang="en" title="Quickstart" desc="Sign up, download and make your first connection in three minutes.">
|
||||
<div class="doc-eyebrow">Docs</div>
|
||||
<h1>Quickstart</h1>
|
||||
<p class="doc-lede">Get from zero to your first fast, stable connection in about three minutes — three steps, no configuration.</p>
|
||||
|
||||
<h2>1. Create an account</h2>
|
||||
<p>Enter your email on the homepage or in the app and confirm the verification code we send you. Registration is free and needs nothing but an email — no payment details, ever. New accounts include a 7-day free trial with full access.</p>
|
||||
|
||||
<h2>2. Download the client</h2>
|
||||
<p>Grab the app for your platform from the <a href="/#download">Download</a> section — Windows, macOS, Android and iOS are supported. One account syncs across every device, up to 5 at once.</p>
|
||||
|
||||
<h2>3. Log in and connect</h2>
|
||||
<p>Open the app, sign in with the same email, and tap the connect button. The app smart-picks the fastest route for you; there is nothing to configure. When you see <strong>Connected</strong>, you're on an accelerated line.</p>
|
||||
|
||||
<div class="doc-card">
|
||||
<h3>Upgrading later</h3>
|
||||
<p>For privacy and risk reasons we never take payment inside the web or app. When you want more, obtain an activation code through an external channel and redeem it in the client to unlock unlimited data and top-speed routes.</p>
|
||||
</div>
|
||||
</Doc>
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
import Doc from '../../../layouts/Doc.astro';
|
||||
---
|
||||
<Doc lang="zh" title="常见问题" desc="关于账户、套餐、设备与激活码的常见疑问。">
|
||||
<div class="doc-eyebrow">文档</div>
|
||||
<h1>常见问题</h1>
|
||||
<p class="doc-lede">关于账户、套餐、设备与兑换码,最常被问到的几个问题,简明作答。</p>
|
||||
|
||||
<h2>如何兑换激活码?</h2>
|
||||
<p>打开 App,进入账户或订阅页,选择<strong>兑换激活码</strong>,粘贴激活码并确认,套餐即刻升级,无需重启。激活码通过外部渠道获取;网页与 App 内均不设收银台。</p>
|
||||
|
||||
<h2>支持哪些平台?</h2>
|
||||
<p>Windows、macOS、Android 与 iOS。一个账户全平台通用,套餐与设置在各设备间同步。</p>
|
||||
|
||||
<h2>免费版有哪些限制?</h2>
|
||||
<p>免费版每天可连接 10 分钟,仅含 1 个基础节点,且每次连接前需观看一段短广告。核心加密与无日志承诺在所有套餐(含免费版)中一视同仁。7 天试用期内不受时长限制。</p>
|
||||
|
||||
<h2>可以用几台设备?</h2>
|
||||
<p>专业版一个账户最多 5 台设备同时在线。各设备用同一邮箱登录即可保持同步。</p>
|
||||
|
||||
<h2>忘记密码了怎么办?</h2>
|
||||
<p>账户采用邮箱验证码登录,没有固定密码需要记忆。登录时重新获取一次验证码、输入即可进入。</p>
|
||||
|
||||
<h2>为什么网页上没有支付按钮?</h2>
|
||||
<p>出于风控与隐私考虑,穿山甲不在网页或 App 内直接收款。你通过外部渠道获取激活码、在客户端内兑换 —— 资金流全程不经过我们的平台。</p>
|
||||
</Doc>
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
import Doc from '../../../layouts/Doc.astro';
|
||||
---
|
||||
<Doc lang="zh" title="隐私政策" desc="我们收集什么、绝不收集什么,以及如何存储。">
|
||||
<div class="doc-eyebrow">文档</div>
|
||||
<h1>隐私政策</h1>
|
||||
<p class="doc-lede">隐私保护是这款产品的底线,而非营销话术。以下用大白话说清我们收集与绝不收集的内容。</p>
|
||||
|
||||
<h2>我们收集什么</h2>
|
||||
<p>我们只保留运行账户与服务所必需的最小信息:</p>
|
||||
<ul>
|
||||
<li><strong>账户邮箱</strong> —— 用于登录、发送验证码,以及将套餐与你绑定。</li>
|
||||
<li><strong>设备标识</strong> —— 一个匿名 ID,用于限制设备数量、在多设备间同步套餐。</li>
|
||||
<li><strong>用量统计</strong> —— 连接时长、流量等粗粒度数据,用于计费限额与容量规划。</li>
|
||||
</ul>
|
||||
|
||||
<h2>我们绝不收集什么</h2>
|
||||
<p>我们不记录你的流量内容、访问的网站或 App、DNS 查询,也不保留任何浏览历史;不存在与你上网行为绑定的逐条连接日志。因为我们从一开始就不采集这类数据,所以也没有这类数据可供交出或泄露。</p>
|
||||
|
||||
<h2>如何存储</h2>
|
||||
<p>上述有限数据存放在我们自有的基础设施上,传输过程加密,且仅在运行账户与服务所需的期限内保留。收款全部经外部渠道完成,任何银行卡信息都不会触及我们的系统。</p>
|
||||
|
||||
<div class="doc-card">
|
||||
<h3>还有疑问?</h3>
|
||||
<p>通过页脚列出的任一渠道联系我们,我们会进一步说明你的数据是如何被处理的。</p>
|
||||
</div>
|
||||
</Doc>
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
import Doc from '../../../layouts/Doc.astro';
|
||||
---
|
||||
<Doc lang="zh" title="协议与安全" desc="数据面基于 sing-box + REALITY,配合严格无日志架构。">
|
||||
<div class="doc-eyebrow">文档</div>
|
||||
<h1>协议与安全</h1>
|
||||
<p class="doc-lede">我们如何在保持极速的同时守护隐私 —— 传输方式、加密机制,以及背后的无日志架构。</p>
|
||||
|
||||
<h2>数据面:sing-box + REALITY</h2>
|
||||
<p>我们的数据面基于 <strong>sing-box</strong>,传输采用 <strong>REALITY</strong>。REALITY 会与真实站点完成一次真正的 TLS 握手,使加速流量与普通加密网页流量融为一体、不易被区分,从而在苛刻网络下依然快速稳定。</p>
|
||||
|
||||
<h2>端到端加密</h2>
|
||||
<p>每一次会话都从你的设备到节点全程加密。加密是所有套餐、所有流量的底线,而非附加项。密钥按会话协商,客户端配置由控制面渲染下发,而非在设备本地拼装。</p>
|
||||
|
||||
<h2>无日志架构</h2>
|
||||
<p>我们不记录你浏览了什么。节点只做流量转发,不保留内容或连接日志;整套系统的设计初衷,就是让敏感数据「压根不存在、无从交出」。我们仅保留运行服务所必需的最小运营数据 —— 具体清单见<a href="/zh/docs/privacy/">隐私政策</a>。</p>
|
||||
|
||||
<div class="doc-card">
|
||||
<h3>Kill Switch</h3>
|
||||
<p>一旦隧道中断,客户端立即阻断网络,在连接重建期间杜绝真实 IP 泄露。</p>
|
||||
</div>
|
||||
</Doc>
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
import Doc from '../../../layouts/Doc.astro';
|
||||
---
|
||||
<Doc lang="zh" title="快速开始" desc="三分钟完成注册、下载与首次连接。">
|
||||
<div class="doc-eyebrow">文档</div>
|
||||
<h1>快速开始</h1>
|
||||
<p class="doc-lede">三步走,约三分钟即可完成第一次极速、稳定的连接,全程无需任何配置。</p>
|
||||
|
||||
<h2>1. 注册账户</h2>
|
||||
<p>在主页或 App 内填写邮箱,输入收到的验证码即可完成注册。注册免费,只要一个邮箱,无需任何付款信息。新账户还附赠 7 天免费试用,功能不设限。</p>
|
||||
|
||||
<h2>2. 下载客户端</h2>
|
||||
<p>在<a href="/zh/#download">下载</a>区选择对应平台的安装包 —— 支持 Windows、macOS、Android 与 iOS。一个账户多端同步,最多 5 台设备同时在线。</p>
|
||||
|
||||
<h2>3. 登录并连接</h2>
|
||||
<p>打开 App,用同一邮箱登录,点一下连接按钮即可。客户端会智能挑选最快线路,无需手动设置。当界面显示<strong>已连接</strong>,你就已经在加速线路上了。</p>
|
||||
|
||||
<div class="doc-card">
|
||||
<h3>之后如何升级</h3>
|
||||
<p>出于风控与隐私考虑,我们不在网页或 App 内直接收款。想要更多时,通过外部渠道获取激活码,在客户端内兑换即可解锁无限流量与极速线路。</p>
|
||||
</div>
|
||||
</Doc>
|
||||
@@ -59,6 +59,125 @@
|
||||
box-shadow: 0 1px 4px rgba(45, 30, 20, 0.08);
|
||||
}
|
||||
|
||||
/* 登录态用户下拉菜单:复用 .langwrap/.langsel/.langmenu 视觉,追加用户名截断
|
||||
与菜单内 <button>(切换用户 / 退出登录需 JS,非纯链接)的等价样式。 */
|
||||
.usermenu .userbtn {
|
||||
max-width: 168px;
|
||||
}
|
||||
.usermenu .uname {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.usermenu .langmenu button {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--fg1);
|
||||
background: transparent;
|
||||
padding: 8px 11px;
|
||||
border-radius: var(--radius-sm);
|
||||
white-space: nowrap;
|
||||
transition: background var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
.usermenu .langmenu button:hover {
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
|
||||
/* 文档正文页(/docs/*):承载标题层级 / 段落 / 列表 / 卡片的可读排版。 */
|
||||
.doc-page {
|
||||
padding: 56px 0 88px;
|
||||
}
|
||||
.doc-article {
|
||||
max-width: 760px;
|
||||
}
|
||||
.doc-article .doc-eyebrow {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
}
|
||||
.doc-article h1 {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 38px;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.15;
|
||||
margin: 12px 0 0;
|
||||
}
|
||||
.doc-article .doc-lede {
|
||||
font-size: 17px;
|
||||
color: var(--fg2);
|
||||
line-height: 1.6;
|
||||
margin: 14px 0 0;
|
||||
}
|
||||
.doc-article h2 {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 22px;
|
||||
letter-spacing: -0.01em;
|
||||
margin: 40px 0 0;
|
||||
}
|
||||
.doc-article h3 {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 17px;
|
||||
margin: 26px 0 0;
|
||||
}
|
||||
.doc-article p {
|
||||
font-size: 15.5px;
|
||||
color: var(--fg2);
|
||||
line-height: 1.7;
|
||||
margin: 12px 0 0;
|
||||
}
|
||||
.doc-article ul,
|
||||
.doc-article ol {
|
||||
margin: 12px 0 0;
|
||||
padding-left: 22px;
|
||||
color: var(--fg2);
|
||||
}
|
||||
.doc-article li {
|
||||
font-size: 15.5px;
|
||||
line-height: 1.7;
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
.doc-article a {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
.doc-article strong {
|
||||
color: var(--fg1);
|
||||
font-weight: 700;
|
||||
}
|
||||
.doc-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 22px 24px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
margin: 26px 0 0;
|
||||
}
|
||||
.doc-card h3 {
|
||||
margin-top: 0;
|
||||
}
|
||||
.doc-back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 44px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* 视觉隐藏(无障碍用,当前未强依赖) */
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* AUTO-GENERATED — 勿手改。源: design/colors_and_type.css。生成器: web/website/scripts/build-tokens.mjs。仅移除第三方 Google Fonts @import。 */
|
||||
/* AUTO-GENERATED — 勿手改。源: design/prototype/tokens.css。生成器: web/website/scripts/build-tokens.mjs。仅移除第三方 Google Fonts @import。 */
|
||||
/* =============================================================
|
||||
穿山甲 VPN · Pangolin VPN — Design Tokens
|
||||
colors_and_type.css
|
||||
|
||||
@@ -42,6 +42,17 @@ img,svg{display:block}
|
||||
.linklogin{font-size:14.5px;font-weight:600;color:var(--fg1);cursor:pointer}
|
||||
.linklogin:hover{color:var(--accent)}
|
||||
|
||||
/* ---------- language dropdown ---------- */
|
||||
.langwrap{position:relative;display:inline-block}
|
||||
.langsel{display:inline-flex;align-items:center;gap:6px;border:1.5px solid var(--border-strong);border-radius:var(--radius-full);padding:8px 14px;background:var(--surface);color:var(--fg1);font-family:var(--font-sans);font-size:14px;font-weight:600;cursor:pointer;transition:border-color var(--dur-fast) var(--ease-out),color var(--dur-fast) var(--ease-out)}
|
||||
.langsel:hover{border-color:var(--accent);color:var(--accent)}
|
||||
.langsel .caret{width:11px;height:11px;transition:transform 140ms var(--ease-out)}
|
||||
.langsel[aria-expanded="true"] .caret{transform:rotate(180deg)}
|
||||
.langmenu{position:absolute;top:calc(100% + 6px);right:0;min-width:160px;display:flex;flex-direction:column;gap:1px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-lg);box-shadow:var(--shadow-lg);padding:5px;z-index:60}
|
||||
.langmenu a{text-align:left;text-decoration:none;font-family:var(--font-sans);font-size:13px;font-weight:500;color:var(--fg1);background:transparent;padding:8px 11px;border-radius:var(--radius-sm);white-space:nowrap;transition:background var(--dur-fast) var(--ease-out)}
|
||||
.langmenu a:hover{background:var(--bg-subtle)}
|
||||
.langmenu a[aria-selected="true"]{color:var(--accent);background:var(--accent-subtle);font-weight:700}
|
||||
|
||||
/* mobile nav */
|
||||
.menu-btn{display:none;border:none;background:transparent;cursor:pointer;padding:7px;border-radius:var(--radius-sm);color:var(--fg1)}
|
||||
.menu-btn svg{width:22px;height:22px}
|
||||
@@ -227,17 +238,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){
|
||||
|
||||