feat(app-dev): add UI and skin-consistency contract for micro apps — checklist gains section H (H1–H12): hardcoded colors, Tailwind palette classes and missing ts-skin skin bridge are die-level; check-app.sh gains H-section static checks (token snapshot integrity, initSkin before createRoot, inline style colors, font stack, z-index, !important); web template now ships external app.css and src/skin/{tokens.ts,apply.ts} with initSkin() called before first frame; new scripts/sync-skin-tokens.sh regenerates the token snapshot from platform skins/index.ts; new references/ui-style.md documents the token table, visual scale and skin-bridge mechanism; SKILL.md adds constraints on var(--sk-*)-only colors, read-only ts-skin bridging and pre-render initSkin, plus skin-related troubleshooting rows

This commit is contained in:
sunjunzhao committed 2026-10-22 20:07:26 +08:00
1 parent ff503b0b76
commit bb054db0ea
11 files changed
+1020 -46

No files matched your search

+45 -20
View File
@@ -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-<listingKey>.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 <ref>] [--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 <ref>] [--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` | 平台改配色/加皮肤后重生成令牌快照;新建时对齐到平台当前版本 |
+19
View File
@@ -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 | 抽查三套皮肤 |
@@ -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 六档,不新增中间档。
*
* 文件必须外置经 <link> 引入(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;
}
}
@@ -3,12 +3,14 @@
2026-10 拍平——严禁残留旧 /_/ 子路径,否则上传后资产全 404)。
PROD CSP 严格 default-src 'self'——禁止内联脚本,JS 必须外置文件。
portal-sdk 用 vendor 版全局注入(不打进 bundle,桥协议版本与宿主同源)。
样式走外置 app.css(不靠内联 style 属性),颜色全由 --sk-* 令牌驱动。
占位:全局替换 my-app → 你的 listingKey。 -->
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>我的应用</title>
<link rel="stylesheet" href="/apps/my-app/app.css" />
</head>
<body>
<div id="root"></div>
@@ -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": {
@@ -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 (
<main style={{ padding: 16 }}>
<h1>首页</h1>
{error && <p role="alert">加载失败:{error}</p>}
<ul>
{items.map((it) => (
<li key={it.id}>{it.title}</li>
))}
</ul>
</main>
<section>
<h1 className="app-title">首页</h1>
{error && (
<div className="empty" role="alert">
<span className="empty-title">加载失败</span>
<span>{error}</span>
</div>
)}
{!error && items.length === 0 && (
<div className="empty">
<span className="empty-title">还没有数据</span>
<span>新建一条记录后会出现在这里。</span>
</div>
)}
{items.length > 0 && (
<table className="table">
<thead>
<tr>
<th>ID</th>
<th>标题</th>
</tr>
</thead>
<tbody>
{items.map((it) => (
<tr key={it.id}>
<td className="mono">{it.id}</td>
<td>{it.title}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
);
}
function SettingsPage() {
return (
<main style={{ padding: 16 }}>
<h1>设置</h1>
<p>仅管理员可见(前端 acl 是 UX 闸,后端四道闸才是真闸)。</p>
</main>
<section>
<h1 className="app-title">设置</h1>
<p className="app-subtitle">仅管理员可见(前端 acl 是 UX 闸,后端四道闸才是真闸)。</p>
<div className="card" style={{ marginTop: 12 }}>
<span className="badge badge-neutral">示例卡片</span>
<p className="app-subtitle">卡片静置无阴影;层级靠底色与 1px 分隔线表达。</p>
</div>
</section>
);
}
@@ -41,13 +77,18 @@ function App() {
const [init, setInit] = useState<InitPayloadLike | null>(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 <main style={{ padding: 16 }}>{failed}</main>;
if (!init) return <main style={{ padding: 16 }}>加载中…</main>;
if (failed) {
return (
<div className="empty" role="alert">
<span className="empty-title">无法启动</span>
<span>{failed}</span>
</div>
);
}
if (!init) {
return (
<div className="empty">
<span>加载中…</span>
</div>
);
}
return (
<div>
<nav style={{ display: 'flex', gap: 12, padding: 12 }}>
{canPage(init, 'home') && <button onClick={() => go('/')}>首页</button>}
{canPage(init, 'settings') && <button onClick={() => go('/settings')}>设置</button>}
</nav>
{route === '/settings' && canPage(init, 'settings') ? <SettingsPage /> : <HomePage />}
<div className="app-shell">
<header className="app-header">
<h2 className="app-title">我的应用</h2>
<nav className="app-nav">
{canPage(init, 'home') && (
<button
className="app-nav-item"
aria-current={route === '/' ? 'page' : undefined}
onClick={() => go('/')}
>
首页
</button>
)}
{canPage(init, 'settings') && (
<button
className="app-nav-item"
aria-current={route === '/settings' ? 'page' : undefined}
onClick={() => go('/settings')}
>
设置
</button>
)}
</nav>
{/* 皮肤指示仅供调试,正式应用可删 */}
<span className="badge badge-neutral mono" style={{ marginLeft: 'auto' }}>
skin: {skin}
</span>
</header>
<main className="app-content">
{route === '/settings' && canPage(init, 'settings') ? <SettingsPage /> : <HomePage />}
</main>
</div>
);
}
@@ -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);
}
@@ -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<string, string>;
}
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';
+142
View File
@@ -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. 三套皮肤下各截一张图,贴进交付报告——"没截图"等于"没验证"。
做不到就如实写「皮肤跟随未验证」,不许拿静态检查绿冒充通过。
+86 -1
View File
@@ -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"
+156
View File
@@ -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<string, string>;');
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