From bb054db0ea37ce5b37c24380c07970d5f6b7449d Mon Sep 17 00:00:00 2001 From: sunjunzhao Date: Thu, 22 Oct 2026 20:07:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(app-dev):=20add=20UI=20and=20skin-consiste?= =?UTF-8?q?ncy=20contract=20for=20micro=20apps=20=E2=80=94=20checklist=20g?= =?UTF-8?q?ains=20section=20H=20(H1=E2=80=93H12):=20hardcoded=20colors,=20?= =?UTF-8?q?Tailwind=20palette=20classes=20and=20missing=20ts-skin=20skin?= =?UTF-8?q?=20bridge=20are=20die-level;=20check-app.sh=20gains=20H-section?= =?UTF-8?q?=20static=20checks=20(token=20snapshot=20integrity,=20initSkin?= =?UTF-8?q?=20before=20createRoot,=20inline=20style=20colors,=20font=20sta?= =?UTF-8?q?ck,=20z-index,=20!important);=20web=20template=20now=20ships=20?= =?UTF-8?q?external=20app.css=20and=20src/skin/{tokens.ts,apply.ts}=20with?= =?UTF-8?q?=20initSkin()=20called=20before=20first=20frame;=20new=20script?= =?UTF-8?q?s/sync-skin-tokens.sh=20regenerates=20the=20token=20snapshot=20?= =?UTF-8?q?from=20platform=20skins/index.ts;=20new=20references/ui-style.m?= =?UTF-8?q?d=20documents=20the=20token=20table,=20visual=20scale=20and=20s?= =?UTF-8?q?kin-bridge=20mechanism;=20SKILL.md=20adds=20constraints=20on=20?= =?UTF-8?q?var(--sk-*)-only=20colors,=20read-only=20ts-skin=20bridging=20a?= =?UTF-8?q?nd=20pre-render=20initSkin,=20plus=20skin-related=20troubleshoo?= =?UTF-8?q?ting=20rows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- skills/app-dev/SKILL.md | 65 +++-- skills/app-dev/references/checklist.md | 19 ++ .../app-dev/references/templates/web/app.css | 261 ++++++++++++++++++ .../references/templates/web/index.html | 2 + .../references/templates/web/package.json | 4 +- .../references/templates/web/src/main.tsx | 125 +++++++-- .../templates/web/src/skin/apply.ts | 60 ++++ .../templates/web/src/skin/tokens.ts | 145 ++++++++++ skills/app-dev/references/ui-style.md | 142 ++++++++++ skills/app-dev/scripts/check-app.sh | 87 +++++- skills/app-dev/scripts/sync-skin-tokens.sh | 156 +++++++++++ 11 files changed, 1020 insertions(+), 46 deletions(-) create mode 100644 skills/app-dev/references/templates/web/app.css create mode 100644 skills/app-dev/references/templates/web/src/skin/apply.ts create mode 100644 skills/app-dev/references/templates/web/src/skin/tokens.ts create mode 100644 skills/app-dev/references/ui-style.md create mode 100755 skills/app-dev/scripts/sync-skin-tokens.sh diff --git a/skills/app-dev/SKILL.md b/skills/app-dev/SKILL.md index 9c9c0e3..b7ee124 100644 --- a/skills/app-dev/SKILL.md +++ b/skills/app-dev/SKILL.md @@ -1,6 +1,6 @@ --- name: app-dev -description: 在独立仓库按 TickShield(刻盾云)平台契约开发、审查、发布生态应用(micro/service):listingKey 四位一体、app.manifest.json、tickshield-node-runtime 基础镜像、@aigrego/guard 四道闸与 tenant_id 租户隔离、micro 前端 portal-sdk 宿主桥、release-cli 发布与商店上架部署。凡用户要求「开发一个平台应用 / 新建 app / 写个 micro 应用 / 做个像 secret-manager / demo-app 那样的应用 / 在门户里加个自己的应用 / 接入 TickShield / 上架应用到商店 / 审查这个 app 能不能部署到平台 / 帮我看看接入还缺什么」,或带着 注册不上去 / 401 missing app credentials / 心跳 404 / host must equal listingKey / 四道闸 403 / 跨租户数据串了 / image registry not registered / invalid manifest skipped / 应用中心导入失败 / 应用装上了但 /svc 不通 / PROD 白屏 CSP 报错 等症状,或提到 listingKey / app.manifest.json / 四道闸 / TDT / tenant_id / portal-sdk / release-cli / tickshield-node-runtime 时,务必使用本 skill——即使用户没说「应用接入」四个字。Use whenever the user develops, scaffolds, audits, onboards, publishes or deploys a TickShield platform app (micro or service) from an independent repository. 边界:非平台场景的通用 Dockerfile 选型评审用 runtime-image;通用 Gitea Actions 工作流规范用 gitea-actions;CI 工作流的发布 job 改造/新建发布工作流用 app-publish;git 提交推送与日报用 push-and-report;平台基座本身(portal/gateway/packages/* 控制面)的开发不归本 skill。 +description: 在独立仓库按 TickShield(刻盾云)平台契约开发、审查、发布生态应用(micro/service):listingKey 四位一体、app.manifest.json、tickshield-node-runtime 基础镜像、@aigrego/guard 四道闸与 tenant_id 租户隔离、micro 前端 portal-sdk 宿主桥与平台同款 UI 皮肤(--sk-* 令牌 + ts-skin 换肤跟随)、release-cli 发布与商店上架部署。凡用户要求「开发一个平台应用 / 新建 app / 写个 micro 应用 / 做个像 secret-manager / demo-app 那样的应用 / 在门户里加个自己的应用 / 接入 TickShield / 上架应用到商店 / 审查这个 app 能不能部署到平台 / 帮我看看接入还缺什么 / 审一下这个应用的 UI / 界面跟平台不一致 / 换肤不跟随 / 深色模式瞎了 / 配色不统一 / 把界面改成平台那样」,或带着 注册不上去 / 401 missing app credentials / 心跳 404 / host must equal listingKey / 四道闸 403 / 跨租户数据串了 / image registry not registered / invalid manifest skipped / 应用中心导入失败 / 应用装上了但 /svc 不通 / PROD 白屏 CSP 报错 / 切到暗夜皮肤应用还是白底 / 青瓷皮肤下按钮还是蓝色 / 应用跟平台长得不一样 等症状,或提到 listingKey / app.manifest.json / 四道闸 / TDT / tenant_id / portal-sdk / release-cli / tickshield-node-runtime / --sk- 令牌 / ts-skin / 皮肤 时,务必使用本 skill——即使用户没说「应用接入」四个字。Use whenever the user develops, scaffolds, audits, reviews (including UI/skin consistency), onboards, publishes or deploys a TickShield platform app (micro or service) from an independent repository. 边界:非平台场景的通用 Dockerfile 选型评审用 runtime-image;通用 Gitea Actions 工作流规范用 gitea-actions;CI 工作流的发布 job 改造/新建发布工作流用 app-publish;git 提交推送与日报用 push-and-report;平台基座本身(platform/gateway/packages/* 控制面)的开发与平台设计系统(DESIGN.md / skins/index.ts)的修改不归本 skill。 --- # app-dev — TickShield 生态应用开发规范(独立仓库 → 可直接部署上架) @@ -10,20 +10,24 @@ TickShield 生态应用的交付物只有两样:**镜像 + `app.manifest.json` - **新建模式**:仓库没有 `app.manifest.json` / `Dockerfile` —— 交互采集四要素 → 照 `references/templates/` 生成骨架 - **审查模式**:仓库已有接入痕迹 —— 对照 `references/checklist.md` 逐项审计 → 出修正清单 -契约锚点:本 skill 基线 = TickShield 平台 v4.0 + APPSTORE-RUNTIME + FR-101 registries 白名单 + FR-102/TKT-557 导航与图标规范(2026-10)。与平台冲突时,以平台仓 `docs/reference/应用开发与上架指引.md` 和 `apps/portal/internal/modules/registry/manifest.go` 的校验代码为准,并回本仓库更新本 skill。`docs/reference/App出仓与集成指引.md`、`App开发者指南.html` 是**已废弃的历史文档**(@xgent 旧体系),禁止照搬。 +micro 应用还有第三条硬要求:**UI 与平台基座同一套视觉,且跟随皮肤切换**(light / dark / jade)。基准见 `references/ui-style.md`,令牌由 `scripts/sync-skin-tokens.sh` 从平台 `skins/index.ts` 生成,跟随机制是 `localStorage['ts-skin']` + `storage` 事件。这不是"美化建议",是接入审核的 H 节(写死色值 / 没有皮肤桥 = die)。 + +契约锚点:本 skill 基线 = TickShield 平台 v4.0 + APPSTORE-RUNTIME + FR-101 registries 白名单 + FR-102/TKT-557 导航与图标规范(2026-10)+ 平台设计系统 DESIGN.md / skins/index.ts(2026-10-22 快照)。与平台冲突时,以平台仓 `docs/reference/应用开发与上架指引.md`、`apps/portal/internal/modules/registry/manifest.go` 的校验代码、`apps/web/src/skins/index.ts` 的令牌定义为准,并回本仓库更新本 skill。`docs/reference/App出仓与集成指引.md`、`App开发者指南.html` 是**已废弃的历史文档**(@xgent 旧体系),禁止照搬。 ## 总流程 ``` 第 0 步 备料分流(判模式 + 采集四要素) -新建:第 1 步 生成骨架 → 第 2 步 静态自检(scripts/check-app.sh) -审查:第 1 步 逐项审计(checklist.md)→ 第 2 步 确认后修正 + 静态自检 -汇合:第 3 步 联调验证(需 dev 栈)→ 第 4 步 发布指引 → 第 5 步 汇报 +新建:第 1 步 生成骨架(含 UI 皮肤层)→ 第 2 步 静态自检(scripts/check-app.sh) +审查:第 1 步 逐项审计(checklist.md A–H)→ 第 2 步 确认后修正 + 静态自检 +汇合:第 3 步 联调验证(需 dev 栈,含换肤跟随)→ 第 4 步 发布指引 → 第 5 步 汇报 ``` 除非用户明确只要聊聊思路,完整走全部 5 步。 -**调用形态分流(硬规则)**:裸调用(无任务输入)= **只读审计态**——只出审计报告与修复/新建方案,**永不主动改文件、永不执行任何写操作**;报告与方案交付即停,等用户确认后才进入对应模式的执行段。空仓库裸调用同理:只输出「接入差距评估 + 新建方案」供确认,不直接生成骨架。带任务输入(如「帮我开发 X 应用」「按报告修」)= 任务即授权对应动作,按流程走;但发布等写平台操作仍需显式确认(见「人机权责」)。 +**调用形态分流(硬规则)**:裸调用(无任务输入)= **只读审计态**——对 app 做**全量审核(含 UI)**,只出审计报告与修复/新建方案,**永不主动改文件、永不执行任何写操作**;报告与方案交付即停,等用户确认后才进入对应模式的执行段。空仓库裸调用同理:只输出「接入差距评估 + 新建方案」供确认,不直接生成骨架。带任务输入(如「帮我开发 X 应用」「按报告修」「把界面改成平台那样」)= 任务即授权对应动作,按流程走;但发布等写平台操作仍需显式确认(见「人机权责」)。 + +**UI 审核不单独开关**:裸调用就是审核,审的就是「接入契约 + UI 一致性」两件事,不给 `--ui` 之类的子命令——用户说「/app-dev」而没带任何要求时,默认动作是审,不是改。 ## 前置:先备齐 4 样(缺一样就不要开始) @@ -51,23 +55,26 @@ TickShield 生态应用的交付物只有两样:**镜像 + `app.manifest.json` - `app.manifest.json`(service / micro 两个模板选一) - `Dockerfile`(`FROM tickshield-node-runtime:base`,照模板,不加 CMD) - `server.js`(service 骨架:guard + tdb + assertTenantTables + logger + tracing + 示例路由) -- micro 型追加:`web/`(index.html / package.json / tsconfig.json / src/sdk.ts / src/api.ts / src/main.tsx) +- micro 型追加:`web/`(index.html / app.css / package.json / tsconfig.json / src/sdk.ts / src/api.ts / src/main.tsx / **src/skin/tokens.ts** / **src/skin/apply.ts**) + - `web/src/skin/tokens.ts` 是**生成物**,别手改:模板里带的是 2026-10-22 快照;用户能提供平台仓路径时,生成骨架后立刻重跑 `bash <本skill目录>/scripts/sync-skin-tokens.sh <平台仓根> --out web/src/skin/tokens.ts` 对齐到平台当前配色。 + - `web/app.css` 是外置样式(PROD CSP 下不靠内联 style),颜色全部 `var(--sk-*)`;构建脚本要把它拷进 `dist/`。 - `.gitea/workflows/release.yaml`(照 `gitea-app-release.yaml`,提示配 secrets) - `docs/PRD_app-.md` 草稿(照 `PRD_app-template.md`,文档状态标「草稿」) - `.aigrego-registry.env.example` + 在 `.gitignore` 追加 `.aigrego-registry.env` -生成后逐文件说明「这个文件哪几处必须改、哪几处永远别动」。 +生成后逐文件说明「这个文件哪几处必须改、哪几处永远别动」;UI 层额外说明一句:**颜色改 class 不改色值,皮肤跟随不要自己实现**(照 `src/skin/apply.ts` 用)。 -→ 验收:文件齐、占位全替换、用户确认清单。 +→ 验收:文件齐、占位全替换、用户确认清单;`web/src/skin/tokens.ts` 有快照日期头且皮肤数 ≥3。 ## 第 1~2 步(审查):逐项审计 → 修正 1. 读 `references/checklist.md`,对照逐项审计,输出三档结论表:**通过 / 不符(die 级)/ 风险(warn 级)**,每项给证据(文件:行号)。 -2. 修正清单列给用户,**经确认后**逐项修;修完跑静态自检。 +2. **UI 审核(H 节)单独成段**:先读 `references/ui-style.md` 建立基准,再按 H1–H12 审。要点是"换肤能不能跟随",不是"好不好看"——判据永远是「写死色值了吗 / 有皮肤桥吗 / 首帧前落令牌了吗 / 三套皮肤实机切过吗」。H11 需要 dev 栈实机切皮肤,跑不了就写「未验证」,不许拿静态检查绿冒充。 +3. 修正清单列给用户,**经确认后**逐项修;修完跑静态自检。 -**裸调用到此为止**:审计报告 + 修复方案就是交付物,不执行任何修正;用户回复确认(如「按方案修」)后才进入第 2 步修正。 +**裸调用到此为止**:审计报告 + 修复方案就是交付物,不执行任何修正;用户回复确认(如「按方案修」)后才进入第 2 步修正。裸调用的报告必须同时含「接入契约结论」与「UI 一致性结论」两段,缺一段等于没审。 -→ 验收:审计表覆盖 checklist 全部 die 级项;修正经用户逐批确认。 +→ 验收:审计表覆盖 checklist 全部 die 级项(A–H 全覆盖);修正经用户逐批确认。 ## 第 2 步:静态自检 @@ -75,7 +82,7 @@ TickShield 生态应用的交付物只有两样:**镜像 + `app.manifest.json` bash <本skill目录>/scripts/check-app.sh <仓库根> ``` -die 级不过 → 修了再往下走;warn 级列进汇报。check-app.sh 只查静态契约(manifest 字段/Dockerfile 三要素/入口接线/保留路径/密钥卫生),不替代第 3 步真环境联调。 +die 级不过 → 修了再往下走;warn 级列进汇报。check-app.sh 只查静态契约(manifest 字段/Dockerfile 三要素/入口接线/保留路径/密钥卫生/**UI 令牌与皮肤桥**),不替代第 3 步真环境联调。UI 部分它只能证明"没写死色值、桥接代码在",**不能证明换肤真的跟随**——那一步必须实机切。 → 验收:脚本 exit 0(或仅剩 warn 且已列清单)。 @@ -88,8 +95,9 @@ die 级不过 → 修了再往下走;warn 级列进汇报。check-app.sh 只 3. 四道闸矩阵:匿名 → 401;跨 aud → 网关 401 / 直连 403 `gate=audience`;无 scope → 403 `gate=scope`;member 调 admin 路由 → 403 `gate=role`;无页面权限 → 403 `gate=acl`;合法调用 → 200。 4. 租户隔离:A 租户写入,B 租户读 = 空集。 5. micro 型追加:门户内打开应用,`sdk.ready()` 握手成功、acl 驱动页面显隐、`callService` 通。 +6. micro 型追加(UI 换肤):门户用户菜单依次切 **暗夜 → 青瓷 → 简约白**,应用**当场**跟随(不刷新、不闪白);青瓷下强调色必须变松绿——还是蓝色说明读了二值 theme 而不是 `ts-skin`;刷新 iframe 后皮肤保持。三套各截一张图留档。 -平台仓 `scripts/verify-onboarding.sh` 是这六项的出口门基准(按其样式改写成自己 app 的演练路由)。 +平台仓 `scripts/verify-onboarding.sh` 是前六项的出口门基准(按其样式改写成自己 app 的演练路由);换肤一项没有现成脚本,照 `references/ui-style.md` §7 手工过。 → 验收:六项全过并留档;做不到的项在汇报里逐项声明「未验证」及原因。 @@ -117,11 +125,12 @@ aigrego-release publish --manifest app.manifest.json [--image ] [--store] [ - 做了什么:模式 / 生成或修改的文件 / 执行过的命令 - 关键标识:listingKey、版本号、镜像 ref、关键文件路径 +- **UI 一致性结论**(micro 必填):令牌快照(皮肤数 / 令牌数 / 快照日期)/ 皮肤桥是否就位 / 换肤实机结果(三套皮肤各切一次,逐套写「跟随 / 未跟随 / 未验证」)/ H 节 die 与 warn 项 - 被跳过的:未生成/未修的项及原因 - 没写成的:占位残留、未替换项 -- 验证做到哪一步:静态自检结果;联调六项各自 过/未验证(**没跑就说没跑**) +- 验证做到哪一步:静态自检结果;联调七项各自 过/未验证(**没跑就说没跑**) - 待平台侧操作:凭证签发 / auto_publish / registries 白名单 / 审核 / 授权安装 -- 开放问题:用户未拍板的事项 +- 开放问题:用户未拍板的事项(如"平台尚未推 theme 事件,皮肤跟随依赖 ts-skin,需平台侧补") ## 硬约束 @@ -136,7 +145,11 @@ aigrego-release publish --manifest app.manifest.json [--image ] [--store] [ - ⑨ **改治理字段要有审核预期**:提交返回 202 = 进了平台管理员审核队列,批准前旧 manifest 继续生效。为什么:scopes/acl 是租户安全边界,平台不允许应用单方面扩权。 - ⑩ **容器规格红线**:商店安装只收 `{image, port, env}`;禁挂 docker.sock/宿主路径;资源限额默认 512Mi/1核/256pids 只可收窄;保留 env(`APP_KEY/APP_SECRET/PORTAL_API_URL/PORT`)禁覆盖。为什么:docker.sock 是宿主 root 等价物,信任不向下传递(平台安全模型 §7)。 -fail-fast 分级:①②③④⑤⑥违反 = **die**(拦死,无绕过口);缺 PRD/CI 模板/README/`.gitignore` 条目 = **warn**(不拦,列清单——不该单方面掐断开发节奏);「首提治理字段直接生效」「latest_version 防回拨」等背景 = **log**(常态路径不挂常驻警告)。 +- ⑪ **颜色只能来自 `var(--sk-*)`**:禁写死 hex/`rgb()`,禁 Tailwind 调色板类(`bg-slate-500` 之类)。为什么:平台侧靠"移除默认调色板"结构性保证,应用仓库没有这层兜底;写死的色值在 dark/jade 皮肤下就是白底黑字、蓝底蓝字,而平台换肤时这块**不会**跟着变——用户看到的是割裂。要新颜色先在 `skins/index.ts` 加令牌,再同步下来。 +- ⑫ **micro 应用必须自带令牌快照 + 皮肤桥,且只读不反写**:`src/skin/tokens.ts`(脚本生成)+ `src/skin/apply.ts`(读 `localStorage['ts-skin']`、听 `storage` 事件)。为什么:micro 跑在同源 iframe 里,**自己的 document 独立,宿主的 `--sk-*` 不跨文档继承**;SDK 的 `payload.theme` 只有 light/dark 二值(jade 被映射成 light),`onTheme` 宿主换肤时也不推事件——`ts-skin` 是唯一能拿到真实皮肤 key 的通道。反写 `ts-skin` 会和宿主抢状态(用户切回原皮肤,应用还在覆盖)。 +- ⑬ **`initSkin()` 必须在 `createRoot()` 之前同步执行**。为什么:令牌是运行时写到 `documentElement` 的,晚一帧就闪一帧裸样式——暗色皮肤下白屏闪一下比不换肤更刺眼,而这类问题在亮色皮肤下根本看不出来。 + +fail-fast 分级:①②③④⑤⑥⑪⑫⑬违反 = **die**(拦死,无绕过口);缺 PRD/CI 模板/README/`.gitignore` 条目、UI 风格细节偏离(字号/圆角/间距/z-index)、令牌快照过期 = **warn**(不拦,列清单——不该单方面掐断开发节奏);「首提治理字段直接生效」「latest_version 防回拨」等背景 = **log**(常态路径不挂常驻警告)。 ## 凭证与配置 @@ -166,6 +179,12 @@ fail-fast 分级:①②③④⑤⑥违反 = **die**(拦死,无绕过口) | 403 `gate=scope/role/acl` | 四道闸各自语义(非 identity 全 403) | 对照 claims.scopes/role/permissions 与路由 spec | | 安装 op failed(30s 未自注册) | 镜像没内嵌注册循环/凭证未注入 | 必须 `FROM tickshield-node-runtime:base` | | 403 `image registry not registered` | 镜像 host 不在 registries 白名单 | 平台侧 console「仓库管理」登记(空表=全拒) | +| 切到暗夜皮肤,应用还是白底黑字 | 颜色写死了 hex / Tailwind 调色板类 | 改 `var(--sk-*)`,跑 check-app.sh H1/H2 | +| 青瓷皮肤下强调色还是蓝色 | 读了 `payload.theme`(二值,jade→light)而不是 `ts-skin` | 改用 `src/skin/apply.ts` 的 `watchSkin` | +| 换肤后必须刷新才生效 | 缺 `storage` 事件监听 | 补 `watchSkin()` 订阅(H3) | +| 打开应用先闪一下白底 | `initSkin()` 在 `createRoot()` 之后 | 挪到首帧前同步调用(H6) | +| 平台加了新皮肤,应用还是老三套 | 令牌快照过期 | 重跑 `sync-skin-tokens.sh`(H5) | +| 应用整体观感与平台"差一口气" | 字号/圆角/间距没走刻度 | 对照 `references/ui-style.md` §3 收敛 | die 消息统一三段式:是什么 + 为什么(后果) + 怎么办。 @@ -178,6 +197,9 @@ die 消息统一三段式:是什么 + 为什么(后果) + 怎么办。 - 不照搬历史文档《App出仓与集成指引》《App开发者指南.html》(@xgent 旧体系,命令端点已失效) - 不把用户的 APP_SECRET 写进任何文件、日志、对话回显;发现仓库里落了明文 secret,提醒轮换 - 不静默截断——被跳过的、没写成的必须出现在汇报里 +- 不手改 `web/src/skin/tokens.ts`(生成物):改了就与平台脱钩。要改配色/加皮肤,先改平台 `apps/web/src/skins/index.ts`,再重跑 `sync-skin-tokens.sh` +- 不把 UI 审核做成审美意见——只判 H1–H12 里可验证的项,说不出「文件:行号 + 违反哪条刻度」的"感觉不好看"不进报告 +- 不反写 `localStorage['ts-skin']`(与 i18n 的 `ts-lang` 同一约定:只读宿主偏好,不回写) ## 边界:这些不归本 skill 管 @@ -185,7 +207,8 @@ die 消息统一三段式:是什么 + 为什么(后果) + 怎么办。 - 通用 Gitea Actions 工作流规范(归 `gitea-actions`;本 skill 只提供平台发布模板) - CI/CD 工作流的发布 job 改造、新建发布工作流(归 `app-publish`;本 skill 第 4 步只管手工发布指引) - git 提交、推送、日报(归 `push-and-report`) -- 平台基座本身(portal/gateway/packages)的开发与修改 +- 平台基座本身(platform/gateway/packages)的开发与修改 +- 平台设计系统本身:`DESIGN.md` / `.impeccable/design.json` / `apps/web/src/skins/index.ts` 的修改与新增皮肤——本 skill 只消费令牌,不定义令牌 - 平台侧 console 操作(建应用签凭证/白名单/审核/授权/安装)——只输出待办清单 ## references(按需读取,不要一次全读) @@ -194,6 +217,8 @@ die 消息统一三段式:是什么 + 为什么(后果) + 怎么办。 |---|---| | `references/contract.md` | 需要契约细节时:manifest 字段全集/注册心跳报文/四道闸 API/tdb 行为/令牌与交换/部署形态 | | `references/publish-deploy.md` | 第 4 步发布时:release-cli 全参数/商店链路/quickstart catalog 物料/验收矩阵 | -| `references/checklist.md` | 审查模式必读:逐项审计清单(die/warn 分级 + 验证方法) | +| `references/checklist.md` | 审查模式必读:逐项审计清单 A–H(die/warn 分级 + 验证方法) | +| `references/ui-style.md` | 写 micro 前端或审 UI 时必读:令牌表/视觉刻度/皮肤桥机制/组件基线/换肤验证步骤 | | `references/troubleshooting.md` | 带症状来时:排障对照全表 | -| `references/templates/` | 新建模式必读:全部可复制模板 | +| `references/templates/` | 新建模式必读:全部可复制模板(`web/src/skin/` 与 `web/app.css` 是 UI 层) | +| `scripts/sync-skin-tokens.sh` | 平台改配色/加皮肤后重生成令牌快照;新建时对齐到平台当前版本 | diff --git a/skills/app-dev/references/checklist.md b/skills/app-dev/references/checklist.md index bb92a4d..848b68d 100644 --- a/skills/app-dev/references/checklist.md +++ b/skills/app-dev/references/checklist.md @@ -85,3 +85,22 @@ | G3 | 注释/日志/文档中文;注释绑定 FR-/TKT-/BUG- 编号写清为什么 | warn | 抽查 | | G4 | 镜像 host 在平台 registries 白名单(部署前找平台管理员确认) | die(部署时) | 平台侧确认 | | G5 | 联调六项(注册/健康/四道闸/租户隔离/SDK 握手/制品交付)演练留档 | die(上线前) | verify 日志 | + +## H. UI 与皮肤一致性(type=micro 时) + +基准见 `references/ui-style.md`。分档口径:**破坏换肤能力的 = die**(写死色值、没有皮肤桥——这类问题在暗夜皮肤下会直接瞎,且修起来便宜);**风格细节偏离 = warn**(字号/圆角/间距/z-index,列清单不拦)。 + +| # | 项 | 档 | 验证方法 | +|---|---|---|---| +| H1 | 业务代码无硬编码色值(`#RRGGBB` / `rgb()` / `hsl()`),白名单仅 `src/skin/tokens.ts` 与 `app.css` 的令牌定义 | die `[S]` | grep 后人工确认白名单 | +| H2 | 无 Tailwind 调色板类(`bg-slate-500` / `text-blue-600` / `border-gray-200` 等) | die `[S]` | grep | +| H3 | 存在皮肤桥:读 `localStorage['ts-skin']` **且** 监听 `storage` 事件;不反写 `ts-skin` | die `[S]` | grep `ts-skin` + `'storage'` | +| H4 | 令牌快照存在(`src/skin/tokens.ts`),含全部皮肤(当前 3 套)与完整令牌键(当前 34 个) | die `[S]` | grep `SKINS` / `--sk-` 计数 | +| H5 | 令牌数与平台一致(漂移检测:`SKIN_TOKEN_COUNT` vs 平台 `skins/index.ts`);快照日期可追溯 | warn `[S]` | 对比计数 + 读文件头 | +| H6 | `initSkin()` 在首帧渲染前同步调用(`createRoot` 之前) | die `[S]` | grep 顺序 | +| H7 | 内联 `style` 不承载颜色(颜色必须走 class + `var(--sk-*)`) | warn `[S]` | grep `style={{` 含 color/background | +| H8 | 字体栈与平台一致(含 `system-ui` / `PingFang SC`),正文基准 13px | warn `[S]` | 读 app.css | +| H9 | 无 z-index 任意值(`999` / `9999`);走既定尺度 | warn `[S]` | grep | +| H10 | 无 `!important`(`prefers-reduced-motion` 兜底除外) | warn `[S]` | grep | +| H11 | 换肤实机验证:三套皮肤各切一次,应用当场跟随(强调色随皮肤变,青瓷下不能还是蓝色),刷新后保持 | die(上线前) | 截图留档 | +| H12 | 无障碍底线:正文/状态文字对比度 ≥ 4.5:1;`prefers-reduced-motion` 有降级 | warn | 抽查三套皮肤 | diff --git a/skills/app-dev/references/templates/web/app.css b/skills/app-dev/references/templates/web/app.css new file mode 100644 index 0000000..defc25a --- /dev/null +++ b/skills/app-dev/references/templates/web/app.css @@ -0,0 +1,261 @@ +/* 应用基础样式:与平台基座同一套视觉刻度(DESIGN.md / references/ui-style.md)。 + * + * 三条铁律: + * 1. 颜色只准 var(--sk-*),令牌由 src/skin/apply.ts 在首帧前写入 documentElement; + * 写死 hex 或 Tailwind 调色板类 = 换肤时应用不跟随(审核 die)。 + * 2. 13px 是正文与控件基准字号;偏离要能说出角色理由。 + * 3. 圆角只取 6 / 8 / 10 / 14 / 16 / 9999 六档,不新增中间档。 + * + * 文件必须外置经 引入(PROD CSP 下内联 style/script 不可靠),构建时拷进 dist。 + */ + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; + height: 100%; +} + +body { + background: var(--sk-bg); + color: var(--sk-text); + font-family: system-ui, -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif; + font-size: 13px; + line-height: 1.55; + -webkit-font-smoothing: antialiased; +} + +#root { + min-height: 100%; +} + +/* ---------- 布局 ---------- */ + +.app-shell { + display: flex; + flex-direction: column; + min-height: 100%; +} + +.app-header { + display: flex; + align-items: center; + gap: 10px; + height: 48px; + padding: 0 16px; + background: var(--sk-surface); + border-bottom: 1px solid var(--sk-border); +} + +.app-content { + flex: 1; + min-height: 0; + padding: 16px; +} + +.app-title { + font-size: 15px; + font-weight: 600; + color: var(--sk-text-strong); + margin: 0; +} + +.app-subtitle { + font-size: 13px; + color: var(--sk-text-muted); + margin: 4px 0 0; +} + +/* ---------- 导航 ---------- */ + +.app-nav { + display: flex; + gap: 6px; + align-items: center; +} + +.app-nav-item { + height: 28px; + padding: 0 10px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--sk-text); + font-size: 13px; + cursor: pointer; + transition: background-color 150ms, color 150ms; +} + +.app-nav-item:hover { + background: var(--sk-hover); +} + +/* 选中态 = 品牌色淡底 + 品牌色文字(tint),不做实心填充 */ +.app-nav-item[aria-current='page'] { + background: var(--sk-active-bg); + color: var(--sk-active-text); +} + +/* ---------- 按钮 ---------- */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + height: 32px; + padding: 0 12px; + border-radius: 6px; + border: 1px solid transparent; + font-family: inherit; + font-size: 13px; + font-weight: 400; + cursor: pointer; + transition: background-color 150ms, border-color 150ms, color 150ms; +} + +.btn-primary { + background: var(--sk-accent); + color: var(--sk-on-accent); +} + +.btn-primary:hover { + background: var(--sk-accent-hover); +} + +.btn-secondary { + background: var(--sk-surface); + border-color: var(--sk-border-strong); + color: var(--sk-text); +} + +.btn-secondary:hover { + background: var(--sk-hover); +} + +.btn-ghost { + background: transparent; + color: var(--sk-text); +} + +.btn-ghost:hover { + background: var(--sk-hover); +} + +.btn-danger { + background: var(--sk-danger); + color: var(--sk-on-danger); +} + +.btn:disabled { + opacity: 0.4; + pointer-events: none; +} + +.btn:focus-visible, +.app-nav-item:focus-visible { + outline: 2px solid var(--sk-accent); + outline-offset: 1px; +} + +/* ---------- 卡片与表格 ---------- */ + +.card { + background: var(--sk-surface); + border: 1px solid var(--sk-border); + border-radius: 10px; + padding: 16px; +} + +.table { + width: 100%; + border-collapse: collapse; + background: var(--sk-surface); + border: 1px solid var(--sk-border); + border-radius: 8px; + overflow: hidden; +} + +.table th { + text-align: left; + padding: 8px 12px; + background: var(--sk-hover); + color: var(--sk-text-muted); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.table td { + padding: 10px 12px; + border-top: 1px solid var(--sk-border); + color: var(--sk-text); +} + +/* 实体 ID / 密钥 / 代码一律等宽 */ +.mono { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11.5px; +} + +/* ---------- 徽章 ---------- */ + +.badge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 9999px; + font-size: 12px; + font-weight: 500; +} + +.badge-neutral { + background: var(--sk-hover); + color: var(--sk-text); +} + +.badge-success { + background: var(--sk-success-muted); + color: var(--sk-success); +} + +.badge-warning { + background: var(--sk-warning-muted); + color: var(--sk-warning); +} + +.badge-danger { + background: var(--sk-danger-muted); + color: var(--sk-danger); +} + +/* ---------- 空/错态 ---------- */ + +.empty { + display: grid; + place-items: center; + gap: 6px; + padding: 32px; + text-align: center; + color: var(--sk-text-muted); +} + +.empty-title { + font-size: 15px; + font-weight: 600; + color: var(--sk-text-strong); +} + +/* ---------- 无障碍 ---------- */ + +@media (prefers-reduced-motion: reduce) { + * { + animation-duration: 0.01ms !important; + transition-duration: 0.01ms !important; + } +} diff --git a/skills/app-dev/references/templates/web/index.html b/skills/app-dev/references/templates/web/index.html index d35f157..056d27b 100644 --- a/skills/app-dev/references/templates/web/index.html +++ b/skills/app-dev/references/templates/web/index.html @@ -3,12 +3,14 @@ 2026-10 拍平——严禁残留旧 /_/ 子路径,否则上传后资产全 404)。 PROD CSP 严格 default-src 'self'——禁止内联脚本,JS 必须外置文件。 portal-sdk 用 vendor 版全局注入(不打进 bundle,桥协议版本与宿主同源)。 + 样式走外置 app.css(不靠内联 style 属性),颜色全由 --sk-* 令牌驱动。 占位:全局替换 my-app → 你的 listingKey。 --> 我的应用 +
diff --git a/skills/app-dev/references/templates/web/package.json b/skills/app-dev/references/templates/web/package.json index d1edc54..3a1a57a 100644 --- a/skills/app-dev/references/templates/web/package.json +++ b/skills/app-dev/references/templates/web/package.json @@ -3,9 +3,9 @@ "version": "0.1.0", "private": true, "type": "module", - "description": "my-app 微应用前端:bun 单文件构建,portal-sdk 经 vendor 全局注入(不打包 SDK)。", + "description": "my-app 微应用前端:bun 单文件构建,portal-sdk 经 vendor 全局注入(不打包 SDK);样式外置 app.css 拷进 dist。", "scripts": { - "build": "bun build src/main.tsx --outfile dist/app.bundle.js", + "build": "bun build src/main.tsx --outfile dist/app.bundle.js && cp app.css dist/app.css", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/skills/app-dev/references/templates/web/src/main.tsx b/skills/app-dev/references/templates/web/src/main.tsx index 75ab4a5..6a4c0d3 100644 --- a/skills/app-dev/references/templates/web/src/main.tsx +++ b/skills/app-dev/references/templates/web/src/main.tsx @@ -1,11 +1,19 @@ -// micro 应用前端入口:ready() 握手 → acl 驱动路由守卫 → callService 调自有后端。 +// micro 应用前端入口:initSkin() 先落令牌 → ready() 握手 → acl 驱动路由守卫 → callService 调后端。 +// +// 皮肤:initSkin() 必须在 createRoot 之前同步调用(首帧前写入 --sk-*,否则闪一帧裸样式); +// watchSkin() 订阅宿主换肤,平台切 light/dark/jade 应用实时跟随。详见 src/skin/apply.ts。 // i18n 约定:只读宿主 localStorage['ts-lang'](zh-CN/en-US)+ 监听 storage 事件实时切换, // 不反写;正式应用照此建 i18n 字典(zh-CN 为唯一事实来源),本骨架从简。 +// 样式:class 全部来自 app.css(令牌驱动),不写内联色值。 // 占位:全局替换 my-app → 你的 listingKey。 import React, { useEffect, useState } from 'react'; import { createRoot } from 'react-dom/client'; import { sdk, initialRoute, canPage, type InitPayloadLike } from './sdk'; import { callApi } from './api'; +import { initSkin, watchSkin } from './skin/apply'; + +// 首帧前落令牌——这一行必须在任何渲染之前。 +initSkin(); function HomePage() { const [items, setItems] = useState<{ id: string; title: string }[]>([]); @@ -16,24 +24,52 @@ function HomePage() { .catch((e) => setError(String(e?.message ?? e))); }, []); return ( -
-

首页

- {error &&

加载失败:{error}

} -
    - {items.map((it) => ( -
  • {it.title}
  • - ))} -
-
+
+

首页

+ {error && ( +
+ 加载失败 + {error} +
+ )} + {!error && items.length === 0 && ( +
+ 还没有数据 + 新建一条记录后会出现在这里。 +
+ )} + {items.length > 0 && ( + + + + + + + + + {items.map((it) => ( + + + + + ))} + +
ID标题
{it.id}{it.title}
+ )} +
); } function SettingsPage() { return ( -
-

设置

-

仅管理员可见(前端 acl 是 UX 闸,后端四道闸才是真闸)。

-
+
+

设置

+

仅管理员可见(前端 acl 是 UX 闸,后端四道闸才是真闸)。

+
+ 示例卡片 +

卡片静置无阴影;层级靠底色与 1px 分隔线表达。

+
+
); } @@ -41,13 +77,18 @@ function App() { const [init, setInit] = useState(null); const [route, setRoute] = useState(initialRoute()); const [failed, setFailed] = useState(''); + const [skin, setSkinKey] = useState(document.documentElement.dataset.skin ?? 'light'); + + // 跟随宿主换肤:平台切换皮肤 → storage 事件 → 重写令牌。 + useEffect(() => watchSkin((s) => setSkinKey(s.key)), []); useEffect(() => { if (!sdk) { setFailed('请在门户内打开本应用(宿主 iframe 桥不可用)。'); return; } - sdk.ready() + sdk + .ready() .then((payload) => setInit(payload)) .catch((e) => setFailed(String(e?.message ?? e))); }, []); @@ -57,16 +98,54 @@ function App() { sdk?.routeSync(path); // 深链上报:刷新后宿主经 ?r= 恢复 } - if (failed) return
{failed}
; - if (!init) return
加载中…
; + if (failed) { + return ( +
+ 无法启动 + {failed} +
+ ); + } + if (!init) { + return ( +
+ 加载中… +
+ ); + } return ( -
- - {route === '/settings' && canPage(init, 'settings') ? : } +
+
+

我的应用

+ + {/* 皮肤指示仅供调试,正式应用可删 */} + + skin: {skin} + +
+
+ {route === '/settings' && canPage(init, 'settings') ? : } +
); } diff --git a/skills/app-dev/references/templates/web/src/skin/apply.ts b/skills/app-dev/references/templates/web/src/skin/apply.ts new file mode 100644 index 0000000..56b2c70 --- /dev/null +++ b/skills/app-dev/references/templates/web/src/skin/apply.ts @@ -0,0 +1,60 @@ +// 皮肤桥:让 micro 应用跟随平台基座换肤(light / dark / jade 三套,实时切换)。 +// +// 机制(与 i18n 的 ts-lang 同一套约定,见 references/checklist.md E7): +// 1. 平台把皮肤 key 存在 localStorage['ts-skin'],与 iframe 同源共享; +// 2. 平台换肤 = persistSkin() 写 localStorage + 重写宿主 documentElement 的 --sk-*; +// 3. iframe 文档独立,宿主的 CSS 变量不跨文档继承 —— 所以应用必须自己再写一遍令牌; +// 4. 跨文档通知靠 storage 事件(同源 iframe 会收到),无需轮询。 +// +// 为什么不用 SDK 的 onTheme:宿主 init 载荷里的 theme 只有 light/dark 二值 +// (jade 会被当成 light),且宿主目前只在握手时下发一次、换肤时不推事件。 +// 拿二值换肤 = 青瓷皮肤下应用变回蓝色强调色。ts-skin 是唯一能拿到真实皮肤 key 的通道。 +// +// 约定:只读 ts-skin,绝不反写(反写会和宿主抢状态);localStorage 不可用时退回默认皮肤。 +import { SKINS, SKIN_STORAGE_KEY, DEFAULT_SKIN_KEY, type Skin } from './tokens'; + +/** 读宿主写入的皮肤 key(不写回)。 */ +function readStoredSkinKey(): string | null { + try { + return localStorage.getItem(SKIN_STORAGE_KEY); + } catch { + return null; // 隐私模式/被禁时退回默认皮肤 + } +} + +export function resolveSkin(key?: string | null): Skin { + return SKINS.find((s) => s.key === key) ?? SKINS.find((s) => s.key === DEFAULT_SKIN_KEY) ?? SKINS[0]; +} + +/** 把一套皮肤写到本应用 documentElement:--sk-* 变量 + data-skin 属性。 */ +export function applySkin(key?: string | null): Skin { + const skin = resolveSkin(key); + const el = document.documentElement; + el.dataset.skin = skin.key; + for (const [name, value] of Object.entries(skin.tokens)) { + el.style.setProperty(name, value); + } + return skin; +} + +/** + * 启动时同步执行——必须在首帧渲染前调用(main.tsx 里放在 createRoot 之前), + * 否则会先闪一帧无令牌的裸样式。 + */ +export function initSkin(): Skin { + return applySkin(readStoredSkinKey()); +} + +/** + * 订阅换肤:宿主切换皮肤 → storage 事件 → 回调。 + * 返回取消订阅函数(应用卸载时调用)。 + */ +export function watchSkin(cb: (skin: Skin) => void): () => void { + const onStorage = (e: StorageEvent) => { + // e.key === null 表示 storage 被整体清空(登录/登出清理),同样按默认皮肤重算。 + if (e.key !== null && e.key !== SKIN_STORAGE_KEY) return; + cb(applySkin(readStoredSkinKey())); + }; + window.addEventListener('storage', onStorage); + return () => window.removeEventListener('storage', onStorage); +} diff --git a/skills/app-dev/references/templates/web/src/skin/tokens.ts b/skills/app-dev/references/templates/web/src/skin/tokens.ts new file mode 100644 index 0000000..37910f5 --- /dev/null +++ b/skills/app-dev/references/templates/web/src/skin/tokens.ts @@ -0,0 +1,145 @@ +// 自动生成,请勿手改——改这个文件等于和平台脱钩。 +// 来源:平台仓 apps/web/src/skins/index.ts(唯一令牌源) +// 同步:bash scripts/sync-skin-tokens.sh <平台仓根> +// 快照日期:2026-10-22 皮肤:light(简约白) / dark(暗夜) / jade(青瓷) 令牌数:34 +// +// 为什么 app 要自带一份:micro 应用跑在同源 iframe 里,自己的 document 独立, +// 宿主的 --sk-* 不会跨文档继承;皮肤 key 走 localStorage['ts-skin'] 共享,令牌值只能本地落一份。 + +export interface Skin { + key: string; + label: string; + description: string; + tokens: Record; +} + +export const SKINS: Skin[] = [ + { + key: 'light', + label: '简约白', + description: '默认亮色,蓝调强调色', + tokens: { + '--sk-bg': '#f6f7f9', + '--sk-surface': '#ffffff', + '--sk-border': '#e5e7eb', + '--sk-border-strong': '#cbd5e1', + '--sk-overlay': 'rgba(15,23,42,0.4)', + '--sk-overlay-drawer': 'rgba(15,23,42,0.35)', + '--sk-overlay-modal': 'rgba(15,23,42,0.5)', + '--sk-shadow-1': '0 1px 2px rgba(15,23,42,0.04), 0 1px 1px rgba(15,23,42,0.06)', + '--sk-shadow-2': '0 4px 12px rgba(15,23,42,0.06), 0 2px 4px rgba(15,23,42,0.04)', + '--sk-shadow-3': '0 12px 28px rgba(15,23,42,0.1), 0 4px 8px rgba(15,23,42,0.06)', + '--sk-shadow-4': '0 24px 56px rgba(15,23,42,0.18), 0 8px 16px rgba(15,23,42,0.1)', + '--sk-ease': 'cubic-bezier(0.2,0.8,0.2,1)', + '--sk-text': '#374151', + '--sk-text-strong': '#0f172a', + '--sk-text-muted': '#5b6b7f', + '--sk-hover': '#f1f5f9', + '--sk-accent': '#2563eb', + '--sk-accent-hover': '#1d4ed8', + '--sk-accent-muted': '#eff6ff', + '--sk-active-bg': 'var(--sk-accent-muted)', + '--sk-active-text': '#1d4ed8', + '--sk-on-accent': '#ffffff', + '--sk-success': '#15803d', + '--sk-success-muted': '#f0fdf4', + '--sk-warning': '#b45309', + '--sk-warning-muted': '#fffbeb', + '--sk-info': '#0369a1', + '--sk-info-muted': '#f0f9ff', + '--sk-danger': '#dc2626', + '--sk-danger-hover': '#b91c1c', + '--sk-on-danger': '#ffffff', + '--sk-danger-muted': '#fef2f2', + '--sk-purple': '#7e22ce', + '--sk-purple-muted': '#faf5ff', + }, + }, + { + key: 'dark', + label: '暗夜', + description: '低亮度深色壳层', + tokens: { + '--sk-bg': '#0f141b', + '--sk-surface': '#171e29', + '--sk-border': '#28313f', + '--sk-border-strong': '#3b4756', + '--sk-overlay': 'rgba(2,6,12,0.6)', + '--sk-overlay-drawer': 'rgba(2,6,12,0.5)', + '--sk-overlay-modal': 'rgba(2,6,12,0.65)', + '--sk-shadow-1': '0 1px 2px rgba(0,0,0,0.3), 0 1px 1px rgba(0,0,0,0.4)', + '--sk-shadow-2': '0 4px 12px rgba(0,0,0,0.35), 0 2px 4px rgba(0,0,0,0.3)', + '--sk-shadow-3': '0 12px 28px rgba(0,0,0,0.45), 0 4px 8px rgba(0,0,0,0.35)', + '--sk-shadow-4': '0 24px 56px rgba(0,0,0,0.55), 0 8px 16px rgba(0,0,0,0.4)', + '--sk-ease': 'cubic-bezier(0.2,0.8,0.2,1)', + '--sk-text': '#c3cdd9', + '--sk-text-strong': '#f1f5f9', + '--sk-text-muted': '#8a99ab', + '--sk-hover': '#1f2937', + '--sk-accent': '#60a5fa', + '--sk-accent-hover': '#3b82f6', + '--sk-accent-muted': 'rgba(96,165,250,0.16)', + '--sk-active-bg': 'var(--sk-accent-muted)', + '--sk-active-text': '#93c5fd', + '--sk-on-accent': '#0b1220', + '--sk-success': '#4ade80', + '--sk-success-muted': 'rgba(74,222,128,0.12)', + '--sk-warning': '#fbbf24', + '--sk-warning-muted': 'rgba(251,191,36,0.12)', + '--sk-info': '#38bdf8', + '--sk-info-muted': 'rgba(56,189,248,0.14)', + '--sk-danger': '#f87171', + '--sk-danger-hover': '#ef4444', + '--sk-on-danger': '#0b1220', + '--sk-danger-muted': 'rgba(248,113,113,0.12)', + '--sk-purple': '#c084fc', + '--sk-purple-muted': 'rgba(192,132,252,0.14)', + }, + }, + { + key: 'jade', + label: '青瓷', + description: '浅青灰底 + 松绿强调色', + tokens: { + '--sk-bg': '#f2f6f5', + '--sk-surface': '#ffffff', + '--sk-border': '#dde7e5', + '--sk-border-strong': '#c6d8d4', + '--sk-overlay': 'rgba(15,23,42,0.4)', + '--sk-overlay-drawer': 'rgba(15,23,42,0.35)', + '--sk-overlay-modal': 'rgba(15,23,42,0.5)', + '--sk-shadow-1': '0 1px 2px rgba(15,23,42,0.04), 0 1px 1px rgba(15,23,42,0.06)', + '--sk-shadow-2': '0 4px 12px rgba(15,23,42,0.06), 0 2px 4px rgba(15,23,42,0.04)', + '--sk-shadow-3': '0 12px 28px rgba(15,23,42,0.1), 0 4px 8px rgba(15,23,42,0.06)', + '--sk-shadow-4': '0 24px 56px rgba(15,23,42,0.18), 0 8px 16px rgba(15,23,42,0.1)', + '--sk-ease': 'cubic-bezier(0.2,0.8,0.2,1)', + '--sk-text': '#334743', + '--sk-text-strong': '#0c1f1c', + '--sk-text-muted': '#52706a', + '--sk-hover': '#ecf4f2', + '--sk-accent': '#0d9488', + '--sk-accent-hover': '#0f766e', + '--sk-accent-muted': '#d9efec', + '--sk-active-bg': 'var(--sk-accent-muted)', + '--sk-active-text': '#0b6a60', + '--sk-on-accent': '#ffffff', + '--sk-success': '#15803d', + '--sk-success-muted': '#f0fdf4', + '--sk-warning': '#b45309', + '--sk-warning-muted': '#fffbeb', + '--sk-info': '#0369a1', + '--sk-info-muted': '#f0f9ff', + '--sk-danger': '#dc2626', + '--sk-danger-hover': '#b91c1c', + '--sk-on-danger': '#ffffff', + '--sk-danger-muted': '#fef2f2', + '--sk-purple': '#7e22ce', + '--sk-purple-muted': '#faf5ff', + }, + }, +]; + +export const SKIN_STORAGE_KEY = 'ts-skin'; +export const DEFAULT_SKIN_KEY = 'light'; +export const SKIN_TOKEN_COUNT = 34; +export const SKIN_SNAPSHOT_DATE = '2026-10-22'; diff --git a/skills/app-dev/references/ui-style.md b/skills/app-dev/references/ui-style.md new file mode 100644 index 0000000..7427996 --- /dev/null +++ b/skills/app-dev/references/ui-style.md @@ -0,0 +1,142 @@ +# UI 风格与皮肤一致性(新建必读 / 审查必查) + +本文件是 app-dev 的 UI 基准,源自平台仓 `DESIGN.md` 与 `.impeccable/design.json`(schemaVersion 2), +以及唯一令牌源 `apps/web/src/skins/index.ts`。**平台改设计时,本文件与 `templates/web/src/skin/tokens.ts` 一起重生成**,不要手改令牌值。 + +什么时候读:新建 micro 应用要写前端时(第 1 步生成骨架);审查模式审 UI 时(checklist H 节)。 + +## 一、三条铁律 + +**① 颜色只能来自 `var(--sk-*)`。** 写死 hex、`rgb()`、或 Tailwind 调色板类(`bg-slate-500`)→ 换肤时这一块不跟随,应用在 dark 皮肤下会白底黑字。平台侧靠结构性手段保证(Tailwind 默认调色板整体移除,写了不生成样式);应用仓库没有这层兜底,靠 `check-app.sh` H 节静态拦。 + +**② 应用必须自带令牌副本 + 主动跟随。** micro 应用跑在同源 iframe 里,**自己的 document 独立,宿主的 `--sk-*` 不会跨文档继承**。所以:令牌值本地落一份(`src/skin/tokens.ts`,脚本生成),换肤通知靠 `localStorage['ts-skin']` + `storage` 事件。 + +**③ 13px 是正文与控件的基准字号。** 偏离(更小或更大)要能说出角色理由。不用流体字号。 + +## 二、令牌表(light 皮肤值;dark / jade 见 `src/skin/tokens.ts`) + +| 语义 | 令牌 | light 值 | 用在哪 | +|---|---|---|---| +| 内容底色 | `--sk-bg` | `#f6f7f9` | 页面/内容区背景 | +| 表面 | `--sk-surface` | `#ffffff` | header、卡片、表格、弹出层 | +| 容器线 | `--sk-border` | `#e5e7eb` | 卡片边框、分隔线、表格行线 | +| 控件线 | `--sk-border-strong` | `#cbd5e1` | 输入框、secondary 按钮边框 | +| 悬停洗 | `--sk-hover` | `#f1f5f9` | 行/菜单项/ghost 按钮 hover、表头底色 | +| 正文 | `--sk-text` | `#374151` | 正文、控件文字 | +| 强调文字 | `--sk-text-strong` | `#0f172a` | 标题、关键数值 | +| 辅助文字 | `--sk-text-muted` | `#5b6b7f` | 说明、时间戳、占位 | +| 品牌色 | `--sk-accent` | `#2563eb` | 主按钮底、focus 边 | +| 品牌 hover | `--sk-accent-hover` | `#1d4ed8` | 主按钮 hover | +| 品牌淡底 | `--sk-accent-muted` | `#eff6ff` | 选中态底色、tint 背景 | +| 选中底/字 | `--sk-active-bg` / `--sk-active-text` | 引用 `accent-muted` / `#1d4ed8` | 导航选中态 | +| 品牌上的字 | `--sk-on-accent` | `#ffffff` | 实心主按钮文字 | +| 危险 | `--sk-danger` / `--sk-danger-hover` / `--sk-danger-muted` / `--sk-on-danger` | `#dc2626` / `#b91c1c` / `#fef2f2` / `#ffffff` | 删除、错误态 | +| 语义 | `--sk-success` `--sk-warning` `--sk-info` `--sk-purple` 各带 `-muted` | `#15803d` `#b45309` `#0369a1` `#7e22ce` + 浅底 | 徽章、提示条、空态图标砖 | +| 阴影 | `--sk-shadow-1..4` | 见 tokens.ts | 1 静置卡 / 2 悬停 / 3 弹出 / 4 模态 | +| 遮罩 | `--sk-overlay-drawer` / `--sk-overlay-modal` | `rgba(15,23,42,0.35/0.5)` | 抽屉 / 模态 | +| 缓动 | `--sk-ease` | `cubic-bezier(0.2,0.8,0.2,1)` | 所有过渡 | + +**单一信号规则**:强调色只用于主操作、选中态、状态指示,任意屏面占比 ≤10%。它的稀有就是它的信号价值。 + +## 三、刻度(与平台同一套,不新增档位) + +| 维度 | 取值 | +|---|---| +| 字号 | Display 20 / Headline 18 / Title 15 / **Body 13(基准)** / Label 11(半粗大写 0.05em) / Meta 11.5–12.5 / Mono 11.5 | +| 字重 | 常规 400;标题/表头/label 600 | +| 圆角 | 控件 6 / 表格与菜单容器 8 / 弹出层 10 / 卡片 14 / 图标砖 16 / 徽章头像 9999 | +| 控件高 | 按钮 xs24 / sm28 / **md32(默认)** / lg40;输入框 36 | +| 间距 | 主力 8 / 10 / 12;紧凑 6;卡片网格才用 16 | +| 行高 | 列表行 42(紧凑 34);长文 `leading-relaxed` | +| z-index | 1 sticky 分组头 / 40 sticky header / 50 壳层弹层 / 800·810 抽屉 / 2000·2001 模态 / 2100 Popover | +| 动效 | 只动颜色/阴影/小位移,不动布局属性;hover 100–150ms;卡片上浮 阴影 + translateY(-1px) 140ms | +| 阴影 | 静置扁平;hover/弹出/模态才上 `--sk-shadow-2/3/4` | + +**扁平静置规则**:卡片静置不带可见阴影,层级靠底色阶(bg < surface)与 1px 分隔线表达。给一排静置卡片加阴影 = 谁也没做的决定。 + +## 四、皮肤桥机制(怎么做到"平台切皮肤,应用跟着切") + +### 4.1 链路 + +``` +平台用户菜单切皮肤 + → persistSkin(key) 写 localStorage['ts-skin'] + 重写宿主 documentElement 的 --sk-* + → iframe 文档收到 storage 事件(同源跨文档天然派发) + → 应用 watchSkin() 回调 → applySkin() 重写自己的 --sk-* + → CSS 里所有 var(--sk-*) 立即生效 +``` + +### 4.2 实现(照 `templates/web/src/skin/` 抄) + +```ts +// main.tsx —— 首帧前同步落令牌,这一行必须在 createRoot 之前 +initSkin(); +// 组件内订阅换肤 +useEffect(() => watchSkin((s) => setSkinKey(s.key)), []); +``` + +- `initSkin()` 同步执行:晚一帧就闪一帧裸样式(白底黑字)。 +- `watchSkin()` 内部只听 `storage`,`e.key === null`(storage 被清空)也按默认皮肤重算。 +- **只读 `ts-skin`,绝不反写**——反写会和宿主抢状态,且用户切回去时应用还在覆盖。 + +### 4.3 为什么不用 SDK 的 `theme` + +| 通道 | 能拿到什么 | 结论 | +|---|---|---| +| `sdk.ready()` 的 `payload.theme` | 只有 `"light" \| "dark"`,jade 被映射成 light | 二值,青瓷皮肤下应用变回蓝色 | +| `sdk.onTheme(cb)` | 协议有,**宿主目前只在握手时下发、换肤时不推事件** | 现在收不到,别依赖 | +| `localStorage['ts-skin']` + `storage` | 真实皮肤 key(light/dark/jade)+ 实时切换 | **唯一可用通道** | + +`templates/web/src/sdk.ts` 开头那句「主题自管——不读握手 payload 里的 theme 硬编码」就是这条结论。 +若将来平台补上 `theme` 事件推送,`onTheme` 可作为兜底加进来,但 `ts-skin` 仍是取全量皮肤 key 的唯一来源。 + +### 4.4 令牌从哪来 + +```bash +bash scripts/sync-skin-tokens.sh <平台仓根> --out web/src/skin/tokens.ts +``` + +脚本解析平台 `apps/web/src/skins/index.ts` 生成快照,文件头写明快照日期与皮肤清单。**平台新增皮肤或改色后必须重跑**,否则应用停在旧配色(checklist H5 查令牌数是否与平台一致)。 + +## 五、组件基线 + +| 组件 | 规格 | +|---|---| +| 主按钮 | `--sk-accent` 实心 + `--sk-on-accent` 字,32 高 / 6 圆角 / 13px;hover 换 `--sk-accent-hover` | +| 次按钮 | `--sk-surface` 底 + `--sk-border-strong` 边 + 正文色;hover `--sk-hover` | +| Ghost | 透明底,hover `--sk-hover` | +| 危险按钮 | `--sk-danger` 实心 + `--sk-on-danger` 字 | +| 禁用 | `opacity:.4` + `pointer-events:none`,**不用 not-allowed 光标** | +| 输入框 | 36 高 / 白面 / `--sk-border-strong` 边 / 6 圆角;focus 品牌色边 + 1px ring | +| 表格 | 容器 8 圆角 + `--sk-border`;表头 `--sk-hover` 底 + 11px 大写 muted;行线 `--sk-border` | +| 徽章 | 9999 圆角 + 同色系淡底(`-muted`)+ 该语义色文字,12px | +| 导航选中 | **tint(`--sk-active-bg` + `--sk-active-text`),绝不实心填充** | +| 空态 | 居中,标题 15 半粗 + 说明 13 muted;不用重组件,纯文字即可 | +| 实体 ID | 等宽字体(`.mono`),并让它参与搜索匹配 | + +## 六、Do / Don't + +**Do** +- hover 一律 `--sk-hover`;选中态用 tint 或白面抬起 +- 卡片静置扁平,hover 才上阴影 + `translateY(-1px)` +- 表单 label 用 11px 半粗大写宽字距 muted +- 危险操作常态只显示 muted,hover 才上危险色 + +**Don't** +- 写死 hex / `rgb()` / Tailwind 调色板类(审核 die) +- 给静置卡片加阴影 +- 实心填充做选中态;渐变文字、玻璃拟态、侧条纹边框 +- 新增 z-index 任意值(999/9999)与新的圆角中间档 +- 内联 `style` 写颜色(外置 `app.css` 里用 `var()`) + +## 七、怎么验(真环境,不是看代码) + +静态检查只证明"没写死色值",不证明"换肤真的跟随"。验收要眼见为实: + +1. 门户里打开应用 → 用户菜单切 **暗夜** → 应用背景/文字/主按钮**当场**变暗,无需刷新。 +2. 切 **青瓷** → 强调色变松绿(若应用强调色仍是蓝色,说明读了二值 theme 而不是 `ts-skin`)。 +3. 切回 **简约白** → 全部复原。 +4. 刷新页面(iframe 重建)→ 皮肤**保持**当前选择,不闪白。 +5. 三套皮肤下各截一张图,贴进交付报告——"没截图"等于"没验证"。 + +做不到就如实写「皮肤跟随未验证」,不许拿静态检查绿冒充通过。 diff --git a/skills/app-dev/scripts/check-app.sh b/skills/app-dev/scripts/check-app.sh index 215aec6..2bea8e0 100755 --- a/skills/app-dev/scripts/check-app.sh +++ b/skills/app-dev/scripts/check-app.sh @@ -167,7 +167,7 @@ if [ ! -f "$ENTRY" ]; then die "缺业务入口 $ENTRY" else grep -q 'createGuard({' "$ENTRY" || die "$ENTRY 未 createGuard({portalUrl, appKey, appSecret})" - grep -q 'guard.routes(\|guard.protect(' "$ENTRY" || die "$ENTRY 路由未经 guard.routes/protect 挂闸口" + grep -qE 'guard\.routes\(|guard\.protect\(' "$ENTRY" || die "$ENTRY 路由未经 guard.routes/protect 挂闸口" grep -qE "['\"](GET|POST|PUT|PATCH|DELETE) /(api|auth|svc)(/|['\"])" "$ENTRY" \ && die "路由用了平台保留前缀 /api|/auth|/svc(换 /v1/ 之类)" if grep -q 'pg' "$ENTRY"; then @@ -223,6 +223,91 @@ fi compgen -G ".gitea/workflows/*.yml" >/dev/null 2>&1 || compgen -G ".gitea/workflows/*.yaml" >/dev/null 2>&1 \ || warn "缺 .gitea/workflows 发布流水线(模板见 references/templates/gitea-app-release.yaml)" +# ---------- H. UI 与皮肤一致性(micro 前端,基准见 references/ui-style.md) ---------- +# 分档口径:破坏换肤能力的(写死色值、没有皮肤桥)= die;风格细节偏离 = warn。 +if [ "$IS_MICRO" = 1 ] && [ -d web/src ]; then + UI=web/src + + # H1 硬编码色值(白名单:令牌快照文件本身) + HEXHITS=$(grep -rnE '#[0-9a-fA-F]{3,8}([^0-9a-fA-F]|$)|rgba?\(|hsla?\(' "$UI" \ + --include='*.ts' --include='*.tsx' --include='*.js' --include='*.css' 2>/dev/null \ + | grep -v 'skin/tokens.ts' | head -5) + if [ -n "$HEXHITS" ]; then + die "UI 硬编码色值(换肤时这块不跟随,暗夜皮肤下会瞎):$(echo "$HEXHITS" | head -3 | tr '\n' ' | ')" + else + ok "UI 颜色全部走 --sk-* 令牌(无硬编码色值)" + fi + + # H2 Tailwind 调色板类(应用侧无平台的结构性兜底,必须静态拦) + PALETTE=$(grep -rohE '(bg|text|border|ring|fill|stroke|divide|placeholder|from|via|to)-(white|black|slate|gray|red|green|amber|yellow|blue|indigo|emerald|rose|sky|orange|pink|fuchsia|violet|lime|neutral|zinc|stone|cyan|teal)-[0-9]{2,3}' \ + "$UI" --include='*.tsx' --include='*.ts' 2>/dev/null | sort -u | head -5) + [ -n "$PALETTE" ] && die "用了 Tailwind 调色板类(应改语义令牌):$(echo "$PALETTE" | tr '\n' ' ')" + + # H3 皮肤桥:读 ts-skin + 监听 storage + 不反写 + grep -rqE "getItem\((\"|')?ts-skin|getItem\(SKIN_STORAGE_KEY" "$UI" 2>/dev/null \ + || die "缺皮肤桥:没有读取 localStorage['ts-skin'](平台换肤应用不会跟随,见 references/ui-style.md §4)" + grep -rqE "addEventListener\((\"|')storage" "$UI" 2>/dev/null \ + || die "缺 storage 事件监听(宿主换肤时应用收不到通知,只能靠刷新)" + grep -rqE "setItem\((\"|')?ts-skin|setItem\(SKIN_STORAGE_KEY" "$UI" 2>/dev/null \ + && die "反写了 ts-skin(会和宿主抢状态:用户切回原皮肤时应用仍覆盖)" + + # H4 令牌快照完整性 + TOK=web/src/skin/tokens.ts + if [ ! -f "$TOK" ]; then + die "缺 $TOK(令牌快照:bash scripts/sync-skin-tokens.sh <平台仓根>)" + else + SKIN_N=$(grep -c "key: '" "$TOK") + TOK_N=$(grep -c "'--sk-" "$TOK") + [ "$SKIN_N" -ge 3 ] || die "令牌快照只有 $SKIN_N 套皮肤(平台当前 3 套:light/dark/jade),重跑 sync-skin-tokens.sh" + [ "$TOK_N" -ge $((SKIN_N * 30)) ] || die "令牌快照只有 $TOK_N 个键(每套应 ≥30),疑似手工裁剪" + # H5 漂移可追溯性 + if grep -q 'SKIN_SNAPSHOT_DATE' "$TOK"; then + ok "令牌快照 ${SKIN_N} 套 / ${TOK_N} 键($(sed -n "s/.*SKIN_SNAPSHOT_DATE = '\([^']*\)'.*/\1/p" "$TOK" | head -1) 快照)" + else + warn "$TOK 缺 SKIN_SNAPSHOT_DATE(手写令牌?漂移无法追溯,重跑 sync-skin-tokens.sh)" + fi + fi + + # H6 首帧前落令牌(晚一帧就闪一帧裸样式) + MAIN=$(ls "$UI"/main.tsx "$UI"/main.ts "$UI"/index.tsx 2>/dev/null | head -1) + if [ -n "$MAIN" ]; then + # 只认「行首语句」形态:否则文件头注释里的 initSkin() 会抢先命中,造成假通过。 + INIT_LINE=$(grep -nE '^[[:space:]]*initSkin\(\)' "$MAIN" | head -1 | cut -d: -f1) + ROOT_LINE=$(grep -nE '^[[:space:]]*createRoot\(' "$MAIN" | head -1 | cut -d: -f1) + if [ -z "$INIT_LINE" ]; then + die "$MAIN 未调用 initSkin()(令牌没落,首屏无样式)" + elif [ -n "$ROOT_LINE" ] && [ "$INIT_LINE" -gt "$ROOT_LINE" ]; then + die "$MAIN 的 initSkin() 在 createRoot() 之后(第 ${INIT_LINE} 行 vs 第 ${ROOT_LINE} 行)——会闪一帧裸样式" + fi + else + warn "找不到前端入口 main.tsx(无法确认 initSkin() 时序)" + fi + + # H7 内联 style 不承载颜色 + INLINE=$(grep -rn 'style={{' "$UI" --include='*.tsx' 2>/dev/null \ + | grep -E 'color|background|border|fill|stroke' | grep -v 'var(--sk-' | head -3) + [ -n "$INLINE" ] && warn "内联 style 写了颜色(应走 class + var(--sk-*)):$(echo "$INLINE" | head -2 | tr '\n' ' | ')" + + # H8 字体栈与正文基准 + if [ -f web/app.css ]; then + grep -q 'PingFang SC' web/app.css && grep -q 'system-ui' web/app.css \ + || warn "web/app.css 字体栈与平台不一致(应含 system-ui / -apple-system / PingFang SC)" + grep -qE 'font-size:[[:space:]]*13px' web/app.css || warn "web/app.css 未见 13px 正文基准(平台十三像素规则)" + else + warn "缺 web/app.css(样式应外置为 CSS 文件,不靠内联 style)" + fi + + # H9/H10 任意 z-index 与 !important(reduced-motion 兜底内的 !important 不算) + ZIDX=$(grep -rnE 'z-index:[[:space:]]*(999|9999)' "$UI" 2>/dev/null | head -3) + [ -n "$ZIDX" ] && warn "z-index 用了任意值 999/9999(走既定尺度 1/40/50/800/810/2000/2001/2100)" + IMP=0 + for f in $(find "$UI" -name '*.css' 2>/dev/null); do + n=$(awk '/@media \(prefers-reduced-motion/{skip=1} !skip{print} skip&&/^}/{skip=0}' "$f" | grep -c '!important') + IMP=$((IMP + n)) + done + [ "$IMP" -gt 0 ] && warn "业务样式用了 $IMP 处 !important(reduced-motion 兜底之外)" +fi + # ---------- 汇总 ---------- echo "---" echo "die=$DIE warn=$WARN" diff --git a/skills/app-dev/scripts/sync-skin-tokens.sh b/skills/app-dev/scripts/sync-skin-tokens.sh new file mode 100755 index 0000000..7343951 --- /dev/null +++ b/skills/app-dev/scripts/sync-skin-tokens.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +# 从平台仓 apps/web/src/skins/index.ts 生成 app 侧皮肤令牌快照(web/src/skin/tokens.ts)。 +# 用法: sync-skin-tokens.sh <平台仓根> [--out <输出文件>] +# 退出码: 0 成功 / 1 平台仓或源文件找不到 / 2 用法错 +# +# 为什么要有这个脚本:app 跑在 iframe 里,自己的 document 独立,CSS 变量不跨文档继承; +# 令牌只能由 app 自己写一遍。手抄 3 套皮肤 × 40 个令牌必然漂移,所以快照一律由脚本生成。 +set -euo pipefail + +usage() { echo "用法: sync-skin-tokens.sh <平台仓根> [--out <文件>]" >&2; exit 2; } + +PLATFORM="${1:-}" +[ -n "$PLATFORM" ] || usage +shift +OUT="web/src/skin/tokens.ts" +while [ $# -gt 0 ]; do + case "$1" in + --out) [ -n "${2:-}" ] || usage; OUT="$2"; shift 2 ;; + *) usage ;; + esac +done + +SKINS="$PLATFORM/apps/web/src/skins/index.ts" +if [ ! -f "$SKINS" ]; then + echo "DIE 找不到 $SKINS" >&2 + echo " 原因: 平台仓根参数不对,或平台把皮肤实现挪了位置。" >&2 + echo " 怎么办: 传平台仓根目录(含 apps/web/src/skins/index.ts 的那层);" >&2 + echo " 若平台确实改了路径,先更新本脚本第 20 行的 SKINS 变量。" >&2 + exit 1 +fi + +command -v node >/dev/null 2>&1 || { echo "DIE 需要 node(app 仓库本身就跑 node,环境里应有)" >&2; exit 1; } + +mkdir -p "$(dirname "$OUT")" + +node - "$SKINS" "$OUT" "$PLATFORM" <<'EOF' +const fs = require('fs'); +const [, , skinsPath, outPath, platformRoot] = process.argv; +const src = fs.readFileSync(skinsPath, 'utf8'); + +/** 逐个抓 `tokens: { ... }` 块(手写花括号扫描,避免正则被 rgba()/cubic-bezier() 的括号干扰)。 + * `export interface Skin` 里的 `tokens: Record<...>` 声明也会命中 "tokens:", + * 所以用「{ 后第一个键必须是 '--sk-」判定真块;伪匹配只前进一个字符, + * 否则会连带吃掉紧跟其后的第一套皮肤。 */ +function tokenBlocks(s) { + const blocks = []; + let from = 0; + for (;;) { + const m = s.indexOf('tokens:', from); + if (m === -1) break; + const start = s.indexOf('{', m); + if (start === -1) break; + if (!s.slice(start + 1).trimStart().startsWith("'--sk-")) { + from = m + 1; + continue; + } + let depth = 0; + let i = start; + let inStr = false; + let quote = ''; + for (; i < s.length; i++) { + const c = s[i]; + if (inStr) { + if (c === quote && s[i - 1] !== '\\') inStr = false; + continue; + } + if (c === "'" || c === '"') { inStr = true; quote = c; continue; } + if (c === '{') depth++; + else if (c === '}') { depth--; if (depth === 0) break; } + } + blocks.push({ headStart: m, bodyStart: start + 1, end: i, body: s.slice(start + 1, i) }); + from = i; + } + return blocks; +} + +const blocks = tokenBlocks(src); +if (!blocks.length) { + console.error('DIE 没从 skins/index.ts 里解析出任何 tokens 块'); + console.error(' 原因: 平台改写了 SKINS 结构(不再是 tokens: { ... } 字面量)。'); + console.error(' 怎么办: 人工核对 skins/index.ts,同步改本脚本的解析逻辑,不要手写令牌。'); + process.exit(1); +} + +const skins = []; +let prevEnd = 0; +for (const b of blocks) { + const header = src.slice(prevEnd, b.headStart); + prevEnd = b.end; + const pick = (k) => { + const m = new RegExp(k + ":\\s*'([^']*)'").exec(header); + return m ? m[1] : ''; + }; + const tokens = {}; + const re = /'(--sk-[a-z0-9-]+)':\s*'([^']*)'/g; + let m; + while ((m = re.exec(b.body))) tokens[m[1]] = m[2]; + const key = pick('key'); + if (!key || !Object.keys(tokens).length) continue; + skins.push({ key, label: pick('label'), description: pick('description'), tokens }); +} + +if (!skins.length) { + console.error('DIE 解析出的皮肤对象为空'); + process.exit(1); +} + +const keyCount = Object.keys(skins[0].tokens).length; +const drift = skins.filter((s) => Object.keys(s.tokens).length !== keyCount); +if (drift.length) { + console.error('WARN 各皮肤令牌键数不一致:' + skins.map((s) => `${s.key}=${Object.keys(s.tokens).length}`).join(' ')); + console.error(' 平台 skins/index.ts 自身可能漏了键(令牌键集合应三处同步)。'); +} + +const today = new Date().toISOString().slice(0, 10); +const lines = []; +lines.push('// 自动生成,请勿手改——改这个文件等于和平台脱钩。'); +lines.push('// 来源:平台仓 apps/web/src/skins/index.ts(唯一令牌源)'); +lines.push('// 同步:bash scripts/sync-skin-tokens.sh <平台仓根>'); +lines.push(`// 快照日期:${today} 皮肤:${skins.map((s) => `${s.key}(${s.label})`).join(' / ')} 令牌数:${keyCount}`); +lines.push('//'); +lines.push('// 为什么 app 要自带一份:micro 应用跑在同源 iframe 里,自己的 document 独立,'); +lines.push('// 宿主的 --sk-* 不会跨文档继承;皮肤 key 走 localStorage[\'ts-skin\'] 共享,令牌值只能本地落一份。'); +lines.push(''); +lines.push('export interface Skin {'); +lines.push(' key: string;'); +lines.push(' label: string;'); +lines.push(' description: string;'); +lines.push(' tokens: Record;'); +lines.push('}'); +lines.push(''); +lines.push('export const SKINS: Skin[] = ['); +for (const s of skins) { + lines.push(' {'); + lines.push(` key: '${s.key}',`); + lines.push(` label: '${s.label}',`); + lines.push(` description: '${s.description}',`); + lines.push(' tokens: {'); + for (const [k, v] of Object.entries(s.tokens)) { + lines.push(` '${k}': '${v}',`); + } + lines.push(' },'); + lines.push(' },'); +} +lines.push('];'); +lines.push(''); +lines.push("export const SKIN_STORAGE_KEY = 'ts-skin';"); +lines.push(`export const DEFAULT_SKIN_KEY = '${skins[0].key}';`); +lines.push(`export const SKIN_TOKEN_COUNT = ${keyCount};`); +lines.push(`export const SKIN_SNAPSHOT_DATE = '${today}';`); +lines.push(''); + +fs.writeFileSync(outPath, lines.join('\n')); +console.log(`ok ${outPath}:${skins.length} 套皮肤 / ${keyCount} 个令牌`); +console.log(` 来源 ${platformRoot}/apps/web/src/skins/index.ts`); +EOF