Compare commits

...

27 Commits

Author SHA1 Message Date
wangjia 8e569b83a7 Merge pull request 'feat(ds-flow): 前端设计系统治理重构(Flutter 五端 + Web 两端)' (#2) from worktree-macos-killswitch into main
ci-pangolin / Cleartext Scan — Android 禁明文 (push) Successful in 27s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (push) Successful in 24s
ci-pangolin / Lint — shellcheck (push) Successful in 7s
ci-pangolin / OpenAPI Sync Check (push) Successful in 33s
ci-pangolin / Flutter — analyze + test (push) Failing after 16s
ci-pangolin / Codegen Drift — token 生成物未漂移 (push) Successful in 4s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (push) Successful in 4s
ci-pangolin / Go — build + test (push) Successful in 8s
ci-pangolin / E2E Smoke — L4 进程级端到端 (push) Failing after 7s
Deploy Site / deploy-site (push) Successful in 2m39s
ci-pangolin / Go — integration (mysql/redis testcontainers) (push) Failing after 4m24s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (push) Successful in 20s
Deploy Client / build-android (push) Successful in 2m30s
Deploy Client / build-windows (push) Failing after 13m38s
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Failing after 14m24s
Deploy Client / build-macos (push) Successful in 3m56s
Deploy Client / build-ios (push) Successful in 3m28s
Deploy Client / release-deploy (push) Successful in 1m38s
2026-07-08 00:46:57 +00:00
wangjia 0f05385995 fix(client/test): pin zh locale 修 i18n 默认英文后失配的单测/组件测试
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (pull_request) Successful in 28s
ci-pangolin / OpenAPI Sync Check (pull_request) Successful in 37s
ci-pangolin / Cleartext Scan — Android 禁明文 (pull_request) Successful in 22s
ci-pangolin / Codegen Drift — token 生成物未漂移 (pull_request) Successful in 13s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (pull_request) Successful in 29s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (pull_request) Successful in 8s
ci-pangolin / Go — build + test (pull_request) Successful in 14s
ci-pangolin / E2E Smoke — L4 进程级端到端 (pull_request) Failing after 9s
ci-pangolin / Lint — shellcheck (pull_request) Failing after 11m2s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (pull_request) Failing after 13m15s
ci-pangolin / Go — integration (mysql/redis testcontainers) (pull_request) Failing after 13m22s
ci-pangolin / Flutter — analyze + test (pull_request) Failing after 15m0s
早前 i18n 把默认语种改英文(localeProvider 默认 AppLang.en),但这几个测试硬编码
const t = StringsZh() 中文预期,被测代码经 appTextProvider 渲染英文 → 断言失配
(既存 broken,先于 ds-flow;DinD 修复后 Flutter test job 才跑到暴露)。

修:各失败测试的 ProviderScope/Container overrides 加
localeProvider.overrideWith((ref) => _FixedLocale(AppLang.zh)),把被测代码钉回
中文(与硬编码 StringsZh 预期一致)。只动测试文件,未碰 lib/golden/test_config。

- connection_watchdog_test(节点异常提示 / 免费额度归零切断)
- node_connect_confirm_test(点节点/智能卡弹确认取消不切换)
- stats_page_test(PeriodCard 周期卡中文标签)

容器复跑 test/unit+widget+contract:+97 All passed。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 08:41:52 +08:00
wangjia fc22300937 fix(ci): docker job 从 ubuntu-latest 容器移到 nas host(修 DinD 全红)
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (pull_request) Successful in 26s
ci-pangolin / Flutter — analyze + test (pull_request) Failing after 25s
ci-pangolin / Cleartext Scan — Android 禁明文 (pull_request) Successful in 27s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (pull_request) Successful in 24s
ci-pangolin / Codegen Drift — token 生成物未漂移 (pull_request) Successful in 59s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (pull_request) Successful in 5s
ci-pangolin / Go — build + test (pull_request) Successful in 59s
ci-pangolin / E2E Smoke — L4 进程级端到端 (pull_request) Failing after 5s
ci-pangolin / Go — integration (mysql/redis testcontainers) (pull_request) Failing after 4m55s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (pull_request) Successful in 19s
ci-pangolin / OpenAPI Sync Check (pull_request) Failing after 13m37s
ci-pangolin / Lint — shellcheck (pull_request) Failing after 13m50s
根因:runs-on: ubuntu-latest 的 job 在 catthehacker 容器里跑,容器内嵌套
`docker run` = DinD,$PWD 在宿主不存在 → 挂载失败 → 所有套 docker 的 job 全红
(含 pre-existing 的 Codegen/Go/Flutter/Golden;纯 bash 的 redline/cleartext/
portable 不套 docker 故绿)。此前 server/site 部署 job 已因同因去 docker 直跑,
但 ci.yml 未跟进。

修:9 个套 docker 的 job(lint/openapi/flutter/codegen-drift/ds-flow/go-server/
e2e/go-integration/golden)runs-on 改 nas(host 模式=mac-pangolin-2 宿主直接跑,
docker run 是宿主真 docker 非嵌套,可正常拉跑镜像;本机已验证这些命令全绿)。
golden 保留 Linux flutter 容器 → 与入库基线渲染一致。3 个纯 bash 扫描留
ubuntu-latest(并行、已绿)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 02:58:14 +08:00
wangjia 4a0c5c4921 chore(todo): #19 ds-flow 前端治理转待验收
ci-pangolin / Lint — shellcheck (pull_request) Failing after 42s
ci-pangolin / OpenAPI Sync Check (pull_request) Failing after 36s
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (pull_request) Successful in 16s
ci-pangolin / Cleartext Scan — Android 禁明文 (pull_request) Successful in 15s
ci-pangolin / Flutter — analyze + test (pull_request) Failing after 3m35s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (pull_request) Successful in 22s
ci-pangolin / Codegen Drift — token 生成物未漂移 (pull_request) Failing after 24s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (pull_request) Failing after 24s
ci-pangolin / Go — build + test (pull_request) Failing after 1m18s
ci-pangolin / E2E Smoke — L4 进程级端到端 (pull_request) Failing after 27s
ci-pangolin / Go — integration (mysql/redis testcontainers) (pull_request) Failing after 19s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (pull_request) Failing after 28s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 01:45:18 +08:00
wangjia d2d75cd33c docs(ds-flow): Phase 5.6 — 前端全景文档 frontend-overview.html
docs/frontend-overview.html(照 jiu 十节裁剪):一次 UI 改动标准路径 · 目录地图 ·
三层真相源模型 · 令牌 codegen · 四道静态闸「违规谁拦」· 像素验收(golden 双主题
全绿含 CJK / fidelity 待建) · 响应式五端 · 规则速查 · 文档索引。登记进 docs/index.html。

同时标注两项延后(非阻塞,前置=原型整屏 HTML 属 L3 新屏工作):
- 4.4 mobile golden 覆盖扩容
- 5.5 fidelity 像素闸(原型无整屏可比,待 design/prototype/screens/ 落地)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 01:44:31 +08:00
wangjia a22db0874f feat(ds-flow): Phase 5.4 — pre-commit 增挂 ds-flow 条件闸
.githooks/pre-commit 在原有(红线/SQL/codegen-drift)基础上增挂三道 ds-flow 闸,
条件触发、秒级:
- 原型校验(check-ds):动了 design/prototype/ 才跑
- 跨端同源(check-l1-sync):动了 prototype/ 或 web/{website,usercenter}/ 才跑
- Flutter 颜色单源(check_ds_code --changed):动了 client/lib/*.dart 才跑(只扫改动)

install-hooks.sh 说明同步更新。启用仍是每台机一次性 `bash ci/install-hooks.sh`
(不代跑,改本机 git config;CLAUDE.md 治理章节已注明)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 01:42:27 +08:00
wangjia 29e225c63c feat(ds-flow): Phase 5.3 — CI 串联三道闸 + golden 扩全量
.gitea/workflows/ci.yml:
- 新增 ds-flow job:原型校验(check-ds)+ 跨端同源(check-l1-sync)+ Flutter
  颜色单源(check_ds_code --strict),均 node:20 容器零依赖跑。
- golden job 从「components+auth」扩到全量 test/golden(desktop/tablet 现均绿,
  含 CJK 真渲染 + tablet zh/en 矩阵)。
- codegen 零 diff(codegen-drift job)已有。

至此四道静态闸 + golden 全部进 CI 硬闸(「规则没上闸=没有规则」)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 01:41:10 +08:00
wangjia 14a9836882 feat(ds-flow): Phase 5.1-5.2 — Flutter 裸色闸 + 原型校验闸
5.1 client/tool/check_ds_code.mjs(照 jiu):禁 Flutter 业务代码裸 Color(0x)/
具名 Colors.x;豁免 token 生成层(pangolin_tokens.gen.dart)+ 实现层
(pangolin_theme.dart);--changed(pre-commit)/--strict(CI)/全量三模式;
剥行尾注释防误报。现状 --strict 通过(0 违规,UI 层零裸色)。

5.2 design/prototype/tools/check-ds.mjs:原型单源守门,5 道——硬编码色(仅严格
扫 atoms.css)/未定义 token/font-family 走 var(--font*)/图标走 icons.js sprite/
组件原子在 index.html 登记;剥 CSS+HTML 注释防误报,SVG fill/stroke 豁免。
现状通过(serve.mjs 改动时自动跑)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 01:39:44 +08:00
wangjia c4eb92fc2d feat(ds-flow): Phase 4.2/4.3/4.5 — CJK golden 字体 + timer 泄漏修复 + 基线重生成
4.5 harness/资源修复:UpdateNotifier 初始延迟由不可取消的 Future.delayed 改为
可取消 Timer,onDispose 一并取消——修 golden 整屏挂载时 pending-timer 泄漏
(生产也受益,provider dispose 不再悬挂 timer)。

4.2 CJK 测试字体:拷生产子集 NotoSansSC-Regular-subset.otf 进 test/fonts/ +
flutter_test_config 注册(family 'Noto Sans SC'),golden 中文真渲染不出豆腐块。

顺带修 tablet_pages_golden_test 既存编译错(6 语言改动遗留):localeProvider
override 返回值 AppLang → 改返回 LocaleNotifier 子类 _FixedLocale(钉死语言态)。

4.3 权威 Linux 容器(ghcr.io/cirruslabs/flutter)重生成全量 golden 基线:
34 tests All passed,32 张 PNG 更新(CJK 真字 + 暖阴影 + tablet zh/en 矩阵)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 01:27:09 +08:00
wangjia bb5acb6c1e feat(ds-flow): Phase 4.1 — 清 adaptive_menu 唯一裸 Material 色
adaptive_menu.dart shadowColor: Colors.black26(冷黑)→ PangolinColors.sand950
withValues(0.24)(暖近黑,对齐 §5 暖阴影铁律)。UI 层(widgets/screens/shell)
自此零裸 Material 色。flutter analyze 通过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 01:11:11 +08:00
wangjia 39324c46a7 feat(ds-flow): Phase 3 — Web 硬编码色清零 + 原子台账收敛
「各自实现 + 同源闸」决策落地(不建跨端组件包):

- website.css 页脚恒暗区灰阶就近吸附主题无关 sand 原色阶(cfc6b8→sand-300、
  a89e8e→sand-400、8a8070→sand-500),消除 5 处硬编码。
- check-l1-sync 白名单加品牌 logo 色 f4efe8(Brand.astro 内联 SVG fill)。
- usercenter 13 处硬编码全为 #fff(白名单),已干净。
- CONTRACT.md 增「§6 ds-flow Web 原子清单 + 屏级三态台账」:公用原子两端映射表、
  图标三端⊆原型、L2 屏级三态(Flutter 快照/Web 代码先行/原子层同步)、硬编码白名单。

同源闸 4 道全绿(token 值 · 图标⊆原型 · Web 无硬编码色 · 幂等零 diff)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 01:09:16 +08:00
wangjia f69696daae feat(ds-flow): Phase 2 — Web token 同源闸 check-l1-sync.mjs
tools/check-l1-sync.mjs:L1 跨端同源闸(纯 Node 零依赖),4 道:
① website tokens.gen.css token 值 ≡ 原型 tokens.css(:root+dark)
② usercenter public/colors_and_type.css 同上
③ 三端图标 ⊆ 原型 icons.js sprite(usercenter LUCIDE 键+路径、Flutter
   _byName 键)
④ Web 硬编码色扫描(白名单 #fff/#000+品牌 logo 色,ds-allow 行内豁免,
   排除生成的 token 定义文件)

现状:①②③ 全过(Web token 与原型一致、Web token 幂等零 diff、图标已收敛);
补原型 icons.js 的 'chart' 别名(Flutter barChart 用,先登记原型)。
④ 余 6 处 website.css 页脚灰阶/Brand.astro 近白 —— Phase 3 清理 worklist。
CI 接线见 Phase 5。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 01:03:36 +08:00
wangjia 83fce75c8c docs(ds-flow): Phase 1.6-1.7 — ui_kits 标 DEPRECATED + 真源指针收敛
用户定:ui_kits 暂留(整屏布局尚无 HTML 替代,重建属 L3 不在本轮),标废弃
不删。

- design/CLAUDE.md:顶部加「真源对照」横幅(令牌→prototype/tokens.css、原子
  →atoms.css+index.html、图标→icons.js、Flutter→client/lib/widgets、Web→
  web/*;colors_and_type.css 降级别名、design/flutter 已删、ui_kits DEPRECATED、
  _ds_manifest/_ds_bundle 历史派生物);§6「修改设计系统时」重写为 ds-flow 流程。
- design/ui_kits/DEPRECATED.md:醒目废弃标记 + 真源指引 + 暂留理由。
- 1.7:确认无构建/CI 消费 _ds_manifest/_ds_bundle/_adherence,登记职责交
  index.html,旧工具产物标历史派生物(不删,无消费者)。

Phase 1(原型单源三件套)完成。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 00:59:26 +08:00
wangjia ad845328fb feat(ds-flow): Phase 1.5 — 原型活登记页 index.html
design/prototype/index.html:设计系统组件登记簿(照 jiu 骨架,light/dark 双主题)。
顶栏主题切换(持久化 localStorage)+ 左侧 dnav scrollspy 导航 + 分区:
① 色板(clay/sand 色阶 + 语义 token,data-swatches 声明式随主题刷新)
② 字号梯度(display-xl→caption,中英分行不并排)
③ 圆角/间距/阴影比例尺
④ 公用组件(atoms.css 每个 class 一张展示卡,全变体全态含 disabled)
⑤ 图标库(icons.js 全 57 图标网格)

链 tokens.css + atoms.css + icons.js(相对路径,自包含)。
验证:每个 atom 类均登记、57 图标全展示、主题切换正常、文案脱敏无红线词。
评审:node design/prototype/serve.mjs → http://localhost:5180/

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 00:16:47 +08:00
wangjia 6def3edaed feat(ds-flow): Phase 1.4 — 原型图标 SVG sprite 单源 icons.js
design/prototype/icons.js:57 个 Lucide 图标的 SVG sprite 单源(零依赖,注入
隐藏 <svg> + <symbol id="i-*">,用法 <svg class="ic"><use href="#i-power"/>)。

路径全部来自权威来源、零手写:35 个取自 usercenter icons.tsx 内联路径、
22 个从 web/website/node_modules/lucide-react 包提取。覆盖三处并集
(usercenter 全键 ∪ Flutter pangolin_icons.dart ∪ website lucide 用量);
别名(loader-circle/chart-no-axes-column/play-circle/more-vertical 等)已解析。

供 Phase 2 check-l1-sync ③「三端图标 ⊆ 原型 sprite」同源闸校验。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 00:10:57 +08:00
wangjia 56f673791e feat(ds-flow): Phase 1.3 — 原型公用组件原子层 atoms.css
design/prototype/atoms.css:把分散在 preview 规格 / usercenter shared.tsx /
website css / Flutter widgets 的组件样式沉淀成 canonical 原子类,只引
var(--token)。含 .btn(primary/ghost/subtle/danger + 尺寸)、.card、.input
/.field、.pill/.badge(success/warning/danger/neutral 状态 + accent/outline)、
.langsel/.menu(对齐刚统一的自控语言下拉)。

明暗双主题靠语义 token 自动适配,文件内无 [data-theme] 分支。
验证:零硬编码色;45 个 token 引用全部在 prototype/tokens.css 有定义。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 00:04:52 +08:00
wangjia 805bf8e2ca feat(ds-flow): Phase 1.1-1.2 — 原型单源目录 + tokens.css 迁为真源
- design/prototype/serve.mjs:零依赖热重载预览服务器(照搬 jiu,check-ds
  存在性守护,Phase 5 前静默跳过)
- design/prototype/tokens.css:token 真源(现结构已满足 ds-flow 的
  base :root + [data-theme=dark],字节迁移保证 codegen 零 diff)
- design/colors_and_type.css:退化为薄 @import 别名(供历史 preview/*.html
  浏览器内引用;codegen 不再读它)
- 三个 codegen(Flutter gen_flutter_tokens / website+usercenter build-tokens)
  + drift 检查全部重指向 prototype/tokens.css
- 生成产物 diff 仅头注释源路径行,token 数值零变化(已验证)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 00:00:38 +08:00
wangjia 3311430db6 docs(ds-flow): Phase 0 — CLAUDE.md 增前端设计系统治理章节
新增「## 前端设计系统治理(ds-flow)」:心智模型(原型单源·代码镜像·静态闸
拦漂移·golden/fidelity 双级验收)、原型三件套(design/prototype/ tokens/atoms/
icons/index.html)、L1/L2/L3 三层治理、codegen(Flutter 生成 + Web 同源不重复
生成)、四道静态闸「违规谁拦」对照表、双级像素验收、pre-commit 启用说明。

 标注部件为 Phase 1-5 建设中,诚实反映当前落地状态,指向计划 #19。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:56:15 +08:00
wangjia 4d274aad7a docs(plan): 前端设计系统治理重构(ds-flow 全端)实现计划
用 ds-flow 把 Flutter 五端 + 官网 + 用户中心收口到「设计单源·代码镜像·
静态闸拦漂移·golden/fidelity 双级像素验收兜底」。

摸底结论:pangolin 非从零 bootstrap,已约 65% 达标(token 单源含暗色、
Flutter codegen+drift 闸、golden+CI 闸、pre-commit 写好未启用)。本计划是
补缺口 + Web 共享原子层去重,非推倒重来。主题保持 light/dark。

6 阶段:CLAUDE.md → 原型三件套(atoms/icons/index.html,收敛 ui_kits) →
Web token 同源闸 → Web 原子层去重(各自实现+同源闸) → Flutter 收尾+golden
补齐 → 静态闸挂满+启用 pre-commit+fidelity 体检。

真相源(含 checkbox)+ HTML 阅读版 + 登记 docs/index.html;todo #19 tier-1。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:41:50 +08:00
wangjia 84448b3064 fix(server/cors): 放行 X-Refresh-Token 头(修用户中心刷新掉线)
Deploy Server / deploy-server (push) Successful in 3m3s
用户中心用 header token 方案:access token 只存内存、refresh token 存
localStorage,页面刷新后靠 POST /v1/auth/refresh 带 X-Refresh-Token 头静默续期
(web/usercenter/lib/api/http.ts)。

但 CORS Allow-Headers 只放行 Content-Type/Authorization,浏览器预检发现
X-Refresh-Token 不在白名单 → 拦截真正的 refresh 请求 → api.refresh() 抛网络错
→ setAuthed(false) → 甩回登录页。表现:登录能成功(不带该头),但一刷新就掉线,
用量/续费等需登录态的页面全打不开。

修:Allow-Headers 加 X-Refresh-Token。加回归断言(cors_test)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:03:39 +08:00
wangjia 386e0bce90 fix(web): 语言下拉换自定义菜单(原生 select 弹层被系统定位/漂移)
Deploy Site / deploy-site (push) Successful in 4m31s
原生 <select> 的选项弹层由 OS 定位(macOS 锚在选中项上、整体偏移=漂移),CSS 控
不了。改成自控下拉:按钮(复用药丸样式)+ 绝对定位菜单(top:100%,right:0)+ 点外/
Esc 关闭 + 选中高亮。用户中心 LangSeg 与官网 Header 同款;官网选项用 <a href> 导航
(SEO 友好)、用户中心用 setLang。两端 build 通过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-07 22:42:00 +08:00
wangjia b584188ce2 fix(ci/site): 用 ! Content-Security-Policy 删官网 CSP(CF _headers 叠加,浏览器取交集)
Deploy Site / deploy-site (push) Successful in 4m36s
上一版判断错了:CF Pages _headers 不是'首个匹配生效',而是【叠加下发】——/* 和
/user/* 都匹配 /user/,两条 CSP 都发,浏览器对多条 CSP 取交集(最严)→ 官网严格
CSP 的 style-src(无 unsafe-inline)/connect-src('self')赢,用户中心内联样式被拦、
连 API 被拦 → 裸奔 + 登录失败。正解:/user/* 里  先删掉
/* 继承的官网 CSP,再设用户中心自己的(排 /* 之后,先加后删)。其余安全头沿用 /*。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-07 22:25:36 +08:00
wangjia 60cd28e085 fix(ci/site): _headers 里 /user/* 须排在 /* 之前(CF 首个匹配 header 生效)
Deploy Site / deploy-site (push) Successful in 4m3s
用户中心 /user/ 页裸奔根因:CF Pages _headers 对同一 header 取【首个匹配规则】值,
而 combine-site 原把 /user/* 追加在官网 /* 之后 → /user/ 命中在前的官网严格 CSP
(style-src 无 unsafe-inline),用户中心的大量内联样式被拦、连 API 的 connect-src
也被收窄 → 样式全丢。改为把 /user/* 规则前置到官网 /* 之前,用户中心 CSP 才生效。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-07 21:56:29 +08:00
wangjia 11ca7531a3 ci: flutter build 前显式 pub get + 重试(防 pub.flutter-io.cn 被 GFW 抖断)
Deploy Client / build-android (push) Successful in 1m46s
Deploy Client / build-windows (push) Successful in 1m55s
Deploy Client / build-macos (push) Successful in 3m40s
Deploy Client / build-ios (push) Successful in 3m28s
Deploy Client / release-deploy (push) Successful in 2m14s
iOS build 曾因 flutter build 内隐式 pub get 拉 google_fonts 时 pub.flutter-io.cn
socket error(exit 69)而失败。_env.sh 加 flutter_pub_get_retry(5 次重试),四个
compile 脚本在 flutter build 前显式预取,成功后 build 命中缓存不再拉网,减少偶发
网络失败。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-07 20:25:01 +08:00
wangjia e6b4ec0459 fix(ci/client): mac 端 job runs-on mac→nas(匹配实际 runner label)
Deploy Client / build-windows (push) Successful in 1m52s
Deploy Client / build-android (push) Successful in 2m4s
Deploy Client / build-macos (push) Successful in 3m38s
Deploy Client / build-ios (push) Failing after 1m57s
Deploy Client / release-deploy (push) Successful in 2m0s
mac runner(mac-pangolin-2)启动脚本注册 label 为 nas:host;此前靠手动加的 mac
label 让 runs-on:mac 命中,但 runner 重启(经启动脚本)会重注册成 nas、丢掉 mac
label → build-android/macos/ios/release-deploy 无 runner 可接、永久排队。改成
runs-on:nas 匹配实际 label,抗重启漂移(nas runner 即这台 mac,唯一)。windows
job 仍 runs-on:windows(windows runner)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-07 20:07:16 +08:00
wangjia 0b86138b55 feat(client/update): 更新提示改 jiu 式顶部横条(非强制不再弹窗)
Deploy Client / build-windows (push) Successful in 1m47s
Deploy Client / build-android (push) Has been cancelled
Deploy Client / build-macos (push) Has been cancelled
Deploy Client / build-ios (push) Has been cancelled
Deploy Client / release-deploy (push) Has been cancelled
按需求去掉居中弹窗,改成不打断的顶部 banner(对照 jiu 黄条):
- 非强制更新 → home_shell 顶部 UpdateBanner「发现新版本 vX + 立即更新 + ×」;
  「立即更新」App 内下载安装,「×」忽略此版本(banner 隐藏,更新版本会重现)
- 强制更新(force_update)→ 仍走不可关闭弹窗 showUpdateDialog
- 忽略状态改 dismissedUpdateVersionProvider(按版本号,响应式);去掉 notifier
  的 _dismissed
- 设置页「检查更新」有更新时:清忽略版本→banner 显示 + toast,不再弹窗
- 新增 6 语「立即更新」文案。flutter analyze 仅 2 个既有 withOpacity info

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-07 19:19:05 +08:00
wangjia aaffe85a4f fix(ci/windows): 同步 Flutter app 版本到 tag(原卡在 pubspec 1.0.48)
compile-windows 此前只 sed Inno 安装器 MyAppVersion,没改 Flutter pubspec 版本,
导致 Windows 包内 package_info 永远是 committed 的 1.0.48 → 设置页显示旧版 +
自动更新永远判"有新版"。补上 build number 计算 + sed 复制后的 pubspec.yaml
(GNU sed,windows runner),与 android/macos 对齐。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-07 19:05:31 +08:00
85 changed files with 2602 additions and 360 deletions
+33 -10
View File
@@ -16,12 +16,19 @@ on:
branches: [main]
workflow_dispatch:
# runner 分配:
# · runs-on: ubuntu-latest —— 在 catthehacker 容器里跑,仅用于纯 bash 扫描
# (redline/cleartext/portable-sql);容器内**不能**嵌套 `docker run`(DinD 挂载
# 失败,$PWD 在宿主不存在),故套 docker 的 job 不能用它。
# · runs-on: nas —— host 模式(mac-pangolin-2 直接在宿主跑),`docker run` 是宿主
# 真 docker(非嵌套),可正常拉/跑 node/golang/flutter/python/shellcheck 镜像。
# golden 保留 ghcr.io/cirruslabs/flutter Linux 容器 → 与入库基线渲染一致。
jobs:
# ── Job 1: Lint (shellcheck) ─────────────────────────────────────────────
lint:
name: Lint — shellcheck
runs-on: ubuntu-latest
runs-on: nas
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -74,7 +81,7 @@ jobs:
# ── Job 2: OpenAPI Sync Check ────────────────────────────────────────────
openapi-check:
name: OpenAPI Sync Check
runs-on: ubuntu-latest
runs-on: nas
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -113,7 +120,7 @@ jobs:
# ── Job 4: Flutter 客户端(分析 + 单测/组件测试)────────────────────────
flutter-client:
name: Flutter — analyze + test
runs-on: ubuntu-latest
runs-on: nas
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -149,7 +156,7 @@ jobs:
# (改了 design/colors_and_type.css 没重生成,或手改了生成物)。
codegen-drift:
name: Codegen Drift — token 生成物未漂移
runs-on: ubuntu-latest
runs-on: nas
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -161,12 +168,28 @@ jobs:
node:20 \
bash ci/check-codegen-drift.sh
ds-flow:
name: DS-flow — 原型/跨端同源/代码色单源闸
runs-on: nas
steps:
- name: Checkout
uses: actions/checkout@v4
- name: 原型校验(check-ds)
run: docker run --rm -v "$PWD:/repo" -w /repo node:20 node design/prototype/tools/check-ds.mjs
- name: 跨端同源(check-l1-sync)
run: docker run --rm -v "$PWD:/repo" -w /repo node:20 node tools/check-l1-sync.mjs
- name: Flutter 颜色单源(check_ds_code --strict)
run: docker run --rm -v "$PWD/client:/app" -w /app node:20 node tool/check_ds_code.mjs --strict
# ── Job 7: Go 服务端(build + test:含契约快照 + sqlite 真库,跳过 integration)──
# 此前 server 测试未进 CI;契约快照(支柱 2)等需在此守门。integration 测试走
# -tags integration(需 docker 起 mysql/redis),见 go-integration job。
go-server:
name: Go — build + test
runs-on: ubuntu-latest
runs-on: nas
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -192,7 +215,7 @@ jobs:
# 详见 scripts/e2e-smoke.sh + server/test/e2e/。
e2e-smoke:
name: E2E Smoke — L4 进程级端到端
runs-on: ubuntu-latest
runs-on: nas
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -216,7 +239,7 @@ jobs:
# · -p 1 串行:一次只起一个 mysql 容器,避免并发把 Docker Desktop 压垮/端口资源争用。
go-integration:
name: Go — integration (mysql/redis testcontainers)
runs-on: ubuntu-latest
runs-on: nas
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -230,8 +253,8 @@ jobs:
# Linux 权威基线(scripts/update-goldens.sh 生成;mac 渲染不一致故钉死 Linux 容器)。
# tablet/desktop-stats golden 与 stats-overhaul 工作区耦合,待其合并后并入本 job。
golden:
name: Golden — 视觉回归 (components + auth)
runs-on: ubuntu-latest
name: Golden — 视觉回归 (全量:components/auth/desktop/tablet)
runs-on: nas
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -243,4 +266,4 @@ jobs:
-v "$PWD/client:/app" -w /app \
-v "$HOME/.cache/pangolin-ci/pubcache:/root/.pub-cache" \
ghcr.io/cirruslabs/flutter:stable \
bash -c "flutter pub get && flutter test test/golden/components_golden_test.dart test/golden/auth_redesign_golden_test.dart"
bash -c "flutter pub get && flutter test test/golden"
+4 -4
View File
@@ -34,7 +34,7 @@ concurrency:
jobs:
build-android:
runs-on: mac
runs-on: nas
env:
GOPROXY: https://goproxy.cn,direct
PUB_HOSTED_URL: https://pub.flutter-io.cn
@@ -98,7 +98,7 @@ jobs:
# simply picks up whatever artifacts DO exist — an absent "macos"
# artifact is not an error there.
build-macos:
runs-on: mac
runs-on: nas
continue-on-error: true
env:
PUB_HOSTED_URL: https://pub.flutter-io.cn
@@ -129,7 +129,7 @@ jobs:
path: dist/
build-ios:
runs-on: mac
runs-on: nas
continue-on-error: true
env:
PUB_HOSTED_URL: https://pub.flutter-io.cn
@@ -160,7 +160,7 @@ jobs:
# (对应平台下载保留 pangolin1 上一版,待可用时下个 client-v* 追上)。
release-deploy:
needs: [build-android]
runs-on: mac
runs-on: nas
steps:
- name: Checkout
uses: actions/checkout@v4
+18
View File
@@ -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)"
+44
View File
@@ -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
+2 -2
View File
@@ -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
+1
View File
@@ -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"
+18
View File
@@ -308,6 +308,24 @@ extension AppLangMisc on AppLang {
return 'Cancelar';
}
}
/// 更新 banner:「立即更新」。
String get updateNow {
switch (this) {
case AppLang.zh:
return '立即更新';
case AppLang.en:
return 'Update now';
case AppLang.ja:
return '今すぐ更新';
case AppLang.ko:
return '지금 업데이트';
case AppLang.ru:
return 'Обновить';
case AppLang.es:
return 'Actualizar';
}
}
}
/// 全部界面文案的抽象契约。zh / en 各实现一份。
+2 -2
View File
@@ -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._();
+5 -4
View File
@@ -15,10 +15,8 @@ import '../state/settings_provider.dart';
import '../state/update_provider.dart';
import '../widgets/pangolin_icons.dart';
import '../widgets/pangolin_toast.dart';
import '../widgets/update_dialog.dart';
/// 「检查更新」手动触发:拉取 `$kApiBaseUrl/version`,失败/无更新走轻提示,
/// 有更新则弹 [showUpdateDialog]
/// 有更新则清掉「已忽略版本」→ 顶部更新 banner 显示 + toast(不弹窗)
Future<void> _checkForUpdate(BuildContext context, WidgetRef ref, AppText t) async {
final info = await ref.read(updateProvider.notifier).forceCheck();
if (!context.mounted) return;
@@ -30,7 +28,10 @@ Future<void> _checkForUpdate(BuildContext context, WidgetRef ref, AppText t) asy
showPangolinToast(context, t.updateUpToDate);
return;
}
await showUpdateDialog(context, t, info);
// 有更新:清掉「已忽略版本」→ 顶部 banner 重新显示(不弹窗);toast 提示一下。
// 强制更新由 home_shell 的 listen 走不可关闭弹窗,这里无需处理。
ref.read(dismissedUpdateVersionProvider.notifier).state = null;
showPangolinToast(context, t.updateAvailableTitle(info.latestVersion));
}
class SettingsPage extends ConsumerWidget {
+22 -13
View File
@@ -22,27 +22,36 @@ class HomeShell extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final c = context.pangolin;
// 启动自动检查更新:watch 惰性拉起 updateProvider(延迟 3s→查→1h 轮询);
// 有新版且本次会话未忽略时弹更新对话框。非强制更新弹前即标 dismiss,避免
// rebuild 重复弹(下轮轮询会重置);强制更新走不可关闭弹窗。
// 启动自动检查更新:watch 惰性拉起 updateProvider(延迟 3s→查→1h 轮询)
// 非强制更新 → 顶部 banner(见下,不打断);强制更新 → 不可关闭弹窗。
ref.listen<AsyncValue<AppUpdateInfo?>>(updateProvider, (prev, next) {
final info = next.valueOrNull;
if (info == null || !info.hasUpdate) return;
final notifier = ref.read(updateProvider.notifier);
if (notifier.isDismissed) return;
if (!info.forceUpdate) notifier.dismiss();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!context.mounted) return;
showUpdateDialog(context, ref.read(appTextProvider), info);
});
if (info != null && info.hasUpdate && info.forceUpdate) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) {
showUpdateDialog(context, ref.read(appTextProvider), info);
}
});
}
});
ref.watch(updateProvider); // 激活 provider(惰性 build)
final info = ref.watch(updateProvider).valueOrNull;
final dismissedVer = ref.watch(dismissedUpdateVersionProvider);
final showBanner = info != null &&
info.hasUpdate &&
!info.forceUpdate &&
dismissedVer != info.latestVersion;
final Widget body = switch (context.formFactor) {
FormFactor.desktop => const DesktopShell(),
FormFactor.tablet => const TabletShell(),
FormFactor.mobile => const MobileShell(),
};
return Scaffold(backgroundColor: c.bg, body: body);
return Scaffold(
backgroundColor: c.bg,
body: showBanner
? Column(children: [UpdateBanner(info: info), Expanded(child: body)])
: body,
);
}
}
+17 -12
View File
@@ -47,29 +47,29 @@ class AppUpdateInfo {
/// 更新检查 Notifier。build() 惰性触发:延迟首查 + 每小时轮询。
class UpdateNotifier extends AsyncNotifier<AppUpdateInfo?> {
Timer? _timer;
bool _dismissed = false;
/// 本次会话是否已被用户「稍后」忽略(强制更新不看这个)。
bool get isDismissed => _dismissed;
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 {
_dismissed = false; // 新一轮允许再次提示
state = AsyncValue.data(await _check());
});
return first;
}
/// 用户点「稍后」——本次会话内不再自动弹(直到下轮轮询)
void dismiss() => _dismissed = true;
/// 设置页「检查更新」手动触发:立即查一次并回结果(不受 dismiss 影响)。
/// 设置页「检查更新」手动触发:立即查一次并回结果
Future<AppUpdateInfo?> forceCheck() async {
_dismissed = false;
final info = await _check();
state = AsyncValue.data(info);
return info;
@@ -133,6 +133,11 @@ class UpdateNotifier extends AsyncNotifier<AppUpdateInfo?> {
final updateProvider =
AsyncNotifierProvider<UpdateNotifier, AppUpdateInfo?>(UpdateNotifier.new);
/// 用户在更新 banner 点「稍后再说」时忽略的版本号。忽略后 banner 隐藏;出现号
/// 不同的更新版本会重新显示;设置页手动「检查更新」会清空以重新提示。响应式,
/// 供 shell 顶部 banner 的显隐判断。
final dismissedUpdateVersionProvider = StateProvider<String?>((ref) => null);
/// 按当前平台从服务端 download_urls 里取对应下载直链。
String? platformDownloadUrl(Map<String, String> downloadUrls) {
if (Platform.isMacOS) return downloadUrls['macos'];
+1 -1
View File
@@ -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),
+59
View File
@@ -6,10 +6,12 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/update/app_updater.dart';
import '../l10n/app_text.dart';
import '../pangolin_theme.dart';
import '../state/app_providers.dart';
import '../state/update_provider.dart';
import 'pangolin_icons.dart';
@@ -78,3 +80,60 @@ class _UpdateDialog extends StatelessWidget {
);
}
}
/// jiu 式顶部更新横条(非强制更新用;强制更新仍走不可关闭弹窗 showUpdateDialog)。
/// 「立即更新」App 内下载安装;「×」忽略此版本(banner 隐藏,更新版本会重现)。
class UpdateBanner extends ConsumerWidget {
const UpdateBanner({super.key, required this.info});
final AppUpdateInfo info;
@override
Widget build(BuildContext context, WidgetRef ref) {
final c = context.pangolin;
final t = ref.watch(appTextProvider);
return Material(
color: c.accentSubtle,
child: SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 8, 6, 8),
child: Row(children: [
Icon(PangolinIcons.zap, size: 17, color: c.accent),
const SizedBox(width: 10),
Expanded(
child: Text(
t.updateAvailableTitle(info.latestVersion),
style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w700),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
TextButton(
onPressed: () => unawaited(startInAppUpdate(context, t, info)),
style: TextButton.styleFrom(
backgroundColor: c.accent,
foregroundColor: c.fgOnAccent,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(PangolinRadius.full)),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: Text(t.lang.updateNow,
style: PangolinText.caption
.copyWith(fontWeight: FontWeight.w700, fontSize: 12.5)),
),
IconButton(
onPressed: () =>
ref.read(dismissedUpdateVersionProvider.notifier).state = info.latestVersion,
icon: Icon(Icons.close, size: 18, color: c.fg3),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
tooltip: t.updateLater,
),
]),
),
),
);
}
}
+3
View File
@@ -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();
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

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),
]);
+10
View File
@@ -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',
+92
View File
@@ -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)' : '(仅报告)'}`);
+21 -6
View File
@@ -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)。
---
+30
View File
@@ -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`
+4 -4
View File
@@ -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(``);
+6 -216
View File
@@ -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.cssds-flow 原型单源)。
本文件仅为浏览器内 @import 转发,供仍 <link> 此路径的历史 preview/*.html 使用。
codegenFlutter 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');
+272
View File
@@ -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 / 卡片 lgxl / 输入 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); }
/* 实心 accentPRO 会员等强调标签) */
.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;
}
+101
View File
@@ -0,0 +1,101 @@
// 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-reactusercenter 没有的从包源提取)。
// 新增图标先在此登记 symbol,再用 <use> 引用;勿手写/瞎猜路径。
(function () {
// key = kebab-namevalue = 该图标的 lucide 子元素串(照搬权威来源)。
var ICONS = {
// ── 源自 usercenterweb/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.0usercenter 未含)──
'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"/>',
'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;
})();
+407
View File
@@ -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 &amp; 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>,卡片 lgxl,输入 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>&lt;select&gt;</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>&lt;svg class="ic"&gt;&lt;use href="#i-&lt;name&gt;"/&gt;&lt;/svg&gt;</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));
/* ── initsprite 注入可能在 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>
+97
View File
@@ -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();
});
+218
View File
@@ -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; }
+94
View File
@@ -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);
+20
View File
@@ -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`
+163
View File
@@ -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>(每刀 committier-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.csswebsite→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>websitewebsite.css/site-extra.css 对齐 atoms 语义,非白/黑/logo 硬编码清零</li>
<li>usercentershared.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-commitinstall-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/darkcheck-ds 12 道全绿</li>
<li><b>同源</b>check-l1-sync 全绿(tokens 逐值 / icons 同集 / Web hex 白名单)</li>
<li><b>Flutter</b>analyze + testgolden ×双主题);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>
+108
View File
@@ -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 → codegenFlutter 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 testgolden
评审原型 → 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 05)。fidelity(⑥)与 mobile golden 扩容为后续项。</p>
</div>
</body>
</html>
+10
View File
@@ -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 双主题)
+15
View File
@@ -29,3 +29,18 @@ ver_from_tag() {
ref="${ref#"${prefix}"-v}"
printf '%s' "$ref"
}
# flutter_pub_get_retry — pub.flutter-io.cn 常被 GFW 抖断(socket error / exit 69),
# 让 flutter build 内隐式的 pub get 偶发失败(见 iOS build 曾因 google_fonts 拉包
# 断线而挂)。构建前显式预取 + 重试;成功后 build 命中缓存不再拉网。在 flutter
# 项目根目录调用。
flutter_pub_get_retry() {
local i
for i in 1 2 3 4 5; do
if flutter pub get; then return 0; fi
echo "==> flutter pub get 失败(第 ${i}/5 次,pub.flutter-io.cn 抖?),8s 后重试..." >&2
sleep 8
done
echo "==> flutter pub get 5 次仍失败,放弃" >&2
return 1
}
+10 -5
View File
@@ -22,14 +22,19 @@ rm -rf "${DIST}/user"
mkdir -p "${DIST}/user"
cp -R "${UC}/." "${DIST}/user/"
# 2) 合并 _headers:用户中心路径规则前缀 /user,追加到官网 _headers 之后。
# 删掉并入产物里那份会被 CF 忽略的 /user/_headers,避免误导。
# 2) 合并 _headers 的 CSP。⚠️ CF Pages _headers 是【叠加】的:/* 与 /user/* 都匹配
# /user/,两条 CSP 都会下发,浏览器对多条 CSP 取【交集(最严)】→ 官网严格 CSP
# 的「style-src 无 unsafe-inline / connect-src 'self'」赢,用户中心的内联样式被拦、
# 连 API 被拦 → 页面裸奔且登录失败。故在 /user/* 里用 `! Content-Security-Policy`
# 先【删掉】/* 继承来的官网 CSP,再设用户中心自己的(须排在 /* 之后:先加后删)。
# 其余安全头(HSTS/X-Frame/…)/* 与用户中心一致,沿用 /* 即可,不重复。
rm -f "${DIST}/user/_headers"
{
echo ""
echo "# ==== 用户中心(/user/*)独立 CSP,combine-site.sh 从 usercenter/_headers 重定 ===="
# 行首 '/'(路径规则)前缀 /user;'#' 注释与缩进的 header 行不动。
sed -E 's#^/#/user/#' "${UC}/_headers"
echo "# ==== 用户中心(/user/*):删掉官网 /* 继承的 CSP,换用用户中心自己的 ===="
echo "/user/*"
echo " ! Content-Security-Policy"
grep -iE '^[[:space:]]*Content-Security-Policy:' "${UC}/_headers"
} >> "${DIST}/_headers"
echo "==> combine-site: done — 用户中心并入 ${DIST}/user/,_headers 已按 /user/* 分域合并"
+1
View File
@@ -131,6 +131,7 @@ EOF
# 现代绝大多数 Android 机)作为唯一下载,约 ~80MB;32 位老机(armeabi-v7a)/模拟器
# (x86_64)本轮不分发(需要再加)。这样官网 + deploy-client 仍是单一稳定 URL。
cd client
flutter_pub_get_retry # 预取包 + 重试,防 pub.flutter-io.cn 被 GFW 抖断(见 _env.sh)
flutter build apk --release --split-per-abi \
"--dart-define=PANGOLIN_API_URL=${API_URL}"
cd ..
+1
View File
@@ -173,6 +173,7 @@ EOF
# ── [6/6] flutter build ipa + 上传 TestFlight ───────────────────────────────
cd "${REPO_ROOT}/client"
flutter_pub_get_retry # 预取包 + 重试,防 pub.flutter-io.cn 被 GFW 抖断(见 _env.sh)
flutter build ipa --release \
--build-name="${VER}" \
--build-number="${BUILD}" \
+1
View File
@@ -182,6 +182,7 @@ echo "==> compile-macos: signing identity '${IDENTITY}'"
# 明),本步应已产出 Developer ID 签名 + hardened runtime 的 .app;下一步的
# inside-out 重签是幂等的安全网,不依赖这一步是否已经"恰好签对"。
cd "${REPO_ROOT}/client"
flutter_pub_get_retry # 预取包 + 重试,防 pub.flutter-io.cn 被 GFW 抖断(见 _env.sh)
flutter build macos --release --dart-define="PANGOLIN_API_URL=${API_URL}"
cd "$REPO_ROOT"
+16 -1
View File
@@ -51,7 +51,15 @@ VER=""
read -r VER < "$ver_file" || true # 无结尾换行的 read 退出码,见 compile-android.sh 同注释
rm -f "$ver_file"
echo "==> compile-windows: tag=${TAG} version=${VER}"
# build number 同 android/macos 规则(MAJOR*10000+MINOR*100+PATCH)。
MAJOR="${VER%%.*}"
_REST="${VER#*.}"
MINOR="${_REST%%.*}"
PATCH="${_REST#*.}"
PATCH="${PATCH%%-*}"
BUILD=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
echo "==> compile-windows: tag=${TAG} version=${VER} build=${BUILD}"
API_URL="${PANGOLIN_API_URL:-https://api.yanmeiai.com}"
@@ -79,6 +87,12 @@ rm -rf "${BUILD_CLIENT}/windows/flutter/ephemeral" \
"${BUILD_CLIENT}/.dart_tool" \
"${BUILD_CLIENT}/build"
# 同步 Flutter app 版本到 tag(GNU sed,windows runner)。否则 package_info 卡在
# pubspec committed 值(1.0.48),设置页显示旧版 + 自动更新永远判"有新版"。
# 此前只改了 Inno 安装器 MyAppVersion(下方),App 内部版本没跟上——本次修复。
sed -i "s/^version:.*/version: ${VER}+${BUILD}/" "${BUILD_CLIENT}/pubspec.yaml"
echo "==> compile-windows: pubspec version -> ${VER}+${BUILD}"
# ── [3/4] build ──────────────────────────────────────────────────────────────
# NOTE(controller): `flutter create` on an existing project only fills in
# scaffolding it manages (ephemeral/, generated_plugins.cmake, ...) and should
@@ -89,6 +103,7 @@ rm -rf "${BUILD_CLIENT}/windows/flutter/ephemeral" \
# on first real CI run that none of those get clobbered.
pushd "$BUILD_CLIENT" > /dev/null
flutter create --platforms=windows . --project-name pangolin_vpn
flutter_pub_get_retry # 预取包 + 重试,防 pub.flutter-io.cn 被 GFW 抖断(见 _env.sh)
flutter build windows --release "--dart-define=PANGOLIN_API_URL=${API_URL}"
popd > /dev/null
+4 -1
View File
@@ -35,7 +35,10 @@ func NewCORS() func(http.Handler) http.Handler {
h.Set("Access-Control-Allow-Origin", origin)
h.Add("Vary", "Origin")
h.Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
h.Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
// X-Refresh-Token:静默续期/登出把 refresh token 放在此自定义头里
// (header token 方案,见 web/usercenter/lib/api/http.ts)。不放行则浏览器
// 预检拦截 /v1/auth/refresh → 登录后一刷新即掉线(login 不带此头故不受影响)。
h.Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Refresh-Token")
h.Set("Access-Control-Max-Age", "600")
}
// 预检请求直接 204(不落到业务路由,避免 /v1/... 的 OPTIONS 404)。
+6
View File
@@ -3,6 +3,7 @@ package httpapi
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
@@ -35,6 +36,11 @@ func TestCORS(t *testing.T) {
if w.Header().Get("Access-Control-Allow-Methods") == "" {
t.Fatalf("preflight missing Allow-Methods")
}
// 静默续期/登出把 refresh token 放 X-Refresh-Token 头,必须在允许头里,
// 否则浏览器预检拦截 /v1/auth/refresh → 登录后一刷新即掉线。
if ah := w.Header().Get("Access-Control-Allow-Headers"); !strings.Contains(ah, "X-Refresh-Token") {
t.Fatalf("preflight Allow-Headers must include X-Refresh-Token, got %q", ah)
}
// 未白名单 Origin:不补 Allow-Origin。
r = httptest.NewRequest("POST", "/v1/auth/login", nil)
+132 -5
View File
@@ -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>
+66 -19
View File
@@ -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"
}
}
]
}
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env node
// tools/check-l1-sync.mjs — L1 设计真相源跨端同源闸(纯 Node 零依赖)
//
// 治理依据:根 CLAUDE.md「前端设计系统治理(ds-flow)」——tokens/icons 以
// design/prototype/ 为单一真源,Web 两端(website/usercenter)只能是同步副本。
// Flutter 的 token 同源由 ci/check-codegen-drift.sh 守护(codegen 零 diff),
// 本闸补 Web token 值同源 + 三端图标 ⊆ 原型 sprite + Web 硬编码色扫描。
//
// 本地/CI 直接跑:node tools/check-l1-sync.mjs
//
// 四道检查(任一违规 exit 1):
// ① website token 值同源 web/website/src/styles/tokens.gen.css 每个 token
// 值 ≡ design/prototype/tokens.css(:root + dark)。
// ② usercenter token 同源 web/usercenter/public/colors_and_type.css 同上。
// ③ 图标 ⊆ 原型 sprite usercenter LUCIDE(键+路径)+ Flutter pangolin_icons
// _byName(键)必须 ⊆ 原型 icons.js ICONS。
// ④ Web 硬编码色扫描 website src / usercenter app|components|lib 禁裸 hex;
// 白名单 #fff/#000 + 品牌 logo 固定色;行内 `ds-allow` 豁免;
// 排除生成的 token 定义文件。
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = fileURLToPath(new URL('..', import.meta.url));
const read = (p) => readFileSync(join(ROOT, p), 'utf8');
const problems = [];
// 解析 CSS 块里的 --var: value;(压掉空白便于比较)
function parseVars(cssBlock) {
const out = {};
for (const [, k, v] of cssBlock.matchAll(/(--[\w-]+)\s*:\s*([^;}]+)[;}]/g)) {
out[k] = v.replace(/\s+/g, ' ').trim();
}
return out;
}
// 取 tokens.css 的 :root + [data-theme="dark"] 全量 token
function protoTokens() {
const css = read('design/prototype/tokens.css');
const root = css.match(/:root\s*\{([^}]*)\}/s)?.[1] ?? '';
const dark = css.match(/\[data-theme="dark"\]\s*\{([^}]*)\}/s)?.[1] ?? '';
return { ...parseVars(root), ...parseVars(dark) };
}
// ── ①② Web token 值同源 ────────────────────────────────────
function checkWebTokenSync(label, webPath) {
if (!existsSync(join(ROOT, webPath))) {
problems.push(`[${label}] 找不到 ${webPath} —— 先跑 npm run gen:tokens`);
return;
}
const proto = protoTokens();
const web = parseVars(read(webPath));
for (const [k, v] of Object.entries(proto)) {
if (!(k in web)) {
problems.push(`[${label}] 缺 token ${k} —— ${webPath} 未同步原型(重跑 gen:tokens)`);
} else if (web[k] !== v) {
problems.push(`[${label}] ${k} 值漂移:原型=${v} Web=${web[k]} —— ${webPath} 应由 build-tokens 从原型再生,勿手改`);
}
}
}
// ── ③ 图标 ⊆ 原型 sprite ────────────────────────────────────
{
// 原型 icons.js: var ICONS = { 'name': '<path.../>', ... }
const protoJs = read('design/prototype/icons.js');
const protoBody = protoJs.match(/var ICONS\s*=\s*\{([\s\S]*?)\n\s*\};/)?.[1] ?? '';
const protoIcons = {};
for (const [, id, body] of protoBody.matchAll(/'([^']+)'\s*:\s*'([^']*)'/g)) protoIcons[id] = body;
if (!Object.keys(protoIcons).length) {
problems.push('[icons] 解析原型 icons.js ICONS 为空 —— 检查文件结构');
}
// usercenter: export const LUCIDE: Record<string,string> = { 'name': '<path/>', ... }
const ucJs = read('web/usercenter/components/icons.tsx');
const ucBody = ucJs.match(/LUCIDE\s*:[^=]*=\s*\{([\s\S]*?)\n\};/)?.[1] ?? ucJs.match(/LUCIDE\s*=\s*\{([\s\S]*?)\n\};/)?.[1] ?? '';
for (const [, id, body] of ucBody.matchAll(/'([^']+)'\s*:\s*'([^']*)'/g)) {
if (!(id in protoIcons)) {
problems.push(`[icons] usercenter 图标「${id}」不在原型 sprite —— 先登记 design/prototype/icons.js 再用`);
} else if (protoIcons[id] !== body) {
problems.push(`[icons] usercenter 图标「${id}」路径与原型 sprite 不一致 —— 以原型为准`);
}
}
// Flutter: static const Map<String,IconData> _byName = { 'name': ..., }
const dart = read('client/lib/widgets/pangolin_icons.dart');
const dartBody = dart.match(/_byName\s*=\s*\{([\s\S]*?)\};/)?.[1] ?? '';
for (const [, id] of dartBody.matchAll(/'([^']+)'\s*:/g)) {
if (!(id in protoIcons)) {
problems.push(`[icons] Flutter 图标「${id}」不在原型 sprite —— 新图标先登记 design/prototype/icons.js`);
}
}
}
// ── ④ Web 硬编码色扫描 ──────────────────────────────────────
{
// 白名单:纯白/纯黑 + 品牌 logo 固定色(SVG 内联 fill,非业务散色)
const ALLOW = new Set(['fff', 'ffffff', '000', '000000', 'b96a3d', 'faf3ed', 'f4efe8', '9e5630', '3d2213']);
// 排除:生成的 token 定义文件 + 构建产物
const SKIP = new Set([
'web/website/src/styles/tokens.gen.css',
'web/usercenter/public/colors_and_type.css',
]);
const SKIP_DIR = new Set(['node_modules', 'dist', 'out', '.next', 'build', '.astro', 'public']);
const ROOTS = ['web/website/src', 'web/usercenter/app', 'web/usercenter/components', 'web/usercenter/lib'];
const files = [];
const walk = (dir) => {
if (!existsSync(join(ROOT, dir))) return;
for (const name of readdirSync(join(ROOT, dir))) {
if (SKIP_DIR.has(name)) continue;
const rel = `${dir}/${name}`;
const st = statSync(join(ROOT, rel));
if (st.isDirectory()) walk(rel);
else if (/\.(astro|jsx|tsx|ts|js|css)$/.test(name) && !SKIP.has(rel)) files.push(rel);
}
};
ROOTS.forEach(walk);
for (const f of files) {
read(f).split('\n').forEach((line, i) => {
if (line.includes('ds-allow')) return;
for (const [hex] of line.matchAll(/#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\b/g)) {
const v = hex.slice(1).toLowerCase();
if (!ALLOW.has(v)) {
problems.push(`[web-hex] ${f}:${i + 1} 硬编码色 ${hex} —— 改用 var(--token),确需保留(如品牌 logo)加行内 ds-allow 注释`);
}
}
});
}
}
checkWebTokenSync('website-token', 'web/website/src/styles/tokens.gen.css');
checkWebTokenSync('usercenter-token', 'web/usercenter/public/colors_and_type.css');
// ── 汇总 ────────────────────────────────────────────────────
const line = '='.repeat(60);
if (problems.length) {
console.error(line);
console.error(`✗ L1 跨端同源闸未过(${problems.length} 处):`);
for (const p of problems) console.error(' · ' + p);
console.error(line);
process.exit(1);
}
console.log(line);
console.log('✓ 通过:L1 跨端同源(website/usercenter token 值 · 三端图标 ⊆ 原型 sprite · Web 无硬编码色)');
console.log(line);
+105 -28
View File
@@ -1,5 +1,6 @@
// shared.tsx — 公共样式常量与原子(承袭 ucapp.jsx 的 ucCard / ucInput / UCLang
import React from 'react';
'use client';
import React, { useEffect, useRef, useState } from 'react';
import type { Lang } from '../lib/i18n';
export const card: React.CSSProperties = {
@@ -22,34 +23,110 @@ export const input: React.CSSProperties = {
boxSizing: 'border-box',
};
const LANGS: [Lang, string][] = [
['en', 'English'], ['zh', '中文'], ['ja', '日本語'],
['ko', '한국어'], ['ru', 'Русский'], ['es', 'Español'],
];
// 自定义下拉:原生 <select> 的弹层由系统定位(macOS 锚在选中项、会"漂移"),
// 改成自控菜单(按钮 + 绝对定位列表),不漂移、样式统一、跨端一致。
export function LangSeg({ lang, setLang }: { lang: Lang; setLang: (l: Lang) => void }) {
// 6 语言用原生下拉(段控横排会挤)。默认 en。
const langs: [Lang, string][] = [
['en', 'English'], ['zh', '中文'], ['ja', '日本語'],
['ko', '한국어'], ['ru', 'Русский'], ['es', 'Español'],
];
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onDoc = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); };
document.addEventListener('mousedown', onDoc);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDoc);
document.removeEventListener('keydown', onKey);
};
}, [open]);
const current = LANGS.find(([v]) => v === lang)?.[1] ?? String(lang);
return (
<select
value={lang}
onChange={(e) => setLang(e.target.value as Lang)}
aria-label="Language"
style={{
// 圆角矩形 + 柔和边框(原纯 --border 太生硬)。
border: '1px solid color-mix(in oklab, var(--border), transparent 55%)',
cursor: 'pointer',
borderRadius: 10,
padding: '5px 10px',
fontFamily: 'var(--font-sans)',
fontSize: 12.5,
fontWeight: 700,
background: 'var(--bg-subtle)',
color: 'var(--fg2)',
boxShadow: '0 1px 2px rgba(45, 30, 20, 0.05)',
}}
>
{langs.map(([v, l]) => (
<option key={v} value={v}>{l}</option>
))}
</select>
<div ref={ref} style={{ position: 'relative', display: 'inline-block' }}>
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-haspopup="listbox"
aria-expanded={open}
aria-label="Language"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
border: '1px solid color-mix(in oklab, var(--border), transparent 55%)',
cursor: 'pointer',
borderRadius: 10,
padding: '5px 10px',
fontFamily: 'var(--font-sans)',
fontSize: 12.5,
fontWeight: 700,
background: 'var(--bg-subtle)',
color: 'var(--fg2)',
boxShadow: '0 1px 2px rgba(45, 30, 20, 0.05)',
}}
>
<span>{current}</span>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" aria-hidden="true"
style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform 140ms' }}>
<path d="M6 9l6 6 6-6" stroke="currentColor" strokeWidth="2.4"
strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
{open && (
<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,
}}
>
{LANGS.map(([v, l]) => {
const on = v === lang;
return (
<button
key={v}
type="button"
role="option"
aria-selected={on}
onClick={() => { setLang(v); setOpen(false); }}
style={{
textAlign: 'left',
border: 'none',
cursor: 'pointer',
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',
}}
>
{l}
</button>
);
})}
</div>
)}
</div>
);
}
+1 -1
View File
@@ -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
+4 -4
View File
@@ -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)`);
+4 -4
View File
@@ -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)`);
+42 -11
View File
@@ -3,7 +3,7 @@
* 迁自 design/ui_kits/website/index.html 的 <header> + site.js 的菜单/滚动毛玻璃逻辑。
* 语言切换由原型的 JS 文本替换改为「路由跳转」(zh=/, en=/en/),单显不并排(铁律 6)。
*/
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Download, Menu } from 'lucide-react';
import { SITE } from '../config/site';
@@ -29,6 +29,17 @@ function Mark() {
export default function Header({ lang = 'zh', t = {} }) {
const [open, setOpen] = useState(false);
const [scrolled, setScrolled] = useState(false);
const [langOpen, setLangOpen] = useState(false);
const langRef = useRef(null);
useEffect(() => {
if (!langOpen) return;
const onDoc = (e) => { if (langRef.current && !langRef.current.contains(e.target)) setLangOpen(false); };
const onKey = (e) => { if (e.key === 'Escape') setLangOpen(false); };
document.addEventListener('mousedown', onDoc);
document.addEventListener('keydown', onKey);
return () => { document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onKey); };
}, [langOpen]);
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 8);
@@ -64,16 +75,36 @@ export default function Header({ lang = 'zh', t = {} }) {
))}
</nav>
<div class="right">
<select
class="langsel"
value={lang}
onChange={(e) => { window.location.href = langHref(e.target.value); }}
aria-label="Language"
>
{langs.map(([code, label]) => (
<option key={code} value={code}>{label}</option>
))}
</select>
<div ref={langRef} style={{ position: 'relative', display: 'inline-block' }}>
<button
type="button"
class="langsel"
onClick={() => setLangOpen((o) => !o)}
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' }}>
<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 }}>
{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' }}>
{label}
</a>
);
})}
</div>
)}
</div>
<a class="linklogin" href={SITE.usercenter}>{t.login}</a>
<a class="btn btn-primary" href="#download">
<Download />
+1 -1
View File
@@ -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
+5 -5
View File
@@ -227,17 +227,17 @@ section{padding:88px 0}
.cta-ghost:hover{border-color:#fff}
/* ---------- footer ---------- */
.ftr{background:var(--sand-950);color:#cfc6b8;padding:64px 0 32px}
.ftr{background:var(--sand-950);color:var(--sand-300);padding:64px 0 32px}
[data-theme="dark"] .ftr{background:#000}
.ftr .top{display:grid;grid-template-columns:1.4fr 1fr 1fr 1fr;gap:36px}
.ftr .nm{font-family:var(--font-display);font-weight:700;font-size:18px;color:#fff;display:flex;align-items:center;gap:10px}
.ftr .tag{font-size:13.5px;line-height:1.6;margin:14px 0 0;max-width:280px;color:#a89e8e}
.ftr h4{font-family:var(--font-sans);font-size:12px;font-weight:700;letter-spacing:0.08em;text-transform:uppercase;color:#8a8070;margin:0 0 14px}
.ftr .tag{font-size:13.5px;line-height:1.6;margin:14px 0 0;max-width:280px;color:var(--sand-400)}
.ftr h4{font-family:var(--font-sans);font-size:12px;font-weight:700;letter-spacing:0.08em;text-transform:uppercase;color:var(--sand-500);margin:0 0 14px}
.ftr ul{list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:10px}
.ftr ul a{font-size:14px;color:#cfc6b8}
.ftr ul a{font-size:14px;color:var(--sand-300)}
.ftr ul a:hover{color:#fff}
.ftr .mono{font-family:var(--font-mono);font-size:12.5px}
.ftr .bot{display:flex;align-items:center;justify-content:space-between;margin-top:46px;padding-top:24px;border-top:1px solid rgba(255,255,255,.1);font-size:12.5px;color:#8a8070;flex-wrap:wrap;gap:12px}
.ftr .bot{display:flex;align-items:center;justify-content:space-between;margin-top:46px;padding-top:24px;border-top:1px solid rgba(255,255,255,.1);font-size:12.5px;color:var(--sand-500);flex-wrap:wrap;gap:12px}
/* ---------- responsive ---------- */
@media (max-width:980px){