瀏覽代碼

fix(code-lit): 附带规则仓到 dist + 清理内部脚本引用

问题:fmode-code-lit 的 deployment.md 引用 .claude/rules(仓库根目录),
但打包只复制 skills/ 子目录,导致规则文档缺失,技能说明失效。

修复:
- build-dist.mjs 支持 extraDirs:可附带技能依赖的额外目录
- code-lit 配置 extraDirs: .claude/rules → references/rules
- deployment.md 的 fmode-app/scripts/deploy.sh 内部引用改为通用表述

验收:13 技能 / 规则文档 14 篇全含 / 触发词 13/13 / 品牌 0 违规
liuyuyang 1 天之前
父節點
當前提交
0d2558ff37
共有 30 個文件被更改,包括 3778 次插入 和 5 次删除
  1. 5 1
      dist/skills-index.json
  2. 112 0
      dist/skills/fmode-code-lit/README.md
  3. 215 0
      dist/skills/fmode-code-lit/SKILL.md
  4. 38 0
      dist/skills/fmode-code-lit/bin/fmode-code-lit.js
  5. 48 0
      dist/skills/fmode-code-lit/package.json
  6. 83 0
      dist/skills/fmode-code-lit/references/light-slot.js
  7. 105 0
      dist/skills/fmode-code-lit/references/rules/architecture.md
  8. 153 0
      dist/skills/fmode-code-lit/references/rules/components.md
  9. 102 0
      dist/skills/fmode-code-lit/references/rules/context-di.md
  10. 211 0
      dist/skills/fmode-code-lit/references/rules/core-library.md
  11. 172 0
      dist/skills/fmode-code-lit/references/rules/deployment.md
  12. 113 0
      dist/skills/fmode-code-lit/references/rules/http-utils.md
  13. 118 0
      dist/skills/fmode-code-lit/references/rules/modules/README.md
  14. 98 0
      dist/skills/fmode-code-lit/references/rules/modules/examples.md
  15. 118 0
      dist/skills/fmode-code-lit/references/rules/pitfalls.md
  16. 98 0
      dist/skills/fmode-code-lit/references/rules/project-structure.md
  17. 453 0
      dist/skills/fmode-code-lit/references/rules/routing-best-practices.md
  18. 163 0
      dist/skills/fmode-code-lit/references/rules/routing.md
  19. 108 0
      dist/skills/fmode-code-lit/references/rules/services.md
  20. 136 0
      dist/skills/fmode-code-lit/references/rules/tabs-keepalive.md
  21. 288 0
      dist/skills/fmode-code-lit/references/rules/web-component-library.md
  22. 80 0
      dist/skills/fmode-code-lit/scripts/check-elements.mjs
  23. 72 0
      dist/skills/fmode-code-lit/scripts/install-rules.mjs
  24. 57 0
      dist/skills/fmode-code-lit/scripts/install.mjs
  25. 221 0
      dist/skills/fmode-code-lit/scripts/new.mjs
  26. 308 0
      dist/skills/fmode-code-lit/scripts/scaffold.mjs
  27. 60 0
      dist/skills/fmode-code-lit/scripts/smoke.mjs
  28. 18 0
      dist/skills/fmode-code-lit/skill-package-manifest.json
  29. 14 0
      scripts/build-dist.mjs
  30. 11 4
      skill-sources.json

+ 5 - 1
dist/skills-index.json

@@ -1,7 +1,7 @@
 {
   "name": "fmode-skills",
   "version": "1.2.0",
-  "builtAt": "2026-10-30T15:50:45.934Z",
+  "builtAt": "2026-10-31T01:39:41.475Z",
   "skills": [
     {
       "name": "fmode-storage",
@@ -47,6 +47,10 @@
       "name": "fmode-video-editing",
       "path": "skills/fmode-video-editing"
     },
+    {
+      "name": "fmode-code-lit",
+      "path": "skills/fmode-code-lit"
+    },
     {
       "name": "fmode-code-agent",
       "path": "skills/fmode-code-agent"

+ 112 - 0
dist/skills/fmode-code-lit/README.md

@@ -0,0 +1,112 @@
+# fmode-code-lit · Lit 原生 ESM 无构建前端技能
+
+> **这是方便数字人(FmodeAgent / FmodeCode)进行项目开发的基础规则技能。**
+> 它把踩过的坑沉淀成硬性约束,让 AI 在生成代码时不重犯同类错误。
+
+> **未来飞马 — 让 AI 进化提前发生,让 AI 落地快人一步**
+
+[![License: MPL-2.0](https://img.shields.io/badge/License-MPL--2.0-brightgreen.svg)](LICENSE)
+[![ESM](https://img.shields.io/badge/module-native--ESM-orange.svg)](#)
+
+---
+
+## 简介
+
+`fmode-code-lit` 是教 AI 用 **无构建 / 无打包 / 纯原生 ESM + Lit 3.x** 写前端的 FmodeCode 技能:
+源码即产物,浏览器直接跑;依赖走 **FMODE Repose** 统一托管,业务码走 **OBS + CDN**。
+
+配套 **规则仓**(本仓 `.claude/rules/`)提供分层、组件、服务、DI、路由、多标签、部署等完整规范。
+
+适用 **FmodeCode / FmodeCode / FmodeAgent**;本技能以原生 ESM 与零依赖脚本交付,Node ≥ 18 直接运行。
+
+## 核心能力
+
+- 🏗 **项目脚手架** —— 一键生成无构建 Lit 项目骨架(index.html + importmap + 分层目录)
+- 🧩 **单元生成器** —— `component` / `service` / `page` 按规范生成(**无装饰器**)
+- 📏 **规则安装器** —— 把 `.claude/rules/` 前端规则装入任意项目
+- 🚀 **部署模板** —— `obsutil` 同步 OBS + CDN 刷新的脚本
+- 📚 **完整规则仓** —— 架构 / 组件 / 服务 / DI / 路由 / 多标签 / 部署 / 坑位
+
+## 快速开始
+
+### 1) 给项目装技能 + 规则
+
+```bash
+# 装到当前项目的 .claude/skills/(随项目走,推荐)
+npx --yes @fmode/skill-code-lit@latest workspace
+
+# 或装到用户级 ~/.claude/skills/(全局)
+npx --yes @fmode/skill-code-lit@latest install
+
+# 把前端规则写入项目 ./.claude/rules/
+npx --yes @fmode/skill-code-lit@latest rules .
+```
+
+### 2) 新建无构建 Lit 项目
+
+```bash
+npx --yes @fmode/skill-code-lit@latest scaffold ./my-admin --project my-admin
+cd my-admin && npx serve src      # 本地预览
+```
+
+### 3) 生成组件 / 服务 / 页面
+
+```bash
+npx --yes @fmode/skill-code-lit@latest new component user-card
+npx --yes @fmode/skill-code-lit@latest new service auth-service
+npx --yes @fmode/skill-code-lit@latest new page page-order-list
+```
+
+### 4) 部署
+
+```bash
+OBS_BUCKET=obs://<bucket> bash scripts/deploy.sh my-admin
+```
+
+## 三条铁律
+
+1. **源码即产物** —— 不引入任何打包器,写好的 `.js` 直接上传。
+2. **依赖不落地项目** —— `lit` / `@lit/*` / `@lit-labs/*` 由 importmap 指向 Repose。
+3. **分层不越界** —— 组件(渲染)→ 服务(业务/状态)→ 工具(纯函数)。
+
+## ⚠️ 无构建硬约束:禁用装饰器
+
+浏览器**原生不执行**装饰器语法(`@customElement` / `@property` / `@state` / `@consume`)。
+一律用 `static properties` + `customElements.define()` + `ContextConsumer`。本技能生成的所有代码均无装饰器。
+
+## 依赖版本(2026-10 实测)
+
+```
+lit@3.3.3  lit-html@3.3.3  lit-element@4.2.2  @lit/reactive-element@2.1.2
+@lit/context@1.1.6  @lit-labs/signals@0.3.0  signal-polyfill@0.2.2
+```
+
+> `@lit-labs/signals` 依赖 `signal-polyfill`(importmap 需映射,入口 `dist/index.js`)。
+> `@lit-labs/context` 已废弃 → 用 `@lit/context`。
+
+## 目录
+
+```
+skills/fmode-code-lit/
+├── SKILL.md                     # 技能说明(AI 读取)
+├── skill-package-manifest.json  # 打包清单
+├── package.json                 # npx 入口
+├── bin/fmode-code-lit.js        # CLI
+└── scripts/
+    ├── scaffold.mjs             # 项目脚手架
+    ├── new.mjs                  # 组件/服务/页面生成器
+    ├── install-rules.mjs        # 规则安装器
+    ├── install.mjs              # 技能安装器
+    └── smoke.mjs                # 自检
+```
+
+## 自检
+
+```bash
+node skills/fmode-code-lit/scripts/smoke.mjs
+# ✓ 生成物全部通过语法检查 / ✓ 生成物无装饰器语法 / … → SMOKE OK
+```
+
+## 许可
+
+MPL-2.0 © 未来飞马 Fmode

+ 215 - 0
dist/skills/fmode-code-lit/SKILL.md

@@ -0,0 +1,215 @@
+---
+name: "fmode-code-lit"
+description: "用 Lit 原生 ESM 无构建方式写前端(零编译/零打包/浏览器直跑)。触发词:lit 项目、lit 组件、WebComponent、Web Component 组件库、纯 ESM 核心库、跨框架组件、无构建前端、原生 ESM、importmap、@lit-labs/signals、@lit-labs/context、多标签 KeepAlive、FMODE OBS+CDN 前端部署。适用场景:(1) 新建 lit 无构建项目并生成目录/importmap/骨架 (2) 写 LitElement 组件/Service/路由 (3) 把逻辑层抽成纯 ESM 核心库(core,零浏览器依赖、Node 可测)或把交互层做成跨框架 Web Component 组件库(wc) (4) 给已有项目补齐前端开发规则 (.claude/rules) (5) 部署到 OBS+CDN 并配 History 回退。产出遵循 fmode-code-lit 规则仓的分层与命名规范。"
+version: "1.0.0"
+license: "MPL-2.0"
+author: "Fmode (未来飞马)"
+keywords: [lit, 项目, lit, 组件, WebComponent, Web, Component, 组件库, 纯, ESM, 核心库, 跨框架组件]
+---
+
+# fmode-code-lit — Lit 原生 ESM 无构建前端
+
+## Overview
+
+教 AI 用 **无构建 / 无打包 / 纯原生 ESM + Lit 3.x** 的方式开发前端:源码即产物,浏览器直接跑,
+依赖走 **FMODE Repose** 统一托管,业务码走 **OBS + CDN**。分层(组件 / 服务 / 工具)、
+`@lit-labs/signals` 响应式、`@lit/context` 依赖注入、自写 `utils/router.js` History 路由(子路径部署**禁用** `@lit-labs/router`)、后台多标签 KeepAlive。
+
+> 完整规则见 `fmode-code-lit` **规则仓**(本技能随仓发布)。
+> 本技能负责「**初始化 + 生成代码**」,规则仓负责「**规范细则**」。
+
+## 何时触发
+
+用户出现以下意图时启用:
+
+- 「新建 / 初始化一个 **lit 项目**」「写一个 **lit 组件**」「**无构建** 前端」「**原生 ESM** + importmap」
+- 「WebComponent / Shadow DOM 组件」「signals 响应式」「context 依赖注入」
+- 「后台多标签 / KeepAlive」「History 干净路由」
+- 「把这套 **前端开发规则** 给项目装上」「部署到 OBS+CDN」
+
+## 三条铁律(生成任何代码前先记住)
+
+1. **源码即产物**:不引入 rollup/esbuild/vite/webpack。写好的 `.js` 直接上传。
+2. **依赖不落地项目**:`lit`、`@lit/*`、`@lit-labs/*` 由 **importmap** 指向 FMODE Repose,禁止把依赖拷进业务仓。
+3. **分层不越界**:组件→服务→工具。组件禁 `fetch`/复杂业务;服务禁 DOM。
+
+## 两个方向:页面 & 库(同一套铁律)
+
+```
+[core] 纯 ESM 核心库   零浏览器依赖、Node 可测 — 跨前端框架最底层
+   ▲ import
+[wc]   组件库(Lit 实现的 Web Component)   仅浏览器交互、零业务 — <my-x> 跨框架直接用
+   ▲ import
+[page] Lit 无构建页面   组合 core 与 wc,持有路由/布局/服务
+```
+
+**core 不认识 DOM,wc 不认识业务,page 组装一切。**
+
+| 需求 | 写在哪 | 生成命令 | 规则篇 |
+|------|--------|----------|--------|
+| 逻辑要在 Node + 多框架复用 | `lib/core/` | `new.mjs core <name>` | `core-library.md` |
+| UI 要跨框架(Lit/Vue/React/原生) | `lib/wc/` | `new.mjs wc <name>` | `web-component-library.md` |
+| 只做本应用的页面 | `src/routes/` | `new.mjs page <name>` | `components.md` |
+
+- **core 铁律**:不 `import lit`、不碰 `window`/`document`/`fetch`;`now`/`id`/`transport` 等不确定输入**注入**;`node --test` 可测。
+- **wc 铁律**:**必须用 Lit 框架特性写**(`extends LitElement` + `html` + `css`,**禁手写原生 wc**:`extends HTMLElement` / `attachShadow()` / 手写 `observedAttributes`);逻辑来自 core;网络由宿主注入 `transport`;只暴露语义属性 + `composed` 事件 + slot + CSS 变量;标签加统一前缀;`customElements.define` **判重**。
+  > Lit 本身构建在 Web Component 标准之上,产物即标准自定义元素 → 「用 Lit 写」与「跨框架」不冲突。
+
+
+## ⚠️ 无构建环境的硬约束(最容易踩)
+
+**禁用一切装饰器语法**:`@customElement` / `@property` / `@state` / `@consume` / `@provide` / `@query`。
+装饰器是 TS/Babel 编译期特性,浏览器原生 JS **不执行**,照抄 Lit 官方「装饰器版」文档会直接 `SyntaxError`。
+
+一律改用标准静态字段 + 编程式 API:
+
+| 装饰器版(❌) | 无构建版(✅) |
+|----------------|----------------|
+| `@customElement('x-y')` | `customElements.define('x-y', XY)` |
+| `@property({type:String}) name` | `static properties = { name: { type: String } }` |
+| `@state() kw=''` | `static properties = { kw: { type: String, state: true } }` |
+| `@consume({context})` | `new ContextConsumer(this, { context, subscribe, callback })` |
+| `@provide({context})` | 模板里 `provide(ctx, value)` 指令 |
+
+## 用法
+
+### 1) 初始化一个新 lit 无构建项目
+
+```bash
+# 在目标目录生成标准骨架(index.html + importmap + 分层目录 + 示例组件)
+node <skill_dir>/scripts/scaffold.mjs ./my-admin --project my-admin
+```
+
+生成结构:
+
+```
+my-admin/
+  src/
+    index.html            # <base> + importmap + 入口
+    importmap.json        # 本地 importmap(生产用 Repose 公共 importmap)
+    app.js                # provide + 路由 + 根组件
+    components/  routes/  services/  utils/
+    .claude/rules/        # 前端规则(从规则仓同步)
+  package.json            # 仅类型提示,不打包
+```
+
+### 2) 给已有项目装上规则
+
+```bash
+node <skill_dir>/scripts/install-rules.mjs .      # 写入 ./.claude/rules/ + CLAUDE.md 片段
+```
+
+### 3) 生成单个组件 / 服务 / 核心库模块 / 组件库组件
+
+```bash
+node <skill_dir>/scripts/new.mjs component user-card      # 页内通用组件
+node <skill_dir>/scripts/new.mjs service auth-service     # 页面服务
+node <skill_dir>/scripts/new.mjs page page-order-list     # 页面
+node <skill_dir>/scripts/new.mjs core pricing             # 核心库模块(纯 ESM,零 DOM)
+node <skill_dir>/scripts/new.mjs wc quote-calc            # Web Component(跨框架组件库)
+```
+
+### 4) 部署
+
+```bash
+bash scripts/deploy.sh <project>   # obsutil 同步 src/ → OBS → CDN 刷新
+```
+
+## 关键约定速查
+
+| 项 | 约定 |
+|----|------|
+| 组件文件 | `kebab-case.js`,类 `PascalCase`,标签含连字符 |
+| 页面 | `page-*.js`,标签 `<page-*>` |
+| 服务 | `*-service.js`,类 `*Service`,单例 `export const xService = new XService()` |
+| 核心库(core) | `lib/core/*.js`,纯函数/类,**无 `window`/`document`/`fetch`,不 import lit**;入口 `index.js` 单一出口 |
+| 组件库(wc) | `lib/wc/*.js`,**`extends LitElement`(Lit 实现,禁原生 wc)**,`customElements.define`(**判重**),标签带统一前缀;逻辑来自 core |
+| 相对导入 | **必须带 `.js` 后缀** |
+| 全局状态 | `@lit-labs/signals` 的 signal + `SignalWatcher` |
+| 组件本地状态 | `static properties`(`state: true`) |
+| 依赖注入 | `@lit/context` 的 `provide()` + `ContextConsumer`(**非** `@lit-labs/context`,已废弃) |
+| 路由 | 自写 `utils/router.js`,History 干净 URL(子路径部署**禁用** `@lit-labs/router`) |
+| 子目录部署 | 必须 `<base href="/<project>/">` |
+
+## 依赖版本(2026-10 实测可用)
+
+```
+lit@3.3.3  lit-html@3.3.3  lit-element@4.2.2  @lit/reactive-element@2.1.2
+@lit/context@1.1.6  @lit-labs/signals@0.3.0  signal-polyfill@0.2.2
+```
+
+> 注意:`@lit-labs/signals` 依赖 **`signal-polyfill`**,importmap 必须映射它(入口 `dist/index.js`)。
+> `@lit-labs/context` 已废弃 → 用 **`@lit/context`**。
+
+## 部署要点
+
+- 依赖 → FMODE Repose(按版本目录,`.js` 必须 `Content-Type: text/javascript`,CORS 放行业务域名,开 HTTP/2)。
+- 业务码 → `obsutil sync src/ obs://<bucket>/<project>/`(`public-read`),再刷 CDN。
+- **CDN History 回退**:不存在路径返回 `index.html` 且状态码 **200**,**禁 302**(否则地址栏改写、OAuth 回调失败、路由丢失)。
+- `<base href>` 与部署前缀一致;`index.html` 设 `no-cache`。
+
+## 常见坑(详见规则仓 `.claude/rules/pitfalls.md`)
+
+装饰器 / CORS / MIME 非 JS / lit 多版本混用 / CDN 302 / base href 缺失 / 相对 import 漏 `.js` /
+signal 与 property 混用 / 老浏览器无 Import Map / 循环依赖。
+
+## 多页面开发铁律(light DOM 五条死线)
+
+> 来源:一次多页看板首次交付 6 页全部错乱的复盘(2026-10-29)
+> **一句话**:`createRenderRoot(){return this}` 不是「让 Tailwind 生效的小开关」,是**渲染模型切换**。切完以后 `<slot>`、`:host`、`::slotted`、样式隔离全部失效,但代码看起来和普通 Lit 一样、编辑器也不报错。
+
+### 死线1:light DOM 下禁用 `<slot>`
+没有 shadow root 就没有投影。`<slot>` 退化成普通未知元素,`assignedSlot` 为 null,**子节点原封不动滞留在宿主元素上**——外壳和内容成了兄弟节点,谁也没包住谁,于是滚动区空、导航错、高度链断。
+**自检一行**:`document.querySelector('main').children.length`,为 0 就是投影断了。
+
+### 死线2:外壳接管路由渲染,不靠投影传内容
+页面元素由外壳**自己按路由产出**,让它天然成为 `<main>` 的子节点,父子关系由渲染函数保证:
+```js
+// app-shell.js
+_renderRoute() {
+  const { name, params } = parseRoute(this.path);
+  switch (name) {
+    case 'dashboard': return html`<page-dashboard></page-dashboard>`;
+    case 'project-detail': return html`<page-project-detail .code=${params.code||''}></page-project-detail>`;
+  }
+}
+// render 里:<main ...><div class="page-enter key-route-${this.active}">${this._renderRoute()}</div></main>
+```
+`app-root` 退化成只负责挂载:`render() { return html\`<app-shell></app-shell>\` }`。
+
+### 死线3:真需传内容的组件(卡片),用 capture/project 手动搬
+light DOM 下只能自己搬节点。现成实现见 `references/light-slot.js`。**顺序不能错**:
+- `capture(host)` 在 **connectedCallback 中、渲染前** 调用,把 `childNodes` 存到 `host.__lightIncoming`
+- `project(host)` 在 **updated() 中、渲染后** 调用,把暂存的节点塞进 `[data-light-slot="body"]` 等容器
+- **必须靠 `dataset.projected` 做幂等**,否则每次重渲染都重复搬、节点来回跳
+- 渲染后再扫宿主子节点是致命的:会把组件自己刚生成的内容当成「外部传入」搬走,越搬越乱
+
+### 死线4:自定义元素必须显式 `display: block; min-width: 0`
+自定义元素是未知元素,浏览器默认按 inline 处理:不吃 height/width(`h-full`/`flex-1` 全失效)、不形成块级盒子(内部 flex/grid 失去参照)、相邻元素间出现空白间隙。
+```css
+app-shell, page-dashboard, page-analysis, data-table, kpi-card, empty-state, ... {
+  display: block; min-width: 0;   /* min-width:0 让 flex 子项可收缩,否则长表格撑破父容器 */
+}
+```
+**两个例外必须排除**:`section-card`、`chart-box` 的 class 自带 `flex h-full flex-col`,写死 `display: block` 会覆盖掉、把高度链断掉。
+
+### 死线5:图表容器用 `height:100% + min-height`,不用 `flex:1`
+```css
+chart-box { height: 100%; min-height: 260px; }   /* 父级无确定高度时 flex:1 算出来是 0,ECharts 拿到 0 高度直接不画 */
+```
+
+### 交付前自检清单
+1. **先跑通最小路径再铺量**:外壳 + 一个空白页 + 一次路由跳转,确认 DOM 父子关系正确,再往里填 6 个页面。规模越大定位成本越高。
+2. `node scripts/check-elements.mjs` 自动校验所有自定义元素都声明了 display —— 自动检查比文档写三遍有用。
+3. 长模板用 `node --check` 检查语法:**少一个闭标签,lit-html 会把后面所有兄弟节点当成它的子节点吞掉**,整页塌陷,且症状和渲染模型错误混在一起,排查方向严重跑偏。
+4. 症状≠根因:“样式全乱”先去查 DOM 结构,别一上来翻 Tailwind 配置(这次查错方向错了两轮)。
+5. **多页症状不一致时先找共同根因**:各页症状不同通常是同一个总根因的不同投影,不是 n 个独立 bug。最严重的那页反而最好的线索。
+
+### 同批高频白屏(非 Lit 问题,但一起记)
+- **子路径白屏**:index.html 用绝对路径引资源,部署到 `/dev/XXX/` 子路径全 404。用 `__BASE__` 占位 + `document.write` 注入。
+- **改配置后白屏**:dev-server 进程还服务旧配置。**改配置必须重启,别信热更新。**
+
+## 诚实失败
+
+生成前先探测目标目录;已存在文件**不覆盖**(除非 `--force`)。若依赖版本无法从 Repose 解析,
+明确报错并给出配置指引,**不生成一个跑不起来的骨架**。

+ 38 - 0
dist/skills/fmode-code-lit/bin/fmode-code-lit.js

@@ -0,0 +1,38 @@
+#!/usr/bin/env node
+/**
+ * fmode-code-lit CLI
+ * 用法:
+ *   npx @fmode/skill-code-lit install      # 装技能到 ~/.claude/skills/(默认)
+ *   npx @fmode/skill-code-lit workspace    # 装技能到 ./.claude/skills/
+ *   npx @fmode/skill-code-lit check        # 校验安装
+ *   npx @fmode/skill-code-lit scaffold <dir> --project <name>   # 生成无构建 Lit 项目骨架
+ *   npx @fmode/skill-code-lit new <component|service|page> <name>
+ *   npx @fmode/skill-code-lit rules [dir]  # 把前端规则装入目标项目 ./.claude/rules/
+ */
+const { spawnSync } = require('node:child_process');
+const path = require('node:path');
+
+const SCRIPTS = path.join(__dirname, '..', 'scripts');
+const [cmd = 'install', ...rest] = process.argv.slice(2);
+
+const MAP = {
+  install: 'install.mjs',
+  workspace: 'install.mjs',
+  check: 'install.mjs',
+  scaffold: 'scaffold.mjs',
+  new: 'new.mjs',
+  rules: 'install-rules.mjs',
+};
+
+const script = MAP[cmd];
+if (!script) {
+  console.error(`未知命令:${cmd}\n可用:${Object.keys(MAP).join(', ')}`);
+  process.exit(2);
+}
+// workspace 需作为首参透传给 install.mjs(install.mjs 默认 install)
+const argv = (cmd === 'workspace' || cmd === 'install' || cmd === 'check')
+  ? [path.join(SCRIPTS, script), cmd, ...rest]
+  : [path.join(SCRIPTS, script), ...rest];
+
+const r = spawnSync(process.execPath, argv, { stdio: 'inherit' });
+process.exit(r.status ?? 1);

+ 48 - 0
dist/skills/fmode-code-lit/package.json

@@ -0,0 +1,48 @@
+{
+  "name": "@fmode/skill-code-lit",
+  "version": "1.0.0",
+  "description": "FmodeCode 技能:Lit 原生 ESM 无构建前端开发(零编译/零打包),分层规范 + importmap + signals + context + router + 多标签 KeepAlive。提供项目脚手架、组件/服务/页面生成器与规则安装器。",
+  "type": "commonjs",
+  "bin": {
+    "fmode-code-lit": "bin/fmode-code-lit.js"
+  },
+  "scripts": {
+    "smoke": "node skills/fmode-code-lit/scripts/smoke.mjs"
+  },
+  "files": [
+    "bin/",
+    "README.md",
+    "LICENSE",
+    "skill-package-manifest.json",
+    "skills/"
+  ],
+  "keywords": [
+    "fmode",
+    "digital-human",
+    "agent-rules",
+    "skill",
+    "FmodeCode",
+    "claude-skill",
+    "lit",
+    "webcomponents",
+    "native-esm",
+    "importmap",
+    "no-build",
+    "signals",
+    "frontend"
+  ],
+  "license": "MPL-2.0",
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/fmodecn/fmode-code-lit.git",
+    "directory": "skills/fmode-code-lit"
+  },
+  "homepage": "https://github.com/fmodecn/fmode-code-lit#readme",
+  "author": {
+    "name": "liuyuyang",
+    "email": "liu@fmode.cn"
+  },
+  "publishConfig": {
+    "access": "public"
+  }
+}

+ 83 - 0
dist/skills/fmode-code-lit/references/light-slot.js

@@ -0,0 +1,83 @@
+/**
+ * light-slot.js — light DOM 下替代 <slot> 的内容投影
+ *
+ * 用法:
+ *   import { capture, project } from './light-slot.js';
+ *
+ *   class MyCard extends LightElement {
+ *     connectedCallback() {
+ *       super.connectedCallback();
+ *       capture(this);              // ← 渲染前捕获外部子节点
+ *     }
+ *     updated() {
+ *       project(this);              // ← 渲染后搬入容器(内部幂等)
+ *     }
+ *     render() {
+ *       return html`
+ *         <div class="card-header">
+ *           <div data-light-slot="actions"></div>
+ *         </div>
+ *         <div class="card-body" data-light-slot="body"></div>
+ *       `;
+ *     }
+ *   }
+ *
+ * 关键约束(违反即出 bug):
+ *  1. capture 必须在渲染前调用。渲染后再扫宿主子节点,会把组件自己刚生成的内容
+ *     当成「外部传入」搬走,越搬越乱。
+ *  2. project 必须靠 dataset.projected 做幂等,否则每次重渲染都重复搬、节点来回跳。
+ *  3. 传入具名内容用 slot="actions" 属性;不写 slot 属性的默认进 body。
+ */
+
+const KEY = '__lightIncoming';
+
+/**
+ * 渲染前捕获宿主元素上的外部子节点
+ * @param {HTMLElement} host
+ */
+export function capture(host) {
+  const body = [];
+  const named = {};
+  for (const n of [...host.childNodes]) {
+    // 元素节点且带 slot 属性 → 具名插槽
+    if (n.nodeType === 1 && n.hasAttribute('slot')) {
+      const name = n.getAttribute('slot');
+      (named[name] ||= []).push(n);
+    } else if (n.nodeType === 1 || (n.nodeType === 3 && n.textContent.trim())) {
+      // 元素节点,或非空白文本节点 → 默认 body
+      body.push(n);
+    }
+  }
+  host[KEY] = { body, named };
+}
+
+/**
+ * 渲染后把捕获的节点搬进对应容器(幂等)
+ * @param {HTMLElement} host
+ */
+export function project(host) {
+  const stash = host[KEY];
+  if (!stash) return;
+
+  // 默认插槽 → body
+  const bodyBox = host.querySelector('[data-light-slot="body"]');
+  if (bodyBox && !bodyBox.dataset.projected) {
+    const nodes = [...stash.body, ...(stash.named.body || [])];
+    for (const n of nodes) {
+      if (n.parentNode === host) bodyBox.appendChild(n);
+    }
+    bodyBox.dataset.projected = '1';
+  }
+
+  // 具名插槽
+  for (const [name, nodes] of Object.entries(stash.named)) {
+    if (name === 'body') continue;
+    const box = host.querySelector(`[data-light-slot="${name}"]`);
+    if (box && !box.dataset.projected) {
+      for (const n of nodes) {
+        if (n.parentNode === host) box.appendChild(n);
+      }
+      box.dataset.projected = '1';
+    }
+  }
+}

+ 105 - 0
dist/skills/fmode-code-lit/references/rules/architecture.md

@@ -0,0 +1,105 @@
+# 架构与分层
+
+> 目标:零编译、零打包、纯原生 JS。基于浏览器原生 ESM + Import Map,依赖托管在内部 **FMODE Repose**,
+> 业务代码直接上传 **OBS + CDN**,保存刷新即预览。对标新版 Angular(History 干净路由、DI、Signal 细粒度响应式、
+> 组件/服务分层、后台多标签,**无 Zone.js**)。
+
+## 一、整体架构
+
+- **无构建无打包**:不用 rollup / esbuild / vite / webpack,源码浏览器直接执行。
+- **依赖统一托管**:`lit`、`@lit-labs/*` 等从 npm 下 **原始 ESM** 原样同步到 **FMODE Repose**,全业务共享。
+- **业务资源独立托管**:业务源码(`index.html`、`js` 组件)部署到 **OBS + CDN**。
+- **分层(对标 Angular)**:
+  - **WebComponent(`LitElement`)**:只渲染视图 + DOM 事件,不写复杂业务逻辑。
+  - **Service(普通 ESM Class)**:存全局状态 / 接口 / 业务计算,与 DOM 解耦。
+  - **Utils(纯 ESM 函数)**:无状态共享工具。
+- **响应式**:`@lit-labs/signals`(细粒度信号)。
+- **DI**:`@lit-labs/context`(组件树注入)。
+- **路由**:自写 `utils/router.js`,History 干净 URL(支持微信 OAuth 回调,需 CDN 回退规则)。
+  子路径部署**禁用** `@lit-labs/router`(全局 `<a>` 拦截 + 未剥前缀 pathname 会污染路由),见 [routing-best-practices.md](./routing-best-practices.md)。
+
+### 一·五、双方向分层:core / wc / page(新增)
+
+上面的「组件 / 服务 / 工具」讲的是**一个应用内部**怎么分。本仓还支持**跨应用复用**的两个方向,
+二者是**同一套无构建铁律**在不同尺度上的应用:
+
+```
+┌─────────────────────────────────────────────────────────────────────┐
+│ [page] 页面层   Lit 无构建页面(本应用)                              │
+│   组合 <my-x> 与页内组件;路由 / 多标签 / 布局只在页面层              │
+│      ▲ import                                                        │
+│ [wc]   组件库   Web Component(仅浏览器交互:DOM / 事件 / Shadow DOM) │
+│   一批 customElements;跨框架(Lit/Vue/React/原生)直接 <my-x> 使用   │
+│      ▲ import                                                        │
+│ [core] 核心库   纯 ESM 逻辑(零浏览器依赖,Node 可 import 单测)        │
+│   纯函数 / 类 / 状态机;不 import Lit、不碰 window/document/fetch      │
+└─────────────────────────────────────────────────────────────────────┘
+```
+
+| 层 | 产物 | 依赖 | 规则篇 |
+|----|------|------|--------|
+| **core** | npm ESM 包(可跑 Node) | 只依赖 `node:*` 与纯 JS 库(`@lit-labs/signals` 可用) | [core-library.md](./core-library.md) |
+| **wc** | 一批自定义元素(浏览器) | `core` + `lit`(仅渲染 API) | [web-component-library.md](./web-component-library.md) |
+| **page** | 一个应用的 `src/`(浏览器) | `wc` / `core` + 页面 `services/` `utils/` | [components.md](./components.md) |
+
+**对应实例**:`fmode-code-agent` = **core**(逻辑层纯 ESM);`designer-chat` 组件 = **wc**;
+任意后台页 = **page**(组合 wc,并可只用页内组件而不用组件库)。
+
+> 什么时候只需两层:只服务本项目、组件不复用 → **只有 page + 内联的 services/utils** 就够(最常见)。
+> 一旦出现「换一个框架还想复用这段逻辑」→ 下沉为 **core**;「换一个框架还想复用这个 UI」→ 上提为 **wc**。
+> 别为了分层而分层,但**一旦跨框架复用,core 必须零 DOM 依赖**,否则复用不成立。
+
+
+## 二、分层职责表(越界即返工)
+
+| 层 | 目录 | 允许 | 禁止 |
+|----|------|------|------|
+| 视图组件 | `components/` `routes/` | 渲染、DOM 事件、读 Signal、调 Service、`@property` 本地状态 | 大量业务逻辑、直接 `fetch`、硬编码接口地址、跨组件直接 new Service |
+| 服务 | `services/` | 持有 Signal、封装接口、校验、数据转换、业务计算 | 依赖 DOM / `LitElement`、`import { html }` 创建 DOM |
+| 工具 | `utils/` | 纯无状态函数、通用封装(http/storage/format) | 持有可变全局状态、依赖上面两层 |
+
+> 判定口诀:**能被单测、不 import Lit 的,就不是组件**。服务必须能在 Node 里 `import` 后直接跑。
+
+> 三分层版的口诀(见 [一·五](#一五分方向分层core--wc--page新增)):
+> **core 不认识 DOM,wc 不认识业务,page 组装一切。** service 若连 `localStorage` / `fetch` 都想抽走,就成 core;
+> 若沾 DOM,就留在 page。
+
+## 三、运行流程
+
+```
+浏览器打开 URL
+  → CDN 回退规则:不存在的路径 200(非 302)返回 index.html
+  → index.html 加载公共 importmap(指向 FMODE Repose)
+  → 加载 app.js
+  → import lit 依赖
+  → 初始化 路由 / Store / 根组件渲染
+```
+
+## 四、部署拓扑(内网细节,勿外泄)
+
+- **FMODE Repose**(静态依赖仓):`/npm/lit/v3.x.x/`、`/npm/@lit-labs/{context,signals,router}/vx/`、`/npm/importmap/lit-v3.json`(公共 importmap)。
+- **OBS + CDN**(业务托管):`index.html`(引入 importmap)、`app.js`、`/routes/`、`/components/`、`/services/`、`/utils/`。
+
+> ⚠️ 上面是**内部拓扑示意**。对外文档/开源材料中只写「内部依赖仓 + OBS+CDN」即可,**不写域名、桶名、AK/SK、obsutil 路径**。
+
+## 五、为什么这么选(对标 Angular zoneless)
+
+| 维度 | 传统 Angular(Zone) | 本方案(Lit + Signals) |
+|------|--------------------|--------------------------|
+| 变更检测 | Zone.js 全局猴子补丁,全组件树 check | Signal 精确到依赖了该信号的组件 |
+| 构建 | 必须 ng build | **无构建**,保存刷新即见 |
+| 依赖 | 打包进 bundle | Repose 共享,一次上传全业务复用 |
+| 路由 | 框架内置 | 原生 History,干净 URL,OAuth 友好 |
+| 样式隔离 | ViewEncapsulation | Shadow DOM 原生隔离 |
+| 心智负担 | 框架黑盒 | 原生 API,无魔法 |
+
+详见 [../docs/retrospective.md](../../docs/retrospective.md)。
+
+## 六、扩展方向
+
+- 全局 HTTP 工具(token 拦截 / 错误处理统一)。
+- 通用弹窗 / 表格 / 表单 WebComponent → 内部组件库,托管 FMODE Repose。
+- 路由守卫鉴权。
+- 全局 loading / 消息提示组件 + 全局 Signal 控制。
+- **逻辑层抽成 core 库**(纯 ESM、零 DOM)→ 同一套逻辑喂给 Lit / Vue / React 多套 UI,见 [core-library.md](./core-library.md)。
+- **组件库升级为跨框架分发**(wc 层)→ 非 Lit 宿主也能 `<my-x>`,见 [web-component-library.md](./web-component-library.md)。

+ 153 - 0
dist/skills/fmode-code-lit/references/rules/components.md

@@ -0,0 +1,153 @@
+# 组件规范(LitElement)
+
+> 组件只做一件事:**把数据渲染成 DOM,并把用户事件转成对 Service 的调用**。
+> 复杂业务逻辑、请求、状态计算 —— 全部下沉到 Service。
+>
+> **本文件讲「页面层组件」**(服务于本应用的 UI)。若这个组件要**跨项目 / 跨框架**复用,
+> 它是**组件库(wc)**,契约更严、逻辑必须来自 [core](./core-library.md) —— 见
+> [web-component-library.md](./web-component-library.md)。分层总图见 [architecture.md 一·五](./architecture.md)。
+
+## 〇、无构建环境的第一约束:**禁用装饰器**
+
+> ⚠️ **禁用 `@customElement` / `@property` / `@state` / `@consume` 等装饰器语法。**
+> 装饰器是 **TypeScript / Babel 的编译期特性**,浏览器**原生不执行**。无构建(源码即产物)下,
+> 一旦写装饰器,浏览器直接 `SyntaxError`。所有 Lit 官方「装饰器版」文档**不能照抄**。
+
+一律改用**标准静态类字段 + 编程式 API**:
+
+| 装饰器版(❌ 禁) | 无构建版(✅ 用) |
+|-------------------|-------------------|
+| `@customElement('x-y')` | 类末尾 `customElements.define('x-y', XY)` |
+| `@property({ type: String }) name` | `static properties = { name: { type: String } }` |
+| `@state() kw = ''` | `static properties = { kw: { type: String, state: true } }` + constructor 初始化 |
+| `@consume({ context })` | `new ContextConsumer(this, { context, subscribe, callback })` |
+| `@provide({ context })` | 模板里的 `provide(ctx, value)` 指令(见 context-di.md) |
+| `@query('#x')` | `this.renderRoot.querySelector('#x')` |
+| `@queryAssignedElements` | `this.querySelectorAll('slot')` / `slot.assignedElements()` |
+
+> `static properties` 是 Lit 的**标准 API**(装饰器只是它的语法糖),无构建下完全可用。
+
+## 一、最小组件骨架(无装饰器)
+
+```js
+// components/user-card.js
+import { LitElement, html, css } from 'lit';
+
+export class UserCard extends LitElement {
+  static properties = {
+    name: { type: String },
+    user: { type: Object },
+  };
+
+  static styles = css`
+    :host { display: block; padding: 12px; border: 1px solid #eee; border-radius: 8px; }
+    .name { font-weight: 600; }
+  `;
+
+  constructor() {
+    super();
+    this.name = '';
+    this.user = null;
+  }
+
+  render() {
+    return html`
+      <div class="name">${this.name}</div>
+      <slot></slot>
+    `;
+  }
+
+  /** 事件只做转发,不做业务 */
+  _onClick() {
+    this.dispatchEvent(new CustomEvent('card-click', {
+      detail: { user: this.user },
+      bubbles: true,
+      composed: true,   // 穿透 Shadow DOM 边界,必须
+    }));
+  }
+}
+
+customElements.define('user-card', UserCard);
+```
+
+## 二、Lit 核心特性用法
+
+| 特性 | 用途 | 要点 |
+|------|------|------|
+| `` html`...` `` | 声明式模板 | 自动转义(**天然防 XSS**);模板字面量按内容缓存,只更新变化部分 |
+| `` css`...` `` | 组件样式 | 在 Shadow DOM 内生效,样式隔离 |
+| Shadow DOM | DOM/CSS 隔离 | 默认 `open`;跨边界事件需 `composed: true` |
+| `@property` | 对外属性(UI 状态 / 入参) | 会触发重渲染,默认 attribute 反射 |
+| `@state` | 内部私有状态 | 不对外暴露、不反射到 attribute |
+| `requestUpdate()` / `updateComplete` | 手动触发 / 等待渲染 | 备选手段,优先用 property 驱动 |
+| `<slot>` | 插槽 | 封装通用布局组件(弹窗外壳、面板) |
+
+## 三、响应式:本地 vs 全局(关键分界)
+
+> ⚠️ **两套响应式必须分开**,混用会导致更新时机错乱。
+
+| 场景 | 怎么用 |
+|------|--------|
+| 组件内部 UI(弹窗开关、输入框值、tab 高亮) | Lit `@property` / `@state` + `this.requestUpdate()` |
+| 跨组件全局业务状态(登录态、字典、购物车、权限) | `@lit-labs/signals`,组件用 `SignalWatcher` 混入 |
+
+需要全局 signal 的组件:用 `SignalWatcher(LitElement)` 包裹,signal 变化自动重渲染。
+
+```js
+import { LitElement, html } from 'lit';
+import { SignalWatcher } from '@lit-labs/signals';
+import { authService } from '../services/auth-service.js';
+
+export class PageDashboard extends SignalWatcher(LitElement) {
+  render() {
+    // authService.user 是 signal:读取即订阅,变化自动重渲染
+    return html`<h1>你好,${authService.user.get()?.name ?? '访客'}</h1>`;
+  }
+}
+```
+
+## 四、组件通信
+
+| 方向 | 手段 |
+|------|------|
+| 父 → 子 | `static properties` 声明的属性(attribute 或 `.prop=${x}` 绑定) |
+| 子 → 父 | `dispatchEvent(new CustomEvent('x', { bubbles, composed }))` |
+| 任意 ↔ 任意 | **Service + Signal**(首选),组件只读 signal / 调方法 |
+| 组件树深层传递依赖 | `@lit-labs/context` 的 `provide()` 指令 + `ContextConsumer`,见 [context-di.md](./context-di.md) |
+
+- 事件名用 `kebab-case`,`detail` 用对象。
+- 跨 Shadow DOM 边界必须 `composed: true`,否则父级监听不到。
+- **禁止**用全局事件总线撒逻辑——用 Service。
+
+## 五、绝对禁止
+
+- ❌ 用装饰器(`@customElement`/`@property`/`@consume`…)—— 无构建下浏览器不执行,见第〇节。
+- ❌ 组件里 `fetch` / 写接口地址 —— 交给 Service。
+- ❌ 组件里写复杂业务计算 / 校验 —— 交给 Service 或 utils。
+- ❌ 组件 new 另一个组件需要的 Service —— 用 `ContextConsumer` 或直接 import 单例。
+- ❌ 用全局 signal 存「只影响本组件」的状态 —— 用 `static properties` 的 `state: true`。
+- ❌ 跨版本混用 `lit` —— 自定义元素注册冲突,见 [pitfalls.md](./pitfalls.md)。
+
+## 六、可复用组件的封装原则
+
+- **只暴露语义属性**(`label`、`value`、`disabled`),不暴露内部实现细节。
+- **用 slot 做内容投影**,不要用 `<... .innerHTML>` 传 HTML。
+- **样式可覆盖**:暴露 CSS 自定义属性(`--card-bg`)而非改内部 class。
+- 通用性强的组件抽到独立目录并考虑托管到 **FMODE Repose 组件库**,跨项目复用。
+
+## 七、从「页内组件」升级为「组件库组件」
+
+同一个组件,跨项目复用时契约要加严。判断与落地:
+
+| 信号 | 动作 |
+|------|------|
+| 第二个项目要引它 / 换框架也要用 | 迁出为**组件库(wc)**,见 [web-component-library.md](./web-component-library.md) |
+| 组件里长出了业务逻辑(校验 / 协议 / 状态机) | 把逻辑**下沉 core**([core-library.md](./core-library.md)),组件只保留接线 |
+| 组件里出现 `fetch` / 依赖宿主全局变量 | 改成**宿主注入**(`el.transport = ...`),组件库不拥有 I/O |
+| 需要让非 Lit 宿主也能用 | 标签加统一前缀;事件 `composed: true`;只通过 `static properties` + slot + CSS 变量暴露 |
+
+三句话记住三分层:
+
+- **core**:不认识 DOM 的纯逻辑(Node 可测)。
+- **wc**:不认识业务的 Web Component(跨框架)。
+- **page**:组装 core 与 wc 的页面(本文件其余章节讲的就是它)。

+ 102 - 0
dist/skills/fmode-code-lit/references/rules/context-di.md

@@ -0,0 +1,102 @@
+# 依赖注入(@lit-labs/context)
+
+> Context 用于把「全局 Service / 配置」注入到组件树深层,避免逐层 `@property` 透传(prop drilling)。
+> 与 Angular 的 DI 对标,但更轻:**令牌 + provide + consume**。
+
+## 一、三件套
+
+| API | 作用 |
+|-----|------|
+| `createContext<T>(key)` | 定义一个注入令牌(模块级唯一) |
+| `provide({ context, initialValue })` | 在某个祖先元素上提供值(通常根组件) |
+| `consume({ context, subscribe })` | 在子组件里消费值 |
+
+## 二、定义令牌
+
+```js
+// services/contexts.js —— 令牌集中定义,避免散落
+import { createContext } from '@lit-labs/context';
+import { authService } from './auth-service.js';
+
+export const authContext = createContext('fmode.auth');
+// 也可以为普通配置/值建令牌
+export const configContext = createContext('fmode.config');
+```
+
+- 令牌 key 用命名空间前缀(`fmode.xxx`)避免与第三方冲突。
+- 令牌必须**同一模块实例**:多处 `createContext` 会得到不同令牌 → 注入失败。集中定义。
+
+## 三、在根组件提供(provide)
+
+```js
+// app.js 或根组件
+import { LitElement, html } from 'lit';
+import { provide } from '@lit-labs/context';
+import { authContext, configContext } from './services/contexts.js';
+import { authService } from './services/auth-service.js';
+
+export class AppRoot extends LitElement {
+  render() {
+    return html`
+      ${provide(authContext, authService)}
+      ${provide(configContext, { apiBase: '/api', appName: 'Admin' })}
+      <app-shell></app-shell>
+    `;
+  }
+}
+```
+
+- `provide` 是一个「无渲染」的模板指令,放在祖先组件 render 里,向下对所有后代生效。
+- 通常**只在根组件 provide**(全局单例 Service),不要把 provide 散在各处。
+
+## 四、在子组件消费(consume)
+
+> ⚠️ 无构建下**禁用 `@consume` 装饰器**,改用编程式 `ContextConsumer`。
+
+```js
+import { LitElement, html } from 'lit';
+import { ContextConsumer } from '@lit-labs/context';
+import { authContext } from '../services/contexts.js';
+
+export class UserBadge extends LitElement {
+  static properties = { _auth: { state: true } };
+
+  #consumer = new ContextConsumer(this, {
+    context: authContext,
+    subscribe: true,                       // 提供的「实例」被替换时回调
+    callback: (value) => { this._auth = value; },
+  });
+
+  render() {
+    return html`<span>${this._auth?.user.get()?.name ?? '未登录'}</span>`;
+  }
+}
+
+customElements.define('user-badge', UserBadge);
+```
+
+- `subscribe: true`:当提供的**值本身**被替换为另一个实例时,组件重新响应(不是 signal 变化,而是「注入对象换了」)。
+- 注入对象内部的 signal 变化,由 `SignalWatcher` 负责驱动重渲染 —— 两者配合。
+- 父组件提供用模板指令 `provide(ctx, value)`(见第三节),同样无需装饰器。
+
+## 五、何时用 Context,何时直接 import
+
+| 情况 | 做法 |
+|------|------|
+| 全局单例 Service,确定唯一 | **直接 `import` 单例** 即可(最简单,推荐) |
+| 需要「同组件树不同区块用不同实例」(多实例 / 可测试替换) | **Context**(provide 不同实例) |
+| 需要向下传配置 / 主题 / 环境值 | **Context** |
+
+> 务实建议:**默认直接 import 单例**;只有当「同一组件要能跑在不同注入环境」时才上 Context。别为了 DI 而 DI。
+
+## 六、与 SignalWatcher 配合
+
+- `consume` 拿到的是 **Service 实例**(不一定变化)。
+- Service 里的 **signal** 变化 → 由混入 `SignalWatcher` 的组件自动重渲染。
+- 两者职责不同:`consume` 解决「拿到谁」,`SignalWatcher` 解决「谁变了要重渲染」。
+
+## 七、坑
+
+- 令牌模块重复实例化 → consume 拿不到 → 返回 `undefined`。**令牌必须集中定义、单例导出**。
+- 在 `render()` 外读取 consumed 属性可能为 `undefined`(首次渲染前);用可选链。
+- provide 的作用域是「该元素的子树」,provide 写错层级 → 后代拿不到。

+ 211 - 0
dist/skills/fmode-code-lit/references/rules/core-library.md

@@ -0,0 +1,211 @@
+# 核心库规范(core:纯 ESM、零浏览器依赖)
+
+> 本仓从「只会写页面」升级为**双方向**:既能写页面(page),也能写 **Web Component 组件库(wc)**
+> 与 **跨前端框架的核心库(core)**。三分层总览见 [architecture.md](./architecture.md),组件库见
+> [web-component-library.md](./web-component-library.md),页面层仍见 [components.md](./components.md)。
+>
+> **核心库 = 一个普通 npm ESM 包**:不含 Lit、不碰 DOM、可在 Node 里直接 `import` 与单测。
+> 浏览器、Node、小程序、SSR、Web Worker 都能跑同一份代码;Web Component 组件库与页面层都**向下依赖**它。
+
+```
+[core]  纯 ESM 逻辑库(本文件)       ← 无 DOM / 无 Lit / 无 window / 无 fetch 硬编码
+   ↑ import
+[wc]    组件库(Web Component)       ← 唯一的浏览器交互层
+   ↑ import
+[page]  页面(Lit 无构建)            ← 组合 wc 与页内组件
+```
+
+## 一、铁律(违反即返工)
+
+1. **零浏览器依赖**:`core/` 下任何文件都不得触碰 `window` / `document` / `navigator` / `localStorage` /
+   `fetch` / `HTMLElement` / `CustomEvent` / `import.meta.url` 拼 DOM 路径。**唯一例外**:`node:` 内置模块(Node 可跑)。
+2. **不 import Lit**:`import ... from 'lit'` / `lit-html` / `@lit/*` 一律禁止 —— 一旦 import Lit 就绑定了浏览器渲染。需响应式时用 `@lit-labs/signals`(`signal`/`computed`),它本身是纯 JS,可跑在 Node。
+3. **只 export 纯逻辑**:纯函数、纯 Class、状态机、校验、格式化、DTO 解析、业务计算。**不 export 单例状态**(每次 `new` 或由调用方注入),不 export「全局 store」。
+4. **副作用零**:模块被 `import` 时**不得**执行任何副作用(注册监听、读写存储、发请求、打印)。模块顶层只允许**声明**。
+5. **Node 可测**:`core/` 的每个文件都必须能在 Node 里 `import` 并跑通测试;测试**不依赖 jsdom**。
+6. **依赖方向单向**:`core` **不认识** `wc` / `page`;`wc`、`page` 认识 `core`。
+
+> 判定口诀:**`node --input-type=module -e "import('./core/index.js')"` 能跑、`grep` 不到 DOM 全局,才是 core。**
+
+## 二、目录规范
+
+```
+<lib>/                          # 一个独立的 ESM 包(可跨项目)
+  src/
+    index.js                    # 公共出口(只在这里 export 对外 API)
+    <domain>/                   # 按领域分文件/子目录,如 workflow/、pricing/
+      *.js
+    internal/                   # 内部实现(不对外 export,可随意重构)
+  test/
+    *.test.js                   # node:test 单测(不依赖 DOM)
+  package.json                  # type: module,exports 映射
+```
+
+- **`src/index.js` 是唯一公共契约**;内部实现对 `internal/` 隐藏,外部只能 import 包名(见第五节 exports)。
+- **一个文件一个关注点**:`validate.js`(校验)、`machine.js`(状态机)、`format.js`(格式)。
+- 命名:小写 kebab-case 文件;`PascalCase` 类;`camelCase` 函数。
+
+## 三、核心库的「好的形状」
+
+| 该做 | 不该做 |
+|------|--------|
+| `export function validate(input, rules)` —— 纯函数,给什么算什么都由参数决定 | 读 `document.querySelector` 拿「当前表单」 |
+| `export class TaskMachine { constructor(config) {} next(event) {} }` —— 状态机,可 `new` 多份 | 模块级 `let store = {}` 饱含状态 |
+| 接收**注入**的 I/O(`fetchImpl`、`now`、`random`、`telemetry`) | 直接 `fetch()` / `Date.now()`(不可测) |
+| `export const parseConfig = (text) => {...}` | 模块顶层 `localStorage.getItem(...)` |
+
+### 可注入的 I/O(让测试确定化)
+
+```js
+// src/pricing/quote.js —— 纯计算,所有不确定输入都从参数注入
+export function quote(items, { taxRate = 0.06, now = () => Date.now(), id = () => crypto.randomUUID() } = {}) {
+  const subtotal = items.reduce((s, it) => s + it.price * it.qty, 0);
+  const tax = Math.round(subtotal * taxRate * 100) / 100;
+  return { id: id(), at: now(), subtotal, tax, total: Math.round((subtotal + tax) * 100) / 100 };
+}
+```
+
+- `now` / `random` / `id` / `fetchImpl` 都可注入 → 测试断言**确定值**,不靠 mock 全局。
+- 需要网络时:core 只定义**纯协议**(URL 拼装、DTO 解析),真正的 `fetch` 由 wc/page 层调用后传入结果。
+
+## 四、Node 可测(node:test,不依赖 jsdom)
+
+```js
+// test/quote.test.js
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { quote } from '../src/index.js';
+
+test('含税总价四舍五入到分', () => {
+  const r = quote([{ price: 19.99, qty: 3 }], { taxRate: 0.06, now: () => 0, id: () => 't1' });
+  assert.equal(r.subtotal, 59.97);
+  assert.equal(r.total, 63.57);
+});
+```
+
+运行:`node --test test/`
+
+- **`package.json` 里 `"type": "module"`**,`node --test`/`node --check` 原生识别 ESM。
+- 测试命名 `*.test.js`,与源码同构目录。
+- **禁止**在 core 测试里引 jsdom / happy-dom —— 需要 DOM 的代码就不该在 core 里。
+- CI 一条命令:`node --check`(语法)+ `node --test`(逻辑)。
+
+## 五、无构建打包为单文件(发布给浏览器用)
+
+> 铁律是「**源码不进构建流程**」—— 业务仓 / 页面仓不打包。**发布库**时若确需单文件(如嵌入第三方页面),
+> 走**独立发布流水线**、产物归档到 FMODE Repose,源码仍是单文件 ESM。**平时优先「无打包」,不要为了发布而给业务仓加 rollup。**
+
+### 5.1 首选:保持多文件 ESM(零打包)
+
+`core/` 本身就是浏览器原生 ESM 可直接 import 的。只要**相对 import 带 `.js`**,`<script type="module">` 与 `import` 都能直跑。
+托管到 Repose `/npm/<lib>/vX.Y.Z/` 目录,importmap 映射:
+
+```json
+{ "imports": {
+  "my-lib": "/npm/my-lib/v1.2.0/src/index.js",
+  "my-lib/": "/npm/my-lib/v1.2.0/src/"
+} }
+```
+
+> 依赖解析走 importmap(同 [project-structure.md 第五节](./project-structure.md));core 若依赖第三方 ESM(如 `signal-polyfill`),
+> 同样从 importmap 解析,**不**把第三方源码拷进包内。
+
+### 5.2 需要单文件时:用 `esbuild` 只在**发布**时打一次(不进业务仓)
+
+```bash
+# 发布流水线,非业务仓
+npx esbuild src/index.js --bundle --format=esm --platform=neutral \
+  --outfile=dist/my-lib.esm.js --external:'node:*'
+```
+
+- `--format=esm`(保持 ESM),`--platform=neutral`(不假设浏览器)。
+- 若要给 `<script src>` 直接挂全局:`--format=iife --global-name=MyLib`(**只给无法用 module 的环境**,默认不用)。
+- 产物**归档版本化**(`dist/` 不提交到源码仓,见第六节)。
+- **禁用装饰器语法**(同 [components.md 第〇节](./components.md)):core 里也不写装饰器,即使被打包器编译过 —— 源码即产物铁律延续。
+
+## 六、版本管理与兼容
+
+| 规则 | 说明 |
+|------|------|
+| **语义化版本** | 公共出口 `src/index.js` 的 API 遵循 semver:破坏性变更 = major |
+| **版本目录化(Repose)** | `/npm/<lib>/vX.Y.Z/` 只增不改;升级 = 新增目录 + 改 importmap 版本段,**不覆盖旧版** |
+| **单一出口** | 只 `export` 自 `src/index.js`;`package.json` `exports` 只放这一个入口(+ 必要子路径) |
+| **不 export 内部路径** | `exports` 里不写 `"./internal/*"`,外部无法深挖 |
+| **CHANGELOG** | 每次 major 写清「删了什么 / 改了什么签名」 |
+| **多版本共存** | 若两项目用不同 major,可在 importmap 用不同裸标识符(`my-lib` / `my-lib-v2`)指向不同版本目录 |
+
+```jsonc
+// package.json —— 核心库
+{
+  "name": "my-lib",
+  "version": "1.2.0",
+  "type": "module",
+  "exports": { ".": "./src/index.js" },
+  "sideEffects": false,
+  "files": ["src", "README.md"],
+  "scripts": { "check": "node --check src/index.js", "test": "node --test test/" }
+}
+```
+
+- `"sideEffects": false`:让打引用方(如有)能安全 tree-shake,且**强制**你遵守「模块无副作用」。
+- **不 import Lit**:`peerDependencies` 里不要出现 `lit`。
+
+## 七、与 wc / page 的契约
+
+```
+fmode-code-agent(逻辑层,core)  ──导出纯逻辑──▶  <designer-chat> / 任何框架的 UI(wc)
+                                                        │
+                                                        ▼
+                                                   Lit 页面(page)组合
+```
+
+- core 导出**类/函数**,wc 在组件内部 `new` 它们、监听状态、渲染。
+- core 的**状态变化通知**可以用 `@lit-labs/signals`(纯 JS,Node 可跑),也可以是自定义的纯观察者(`subscribe(fn)` 返回 `unsubscribe`)—— 选一个,**别在 core 里用 `EventTarget` 以外的 DOM 事件**(`EventTarget` 在 Node 18+ 可用,可接受;`CustomEvent` 在 Node 需 polyfill,尽量用自定义 `subscribe`)。
+- **错误契约**:core 抛**普通 `Error` 子类**,信息里带可判定字段(`code`),不抛 DOMException。
+
+```js
+// src/agent/machine.js —— 逻辑层状态机(纯 JS,Node 可测)
+export class AgentMachine {
+  #listeners = new Set();
+  #state = 'idle';
+  constructor({ transport }) { this.#transport = transport; }     // 注入 I/O
+  get state() { return this.#state; }
+  subscribe(fn) { this.#listeners.add(fn); return () => this.#listeners.delete(fn); }
+  #emit(next) { this.#state = next; for (const f of this.#listeners) f(next); }
+  async send(text) {
+    if (this.#state !== 'idle') throw Object.assign(new Error('busy'), { code: 'E_BUSY' });
+    this.#emit('thinking');
+    try { return await this.#transport.call({ text }); } finally { this.#emit('idle'); }
+  }
+}
+```
+
+- wc 层用 `new AgentMachine({ transport: browserTransport })`;Node 测试注入 `transport: fakeTransport`。
+- 同一核心库 → 同一逻辑 → 换任何前端框架 UI 行为一致。
+
+## 八、红线清单
+
+- ❌ `core/` 内出现 `window` / `document` / `localStorage` / `fetch(` / `HTMLElement` / `import 'lit'`
+- ❌ 模块顶层有副作用(`addEventListener`、`localStorage`、`console.log`)
+- ❌ export 可变单例(「全局配置对象」)
+- ❌ `Date.now()` / `Math.random()` 直接写死 → 注入 `now` / `random`
+- ❌ 在业务仓引入 rollup/tsc 只为打 core → 发布流水线单独处理
+- ❌ 写装饰器
+- ❌ 从 `internal/` 深 import
+
+## 九、core 发布 checklist
+
+- [ ] `grep -rE "\b(window|document|localStorage|sessionStorage|navigator)\b|\bfetch\(" src/` 无命中
+- [ ] `grep -rn "from 'lit" src/` 无命中
+- [ ] 模块导入无副作用:`node -e "import('./src/index.js')"` 干净退出
+- [ ] `node --check` 全过;`node --test test/` 全过
+- [ ] `src/index.js` 是唯一出口;`exports` 无内部路径
+- [ ] semver 版本号 + CHANGELOG;Repose 新版本目录(不覆盖旧版)
+- [ ] 相对 import 带 `.js`;第三方依赖走 importmap(不拷源码)
+
+## 相关
+
+- [architecture.md](./architecture.md) —— 三分层(core / wc / page)总图
+- [web-component-library.md](./web-component-library.md) —— 组件库(浏览器交互层)
+- [components.md](./components.md) —— 页面层组件规范
+- [services.md](./services.md) —— 页面内的 Service(也可抽成 core,但若沾 DOM 只能留在页面)

+ 172 - 0
dist/skills/fmode-code-lit/references/rules/deployment.md

@@ -0,0 +1,172 @@
+# 部署规范(FMODE Repose + OBS/CDN)
+
+> 本篇含内部部署细节,**禁止**复制到对外文档 / 开源材料 / 公开仓库(本仓对外以「开源前已脱敏」版本发布)。
+> 无论对内对外,**AK/SK 密钥一律不写进任何文档或代码**,只从既有部署脚本引用。
+
+## 一、两条托管线
+
+| 内容 | 托管方 | 说明 |
+|------|--------|------|
+| **依赖**(lit / @lit-labs/*) | **FMODE Repose** | 静态依赖仓,从 npm 下**原始 ESM** 原样同步,按版本目录化,全业务共享 |
+| **业务码**(index.html / js) | **OBS + CDN** | `obsutil` 同步 `src/` 到华为云 OBS,`public-read`,CDN 加速 |
+
+## 二、FMODE Repose(静态依赖仓)
+
+**存放布局**
+
+```
+/npm/lit/v3.x.x/                      # lit 运行时(原始 ESM)
+/npm/@lit-labs/context/vx/            # context
+/npm/@lit-labs/signals/vx/            # signals
+/npm/@lit-labs/router/vx/             # router
+/npm/importmap/lit-v3.json            # 公共 importmap(全业务复用)
+```
+
+**硬性要求**
+
+1. `.js` 响应的 `Content-Type` **必须** `text/javascript`(否则浏览器拒绝作为模块执行)。
+2. **CORS 允许业务域名**(否则跨域模块加载失败)。
+3. 开启 **HTTP/2(或 /3)** —— ESM 小文件多,靠多路复用降延迟。
+4. 依赖按**版本目录**化(`/vX.Y.Z/`),升级 = 新增目录 + 改 importmap 版本号,**不覆盖旧版本**。
+5. 保持 npm 包的**相对 import 结构**原样(原样同步,不重写 import)。
+
+**同步方式**:把 `node_modules/lit`、`node_modules/@lit-labs/*` 原样上传到 Repose 对应版本目录。
+(仓库内提供同步脚本入口占位,实际凭据从环境 / 既有脚本注入。)
+
+**公共 importmap**(托管在 Repose,业务引用)
+
+```json
+{
+  "imports": {
+    "lit": "/npm/lit/v3.2.1/lit.js",
+    "lit/": "/npm/lit/v3.2.1/",
+    "lit/decorators.js": "/npm/lit/v3.2.1/decorators.js",
+    "@lit-labs/signals": "/npm/@lit-labs/signals/v0.1.3/index.js",
+    "@lit-labs/context": "/npm/@lit-labs/context/v0.5.1/index.js",
+    "@lit-labs/router": "/npm/@lit-labs/router/v0.2.0/router.js"
+  }
+}
+```
+
+> 版本号以仓库实际落地为准,此处为示例形态。升级依赖 = 追加新路径 + 改这里的版本段。
+
+## 三、OBS + CDN(业务部署)
+
+**入口 index.html**
+
+```html
+<!doctype html>
+<html lang="zh-CN">
+<head>
+  <meta charset="utf-8" />
+  <base href="/<project>/" /><!-- 二级路径部署必须;根部署可省略或 "/" -->
+  <link rel="importmap" href="..."><!-- 见下 -->
+  <script type="importmap" src="<公共 importmap URL>"></script>
+</head>
+<body>
+  <app-root></app-root>
+  <script type="module" src="./app.js"></script>
+</body>
+</html>
+```
+
+- 公共 importmap 由 **Repose 托管**,全业务用同一份:
+  `<script type="importmap" src="<Repose>/npm/importmap/lit-v3.json"></script>`。
+- `<base href>` 决定相对路径基准,子目录部署**必须**写对。
+
+**上传(obsutil)**
+
+- 工具:`obsutil`(华为云官方 CLI,安装路径按各自环境而定)
+- 桶(bucket)与端点、AK/SK:**从你的部署环境配置中读取**(环境变量或本地凭据文件),**禁止**写入本仓文档/代码,也禁止提交到仓库。
+- 典型命令形态:
+
+```bash
+# 同步业务源码到 OBS 前缀,公开读
+obsutil sync ./src/ obs://<bucket>/<project>/ \
+  -e=obs.<region>.myhuaweicloud.com -acl=public-read -f
+```
+
+- **index.html 先删后传**:`sync` 按 size+mtime 判 Skip,`index.html` 名字固定、内容随发布变化,需先 `rm` 再 `cp`,避免被 Skip。
+- 静态资源设 `Cache-Control`:`index.html` 用 `no-cache`(保证回退拿到最新),带 hash 的资源可长缓存。
+
+**CDN 刷新**:上传后调用 CDN refresh(仓库内 `scripts/deploy.sh` 已接入既有刷新脚本)。刷新**范围**由传入的 URL 域名决定。
+
+> ⚠️ **CDN 刷新的两个假成功陷阱**(实测踩过,详见
+> [routing-best-practices.md 坑 5](./routing-best-practices.md#坑-5cdn-刷新看似成功实则没刷)):
+> 1. **域名对号入座**:每个加速域有各自的 `domain_id` 与凭证;用错只刷了一个域名,
+>    用户访问的另一个域名照旧是旧内容。未知域名应**直接报错**,别静默跳过。
+> 2. **SDK 返回结构**:`refresh_task` 是**字符串 ID**,不是对象。若读成
+>    `result.refresh_task.id` 会恒得 `undefined`,脚本**照常退出 0**(假成功)。
+>    必须对结果做**非空断言**,任一失败则**退出码非 0**。
+>
+> 刷新用 `type: 'directory'`(URL 以 `/` 结尾)一次覆盖目录,比逐个 file 省事且不易漏。
+
+## 三·五、验收:不能只 `curl` 首页
+
+`curl` 首页 200 **只证明 `index.html` 在**;无构建应用白屏的典型原因(某模块 404、MIME 错、
+回退没配、相对路径错一个字符)它一个都不覆盖。分层验收:
+
+1. **静态体检**(部署前):`node --check` 无语法/装饰器残留;相对 import 带 `.js`;
+   相对目标存在;裸标识符都在 importmap;自定义元素标签含连字符。
+2. **模块图爬取**(部署后,HTTP 层):从入口 `app.js` 跟随相对 import 走完整图,
+   断言每个文件 **200 + `Content-Type: text/javascript`**;importmap 每条目标可达;
+   **深链接返回 200 且内容是 `index.html`**(非 302)。
+3. **真实浏览器**(最终验收):点导航 + 刷新深链接,确认不 404、无控制台报错。
+4. **缓存层**:核对 CDN 返回的**内容**是新版(不只是状态码)。
+
+> 把 1–2 步固化成一条命令纳入部署流程。可复用脚本形态见
+> [routing-best-practices.md 坑 7](./routing-best-practices.md#坑-7只-curl-首页不算验收)。
+
+> 🔎 **CDN 冷边缘**:新上传的 key 首次被某边缘节点拉取时**偶发 404**(回源竞争),
+> 重跑一次即消失 —— 验收脚本碰到新 key 的首次 404 不要立刻判故障。
+
+## 四、History 路由的 CDN 回退(关键)
+
+| 规则 | 匹配 | 动作 |
+|------|------|------|
+| 规则 1 | 命中真实静态资源 | 直接返回该文件 |
+| 规则 2 | 路径不存在 | 返回 `index.html`,状态码 **200**,**禁止 301/302** |
+
+- 用 **200** 而非 302:302 会改写地址栏 / 丢 `location.pathname`,破坏路由参数与微信 OAuth 回调。
+- `index.html` 设 `no-cache`,确保回退总是拿到最新壳。
+
+## 五、部署脚本(模板)
+
+```bash
+#!/usr/bin/env bash
+# scripts/deploy.sh —— 无构建,直接同步 src/ 到 OBS,再刷 CDN
+set -euo pipefail
+PROJECT="${1:?usage: deploy.sh <project>}"
+BUCKET="${OBS_BUCKET:?set OBS_BUCKET}"           # 例: obs://my-bucket
+PREFIX="$PROJECT"
+EP="${OBS_ENDPOINT:?set OBS_ENDPOINT}"           # 例: obs.<region>.myhuaweicloud.com
+OBS="${OBSUTIL:-obsutil}"
+
+# 1) index.html 先删后传(防 Skip)+ no-cache
+"$OBS" rm "$BUCKET/$PREFIX/index.html" -e="$EP" -f >/dev/null 2>&1 || true
+"$OBS" cp ./src/index.html "$BUCKET/$PREFIX/index.html" -e="$EP" -acl=public-read -f \
+  -meta="Cache-Control:no-cache#Content-Type:text/html; charset=utf-8"
+
+# 2) 其余源码同步
+"$OBS" sync ./src/ "$BUCKET/$PREFIX/" -e="$EP" -acl=public-read -f
+
+# 3) 刷新 CDN(调用既有刷新脚本,凭据由该脚本/环境提供)
+# node <既有刷新脚本> "https://<cdn-domain>/$PREFIX/"
+```
+
+- **凭据注入**:AK/SK 从环境变量 / 既有脚本读取,**不落本仓**。
+- 无构建 → 无需构建步骤,`src/` 即产物。
+
+## 六、发布 checklist
+
+- [ ] `<base href>` 与部署前缀一致(**同一变量派生**,脚本里硬校验)
+- [ ] importmap 指向 Repose(**内联**在 index.html),版本号/前缀正确
+- [ ] 依赖已同步到 Repose(`Content-Type: text/javascript` + CORS 放行)
+- [ ] OBS 上传完成,`index.html` 已先删后传 + `no-cache`
+- [ ] CDN 回退规则:不存在路径 200 返回 index.html,**禁 301/302**
+- [ ] CDN 刷新完成(`type:directory`,**domain_id 对号入座**,读 `refresh_task` 字符串,失败退出非 0)
+- [ ] 直接命中深链接(如 `/users/42`)**刷新**可用(200 且为 index.html)
+- [ ] 静态体检 + 模块图爬取通过;**真实浏览器**点导航/刷新深链接无报错
+
+> 路由 / base / CDN 的完整肺坑与 check 清单见
+> [routing-best-practices.md](./routing-best-practices.md)。

+ 113 - 0
dist/skills/fmode-code-lit/references/rules/http-utils.md

@@ -0,0 +1,113 @@
+# HTTP 与工具(utils)
+
+> 用原生 `fetch` 薄封装,**不引 axios**。工具层是**纯无状态函数**,谁都能 import,不持有全局状态。
+
+## 一、utils 的边界
+
+| 允许 | 禁止 |
+|------|------|
+| 纯函数(格式化、校验、URL 拼接、深拷贝) | 持有可变全局状态 |
+| 通用封装(http、storage) | import `components/` 或 `services/` |
+| 依赖浏览器 API(fetch/localStorage/URL) | 依赖 Lit / DOM 渲染 |
+
+> utils 必须可在 Node 或任意上下文 import 而不产生副作用。
+
+## 二、HTTP 封装(utils/http.js)
+
+```js
+// utils/http.js
+import { authService } from '../services/auth-service.js';
+
+const BASE = '/api';   // 或从 config 注入
+
+class HttpError extends Error {
+  constructor(status, body) { super(`HTTP ${status}`); this.status = status; this.body = body; }
+}
+
+async function request(method, path, { body, headers = {}, query } = {}) {
+  let url = BASE + path;
+  if (query) url += '?' + new URLSearchParams(query).toString();
+
+  const finalHeaders = { 'Content-Type': 'application/json', ...headers };
+  const token = authService?.getToken?.();
+  if (token) finalHeaders.Authorization = `Bearer ${token}`;
+
+  const res = await fetch(url, {
+    method,
+    headers: finalHeaders,
+    body: body != null ? JSON.stringify(body) : undefined,
+    credentials: 'same-origin',
+  });
+
+  if (res.status === 401) {
+    authService.logout();
+    // 触发跳转登录(由路由/根组件响应 signal)
+    throw new HttpError(401, null);
+  }
+  const ct = res.headers.get('content-type') || '';
+  const data = ct.includes('application/json') ? await res.json() : await res.text();
+  if (!res.ok) throw new HttpError(res.status, data);
+  return data;
+}
+
+export const http = {
+  get: (p, o) => request('GET', p, o),
+  post: (p, body, o) => request('POST', p, { ...o, body }),
+  put: (p, body, o) => request('PUT', p, { ...o, body }),
+  del: (p, o) => request('DELETE', p, o),
+};
+```
+
+设计要点:
+- **token 自动注入**(从 `authService` 取)。
+- **401 统一处理**:登出 + 让 UI 响应 signal 跳登录。
+- **统一错误**:抛 `HttpError`,携带 `status` / `body`,调用方可 `catch`。
+- **DTO 解析**按 content-type 分流。
+- 循环依赖注意:`http` 引用 `authService`,`authService` 又用 `http` → 用**惰性调用**(函数内取)而非模块顶层解构,避免 ESM 循环初始化问题。
+
+## 三、Storage 封装(utils/storage.js)
+
+```js
+export const storage = {
+  get(key, fallback = null) {
+    try { const v = localStorage.getItem(key); return v == null ? fallback : JSON.parse(v); }
+    catch { return fallback; }
+  },
+  set(key, value) { localStorage.setItem(key, JSON.stringify(value)); },
+  remove(key) { localStorage.removeItem(key); },
+};
+```
+
+- 加 try/catch(隐私模式 / 配额满会抛)。
+- JSON 序列化,避免手写类型转换。
+- 敏感数据(token)单独考虑,不要明文长期存(见安全)。
+
+## 四、格式化(utils/format.js)
+
+```js
+export const formatMoney = (n, currency = '¥') =>
+  `${currency}${Number(n ?? 0).toFixed(2)}`;
+
+export const formatDate = (d, fmt = 'YYYY-MM-DD') => { /* ... */ };
+
+export const debounce = (fn, ms = 200) => {
+  let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); };
+};
+```
+
+纯函数,无副作用,可单测。
+
+## 五、其他常用工具
+
+| 工具 | 用途 |
+|------|------|
+| `utils/router.js` | `navigate()` / `href()` / `currentPath()`(把应用内路径拼到 base 的 **pathname** 上;见 [routing-best-practices.md](./routing-best-practices.md)) |
+| `utils/dom.js` | 少量原生 DOM 辅助(慎用,优先 Lit 指令) |
+| `utils/validate.js` | 表单校验纯函数 |
+
+## 六、禁止
+
+- ❌ 在 utils 里 `import { html }`。
+- ❌ 在 utils 里持有跨调用的可变状态(那属于 Service)。
+- ❌ 在组件里直接 `fetch`(一律走 `http`)。
+- ❌ 手写重复的 URL 拼接 / 时间格式化(抽到 utils)。

+ 118 - 0
dist/skills/fmode-code-lit/references/rules/modules/README.md

@@ -0,0 +1,118 @@
+# 模块划分最佳实践
+
+> 无构建项目最容易「长成一坨」:所有组件堆在 `components/`、所有逻辑塞进一个超大 service。
+> 本篇给出**功能模块 / 通用能力 / 业务模块**的拆分准则与落地目录。
+
+## 一、三种模块,三种归属
+
+| 类型 | 定义 | 归属 | 是否可跨项目 |
+|------|------|------|--------------|
+| **通用能力(util skill)** | 与业务无关的纯能力:http、storage、format、debounce | `utils/` | ✅ 可抽成包托管 Repose |
+| **通用组件(UI kit)** | 与业务无关的可复用 UI:弹窗、表格、表单、布局壳 | `components/` | ✅ 可抽成组件库 |
+| **功能模块(feature)** | 一块完整业务功能:用户管理、订单、报表 | `modules/<feature>/` | ❌ 业务专属 |
+
+> 判别法:**换一个业务项目,它还有用吗?** 有用 → 通用(utils/components);没用 → 功能模块。
+
+## 二、推荐目录(功能模块化)
+
+```
+src/
+  app.js
+  components/                 # 跨模块通用 UI
+    app-shell.js
+    tabs-bar.js
+    base-table.js
+  modules/                    # 功能模块(一块一块业务)
+    user/
+      page-user-list.js       # 页面组件
+      page-user-detail.js
+      user-service.js         # 模块内 service
+      user-model.js           # 模块内数据结构/转换
+    order/
+      page-order-list.js
+      order-service.js
+  services/                   # 跨模块共享 service(全局状态)
+    auth-service.js
+    dict-service.js
+  utils/                      # 通用能力
+    http.js
+    storage.js
+    format.js
+  index.html
+```
+
+规则:
+- **模块内聚**:一个功能模块的页面、service、model 放在同一目录,便于整体搬走 / 整体删除。
+- **跨模块共享**才上提到 `services/`(如 auth、字典、权限)。
+- **通用 UI** 才留在顶层 `components/`;只在某模块用的组件放在该模块目录内。
+
+## 三、模块的对外契约
+
+一个功能模块**只暴露页面组件**(供路由挂载)与必要的 service。内部实现(子组件、model)不外泄。
+
+```
+modules/user/
+  index.js            # 可选:统一出口
+    export { PageUserList } from './page-user-list.js';
+    export { userService } from './user-service.js';
+```
+
+- 别的模块**不要**深挖 `modules/user/内部子组件.js`。
+- 需要跨模块调用的,通过模块出口或上提的共享 service。
+
+## 四、Service 的粒度
+
+| 粒度 | 例子 | 归属 |
+|------|------|------|
+| 全局域(登录、权限、字典) | `auth-service` | `services/` |
+| 模块域(仅本模块用) | `user-service` | `modules/user/` |
+| ❌ 万能 service | `app-service`(什么都塞) | 拆掉 |
+
+- 一个 service 一个关注点。
+- 模块 service 若被第二个模块需要 → 上提到 `services/`(**上提,而非复制**)。
+
+## 五、什么时候抽到 Repose(组件库 / 能力包)
+
+满足**全部**以下条件才抽:
+
+1. ≥2 个项目在用;
+2. 接口稳定(属性/事件契约不频繁变);
+3. 无业务耦合(不依赖具体业务 model / 接口)。
+
+抽出的产物:
+- **UI 组件库**:一批 `kebab-case` 自定义元素,托管到 FMODE Repose,业务用 importmap 引入。
+- **能力包**:`http`、`storage` 等 utils 打成 ESM 包。
+
+> 抽早了是负担。先用项目内 `components/` `utils/` 沉淀,稳定后再上提。
+
+## 六、依赖方向(严格单向)
+
+```
+routes/modules  →  components  →  services  →  utils
+       │                │              │
+       └────────────────┴──────────────┘   禁止反向
+```
+
+- 上层可依赖下层,**下层禁止反向依赖上层**。
+- `utils` 不认识 `services` / `components`。
+- `services` 不 `import` Lit / 组件。
+- 反向依赖一旦出现,说明职责放错了层。
+
+## 七、模块划分 checklist
+
+- [ ] 换业务就失效的代码,是否都收在 `modules/<feature>/` 里?
+- [ ] 跨模块共享的状态是否上提到 `services/`(而非每个模块各存一份)?
+- [ ] 通用 UI 是否在 `components/`,且不 import 任何业务 model?
+- [ ] 依赖方向是否单向(无 `utils → services` 之类的反向 import)?
+- [ ] 是否有「万能 service / 万能组件」需要拆?
+- [ ] 满足复用条件的组件 / 工具,是否已考虑上提 Repose?
+
+## 八、反模式
+
+| 反模式 | 症状 | 修复 |
+|--------|------|------|
+| 上帝 service | 一个 service 上千行,改哪都怕 | 按关注点拆分 |
+| 组件内联业务 | 页面组件里 assert 校验 + fetch + 拼接口 | 逻辑下沉 service |
+| utils 有状态 | utils 里存了「上次结果」 | 移到 service |
+| 复制粘贴跨模块 | 两个模块各有一份 tableservice | 上提共享 |
+| 过早抽象 | 只有一个用户就搞组件库 | 先项目内沉淀 |

+ 98 - 0
dist/skills/fmode-code-lit/references/rules/modules/examples.md

@@ -0,0 +1,98 @@
+# 场景化模块拆分示例
+
+> 配套 [README.md](./README.md),给出一个「运营后台」的完整拆分演练。
+
+## 一、需求
+
+一个运营后台,含:
+- 登录 / 登出、当前用户、权限判断
+- 侧边菜单(按权限过滤)
+- 用户管理(列表 / 详情 / 编辑)
+- 订单管理(列表 / 详情)
+- 数据字典(下拉选项,多处用)
+- 多标签工作区
+
+## 二、拆分结果
+
+```
+src/
+  app.js                         # 装配:provide 全局 service + 路由 + 根壳
+  components/                    # 通用 UI(无业务耦合)
+    app-shell.js                 # 布局壳(菜单 + 标签 + 内容区)
+    side-menu.js                 # 侧边菜单(接收菜单数据,不自己拉接口)
+    tabs-bar.js                  # 多标签栏
+    tabs-container.js            # KeepAlive 容器
+    base-table.js                # 通用表格(列配置驱动)
+    base-modal.js                # 通用弹窗(slot 内容)
+  modules/
+    auth/
+      page-login.js
+      auth-service.js            # 登录态 + token + 权限码
+    user/
+      page-user-list.js
+      page-user-detail.js
+      user-service.js            # 用户 CRUD
+      user-model.js              # 用户数据结构 + 转换
+    order/
+      page-order-list.js
+      page-order-detail.js
+      order-service.js
+  services/                      # 跨模块共享
+    dict-service.js              # 数据字典(权限/订单模块都用)
+    menu-service.js              # 菜单树(app-shell 用)
+  utils/
+    http.js
+    storage.js
+    format.js
+    router.js
+```
+
+## 三、归属决策演示
+
+| 代码 | 归属 | 理由 |
+|------|------|------|
+| 登录态、token、权限码 | `modules/auth/auth-service.js` | 认证是独立功能域 |
+| 用户 CRUD | `modules/user/user-service.js` | 仅用户模块用 |
+| 订单 CRUD | `modules/order/order-service.js` | 仅订单模块用 |
+| 数据字典 | `services/dict-service.js` | **跨模块**(用户、订单都拉下拉项)→ 上提 |
+| 菜单树 | `services/menu-service.js` | app-shell 用,且依赖权限 → 跨模块 |
+| 表格 / 弹窗 | `components/base-table.js` 等 | 无业务耦合,可复用/可抽库 |
+| http / storage | `utils/` | 纯能力 |
+
+## 四、关键交互(数据流)
+
+```
+page-user-list(视图)
+  ├─ 读 dictService.roles(signal)→ 渲染筛选项
+  ├─ 调 userService.fetchList(query) → 拿到数据
+  └─ 渲染 base-table(传入列配置 + 数据)
+        └─ 行点击 → dispatchEvent('row-click')
+              └─ page-user-list 收到 → router.navigate('/users/:id')
+                    └─ tabsService.open(...) + toggle 激活 → KeepAlive
+```
+
+原则回顾:
+- **组件只读 signal、只转发事件**,不 fetch。
+- **service 持有状态与业务**,无 DOM。
+- **跨模块**的状态在 `services/`,**模块内**的在模块目录。
+
+## 五、依赖方向检查
+
+- `page-user-list` → `base-table`、`user-service`、`dictService` ✅(上层依赖下层/共享)
+- `user-service` → `utils/http` ✅
+- `utils/http` → `auth-service`(取 token)⚠️ —— 这是「工具反向依赖服务」。
+  - **处理**:不改依赖方向,而是让 `auth-service` **注入** token getter 给 http,或 `http` 通过参数接收 token。
+  - 例:`configureHttp({ getToken: () => authService.getToken() })` 在 `app.js` 装配时调用。这样 `utils` 不 import `services`。
+
+## 六、可测试性验证
+
+- `user-service` / `dict-service` 可在 Node 直接 import,mock `http` 后单测。
+- `format.js` / `storage.js` 纯函数,直接断言。
+- `base-table` 组件逻辑(列排序/选择)用 DOM 测试或抽纯函数到 utils 测。
+
+## 七、演进路径(从项目内到 Repose)
+
+1. 项目内先用 `components/base-table.js`。
+2. 第二个项目也要 → 抽到独立包 `fmode-ui-table`(ESM,无构建)。
+3. 托管到 FMODE Repose,两项目 importmap 引入。
+4. 保持契约稳定(属性/事件版本化)。

+ 118 - 0
dist/skills/fmode-code-lit/references/rules/pitfalls.md

@@ -0,0 +1,118 @@
+# 风险与坑位
+
+> 无构建方案的红线。踩中任何一条,症状往往隐蔽(白屏 / 404 / 路由丢失),
+> 排查时先按本表逐条对照。
+
+## 〇、用了装饰器语法(`@customElement` 等)
+
+**症状**:整个模块 `SyntaxError: Invalid or unexpected token` / `Unexpected character '@'`,白屏。
+**原因**:装饰器是 **TS/Babel 编译期**特性,浏览器原生 JS **不执行**。Lit 官方文档默认「装饰器 + 构建」,
+无构建下照抄即挂。
+**对策**:一律用 `static properties` + `customElements.define()` + `ContextConsumer` 编程式 API。
+详见 [components.md](./components.md) 第〇节。**本项目所有示例均无装饰器**。
+
+## 一、CORS(最高频)**症状**:模块加载失败、`Failed to fetch dynamically imported module`、控制台 CORS 报错。
+**原因**:FMODE Repose 未放行业务域名。
+**对策**:Repose 的 CORS 必须允许所有业务域名;跨域模块请求带 `Origin`,响应需 `Access-Control-Allow-Origin`。
+> ESM 模块请求是 **CORS 请求**(比普通脚本严格),勿用「能下载就能跑」判断。
+
+## 二、Content-Type 不是 `text/javascript`
+
+**症状**:`Failed to load module script: Expected a JavaScript module script but the server responded with a MIME type of ...`。
+**原因**:依赖仓把 `.js` 当 `text/plain` / `application/octet-stream` 返回。
+**对策**:Repose 上传时显式设 `Content-Type: text/javascript; charset=utf-8`。
+
+## 三、多版本 lit 混用
+
+**症状**:自定义元素重复注册报错 `Failed to execute 'define' ... has already been used`;或响应式失效。
+**原因**:importmap / 直接路径引到了两个不同版本的 lit(如 `lit@3.1` 与 `lit@3.2` 各被加载一次,`LitElement` 类不同)。
+**对策**:**全业务统一 importmap**,只允许一个 lit 版本;所有包都从同一 Repose 版本段解析。除 `lit` 本身,注意 `lit-html` / `lit-element` / `@lit/reactive-element` 也要**单实例**(importmap 里补全子路径映射)。
+
+## 四、History 路由被 CDN 302
+
+**症状**:深链接刷新后地址栏变成 `/index.html`,路由参数丢失;微信 OAuth 回调失败。
+**原因**:CDN 回退用 301/302 重定向。
+**对策**:回退**必须 200** 返回 `index.html`,地址栏不变。见 [deployment.md](./deployment.md)。
+
+## 四·一、`new URL(path, baseURI)` 丢部署前缀(子路径部署最高频)
+
+**症状**:子路径部署下(`/dev/myapp/`)导航跳到域名根 `https://cdn/students`,刷新 404;
+根部署时正常。
+**原因**:`new URL` 的相对解析:`input` 以 `/` 开头时是绝对路径,会**整体替换** base 的
+path,**不保留** base 的目录前缀。
+**对策**:把应用内路径**拼到 base 的 pathname 上**再解析;导航一律走 `utils/router.js`。
+见 [routing-best-practices.md 坑 1](./routing-best-practices.md#坑-1任意的-new-urlpath-baseuri-会丢掉部署前缀)。
+
+## 四·二、`@lit-labs/router` 污染子路径部署的路由状态
+
+**症状**:引入后路由偶发跳错/停错页/点内部链接整页刷新,根部署正常、二级路径异常。
+**原因**:它装全局 `<a>` click 拦截器自行 `pushState`;且用**未剥前缀**的 `location.pathname`
+匹配(路由表是应用内路径)。
+**对策**:子路径部署**自写** `utils/router.js`(~60 行);根部署可选。
+见 [routing-best-practices.md 坑 2](./routing-best-practices.md#坑-2lit-labsrouter-在子路径部署下污染路由状态)。
+
+## 五、子目录部署缺 `<base href>`(或三处前缀不一致)
+
+**症状**:`./app.js`、相对 import 全部 404(被解析到域名根)。
+**原因**:子目录部署(`/admin/`)未写 `<base href="/admin/">`;或 `<base href>`、
+OBS 上传前缀、importmap 路径前缀**三处不一致**。
+**对策**:三处由**同一变量派生**,部署脚本里 `grep` 硬校验;`navigate()` 基于 base 拼 URL。
+见 [routing-best-practices.md 坑 4](./routing-best-practices.md#坑-4base-href-与实际部署前缀不一致三处必须同源)。
+
+## 六、相对 import 漏写 `.js` 后缀
+
+**症状**:`Failed to resolve module specifier './foo'` / 404。
+**原因**:浏览器原生 ESM **不做扩展名补全**。
+**对策**:相对导入一律带 `.js`(`import x from './foo.js'`)。裸标识符(`lit`)走 importmap。
+
+## 七、Signal 与 Lit property 混用
+
+**症状**:更新时机错乱、该刷不刷、或刷两次。
+**原因**:把「组件本地 UI 状态」也塞进全局 signal,或把「跨组件业务状态」放在 `@property` 里手动同步。
+**对策**:**划清界限** —— 组件内 UI 用 `@property`/`@state`;跨组件业务用 signal + `SignalWatcher`。
+
+## 八、ESM 小文件多 → 请求多
+
+**症状**:首屏依赖瀑布、加载慢。
+**原因**:无打包 = 每个模块一次请求。
+**对策**:Repose 开 **HTTP/2/3** 多路复用;合理目录深度(别过深);`Cache-Control` 长缓存版本化依赖。
+
+## 九、老浏览器不支持 Import Map
+
+**症状**:低版本浏览器无法解析裸标识符。
+**原因**:Import Map 是现代浏览器原生特性。
+**对策**:内部后台一般可接受现代浏览器基线;如需兼容,引入 import-map polyfill(es-module-shims)放在所有 module 之前。
+
+## 十、循环依赖导致初始化时机问题
+
+**症状**:某模块拿到 `undefined`(如 `http` 顶层解构了尚未初始化的 `authService`)。
+**原因**:ESM 循环引用 + 顶层立即求值。
+**对策**:跨模块引用**延迟到函数内**取值(别在模块顶层解构相互引用的单例)。
+
+## 十一、无构建下的其他红线
+
+| 坑 | 说明 |
+|----|------|
+| 引入需要打包器转译的依赖(CJS/TS) | 浏览器无法直接跑 CJS;只选 **原生 ESM** 依赖 |
+| 用 `.ts` 源码直发 OBS | 浏览器不认 TS;无构建下**源码即 JS** |
+| 直接改 `src/` 后忘记刷 CDN | 看到旧版本;发布流程要含 CDN 刷新 |
+| `index.html` 被 CDN 长缓存 | 回退/更新拿到旧壳;`index.html` 设 `no-cache` |
+| `index.html` 被 `sync` 跳过 | `sync` 按 size+mtime 判 Skip;**先删后传** |
+| CDN 刷新「看似成功」 | `refresh_task` 是字符串 ID,读 `.id` 恒 undefined → 假成功;须非空断言 + 失败退出非 0 |
+| CDN 刷新只刷了一个域名 | domain_id/凭证对号入座;未知域名直接报错 |
+| 只 `curl` 首页算验收 | 首页 200 ≠ 能跑;须模块图爬取 + 真实浏览器 |
+| 自定义元素标签忘带连字符 | `define` 抛错,如 `approot` 应 `app-root` |
+| 在卡片内嵌链接未 `stopPropagation` | 点内层链接触发外层跳转;内层 `@click` 断冒泡 |
+
+> 路由 / base / CDN 的完整肺坑见 [routing-best-practices.md](./routing-best-practices.md)。
+
+## 十二、排查口诀
+
+1. 看控制台 **MIME / CORS / 404** 三类报错——先分清是「依赖没托管好」还是「业务路径错」。
+2. 打开 Network:模块请求的 **Content-Type** 与 **状态码**。
+3. 深链接刷新 404 → 查 CDN 回退规则(是否 200)。
+4. 相对资源 404 → 查 `<base href>` 与部署前缀是否一致。
+5. 导航跳到域名根 / 前缀丢失 → 查是否用了 `new URL(path, baseURI)`。
+6. 点内部链接整页刷新 / 路由跳错 → 查是否引入 `@lit-labs/router`(子路径部署)或裸 `<a>`。
+7. 线上还是旧版 → 查 CDN 刷新的 domain_id 与 `refresh_task` 返回值(别信「假成功」日志)。
+8. 元素重复注册 → 查 lit 是否多版本。

+ 98 - 0
dist/skills/fmode-code-lit/references/rules/project-structure.md

@@ -0,0 +1,98 @@
+# 项目目录规范
+
+> 无打包 → `src/` 目录结构会**原样**上传到 OBS(不存在 tree-shaking / 文件重命名 / hash),
+> 所以目录即 URL 路径,文件即模块。摆放必须克制、可预测。
+
+## 一、标准目录
+
+```
+src/
+  index.html            # 入口:<base> + <script type="importmap"> + <script type="module" src="./app.js">
+  app.js                # 应用装配:全局 DI provide + 路由挂载 + 根组件渲染 + 多标签
+  components/           # 通用组件(可跨页复用)
+    side-menu.js
+    user-card.js
+    tabs-container.js
+  routes/               # 页面级组件(一页一文件)
+    page-user-list.js
+    page-user-detail.js
+    page-404.js
+  services/             # 业务服务(状态 + 接口 + 计算)
+    auth-service.js
+    table-service.js
+    router-service.js
+  utils/                # 无状态工具
+    http.js
+    storage.js
+    format.js
+```
+
+`examples/hello-lit/` 是本规范的完整可运行样例。
+
+## 二、命名约定
+
+| 类型 | 文件 | 类 / 导出 | 自定义元素标签 |
+|------|------|-----------|----------------|
+| 通用组件 | `kebab-case.js` | `PascalCase`(如 `UserCard`) | `kebab-case`(`<user-card>`,**必须含连字符**) |
+| 页面组件 | `page-xxx.js` | `PageXxx` | `<page-xxx>` |
+| 服务 | `xxx-service.js` | `XxxService` | 单例:`export const xxxService = new XxxService()` |
+| 工具 | `xxx.js` | 具名导出纯函数 | — |
+
+- 目录一律小写 kebab-case。
+- 自定义元素标签**必须**包含短横线(HTML 规范,否则注册失败)。
+- 一个文件一个默认/具名导出的组件类,文件名与标签对应。
+
+## 三、为什么要「目录即路径」
+
+无构建下浏览器按 URL 解析相对 import:
+
+```js
+// 在 /projects/admin/routes/page-user-list.js 中
+import { authService } from '../services/auth-service.js';  // → /projects/admin/services/auth-service.js
+```
+
+因此:
+- **相对导入必须带 `.js` 后缀**(浏览器原生 ESM,不做扩展名补全)。❌ `import x from './foo'`
+- 移动文件 = 改 URL,改文件必须同步改所有引用。
+- 不要深层嵌套(超过 3 层)——ESM 小文件多,路径越长越啰嗦。
+
+## 四、二级子路径部署
+
+子目录部署(如 `/projects/admin/`)时,**必须在 `index.html` 的 `<head>` 写 `<base href>`**:
+
+```html
+<base href="/<project>/"><!-- 例:/admin/ 或 /dev/fmode-app/ -->
+```
+
+否则浏览器会把 `./app.js` 解析到域名根,相对 JS 全部 **404**。
+
+- `<base>` 影响:所有**相对 URL**(`href`/`src`/`fetch` 相对路径/`import` 裸相对路径)。
+- 路由若用 History API 生成链接,需基于 `base` 或应用前缀拼装,见 [routing.md](./routing.md)。
+- 切换部署路径只需改这一处 `<base href>` + importmap 里的业务前缀(若有)。
+
+## 五、依赖不进业务仓
+
+- **禁止**在业务仓 `node_modules` 里放 `lit` 供运行时 `import`。
+- **禁止**把 `lit` 源码拷进 `src/`。
+- 运行时依赖全部由 `index.html` 的 importmap 指向 **FMODE Repose** 解析,见 [deployment.md](./deployment.md)。
+- 本地 `npm install lit @lit-labs/*` **仅**用于 IDE/TS 类型提示,**不参与产物**。
+
+```jsonc
+// package.json —— 仅开发期类型提示,不打包
+{
+  "private": true,
+  "devDependencies": { "lit": "^3.2.0", "@lit-labs/signals": "^0.1.0" },
+  "scripts": { "deploy": "bash scripts/deploy.sh" }
+}
+```
+
+## 六、文件摆放决策表
+
+| 这个东西是… | 放哪 |
+|-------------|------|
+| 全站复用的 UI(菜单、卡片、弹层) | `components/` |
+| 一个路由对应的一整页 | `routes/page-*.js` |
+| 有状态、要跨页共享的业务逻辑 | `services/*-service.js` |
+| 无状态、纯计算的函数 | `utils/` |
+| 全局装配(DI、路由、根标签) | `app.js` |
+| 第三方运行时依赖 | **不放项目里**,走 Repose + importmap |

+ 453 - 0
dist/skills/fmode-code-lit/references/rules/routing-best-practices.md

@@ -0,0 +1,453 @@
+# 路由最佳实践与常见坑(子路径部署 / History 模式 / CDN 回退)
+
+> 本篇是 [routing.md](./routing.md) 的**实战补充**:routing.md 讲「怎么做」,本篇讲
+> 「为什么这么做、错了会怎样、如何一眼定位」。
+>
+> 素材来自一次真实的多页看板交付(Lit 无构建、部署在 `/<env>/<project>/` 二级路径、
+> History 干净 URL、走 OBS + CDN)。所有条目都对应一次真实故障。
+>
+> 通篇只讲**网页应用**的通用规律(路由 / base / CDN / 部署 / 验收),不含任何具体业务。
+
+## 阅读方式
+
+每条按 **症状 → 根因 → 最佳实践 / 规避** 组织。排查时按症状检索即可。
+
+- 前缀缺失、深链接刷新 404:见 [坑 1](#坑-1任意的-new-urlpath-baseuri-会丢掉部署前缀)
+- 路由状态被第三方库污染:见 [坑 2](#坑-2lit-labsrouter-在子路径部署下污染路由状态)
+- 发版后看到旧页面:见 [坑 5](#坑-5cdn-刷新看似成功实则没刷)
+- 部署脚本「跑完了但东西没上去/地址不对」:见 [坑 8](#坑-8部署脚本的-static-cd-陷阱与硬编码-url)
+
+---
+
+## 零、一张图记住全部关系
+
+```
+浏览器地址栏  https://<cdn>/<env>/<project>/students/s01
+                         └────────┬─────────┘
+                    部署前缀(必须三处一致)
+                    = OBS 上传前缀
+                    = <base href>
+                    = importmap 里业务/依赖的路径前缀
+
+index.html   <base href="/<env>/<project>/">     ← 相对 import 的解析基准
+utils/router 把「应用内路径 /students/s01」拼到 base 的 pathname 上 → 真实 URL
+CDN 回退      命中不了静态资源 → 200 返回 index.html(禁 302)
+```
+
+**一句话:应用内部只认「不含前缀的应用内路径」(`/students/s01`),
+前缀只在三处出现一次(base href / 上传前缀 / importmap),且三者必须由同一个变量派生。**
+
+---
+
+## 坑 1:任意的 `new URL(path, baseURI)` 会丢掉部署前缀
+
+**症状**
+- 子路径部署(如 `/dev/myapp/`)下,点导航跳到 `https://<cdn>/students`(**域名根**,前缀没了)。
+- 刷新该页 → **404**(OBS 上根目录没有这个文件,CDN 回退也没覆盖到)。
+- 本地开发用根路径部署时一切正常,一上线换二级路径就崩 —— 典型的「环境相关」假象。
+
+**根因**
+
+`new URL(input, base)` 的语义是 **RFC 3986 相对解析**:当 `input` **以 `/` 开头**时,
+它是**绝对路径**,会**整体替换** base 的 path,**不保留 base 的目录前缀**。
+
+```js
+new URL('/students', 'https://cdn/dev/myapp/')
+// → https://cdn/students        ❌ 前缀 /dev/myapp 被丢掉
+new URL('students', 'https://cdn/dev/myapp/')
+// → https://cdn/dev/myapp/students  ✅ 但依赖 base 以 / 结尾,脆弱
+```
+
+而应用内部路径(`/students`)天然带前导 `/`,于是「尊重 `<base href>`」这个直觉写法
+在子路径部署下**必然**丢前缀。
+
+**最佳实践**
+
+先把应用内路径**拼到 base 的 pathname 上**,再交给 URL 解析(此时相对解析不会丢前缀):
+
+```js
+/** base 路径(去尾斜杠),如 '/dev/myapp';根部署时为 '' */
+export function basePath() {
+  return new URL(document.baseURI).pathname.replace(/\/+$/, '');
+}
+
+/** 应用内路径 → 保留部署前缀的绝对 URL(查询串 / hash 原样保留) */
+export function resolveUrl(path) {
+  const base = basePath();
+  const q = path.indexOf('?');
+  const h = path.indexOf('#');
+  let cut = path.length;
+  if (q !== -1) cut = Math.min(cut, q);
+  if (h !== -1) cut = Math.min(cut, h);
+  let p = path.slice(0, cut);
+  const rest = path.slice(cut);          // ?query#hash 原样带过去
+  if (!p.startsWith('/')) p = '/' + p;   // 容错:允许传 'students'
+  return new URL(base + p + rest, location.origin);
+}
+```
+
+**红线(写进 code review 清单)**:业务代码里**禁止**出现 `new URL(path, document.baseURI)`
+用在「导航 / 内部链接」上。一切经 `utils/router.js` 的 `resolveUrl()`。
+
+---
+
+## 坑 2:`@lit-labs/router` 在子路径部署下污染路由状态
+
+**症状**
+- 引入 `@lit-labs/router` 后,子路径部署的应用里路由「偶尔跳错 / 停在错误页 / 点内部链接整页刷新」。
+- 根部署时完全正常,换二级路径后诡异。
+
+**根因(两点叠加)**
+
+1. 它在 `window` 上装了**全局 click 拦截器**,对**同源 `<a>` 自行 `pushState`** ——
+   绕过你自己的导航函数(也就绕过了 base 前缀处理),且与「手动 `<a>` + 原生刷新」的行为预期冲突。
+2. 它在 `hostConnected` / `popstate` 时,用**未经剥离的 `location.pathname`**(带着 `/dev/myapp/`)
+   去匹配路由表 —— 而路由表里的 path 是**应用内路径**(`/students`),于是匹配不上 / 匹配错。
+
+**最佳实践**
+
+- **子路径部署的项目,不要用 `@lit-labs/router`**。自写一个 ~60 行的 `utils/router.js`:
+  - `navigate(path, {replace})`:`pushState` 后**手动派发 `popstate`**(History 原生不触发),
+    让订阅者感知;内部走 `resolveUrl()`(保留前缀)。
+  - `currentPath()`:从 `location.pathname` **剥掉 base 前缀**,返回应用内路径。
+  - `href(path)`:给**真实 `<a>`** 用,返回保留前缀的完整 URL。
+- 要不要界面路由框架?**不需要**。无构建项目里「路由表 + `currentPath()` + `popstate` 订阅」
+  就是最小的、可调试的路由。见 [routing.md 第二节](./routing.md)。
+- 若坚持用第三方 router:必须在 `goto()`/`popstate` 前把 `pathname` 剥前缀,并
+  关掉/绕过它的全局 `<a>` 拦截 —— 成本通常高于自写。
+
+**配套:`currentPath()` 要稳**
+
+```js
+export function currentPath() {
+  const base = basePath();
+  const p = location.pathname;
+  let path = p.startsWith(base) ? p.slice(base.length) : p;  // 脏 URL 退回全路径
+  if (!path.startsWith('/')) path = '/' + path;
+  return path.replace(/\/{2,}/g, '/') || '/';   // 折叠 '//students'
+}
+```
+
+- **剥前缀**:路由表只认应用内路径。
+- **脏数据兜底**:万一 URL 落在前缀之外(历史书签 / 手工输入),**退回到 `/` 开头的全路径**,
+  交给路由表落到 404 页 —— 别抛异常、别白屏。
+- **折叠重复斜杠**:`/dev/myapp//students` 不该变成两条不同路由。
+
+---
+
+## 坑 3:深链接刷新 404 —— CDN 回退不是 200
+
+**症状**
+- 站内点击正常(SPA 前端接管),但**在该 URL 上直接刷新 / 粘贴给别人打开 → 404**。
+- 或:刷新后**地址栏变成了 `/index.html`**,路由参数全丢(这是 302 的特征)。
+- 微信 OAuth 回调失败(`redirect_uri` 与实际落地 URL 不一致)。
+
+**根因**
+
+History 模式下,`/dev/myapp/students/s01` 这个路径在 OBS 上**根本不存在**(OBS 里只有
+`index.html` 和真实文件)。没有回退规则时,CDN 直接 404。
+
+**最佳实践**
+
+CDN 配置**两条回退规则**,顺序不能反:
+
+| 顺序 | 匹配 | 动作 |
+|------|------|------|
+| 规则 1 | 命中真实静态资源(`.js` / `.css` / `.png` …) | 直接返回该文件 |
+| 规则 2 | 其余(路径不存在) | 返回 `index.html`,**状态码 200** |
+
+- **必须 200,禁止 301/302**:302 会由浏览器改写地址栏、丢失 `location.pathname`,
+  路由参数与 OAuth 回调全部破坏。回退「返回内容」而非「重定向」。
+- `index.html` 设 `Cache-Control: no-cache`,保证回退永远拿到最新壳。
+- 部署后**逐条实测深链接**(含一个不存在的路径,验证它会落到 404 页而不是报错)。
+  见 [坑 7 验收清单](#坑-7只-curl-首页不算验收)。
+
+---
+
+## 坑 4:`<base href>` 与实际部署前缀不一致(三处必须同源)
+
+**症状**
+- 所有相对 import / 相对资源(`./app.js`、`./components/x.js`)**全部 404**,
+  被解析到**域名根**而不是项目目录。
+- 或换个环境部署后,importmap 指向的依赖路径 404。
+
+**根因**
+
+`<base href>`、OBS 上传前缀、importmap 里的路径前缀,是**同一个前缀的三个副本**。
+只要有一处不一致(比如手工改了其中一处),就从那一处开始 404。
+
+**最佳实践**
+
+1. **三处同源**:在部署脚本里用**一个变量**派生全部前缀,禁止三处各写各的:
+
+   ```bash
+   OBS_PREFIX="dev/${PROJECT}"       # OBS 上传前缀
+   BASE_HREF="/dev/${PROJECT}/"      # <base href> 期望值
+   # importmap 里的业务/依赖路径 = /${OBS_PREFIX}/...
+   ```
+
+2. **部署脚本里硬校验**(失败即中止,别等到线上白屏):
+
+   ```bash
+   grep -q "<base href=\"${BASE_HREF}\"" src/index.html \
+     && echo "ok base href = ${BASE_HREF}" \
+     || { echo "!! base href 与部署前缀 ${BASE_HREF} 不一致,相对 import 会 404" >&2; exit 1; }
+   ```
+
+3. **importmap 用内联**(`<script type="importmap">{...}</script>`),不要用
+   `<script type="importmap" src="...">` 外链 —— 外链 importmap 浏览器支持面窄,
+   内联是唯一 100% 可靠的形式。内联的那份同时托管一份到依赖仓供他项目复用。
+4. **依赖也带前缀**:若依赖与业务同源(如都在 `/<env>/npm/`),importmap 里依赖路径
+   **必须**写成 `/<env>/npm/lit/vX/index.js`,与 `<base href>` 前缀一致;
+   同源即无 CORS 问题,是省事之选。
+
+> 判定口诀:**根部署(`/`)时它无所谓,子路径部署时它是所有 404 的总开关。**
+
+---
+
+## 坑 5:CDN 刷新「看似成功实则没刷」
+
+**症状**
+- 发布后线上仍是**旧页面**(甚至新旧混着:壳是新的、某个 js 是旧的)。
+- 刷新脚本日志打印 `taskId: undefined` 或干脆只刷了一个域名 —— 但**退出码为 0**,流水线绿灯。
+
+**根因(两类,都要防)**
+
+1. **域名/凭证错配**:脚本用的 domain_id(或 AK)只对某个加速域有效,
+   只刷了 `a.example.com`,没刷 `app.example.com` —— 而用户访问的是后者。
+2. **SDK 返回结构读错**:脚本读 `result.refresh_task.id`,而 SDK 实际返回的
+   `refresh_task` 是**字符串 ID**(不是对象)→ 恒得 `undefined` → 打印
+   `taskId: undefined` 后**照样退出 0**,形成**假成功**。
+
+**最佳实践**
+
+- 刷新 **`type: 'directory'`**(URL 以 `/` 结尾),一次覆盖目录下新增/变更文件,
+  比逐个 file 刷新省事、不易漏。
+- **按域名对号入座**:维护 `host → domain_id` 映射表,URL 里解析出 host 再取对应
+  domain_id + 凭证;**未知域名直接报错退出**,绝不静默跳过。
+- **把假成功变成真失败**:对刷新结果**做非空断言**,任一失败则**退出码非 0**:
+
+  ```js
+  const taskId = r.refresh_task;              // ← 字符串 ID,不是 r.refresh_task.id
+  if (!taskId) { failed++; continue; }        // 没有 taskId = 没提交成功,不得算成功
+  ...
+  process.exit(failed ? 1 : 0);
+  ```
+
+- 部署脚本调用刷新失败时**不要吞掉**(最多提示可手动重试,但要让流水线红)。
+
+---
+
+## 坑 6:`index.html` 被 `sync` 跳过 / 被长缓存
+
+**症状**
+- 发了新版,`index.html` 还是旧的(但新增的文件都在)—— 典型的「壳没更新」。
+- 或者 CDN 回退拿到的 `index.html` 是缓存里的旧壳。
+
+**根因**
+
+- `obsutil sync` 按 **size + mtime** 判定 Skip。`index.html` 名字固定、内容随发布变化,
+  若 size 恰好相同(或 mtime 未变),**会被静默跳过**。
+- CDN 若把 `index.html` 也长缓存,回退/更新都拿到旧壳。
+
+**最佳实践**
+
+- **先删后传**:`obsutil rm <prefix>/index.html` 再 `obsutil cp ./src/index.html ...`,
+  并排除它不参与上一步的 `sync`(`-exclude="index.html"`)。
+- 传 `index.html` 时显式设头:
+  `-meta="Cache-Control:no-cache, no-store, must-revalidate#Content-Type:text/html; charset=utf-8"`。
+- 带内容 hash 的静态资源可长缓存;`index.html` 永远 `no-cache`。
+- ⚠️ **CDN 冷边缘**:新上传的 key 首次被某边缘节点拉取时**偶发 404**(回源竞争),
+  **重跑一次即消失**。验收脚本遇到新 key 的首次 404 不要立刻判定为故障。
+
+---
+
+## 坑 7:只 `curl` 首页不算验收
+
+**症状**
+- 部署脚本 `curl -w '%{http_code}'` 首页 → 200 → 认为发布成功。
+- 用户打开 → **白屏**(某个模块 404 / MIME 错 / 回退没配 / 某个相对路径错一个字符)。
+
+**根因**
+
+`curl` 首页 200 只能证明 `index.html` 在。浏览器加载一个无构建 ESM 应用会走一条**长链路**,
+任一环断裂都白屏,而 `curl` 一个都不覆盖:
+
+```
+index.html → 内联 importmap → 解析裸标识符 → 拉模块(MIME 必须 text/javascript)
+           → 模块内相对 import(必须带 .js)→ 再拉… → 深链接回退(必须 200)
+```
+
+**最佳实践(分层验收,越往下越真)**
+
+1. **静态体检**(部署前,`node --check` + 解析):语法/装饰器残留、相对 import 带 `.js`、
+   相对目标存在、裸标识符都在 importmap、自定义元素标签含连字符。
+2. **模块图爬取**(部署后,HTTP 层):从入口 `app.js` 出发**跟随相对 import 走完整图**,
+   断言每个文件 **200 + MIME 为 `text/javascript`**;再断言 importmap 每条目标可达;
+   再断言**深链接返回 200 且内容是 index.html**(`num_redirects=0`)。
+   —— 这一步比人眼快,且能在 CI 里跑。
+3. **真实浏览器**(最终验收):用 Playwright/真实浏览器**点导航 + 刷新深链接**,
+   确认不 404、无控制台报错。HTTP 层过了不代表渲染没问题(渲染报错、Signal 时机等)。
+4. **缓存层**:核对 CDN 实际返回的**内容**(不只是状态码),确认是新版。
+
+> 原则:**能自动断言的都断言,别靠「我 curl 了一下 200」。**
+> 一个可复用的验收脚本应把上面 1–2 步固化成一条命令,纳入部署流程。
+
+---
+
+## 坑 8:部署脚本的 static cd 陷阱与硬编码 URL
+
+**症状**
+- 脚本在某个目录下「卡住」或后续命令全部相对错位。
+- 文档里的交付链接**换一个项目 / 换一个篇目就 404**(把上一篇的 URL 抄到了新篇)。
+
+**根因**
+
+- **`cd` 相对路径**:脚本用相对路径 `cd`,或在管道/子 shell 里改了 cwd,
+  后续 `obsutil` / 文件路径全部相对错位。
+- **硬编码 URL**:把「某一次的线上地址」写死进文档/配置;换项目后就指向不存在的资源。
+
+**最佳实践**
+
+- 脚本开头用**以脚本自身位置推导的绝对路径**,不依赖调用者的 cwd:
+
+  ```bash
+  set -euo pipefail
+  REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+  cd "$REPO_ROOT"
+  ```
+
+- **参数化**:域名 / 前缀 / 桶都从**变量或参数**取(`PROJECT`、`OBS_PREFIX`、`BASE_HREF`),
+  绝不散落字面量;URL 用这些变量拼出来。
+- **交付物链接**用统一的 URL 构造函数生成(`https://<cdn>/<prefix>/<path>`),
+  不要人肉抄。文档里若必须贴链接,**注明它是哪个项目/前缀**,避免跨项目误用。
+- 顺手加 `--dry` 只打印不执行的开关,方便核对将要跑的命令。
+
+---
+
+## 坑 9:裸 `<a href>` 与 SPA 导航冲突;`href()` 要留出「新标签打开」
+
+**症状**
+- 点内部链接**整页刷新**:多标签 KeepAlive 全丢、状态重置、白屏一瞬。
+- 或:为了 SPA 全部改用 `navigate()`,结果**中键/右键「在新标签打开」失效**。
+
+**根因**
+
+- 原生 `<a href="/x">` 触发**整页导航**,不是 SPA 行为。
+- 但**全部**改成 JS click 又牺牲了浏览器原生能力(新标签、复制链接、无障碍)。
+
+**最佳实践**
+
+两套出口,按需选:
+
+| 场景 | 用什么 | 说明 |
+|------|--------|------|
+| 站内跳转(按钮/菜单项,语义是「切页面」) | `navigate(path)` | `pushState` + 派发 `popstate`,SPA 不刷新 |
+| 真链接(要给用户「新标签打开/复制链接」) | `<a href=${href(path)}>` | `href()` 返回**保留前缀**的完整 URL,中间键/右键正常 |
+| 在卡片/行内点击的**链接**(防止触发外层跳转) | `@click=${e => e.stopPropagation()}` | 见 [坑 10](#坑-10可点击交付链接点它不该触发外层卡片跳转) |
+
+- 内部导航**一律**经 `utils/router.js`,**禁止**裸 `new URL(path, baseURI)`(见坑 1)
+  与第三方全局拦截(见坑 2)。
+- 菜单项若用 `<div>` 模拟链接,记得补 `role="link" tabindex="0"` 与
+  `@keydown` 回车触发,保持键盘可达。
+
+---
+
+## 坑 10:可点击交付链接,点它不该触发外层卡片跳转
+
+**症状**
+- 卡片整体可点(进详情),卡片里还嵌了「打开报告 / 打开仓库」链接 ——
+  点内层链接时**外层跳转也触发了**,或整页被导航走、新标签没打开。
+
+**根因**
+
+事件冒泡:内层 `<a>` 的 `click` 冒泡到外层卡片的 `click` 处理器。
+
+**最佳实践**
+
+- 内层链接 `@click=${(e) => e.stopPropagation()}`,切断冒泡;用**真实 `<a>` + `target="_blank"`**
+  让浏览器开新标签,别用 `window.open` 绕。
+- 交给数据的链接用**统一结构**(如 `{kind, label, url}` 数组),渲染层据 `kind` 决定
+  图标/文案,便于一致地展示「报告 / 看板 / 文档 / 仓库」等多种入口。
+- **进度/状态展示**这类看板常见模式:
+  - **不要编造数据**:字段缺失就**留空**(如还没有的真实 URL 一律空),
+    比填假的强 —— 空会被发现,假的会被当成事实。
+  - 筛选层统一剔除**不该计入统计的条目**(内部人员/测试数据),
+    在服务层做,不要在展示层各自打补丁。
+  - 进度口径**写清楚**(「以输出报告为最终节点」这类),口径变了要在 UI/文档同步标注,
+    避免同一数字两种理解。
+
+---
+
+## 附:内部导航工具(可直接复制的最小实现)
+
+```js
+// utils/router.js —— 子路径部署安全的导航(只用 History API,干净 URL)
+export function basePath() {
+  return new URL(document.baseURI).pathname.replace(/\/+$/, '');
+}
+
+export function resolveUrl(path) {
+  const base = basePath();
+  const q = path.indexOf('?'), h = path.indexOf('#');
+  let cut = path.length;
+  if (q !== -1) cut = Math.min(cut, q);
+  if (h !== -1) cut = Math.min(cut, h);
+  let p = path.slice(0, cut);
+  const rest = path.slice(cut);
+  if (!p.startsWith('/')) p = '/' + p;
+  return new URL(base + p + rest, location.origin);
+}
+
+export function navigate(path, { replace = false } = {}) {
+  const url = resolveUrl(path);
+  if (replace) history.replaceState({}, '', url);
+  else history.pushState({}, '', url);
+  window.dispatchEvent(new PopStateEvent('popstate'));  // History 原生不触发
+}
+
+export function currentPath() {
+  const base = basePath();
+  let path = location.pathname;
+  if (base && path.startsWith(base)) path = path.slice(base.length);
+  if (!path.startsWith('/')) path = '/' + path;
+  return path.replace(/\/{2,}/g, '/') || '/';
+}
+
+export function currentQuery() {
+  return Object.fromEntries(new URLSearchParams(location.search));
+}
+
+export function href(path) { return resolveUrl(path); }   // 供真实 <a href>
+```
+
+路由表 + 订阅(`app-shell` 里):监听 `popstate`,用 `currentPath()` 匹配路由表,
+命中则「打开/激活」对应视图(多标签场景下不要整体重渲染,见
+[tabs-keepalive.md](./tabs-keepalive.md))。
+
+---
+
+## 附:上线前 checklist(路由 / base / CDN 维度)
+
+- [ ] `<base href>` == OBS 上传前缀 == importmap 路径前缀(**同一变量派生**,脚本里有硬校验)
+- [ ] importmap **内联**在 `index.html`,依赖路径带正确的部署前缀
+- [ ] 业务代码**无** `new URL(path, baseURI)` 用于导航;无 `@lit-labs/router`(子路径部署)
+- [ ] `currentPath()` 剥前缀 + 脏 URL 兜底 + 折叠 `//`
+- [ ] CDN 回退:命中真实资源直返;不存在路径 **200** 返回 `index.html`,**禁 301/302**
+- [ ] `index.html` 先删后传 + `no-cache`
+- [ ] CDN 刷新:`type:directory`,**对号入座 domain_id**,读 `refresh_task`(字符串),失败退出非 0
+- [ ] 静态体检(语法/装饰器/`.js` 后缀/importmap 覆盖/标签连字符)通过
+- [ ] 模块图爬取(200 + `text/javascript`)通过
+- [ ] 深链接**刷新** 200 且为 `index.html`(含一个不存在的路径 → 落 404 页)
+- [ ] **真实浏览器**点导航 + 刷新深链接,无 404、无控制台报错
+- [ ] 交付链接 `target=_blank` 可点、不触发外层跳转;无数据不编造,留空
+
+---
+
+## 相关
+
+- [routing.md](./routing.md) —— 路由基本用法(History、守卫、OAuth、多标签交互)
+- [deployment.md](./deployment.md) —— Repose 依赖托管 + OBS/CDN 部署 + 回退规则
+- [pitfalls.md](./pitfalls.md) —— 无构建的通用坑(装饰器 / CORS / MIME / 多版本 lit)
+- [tabs-keepalive.md](./tabs-keepalive.md) —— 多标签 KeepAlive 与路由的配合
+- [project-structure.md](./project-structure.md) —— 子路径部署与 `<base href>`

+ 163 - 0
dist/skills/fmode-code-lit/references/rules/routing.md

@@ -0,0 +1,163 @@
+# 路由(History 干净 URL)
+
+> 使用 History 模式得到**干净 URL**(无 `#`)。这是内部后台 + 微信 OAuth 回调的硬性前提:
+> OAuth 回调 URL 不能带 hash,且必须由 CDN 返回 **200**(不能 302)。
+>
+> ⚠️ **子路径部署(`/<env>/<project>/`)优先自写 `utils/router.js`,不要用 `@lit-labs/router`**:
+> 它装全局 `<a>` click 拦截器,且用**未剥前缀**的 `location.pathname` 匹配路由,会污染路由状态。
+> 根部署可选用它;子路径部署一律自写 —— 最小实现与完整踩坑见
+> [routing-best-practices.md](./routing-best-practices.md)(坑 1、坑 2)。
+
+## 〇、子路径部署的红线(先看这条)
+
+应用内部只认「**不含前缀的应用内路径**」(`/students/s01`),前缀只在三处出现且必须同源:
+`<base href>` == OBS 上传前缀 == importmap 路径前缀。
+
+- ❌ **禁止** `new URL(path, document.baseURI)` 做导航 —— 当 `path` 以 `/` 开头时
+  会**丢掉 base 的路径前缀**,刷新即 404。正确做法:拼到 base 的 **pathname** 上再解析。
+- `currentPath()` 要**剥掉前缀**并折叠重复斜杠,脏 URL 兜底到 404 页。
+
+完整根因与规避见 [routing-best-practices.md](./routing-best-practices.md)。
+
+## 一、基本用法(@lit-labs/router,仅根部署)
+
+```js
+// services/router-service.js 或 app.js
+import { Router } from '@lit-labs/router';
+
+export function createRouter(host, routes) {
+  return new Router(host, {
+    routes: [
+      { path: '/', render: () => html`<page-home></page-home>` },
+      { path: '/users', render: () => html`<page-user-list></page-user-list>` },
+      { path: '/users/:id', render: ({ id }) => html`<page-user-detail .userId=${id}></page-user-detail>` },
+      { path: '/*', render: () => html`<page-404></page-404>` },
+    ],
+  });
+}
+```
+
+在根组件里挂载:
+
+```js
+import { Router } from '@lit-labs/router';
+
+export class AppShell extends LitElement {
+  #router = new Router(this, [
+    { path: '/', render: () => html`<page-home></page-home>` },
+    { path: '/users', render: () => html`<page-user-list></page-user-list>` },
+    { path: '/*', render: () => html`<page-404></page-404>` },
+  ]);
+
+  render() {
+    return html`
+      <side-menu></side-menu>
+      <main>${this.#router.outlet()}</main>
+    `;
+  }
+}
+```
+
+- `path` 支持参数段 `:id` 与通配 `/*`。
+- `render(params)` 返回模板;参数从 `params` 取。
+- `outlet()` 是渲染出口。
+
+## 二、导航(干净 URL)
+
+```js
+import { navigate } from './router-utils.js';
+
+navigate('/users/42');          // pushState
+navigate('/login', { replace: true });
+```
+
+`utils/router.js` 里封装,**把应用内路径拼到 base 的 pathname 上**(不能用
+`new URL(path, document.baseURI)`,会丢前缀):
+
+```js
+export function basePath() {
+  return new URL(document.baseURI).pathname.replace(/\/+$/, '');   // '/dev/myapp' 或 ''
+}
+export function resolveUrl(path) {
+  const base = basePath();
+  const q = path.indexOf('?'), h = path.indexOf('#');
+  let cut = path.length;
+  if (q !== -1) cut = Math.min(cut, q);
+  if (h !== -1) cut = Math.min(cut, h);
+  let p = path.slice(0, cut);
+  const rest = path.slice(cut);              // 查询串 / hash 原样保留
+  if (!p.startsWith('/')) p = '/' + p;
+  return new URL(base + p + rest, location.origin);
+}
+export function navigate(path, { replace = false } = {}) {
+  const url = resolveUrl(path);
+  if (replace) history.replaceState({}, '', url);
+  else history.pushState({}, '', url);
+  window.dispatchEvent(new PopStateEvent('popstate'));
+}
+```
+
+- **拼到 base 的 pathname 上**再解析,子目录部署(`/admin/`)才不会丢前缀。
+- 手动 `pushState` 后派发 `popstate`,让订阅者感知(History 原生不触发)。
+- 真链接用 `href(path)`(= `resolveUrl`)供 `<a href>` 使用,保留中键「新标签打开」。
+- 完整实现与坑见 [routing-best-practices.md](./routing-best-practices.md)。
+
+## 三、CDN 回退规则(History 路由的前提)
+
+History 模式下,用户在 `/users/42` 直接刷新 → 该路径在 OBS 上不存在 → **必须由 CDN 回退规则**处理:
+
+| 规则 | 行为 |
+|------|------|
+| 规则 1 | 路径命中真实静态资源(`.js` / `.css` / `.png` …)→ 直接返回 |
+| 规则 2 | 路径不存在 → 返回 `index.html`,**状态码 200**,**禁止 301/302** |
+
+> ⚠️ 若用 302 重定向到 `/index.html`,浏览器地址栏会被改写、`location.pathname` 丢失,路由参数全丢,微信 OAuth 回调也会失败。**必须 200**。
+
+配置见 [deployment.md](./deployment.md)。
+
+## 四、路由守卫(鉴权)
+
+```js
+const requireAuth = (render) => (params) => {
+  if (!authService.isLoggedIn.get()) {
+    navigate('/login');
+    return html``;   // 或渲染登录页占位
+  }
+  return render(params);
+};
+
+new Router(this, [
+  { path: '/users', render: requireAuth((p) => html`<page-user-list></page-user-list>`) },
+]);
+```
+
+- 守卫是**纯函数包装**,不引入框架概念。
+- 异步守卫(先校验 token 再放行)可返回 Promise 模板,或先渲染 loading。
+
+## 五、OAuth 回调(微信)
+
+- 回调地址设为业务前缀下的干净路径,如 `https://<cdn-domain>/<project>/oauth/callback`。
+- 该路径在 CDN 上不存在 → 由回退规则 200 返回 `index.html` → 前端读取 `location.search` 里的 `code`。
+- **全程不能出现 302**,否则微信校验的 `redirect_uri` 与实际落地 URL 不一致。
+
+## 六、多标签的交互
+
+若应用是多标签后台(见 [tabs-keepalive.md](./tabs-keepalive.md)),路由切换需驱动「打开/激活标签」而非整体重渲染:
+
+```js
+// Router 命中新路径时 → tabsService.open(path, { title, component })
+// 已存在同 path 标签 → 仅激活;不存在 → 新开
+```
+
+## 七、坑
+
+- 忘记 CDN 回退 → 刷新 404。回退**必须 200**,禁 301/302(302 会丢 `pathname`)。
+- `<base href>` 配错 / 与部署前缀不一致 → 相对 import 404。
+- `new URL(path, document.baseURI)` 丢部署前缀 → 子路径深链接 404(**最高频**,见坑 1)。
+- `@lit-labs/router` 在子路径部署下污染路由状态(全局 `<a>` 拦截 + 未剥前缀 pathname)。
+- 用 `<a href="/x">` 原生跳转 → 整页刷新(非 SPA)。内部导航一律 `navigate()` + `popstate`;
+  要保留「新标签打开」的用 `href()`。
+- 只 `curl` 首页 200 就算验收 → 用户白屏。**必须**跑模块图爬取 + 真实浏览器。
+- 混用 hash 路由与 History 路由 → 混乱。**只用 History**。
+
+> 以上每条的**症状 → 根因 → 规避**,逐条见 [routing-best-practices.md](./routing-best-practices.md)。

+ 108 - 0
dist/skills/fmode-code-lit/references/rules/services.md

@@ -0,0 +1,108 @@
+# 服务规范(Service / Signal)
+
+> Service 是普通 ESM Class:**存全局状态(Signal)、封装接口、做校验与转换、写业务计算**。
+> 它**不依赖 DOM、不 import Lit**,因此能在 Node 里直接单测。
+
+## 一、Service 骨架(Signal + 单例)
+
+```js
+// services/auth-service.js
+import { signal, computed } from '@lit-labs/signals';
+import { http } from '../utils/http.js';
+
+class AuthService {
+  /** 私有信号:当前用户(null 表示未登录) */
+  #user = signal(null);
+  /** 私有信号:token,初始化时从 localStorage 恢复 */
+  #token = signal(localStorage.getItem('token') || '');
+
+  /** 对外只读视图 */
+  get user() { return this.#user; }
+  get isLoggedIn() { return computed(() => this.#user.get() != null); }
+
+  async login(username, password) {
+    const { token, user } = await http.post('/api/auth/login', { username, password });
+    this.#token.set(token);
+    this.#user.set(user);
+    localStorage.setItem('token', token);
+    return user;
+  }
+
+  logout() {
+    this.#token.set('');
+    this.#user.set(null);
+    localStorage.removeItem('token');
+  }
+
+  getToken() { return this.#token.get(); }
+}
+
+/** 全局单例:全应用共享同一份状态 */
+export const authService = new AuthService();
+```
+
+要点:
+- 状态用 `signal()` 持有;派生值用 `computed()`;副作用用 `effect()`。
+- 对外通过 getter 暴露**只读** signal,写操作走方法(封装校验)。
+- **单例导出**(`export const xxxService = new XxxService()`),全应用 import 同一实例。
+- 不在 Service 里 `import { html }` 或操作 `document`。
+
+## 二、Signal 三件套
+
+| API | 用途 | 例 |
+|-----|------|----|
+| `signal(v)` | 可读写信号 | `const count = signal(0); count.set(1); count.get();` |
+| `computed(fn)` | 派生只读信号 | `const total = computed(() => items.get().length);` |
+| `effect(fn)` | 副作用(自动追踪依赖) | `effect(() => console.log(count.get()));` |
+
+> 在组件里用 `SignalWatcher(LitElement)` 混入后,`render()` 里**读取** signal 即自动订阅。
+
+## 三、状态放哪(再强调)
+
+| 场景 | 方案 |
+|------|------|
+| 全局业务状态,多页共享(登录 / 字典 / 权限) | **Signal Service**(本文件) + Context 注入 |
+| 组件本地状态(弹窗 / 输入) | Lit `@property` / `@state` |
+| 简单应用不想引 signals | `EventTarget` 简易 Store(下方) |
+| 一次性持久化配置 | `utils/storage.js` 封装 |
+
+## 四、不引 signals 时的简易 Store
+
+```js
+// services/store.js —— 仅当不想引 @lit-labs/signals 时的轻量替代
+export class Store extends EventTarget {
+  #state = {};
+  get state() { return this.#state; }
+  setState(patch) {
+    this.#state = { ...this.#state, ...patch };
+    this.dispatchEvent(new CustomEvent('change', { detail: this.#state }));
+  }
+}
+export const store = new Store();
+```
+
+组件里监听 `store.addEventListener('change', () => this.requestUpdate())`。
+
+## 五、Service 与 DOM 的边界
+
+- Service **可以** 读写 `localStorage`、调 `fetch`、算业务 —— 这些是「环境无关」的。
+- Service **不可以** 持有元素引用、操作 `document`、`import Lit`。
+- 需要通知 UI 的地方 → 改 signal,UI 自然响应,**不要**反向去 call 组件方法。
+
+## 六、可测试性(这是分层的回报)
+
+```js
+// auth-service.test.js(Node 直接跑,无需浏览器)
+import { authService } from './auth-service.js';
+// 依赖 http 可注入 mock;signal 可直接 set/get 断言
+```
+
+- Service 无 DOM 依赖 → 可在 Node / CI 单测。
+- 依赖的外部调用(http)建议通过构造参数注入,便于 mock。
+- 业务计算放纯函数到 `utils/`,Service 只做编排。
+
+## 七、命名与目录
+
+- 文件:`services/<name>-service.js`。
+- 类:`<Name>Service`;单例:`export const <name>Service = new <Name>Service()`。
+- 一个 Service 一个关注点:`auth-service`、`table-service`、`router-service`,不要「万能 service」。

+ 136 - 0
dist/skills/fmode-code-lit/references/rules/tabs-keepalive.md

@@ -0,0 +1,136 @@
+# 后台多标签 + KeepAlive
+
+> 同一 `index.html` 内前端路由切换**不刷新页面**;KeepAlive 用 `display:none` 隐藏非激活页容器,
+> **组件实例常驻**,表单 / 内部状态不丢失。对标后台管理系统的多 Tab 工作区。
+
+## 一、核心思路
+
+```
+根组件维护 tabs 数组 → 渲染所有已打开标签页的容器
+每个标签页由一个「路由判断激活」的 display 控制显隐
+激活的标签 display:block,非激活 display:none(组件不销毁)
+```
+
+- **不销毁 = 状态保留**:组件实例一直在 DOM 里,`@state` 里的表单值、滚动位置都在。
+- **不是 `<iframe>` / 不是多页**:单页应用,只是同页多容器。
+
+## 二、tabsService(状态)
+
+```js
+// services/tabs-service.js
+import { signal } from '@lit-labs/signals';
+
+class TabsService {
+  #tabs = signal([
+    { id: 'home', path: '/', title: '首页', closable: false },
+  ]);
+  #activeId = signal('home');
+
+  get tabs() { return this.#tabs; }
+  get activeId() { return this.#activeId; }
+
+  /** 打开(或激活已存在的)标签:同 path 不重复开 */
+  open({ id, path, title, closable = true }) {
+    const list = this.#tabs.get();
+    if (!list.some((t) => t.id === id)) {
+      this.#tabs.set([...list, { id, path, title, closable }]);
+    }
+    this.#activeId.set(id);
+  }
+
+  close(id) {
+    const list = this.#tabs.get().filter((t) => t.id !== id);
+    this.#tabs.set(list);
+    if (this.#activeId.get() === id && list.length) {
+      this.#activeId.set(list[list.length - 1].id);
+    }
+  }
+
+  activate(id) { this.#activeId.set(id); }
+}
+
+export const tabsService = new TabsService();
+```
+
+## 三、tabs-container(渲染 + KeepAlive 容器)
+
+```js
+// components/tabs-container.js
+import { LitElement, html, css } from 'lit';
+import { SignalWatcher } from '@lit-labs/signals';
+import { tabsService } from '../services/tabs-service.js';
+
+export class TabsContainer extends SignalWatcher(LitElement) {
+  static styles = css`
+    .pane { display: none; }
+    .pane.active { display: block; }
+  `;
+
+  render() {
+    const tabs = tabsService.tabs.get();
+    const activeId = tabsService.activeId.get();
+    return html`
+      <!-- 所有已打开标签页的容器常驻;仅激活的显示 -->
+      ${tabs.map((t) => html`
+        <div class="pane ${t.id === activeId ? 'active' : ''}" data-tab="${t.id}">
+          <page-router-view .route=${t.path} .active=${t.id === activeId}></page-router-view>
+        </div>
+      `)}
+    `;
+  }
+}
+```
+
+要点:
+- 用 `display:none/block` 控制显隐,**不要**用条件渲染(`? :` 会销毁实例)。
+- 每个 pane 内容由 `route` 决定渲染哪个页面组件。
+- 页面组件可在 `active` 变化时决定是否暂停轮询 / 动画(省资源)。
+
+## 四、标签栏 UI(tabs-bar)
+
+```js
+// components/tabs-bar.js
+export class TabsBar extends SignalWatcher(LitElement) {
+  render() {
+    return html`
+      <nav>
+        ${tabsService.tabs.get().map((t) => html`
+          <a class=${t.id === tabsService.activeId.get() ? 'on' : ''}
+             @click=${() => tabsService.activate(t.id)}>
+            ${t.title}
+            ${t.closable ? html`<button @click=${(e) => { e.stopPropagation(); tabsService.close(t.id); }}>×</button>` : ''}
+          </a>
+        `)}
+      </nav>
+    `;
+  }
+}
+```
+
+## 五、与路由联动
+
+路由命中新路径时打开/激活对应标签:
+
+```js
+// 在 route change handler
+tabsService.open({
+  id: path,            // 用 path 做唯一 id(也可带参数合成)
+  path,
+  title: routeTitle(path),
+});
+```
+
+- 同 path → 仅激活(不重复开)。
+- 带参数的路由(`/users/:id`)用「实际路径」做 id,`/users/1` 与 `/users/2` 是两个标签。
+
+## 六、KeepAlive 的「刷新」语义
+
+- **刷新单个标签**:给 pane 换一个 `key`(强制重建该子组件)——用 Lit 的 `repeat()` + 改变 key,或临时移除再插回。
+- **关闭标签**:从 `tabs` 移除 → 容器消失 → 实例被 GC,状态释放。
+- **整页刷新**:浏览器级 `location.reload()`,所有标签重置。
+
+## 七、性能与注意
+
+- 标签数量多时,非激活 pane 里的定时器 / 动画应暂停(监听 `active` 属性)。
+- 大列表页面全部常驻会占内存 —— 对「重量级页面」可选用「关闭即销毁 + 重进重建」策略,或对非激活 pane 做懒初始化(首次激活才渲染内容)。
+- 事件在非激活 pane 里仍会冒泡(DOM 存在)——注意别让隐藏 pane 的全局监听重复响应;用 `active` 守卫。

+ 288 - 0
dist/skills/fmode-code-lit/references/rules/web-component-library.md

@@ -0,0 +1,288 @@
+# Web Component 组件库规范(wc:Lit 实现的浏览器交互层)
+
+> 组件库 = 一批**用 `customElements.define()` 注册的自定义元素**,内部封装 **DOM / 交互 / 样式**,
+> 逻辑**全部来自核心库 [core](./core-library.md)**。产物是标准 Web Component ——
+> 在 Lit、Vue、React、Angular、原生 HTML 里都能**直接写 `<my-x>`**,无需跑任何前端框架。
+>
+> 与页面内组件([components.md](./components.md))的区别:页面组件服务于「一个应用」;
+> **组件库组件服务于「多个应用 / 多个框架」**,因此契约更严、实现更克制。
+
+## 零、wc 必须用 Lit 框架特性开发(最重要的一条)
+
+> ⚠️ **本章所有 wc 组件必须用 Lit 框架特性(`LitElement` / `html` / `css` / Shadow DOM / `static properties` /
+> Lit 响应式)开发。禁止手写原生 Web Component。**
+>
+> - **Lit 本身就是 Web Component 的实现层**:`LitElement` 继承自 `ReactiveElement`(再继承 `HTMLElement`),
+>   `html` 产出 Shadow DOM 里的模板,`customElements.define()` 注册的是标准自定义元素。
+>   所以「用 Lit 写」与「产出标准 Web Component、跨框架可用」**不矛盾** —— 前者是实现方式,后者是产物形态。
+> - **✅ 写法**:`class X extends LitElement` + `static properties` + `static styles = css\`\`` + `render() { return html\`\`\` }`。
+> - **❌ 禁止的原生写法**(等价重造 Lit,且丢掉 Lit 的模板缓存 / 属性转换 / 响应式更新):
+>   - `class X extends HTMLElement`
+>   - `this.attachShadow({ mode: 'open' })` + 手动 `innerHTML` / `document.createElement`
+>   - 手动实现 `observedAttributes` / `attributeChangedCallback` / `connectedCallback` 里刷 DOM
+>   - 字符串拼接模板 + `this.shadowRoot.querySelector(...).textContent = ...`
+> - **「原生」只出现在宿主侧**:`plain.html` 里 `<script type="module">` 直接挂 `<quote-calc>` 是**验证跨框架**的宿主写法,组件本身仍是 Lit。宿主用什么框架,与组件用不用 Lit 无关。
+> - **例外(仅限非 UI 件)**:`browser-transport.js` 这类**不渲染 DOM 的 I/O 适配器**不是组件,可以不 import lit;但只要文件里 `customElements.define()` 注册了元素,就必须是 `LitElement`。
+>
+> `scripts/check.mjs` 对 `wc/` 下每个「注册了自定义元素」的文件做硬校验:必须 import lit、必须 `extends LitElement`、不得出现 `extends HTMLElement` / `attachShadow(` / `observedAttributes`。
+
+## 一、定位与边界
+
+| 项 | 组件库(wc) | 页面内组件(page) |
+|----|--------------|--------------------|
+| 消费方 | 多项目、多框架,非本项目也能用 | 仅本应用 |
+| 契约 | 语义属性 / DOM 事件 / slot / CSS 自定义属性,**稳定** | 可随应用演进 |
+| 业务逻辑 | ❌ 一律来自 core,组件只做**接线** | 可在页面 Service 中 |
+| 样式 | Shadow DOM 强隔离,主题走 CSS 变量 | 可宽松 |
+| 依赖 | `core` + `lit`(仅 `LitElement`/`html`/`css`;**必须用 Lit,禁原生 wc 手写**) | `core` + 页面 `services/` |
+
+```
+fmode-code-agent(core,纯 ESM)
+        ▲ 只 import 纯逻辑
+  <designer-chat>(wc,LitElement,仅 DOM/交互/样式)
+        ▲ 任意框架直接 <designer-chat>
+  页面(page:Lit / Vue / React / 原生 HTML …)
+```
+
+## 二、铁律(违反即返工)
+
+0. **用 Lit 写,不手写原生 wc**:组件类一律 `extends LitElement`,模板用 `html`、样式用 `css`(见第〇节)。禁止 `extends HTMLElement` / 手写 `attachShadow` / 手写 `observedAttributes`。
+1. **无装饰器**:与 [components.md 第〇节](./components.md) 一致,`static properties` + `customElements.define()`。组件库要让**任何无构建页面**直接跑,装饰器会让源码 `SyntaxError`。注意:无装饰器 ≠ 原生 wc —— 仍然用 Lit 的静态字段 API。
+2. **逻辑不进组件**:状态机 / 校验 / 协议都在 core;组件**只**做「DOM 事件 → 调 core → 用结果重渲染」。组件里能单测的逻辑,都应已在 core 里。
+3. **不 import 业务**:组件库不 import 任何应用的 `services/`、`routes/`、业务 model;外部世界只通过**属性**和**事件**进入。
+4. **不直接 `fetch`**:网络由**宿主注入**(见第五节)或由 core 的协议函数描述、宿主执行。
+5. **样式全在 Shadow DOM**:不写全局 `document.head` 样式;主题只通过 **CSS 自定义属性** + `::part()` 暴露。
+6. **标签命名空间**:组件库标签带统一前缀(如 `<fm-chat>`、`<fm-icon>`),**避免与其他库撞名**(撞名 → `define` 抛错)。
+7. **注册幂等**:注册前 `customElements.get()` 判空 —— 允许同一页面被加载两次(多入口 / 宿主重复 import)。
+
+## 三、组件骨架(无装饰器、依赖 core)
+
+```js
+// src/chat/designer-chat.js
+import { LitElement, html, css } from 'lit';
+import { AgentMachine } from 'fmode-code-agent';          // ← 核心库(core),纯 ESM
+
+export class DesignerChat extends LitElement {
+  /** 对外契约:语义属性 */
+  static properties = {
+    prompt: { type: String },                // 宿主可传 / attribute 可设
+    disabled: { type: Boolean, reflect: true },
+    _state: { state: true },                 // 内部:core 的状态映射
+    _messages: { state: true },              // 内部:渲染用
+  };
+
+  static styles = css`
+    :host { display: block; --dc-accent: #2b6cb0; }   /* 主题变量,可被外部覆盖 */
+    button { background: var(--dc-accent); }
+    .log { max-height: 240px; overflow: auto; }
+  `;
+
+  #machine = null;
+  #unsubscribe = null;
+
+  constructor() {
+    super();
+    this.prompt = '';
+    this.disabled = false;
+    this._state = 'idle';
+    this._messages = [];
+  }
+
+  /** 宿主注入 I/O(transport),组件层不写死 fetch */
+  set transport(t) { this.#machine = new AgentMachine({ transport: t }); }
+
+  connectedCallback() {
+    super.connectedCallback();
+    if (!this.#machine) {
+      // 未注入 transport 时给一个 fallback,避免 use 时崩
+      this.#machine = new AgentMachine({ transport: { call: async ({ text }) => ({ text: `(echo) ${text}` }) } });
+    }
+    this.#unsubscribe = this.#machine.subscribe((s) => { this._state = s; });
+    this._state = this.#machine.state;
+  }
+
+  disconnectedCallback() {
+    this.#unsubscribe?.();
+    this.#unsubscribe = null;
+    super.disconnectedCallback();
+  }
+
+  render() {
+    return html`
+      <form @submit=${this.#onSubmit}>
+        <input .value=${this.prompt} @input=${(e) => { this.prompt = e.target.value; }}
+               ?disabled=${this.disabled || this._state !== 'idle'} placeholder="描述你想要的图片…" />
+        <button ?disabled=${this.disabled || this._state !== 'idle'}>${this._state === 'idle' ? '生成' : '生成中…'}</button>
+      </form>
+      <div class="log" part="log">
+        ${this._messages.map((m) => html`<p>${m}</p>`)}
+      </div>
+    `;
+  }
+
+  async #onSubmit(e) {
+    e.preventDefault();
+    const text = this.prompt.trim();
+    if (!text) return;
+    this._messages = [...this._messages, `> ${text}`];
+    /** 子 → 父:语义事件,composed 穿透 Shadow DOM */
+    this.dispatchEvent(new CustomEvent('dc-submit', { detail: { prompt: text }, bubbles: true, composed: true }));
+    try {
+      const result = await this.#machine.send(text);
+      this._messages = [...this._messages, result?.text ?? ''];
+      this.dispatchEvent(new CustomEvent('dc-done', { detail: { result }, bubbles: true, composed: true }));
+    } catch (err) {
+      this.dispatchEvent(new CustomEvent('dc-error', { detail: { code: err.code, message: err.message }, bubbles: true, composed: true }));
+    }
+  }
+}
+
+if (!customElements.get('designer-chat')) customElements.define('designer-chat', DesignerChat);
+```
+
+要点:
+- **对外只有 `prompt` / `disabled` / `transport` / `dc-*` 事件**;`_state` / `_messages` 是内部渲染状态。
+- `static styles` 全在 Shadow DOM;外部换主题只改 `--dc-accent`、`::part(log)`。
+- 状态来自 core(`machine.subscribe`),组件不自造状态机。
+- **用私有字段 `#` 存机器实例**,不挂在公开属性上。
+
+## 四、对外契约(跨框架就是靠这三样)
+
+| 手段 | 用法 | 规则 |
+|------|------|------|
+| **属性(attribute)** | `<designer-chat disabled>` | 只用**字符串可表达**的语义(`disabled`、`placeholder`、`size="lg"`);布尔用 `?attr` 或 presence |
+| **属性(property)** | 框架里 `el.prompt = '…'`、Lit 里 `.prompt=${x}` | 复杂对象(回调、配置)走 property;**Lit/原生里要用 `.prop=` 绑定**,别指望 attribute 传对象 |
+| **事件** | `el.addEventListener('dc-done', fn)` | `kebab-case` 事件名;`detail` 为对象;**`bubbles: true, composed: true`**(穿透 Shadow DOM);组件库事件统一前缀避免撞车 |
+| **Slot** | 宿主投影内容 | `<slot name="header">` / `<slot>`;**不**用 `.innerHTML` |
+| **CSS 自定义属性** | `--dc-accent: red` 作用在宿主或其祖先 | 主题的唯一入口 |
+| **`::part()`** | `designer-chat::part(log) { ... }` | 需要暴露的内部结构加 `part="…"` |
+| **方法(可选)** | `el.focus()`、`el.reset()` | 只给「命令式交互」,不暴露内部状态改写入 |
+
+- **属性即契约**:改属性名 / 事件名 = **破坏性变更** → 升 major(同 core 的版本纪律)。
+- **不要暴露**内部类名 / 内部 DOM id(`::part` 是唯一对外样式钩子)。
+- **不要用全局事件总线**在组件库内部通信(同 [components.md 第四节](./components.md))。
+
+## 五、依赖注入:core 实例与 I/O 由宿主注入
+
+组件库**不知道**宿主的网络、登录、路由、存储。两种注入方式:
+
+**A. 宿主直接传实例(最通用,跨框架 OK)**
+
+```html
+<script type="module">
+  import { AgentMachine } from 'fmode-code-agent';
+  const el = document.querySelector('designer-chat');
+  el.transport = {
+    call: async ({ text }) => {
+      const res = await fetch('/api/agent', { method: 'POST', body: JSON.stringify({ text }) });
+      return res.json();
+    },
+  };
+</script>
+```
+
+**B. Lit 页面里用 Context(仅 Lit 宿主)** —— 见 [context-di.md](./context-di.md)。组件库内部用 `ContextConsumer` 兜底读注入的实例,读不到时用属性。
+
+> 原则:**组件库只定义 shape,不拥有实例**。这也让组件在 Node / SSR 里并不被实例化,逻辑仍留在 core。
+
+## 六、跨框架使用方式
+
+组件的**唯一运行要求**:浏览器能执行 ESM + `customElements`。下图是同一份 `dist` 在四个宿主里的用法:
+
+```html
+<!-- ① 原生 HTML(无任何框架) -->
+<designer-chat></designer-chat>
+<script type="module" src="/npm/designer-chat/v1.0.0/index.js"></script>
+```
+
+```html
+<!-- ② Lit 页面(本仓页面层):先 import 触发注册,再在模板里使用 + 监听事件 -->
+<script type="module">import './components.js';</script>
+<designer-chat .prompt=${q} @dc-done=${(e) => console.log(e.detail)}></designer-chat>
+```
+
+```jsx
+// ③ React 19:自定义元素已原生支持(prop 走 attribute/property 自动映射)
+<designer-chat ref={ref} prompt={q} onDcDone={onDone}></designer-chat>
+// ④ React 18-:需包装层(ref + addEventListener),或复用 @lit/react 的 createComponent
+```
+
+```vue
+<!-- ④ Vue 3:.vue 里把标签加进 compilerOptions.isCustomElement,或 app.config.compilerOptions.isCustomElement -->
+<designer-chat :prompt="q" @dc-done="onDone" />
+<!-- Vue 3 对自定义元素:attribute 透传;复杂属性建议 ref + el.transport = ... -->
+```
+
+| 宿主 | 属性绑定 | 事件 | 注意事项 |
+|------|----------|------|----------|
+| 原生 / Lit | `.prop=`(Lit) / `el.prop =` | `addEventListener` / `@event` | Lit 必须 **`.` 前缀**绑定复杂值 |
+| Vue 3 | `:prop`(复杂值透传为 property) | `@kebab-event` | 配置 `isCustomElement`(构建型 Vue) |
+| React 19+ | `prop={...}`(已映射到 property) | `onEvent={...}`(`dc-done` → `onDcDone`) | 事件名注意大小写映射 |
+| React 18- | 需 wrapper(`createComponent`) | ref + `addEventListener` | 低版本 React 会把未知 prop 当 attribute |
+
+> **验收口诀**:组件库必须在**一个非 Lit 宿主**(纯 HTML 或 Vue)里也能跑,否则就不是跨框架组件库。
+
+## 七、样式隔离与主题
+
+- **Shadow DOM 是默认**:组件内部样式不泄漏、外部项目样式也不进来。
+- **主题三件套**(按优先级):
+  1. **CSS 自定义属性**:`:host { --dc-accent: #2b6cb0 }`,外部可在祖先或宿主上覆盖;
+  2. **`::part()`**:`part="log"` 暴露需要精细调整的节点;
+  3. **`slots`**:允许宿主替换整块内容(如标题、空状态)。
+- **禁止**:`document.adoptedStyleSheets` 注入全局样式;`!important` 向外压制;依赖宿主 CSS 类名。
+- 设计令牌(颜色/间距/字号)建议作为**包的静态 CSS** 发布一份(如 `theme.css`),宿主 `<link>` 引入即可,变量名统一前缀(`--fm-`)。
+
+## 八、无构建分发
+
+组件库与 core 一样:**无构建**、源码即产物,托管到 **FMODE Repose**,用 importmap 引入。
+
+```
+/npm/designer-chat/v1.0.0/
+  index.js                # 入口:import 所有组件并注册(或单组件入口)
+  designer-chat.js        # 单组件
+  theme.css               # 可选:设计令牌
+```
+
+```json
+{ "imports": {
+  "designer-chat": "/npm/designer-chat/v1.0.0/index.js",
+  "fmode-code-agent": "/npm/fmode-code-agent/v2.0.0/src/index.js"
+} }
+```
+
+- `index.js` **只做 import + 注册**:副作用仅「注册自定义元素」,无渲染、无请求。
+- **lit 依赖不内置**:组件库 import 裸 `lit`,由宿主的**公共 importmap** 解析 → 全局单实例 lit(否则重复注册 / 多版本冲突,见 [pitfalls.md 第三节](./pitfalls.md))。
+- **core 依赖版本化**:组件库在 importmap 里映射自己需要的 core 版本;同一页面若用了两个 core major,映射两个标识符(见 [core-library.md 第六节](./core-library.md))。
+- 发布 checklist 中增加:**在一个非 Lit 宿主里 Smoke**(见第九节)。
+
+## 九、验收 & Test
+
+| 层 | 怎么做 |
+|----|--------|
+| core 逻辑 | `node --test`(不依赖 DOM,见 [core-library.md 第四节](./core-library.md)) |
+| 组件契约 | 浏览器里断言:`customElements.get('x-y')` 存在;改属性 → 渲染变化;点按钮 → 事件带 `detail` 且 `composed` |
+| 跨框架 | 同一页面用**纯 HTML** 与 **Lit** 各挂一次;两个入口渲染结果一致 |
+| 样式隔离 | 宿主页全局 `p { color: red }` **不影响**组件内部;`--dc-accent` 能改主题 |
+| 幂等 | 同名脚本 import 两次不报 `define` 冲突 |
+| 无装饰器 | `node --check` 全过;`grep` 无 `@property` 等 |
+| **Lit 实现** | `grep` 注册元素的文件均 `import ... from 'lit'` 且 `extends LitElement`;无 `extends HTMLElement` / `attachShadow(` / `observedAttributes` |
+
+## 十、红线清单
+
+- ❌ 组件里写业务逻辑 / 请求 / 协议解析 → 下沉 core,由宿主注入 I/O
+- ❌ 依赖宿主的 CSS 类名 / 全局样式 / 全局 window 变量
+- ❌ 暴露内部状态写入方法(`el.setMessages()`),只暴露语义属性 + 语义事件
+- ❌ 事件漏 `composed: true` → 穿过 Shadow DOM 后宿主收不到
+- ❌ 无前缀标签名(`<chat>` 与别的库撞车)
+- ❌ `customElements.define` 不判重 → 二次加载 `NotSupportedError`
+- ❌ 组件库内置 lit 副本 → 宿主多实例 lit,见 [pitfalls.md](./pitfalls.md)
+- ❌ 装饰器
+- ❌ 手写原生 wc(`extends HTMLElement` / `attachShadow()` / `observedAttributes` / 字符串模板刷 `innerHTML`)→ 必须 `extends LitElement` + `html` + `css`,见第〇节
+
+## 相关
+
+- [core-library.md](./core-library.md) —— 组件库依赖的逻辑层
+- [components.md](./components.md) —— 页面内组件;组件库是它的「契约加严版」
+- [architecture.md](./architecture.md) —— 三分层总图
+- [context-di.md](./context-di.md) —— Lit 宿主中的注入
+- [deployment.md](./deployment.md) / [pitfalls.md](./pitfalls.md) —— 分发与多版本冲突

+ 80 - 0
dist/skills/fmode-code-lit/scripts/check-elements.mjs

@@ -0,0 +1,80 @@
+#!/usr/bin/env node
+/**
+ * check-elements.mjs —— 校验 src/ 下所有自定义元素都显式声明了 display
+ *
+ * 背景:light DOM / Shadow DOM 下自定义元素都是「未知元素」,浏览器默认按 inline 处理,
+ * 不吃 height/width、不形成块级盒子,常见症状是 h-full / flex-1 全失效、高度链断裂。
+ * 规则见 SKILL.md「多页面开发铁律 · 死线4」。
+ *
+ * 用法:
+ *   node scripts/check-elements.mjs [srcDir]      # 默认 ./src
+ *
+ * 判定:
+ *   1. 收集源码中 customElements.define('tag', ...) 注册的全部标签;
+ *   2. 在 .css 文件与内联 <style> 中查找该标签的选择器块;
+ *   3. 选择器块含 display 声明 → 通过;写 `display: skip` 标记为例外(如自带 flex 布局的元素);
+ *   4. 未声明 → 报错,退出码 1。
+ */
+
+import { readdirSync, readFileSync, statSync, existsSync } from 'node:fs';
+import { join, extname } from 'node:path';
+
+const SRC = process.argv[2] || 'src';
+if (!existsSync(SRC)) {
+  console.error(`✗ 目录不存在:${SRC}`);
+  process.exit(2);
+}
+
+/** 递归收集文件 */
+function walk(dir, out = []) {
+  for (const name of readdirSync(dir)) {
+    const p = join(dir, name);
+    const st = statSync(p);
+    if (st.isDirectory()) walk(p, out);
+    else if (/\.(js|mjs|html|css|jsx|tsx?)$/.test(name)) out.push(p);
+  }
+  return out;
+}
+
+const files = walk(SRC);
+const defined = new Set();       // customElements.define 注册的标签
+let cssText = '';
+
+for (const f of files) {
+  const text = readFileSync(f, 'utf8');
+  for (const m of text.matchAll(/customElements\.define\(\s*['"]([\w-]+)['"]/g)) defined.add(m[1]);
+  if (extname(f) === '.css') cssText += '\n' + text;
+  // 内联 <style> 块
+  for (const m of text.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/g)) cssText += '\n' + m[1];
+}
+
+/** 在 cssText 中查找某个标签的样式块(tag { ... }、tag[attr] { ... }、逗号分组 a, tag { ... }) */
+function findRules(tag) {
+  const re = /([^{}]+)\{([^{}]*)\}/g;
+  const rules = [];
+  for (const m of cssText.matchAll(re)) {
+    const selectors = m[1].split(',').map((s) => s.trim());
+    const hit = selectors.some((s) => new RegExp(`(^|[\\s>+~:.#\\[])${tag}(?![\\w-])`).test(s));
+    if (hit) rules.push(m[2]);
+  }
+  return rules;
+}
+
+const RED = '\x1b[31m', GREEN = '\x1b[32m', YELLOW = '\x1b[33m', DIM = '\x1b[2m', RESET = '\x1b[0m';
+let bad = 0, skipped = 0, ok = 0;
+
+// 只检查「已注册的自定义元素」—— 未知/拼错的标签不归本检查管
+const tags = [...defined];
+
+for (const tag of tags.sort()) {
+  const rules = findRules(tag);
+  const declared = rules.some((r) => /display\s*:/.test(r));
+  const exempted = rules.some((r) => /display\s*:\s*skip/.test(r));
+  if (exempted) { skipped++; console.log(`${YELLOW}-${RESET} ${tag} ${DIM}(例外声明 display: skip)${RESET}`); }
+  else if (declared) ok++;
+  else { bad++; console.log(`${RED}✗${RESET} ${tag} 未声明 display  —  add: ${tag} { display: block; min-width: 0; }`); }
+}
+
+console.log(`\n自定义元素 ${tags.length} 个:${GREEN}已声明 ${ok}${RESET},${YELLOW}例外 ${skipped}${RESET},${bad ? RED + '未声明 ' + bad : '未声明 0'}${RESET}`);
+console.log(bad ? `${RED}CHECK FAILED${RESET}` : `${DIM}回滚显示/高度链相关白屏时,先看这些元素是否 inline。${RESET}`);
+process.exit(bad ? 1 : 0);

+ 72 - 0
dist/skills/fmode-code-lit/scripts/install-rules.mjs

@@ -0,0 +1,72 @@
+#!/usr/bin/env node
+/**
+ * install-rules.mjs —— 把 fmode-code-lit 的前端规则装入目标项目的 ./.claude/rules/。
+ *
+ * 用法:
+ *   node install-rules.mjs [targetDir] [--force]
+ *   node install-rules.mjs [targetDir] --from <rulesRepoDir>   # 指定规则仓根目录
+ *
+ * 规则来源优先级:--from > 技能同仓 ../../.claude/rules > 内置最小规则集。
+ * 已存在文件默认不覆盖(--force 覆盖)。
+ */
+import { mkdirSync, writeFileSync, existsSync, readdirSync, readFileSync, statSync, copyFileSync } from 'node:fs';
+import { join, resolve, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const args = process.argv.slice(2);
+const target = resolve(args.find((a) => !a.startsWith('--') && a !== undefined && !a.startsWith('-')) || '.');
+const force = args.includes('--force');
+const fromIdx = args.indexOf('--from');
+const fromDir = fromIdx > -1 ? resolve(args[fromIdx + 1]) : null;
+
+// 规则来源候选
+const candidates = [
+  fromDir,
+  resolve(__dirname, '../../../.claude/rules'), // 技能在 <repo>/skills/fmode-code-lit/scripts/
+  resolve(__dirname, '../../.claude/rules'),
+].filter(Boolean);
+const srcRules = candidates.find((d) => d && existsSync(d) && existsSync(join(d, 'architecture.md')));
+
+const destRules = join(target, '.claude', 'rules');
+mkdirSync(destRules, { recursive: true });
+
+let created = 0, skipped = 0;
+function copyTree(from, to) {
+  for (const name of readdirSync(from)) {
+    const s = join(from, name), d = join(to, name);
+    const st = statSync(s);
+    if (st.isDirectory()) { mkdirSync(d, { recursive: true }); copyTree(s, d); continue; }
+    if (!name.endsWith('.md')) continue;
+    if (existsSync(d) && !force) { skipped++; console.log(`  skip  ${d}`); continue; }
+    copyFileSync(s, d); created++; console.log(`  write ${d}`);
+  }
+}
+
+if (srcRules) {
+  console.log(`\n规则来源:${srcRules}`);
+  copyTree(srcRules, destRules);
+} else {
+  console.log('\n未找到规则仓,写入内置最小规则集。');
+  const minimal = {
+    'lit-no-build.md': `# Lit 无构建前端 · 最小规则\n\n1. 源码即产物:不引入任何打包器。\n2. 依赖由 importmap 指向 FMODE Repose,禁止拷进项目。\n3. **禁用装饰器**(@customElement/@property/@state/@consume)——用 static properties + customElements.define + ContextConsumer。\n4. 分层:组件(渲染)→服务(业务/状态)→工具(纯函数);组件禁 fetch,服务禁 DOM。\n5. 相对导入必须带 .js 后缀。\n6. 全局状态用 @lit-labs/signals;组件本地状态用 static properties(state:true)。\n7. 子目录部署必须 <base href=\"/<project>/\">,且与 OBS 前缀、importmap 前缀三者同源。\n8. 导航一律走 utils/router.js(把应用内路径拼到 base 的 pathname 上);禁止 new URL(path, baseURI),子路径部署会丢前缀。\n9. 子路径部署禁用 @lit-labs/router(全局 <a> 拦截 + 未剥前缀 pathname 会污染路由)。\n10. CDN History 回退:不存在路径 200 返回 index.html,禁 302。\n`,
+  };
+  for (const [name, content] of Object.entries(minimal)) {
+    const d = join(destRules, name);
+    if (existsSync(d) && !force) { skipped++; continue; }
+    writeFileSync(d, content); created++; console.log(`  write ${d}`);
+  }
+}
+
+// CLAUDE.md 片段(追加式,不覆盖已有正文)
+const claudeMd = join(target, 'CLAUDE.md');
+const snippet = `\n## 前端(Lit 原生 ESM 无构建)\n\n本项目前端遵循 \`.claude/rules/\` 下的 Lit 无构建规则:源码即产物、依赖走 importmap、\n分层(组件/服务/工具)、**禁用装饰器**、@lit-labs/signals 响应式、自写 utils/router.js History 路由(子路径部署禁用 @lit-labs/router)。\n详见 \`.claude/rules/architecture.md\` 与 \`.claude/rules/components.md\`。\n`;
+if (existsSync(claudeMd)) {
+  const cur = readFileSync(claudeMd, 'utf8');
+  if (!cur.includes('Lit 原生 ESM 无构建')) { writeFileSync(claudeMd, cur + snippet); console.log(`  append ${claudeMd}`); }
+  else console.log(`  skip  ${claudeMd}(已含片段)`);
+} else {
+  writeFileSync(claudeMd, `# 项目规则\n${snippet}`); created++; console.log(`  write ${claudeMd}`);
+}
+
+console.log(`\n✓ 完成:新建 ${created},跳过 ${skipped}。规则目录:${destRules}\n`);

+ 57 - 0
dist/skills/fmode-code-lit/scripts/install.mjs

@@ -0,0 +1,57 @@
+#!/usr/bin/env node
+/**
+ * install.mjs —— 把 fmode-code-lit 技能安装到 FmodeCode 技能目录。
+ *
+ * 用法:
+ *   node install.mjs install     # → ~/.claude/skills/fmode-code-lit/(全局,默认)
+ *   node install.mjs workspace   # → ./.claude/skills/fmode-code-lit/(当前项目)
+ *   node install.mjs install --target <dir> [--force]
+ *   node install.mjs check       # 校验安装
+ */
+import { mkdirSync, copyFileSync, existsSync, readdirSync, statSync } from 'node:fs';
+import { join, resolve, dirname } from 'node:path';
+import { homedir } from 'node:os';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const SKILL_SRC = resolve(__dirname, '..');           // <...>/skills/fmode-code-lit
+const SKILL_NAME = 'fmode-code-lit';
+
+const args = process.argv.slice(2);
+const cmd = args[0] && !args[0].startsWith('--') ? args[0] : 'install';
+const force = args.includes('--force');
+const tIdx = args.indexOf('--target');
+const globalTarget = join(homedir(), '.claude', 'skills', SKILL_NAME);
+const workspaceTarget = join(process.cwd(), '.claude', 'skills', SKILL_NAME);
+
+function copyTree(from, to) {
+  mkdirSync(to, { recursive: true });
+  for (const name of readdirSync(from)) {
+    if (name === 'node_modules') continue;
+    const s = join(from, name), d = join(to, name);
+    if (statSync(s).isDirectory()) copyTree(s, d);
+    else if (!existsSync(d) || force) copyFileSync(s, d);
+  }
+}
+
+function verify(target) {
+  const must = ['SKILL.md'];
+  const missing = must.filter((f) => !existsSync(join(target, f)));
+  if (missing.length) { console.error(`✗ 安装不完整,缺少:${missing.join(', ')}`); process.exit(1); }
+  console.log(`✓ 技能已就位:${target}`);
+  console.log(`  SKILL.md 存在,scripts/ ${existsSync(join(target, 'scripts')) ? '存在' : '缺失'}`);
+}
+
+function main() {
+  const target = cmd === 'workspace' || cmd === 'install-workspace'
+    ? workspaceTarget
+    : (tIdx > -1 ? resolve(args[tIdx + 1]) : globalTarget);
+  if (cmd === 'check') { verify(target); return; }
+  if (!existsSync(SKILL_SRC)) { console.error(`✗ 找不到技能源:${SKILL_SRC}`); process.exit(1); }
+  console.log(`安装 ${SKILL_NAME} → ${target}${force ? '(--force 覆盖)' : ''}`);
+  copyTree(SKILL_SRC, target);
+  verify(target);
+  console.log(`\n触发词:lit 项目 / lit 组件 / 无构建前端 / WebComponent / importmap`);
+}
+
+main();

+ 221 - 0
dist/skills/fmode-code-lit/scripts/new.mjs

@@ -0,0 +1,221 @@
+#!/usr/bin/env node
+/**
+ * new.mjs —— 生成单个 Lit 无构建单元(组件 / 服务 / 页面 / 核心库 / 组件库)。
+ *
+ * 用法:
+ *   node new.mjs component user-card         [--dir src/components]
+ *   node new.mjs service   auth-service      [--dir src/services]
+ *   node new.mjs page      page-order-list   [--dir src/routes]
+ *   node new.mjs core      pricing           [--dir lib/core]   # 纯 ESM 逻辑模块(零 DOM)
+ *   node new.mjs wc        quote-calc        [--dir lib/wc]     # Web Component(跨框架组件库)
+ *
+ * 生成物一律无装饰器,遵循规则仓命名与分层规范。
+ */
+import { mkdirSync, writeFileSync, existsSync } from 'node:fs';
+import { join, resolve } from 'node:path';
+
+const args = process.argv.slice(2);
+const kind = args[0];
+const name = args[1];
+const dirIdx = args.indexOf('--dir');
+const force = args.includes('--force');
+
+const KINDS = {
+  component: { dir: 'src/components', file: (n) => n + '.js' },
+  service: { dir: 'src/services', file: (n) => n + '.js' },   // 文件名已含 -service 后缀
+  page: { dir: 'src/routes', file: (n) => n + '.js' },
+  core: { dir: 'lib/core', file: (n) => n + '.js' },          // 纯 ESM 核心库(零浏览器依赖)
+  wc: { dir: 'lib/wc', file: (n) => n + '.js' },              // Web Component 组件库
+};
+
+if (!kind || !KINDS[kind] || !name) {
+  console.error(`用法:node new.mjs <component|service|page|core|wc> <name> [--dir <dir>] [--force]`);
+  process.exit(2);
+}
+if (!/^[a-z][a-z0-9-]*$/.test(name)) {
+  console.error(`✗ 名称须为小写 kebab-case:${name}`);
+  process.exit(2);
+}
+
+const spec = KINDS[kind];
+const dir = dirIdx > -1 ? resolve(args[dirIdx + 1]) : resolve(spec.dir);
+// service:允许传 order 或 order-service,统一补一个 -service 后缀(避免 order-service-service)
+const baseName = kind === 'service' ? name.replace(/-service$/, '') : name;
+const fileBase = kind === 'service' ? baseName + '-service' : name;
+const pascal = fileBase.split('-').map((s) => s[0].toUpperCase() + s.slice(1)).join('');
+// 标签必须含连字符:page- 前缀已保证;component 若单段则补前缀
+const tag = (kind === 'service' || kind === 'core') ? null : (fileBase.includes('-') ? fileBase : 'x-' + fileBase);
+const path = join(dir, spec.file(fileBase));
+const relToUtils = kind === 'page' ? '../../utils' : '../utils';
+
+if (existsSync(path) && !force) {
+  console.error(`✗ 已存在:${path}(用 --force 覆盖)`);
+  process.exit(1);
+}
+mkdirSync(dir, { recursive: true });
+
+let content = '';
+if (kind === 'component') {
+  content = `/**
+ * ${name} —— 通用组件。
+ * 无装饰器:static properties + customElements.define。
+ */
+import { LitElement, html, css } from 'lit';
+
+export class ${pascal} extends LitElement {
+  static properties = {
+    label: { type: String },
+  };
+
+  static styles = css\`
+    :host { display: block; }
+  \`;
+
+  constructor() { super(); this.label = ''; }
+
+  render() {
+    return html\`<div>\${this.label}</div>\`;
+  }
+}
+
+customElements.define('${tag}', ${pascal});
+`;
+} else if (kind === 'service') {
+  const endpoint = baseName;
+  const singleton = pascal.charAt(0).toLowerCase() + pascal.slice(1);
+  content = `/**
+ * ${pascal} —— 业务服务(Signal + 单例)。无 DOM 依赖,可单测。
+ */
+import { signal, computed } from '@lit-labs/signals';
+// import { http } from '${relToUtils}/http.js';
+
+class ${pascal} {
+  #items = signal([]);
+  get items() { return this.#items; }
+  get count() { return computed(() => this.#items.get().length); }
+
+  async fetchAll() {
+    // const data = await http.get('/${endpoint}');
+    // this.#items.set(data.items ?? []);
+    return this.#items.get();
+  }
+
+  set(items) { this.#items.set(items); }
+}
+
+export const ${singleton} = new ${pascal}();
+`;
+} else if (kind === 'core') {
+  content = `/**
+ * ${pascal} —— 核心库模块(core):纯 ESM、零浏览器依赖。
+ *
+ * 铁律:不碰 window/document/localStorage/fetch,不 import lit。
+ * 不确定输入(时间/id/网络)一律从参数注入 → Node 可确定化单测。
+ * 规则:见 .claude/rules/core-library.md
+ */
+
+/**
+ * @param {any[]} items
+ * @param {{ now?: () => number, id?: () => string }} [opts]
+ */
+export function ${pascal.charAt(0).toLowerCase() + pascal.slice(1)}(items, opts = {}) {
+  const { now = () => Date.now(), id = () => Math.random().toString(36).slice(2, 10) } = opts;
+  // TODO: 纯逻辑(校验 / 计算 / 状态机),全部由入参决定
+  return { id: id(), at: now(), count: Array.isArray(items) ? items.length : 0 };
+}
+`;
+} else if (kind === 'wc') {
+  content = `/**
+ * ${pascal} —— 组件库(Web Component):仅浏览器交互,逻辑来自 core。
+ *
+ * ⚠️ 必须用 Lit 框架特性实现(extends LitElement + html + css),禁止手写原生 wc
+ *    (extends HTMLElement / attachShadow() / 手写 observedAttributes)。
+ *    Lit 本身基于 Web Component 标准,产物仍是跨框架可用的自定义元素。
+ *
+ * 契约:语义属性 + composed 事件 + slot + CSS 变量 + ::part
+ * 无装饰器:static properties + customElements.define(判重)
+ * 规则:见 .claude/rules/web-component-library.md
+ */
+import { LitElement, html, css } from 'lit';
+
+export class ${pascal} extends LitElement {
+  static properties = {
+    label: { type: String },
+    value: { type: Object },
+    disabled: { type: Boolean, reflect: true },
+    _state: { state: true },
+  };
+
+  static styles = css\`
+    :host { display: block; --x-accent: #2b6cb0; }
+    button { background: var(--x-accent); color: #fff; border: 0; border-radius: 6px; padding: 6px 12px; }
+  \`;
+
+  /** 宿主注入 I/O(组件库不自己 fetch) */
+  #transport = null;
+  set transport(t) { this.#transport = t; }
+  get transport() { return this.#transport; }
+
+  constructor() {
+    super();
+    this.label = '';
+    this.value = null;
+    this.disabled = false;
+    this._state = 'idle';
+  }
+
+  render() {
+    return html\`
+      <button part="button" ?disabled=\${this.disabled || this._state !== 'idle'}
+              @click=\${this.#onClick}>
+        <slot name="label">\${this.label}</slot>
+      </button>
+    \`;
+  }
+
+  async #onClick() {
+    this._state = 'busy';
+    try {
+      // 逻辑应由 core 提供;这里仅演示接线
+      const result = this.#transport ? await this.#transport.call(this.value) : this.value;
+      this.dispatchEvent(new CustomEvent('${tag}-done', {
+        detail: { result }, bubbles: true, composed: true,
+      }));
+    } catch (err) {
+      this.dispatchEvent(new CustomEvent('${tag}-error', {
+        detail: { code: err.code, message: err.message }, bubbles: true, composed: true,
+      }));
+    } finally {
+      this._state = 'idle';
+    }
+  }
+}
+
+if (!customElements.get('${tag}')) customElements.define('${tag}', ${pascal});
+`;
+} else {
+  content = `/**
+ * ${name} —— 页面组件(路由挂载)。
+ */
+import { LitElement, html, css } from 'lit';
+import { SignalWatcher } from '@lit-labs/signals';
+// import { navigate } from '${relToUtils}/router.js';
+
+export class ${pascal} extends SignalWatcher(LitElement) {
+  static properties = {};
+
+  static styles = css\`
+    :host { display: block; }
+  \`;
+
+  render() {
+    return html\`<h2>${pascal}</h2>\`;
+  }
+}
+
+customElements.define('${tag}', ${pascal});
+`;
+}
+
+writeFileSync(path, content);
+console.log(`✓ 已生成 ${path}`);

+ 308 - 0
dist/skills/fmode-code-lit/scripts/scaffold.mjs

@@ -0,0 +1,308 @@
+#!/usr/bin/env node
+/**
+ * scaffold.mjs —— 生成 Lit 原生 ESM 无构建项目骨架。
+ *
+ * 用法:
+ *   node scaffold.mjs <targetDir> [--project <name>] [--force]
+ *
+ * 原则:已存在的文件默认不覆盖;不引入任何构建工具;所有代码无装饰器。
+ */
+import { mkdirSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs';
+import { join, resolve, basename } from 'node:path';
+
+const args = process.argv.slice(2);
+const target = resolve(args.find((a) => !a.startsWith('--')) || '.');
+const force = args.includes('--force');
+const projIdx = args.indexOf('--project');
+const project = projIdx > -1 ? args[projIdx + 1] : basename(target);
+
+if (!project || !/^[a-z0-9][a-z0-9-]*$/.test(project)) {
+  console.error(`✗ 项目名非法:${project}(须为小写 kebab-case)`);
+  process.exit(2);
+}
+
+let created = 0, skipped = 0;
+function write(rel, content) {
+  const p = join(target, rel);
+  if (existsSync(p) && !force) { skipped++; console.log(`  skip  ${rel}(已存在)`); return; }
+  mkdirSync(join(p, '..'), { recursive: true });
+  writeFileSync(p, content);
+  created++; console.log(`  write ${rel}`);
+}
+
+const DEP_VERSIONS = {
+  lit: '3.3.3', 'lit-html': '3.3.3', 'lit-element': '4.2.2', '@lit/reactive-element': '2.1.2',
+  '@lit/context': '1.1.6', '@lit-labs/signals': '0.3.0', 'signal-polyfill': '0.2.2',
+};
+
+console.log(`\n生成 Lit 无构建项目:${target}\n项目名:${project}\n`);
+
+// 目录
+for (const d of ['src/components', 'src/services', 'src/utils', 'src/routes']) {
+  mkdirSync(join(target, d), { recursive: true });
+}
+
+// index.html
+write('src/index.html', `<!doctype html>
+<html lang="zh-CN">
+<head>
+  <meta charset="utf-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1" />
+  <title>${project}</title>
+  <!-- 二级子路径部署必须;根部署可改 "/" -->
+  <base href="/${project}/" />
+  <!-- 生产:指向 FMODE Repose 公共 importmap -->
+  <script type="importmap" src="/npm/importmap/lit-v3.json"></script>
+  <!-- 本地无 Repose 时改用: <script type="importmap" src="./importmap.json"></script> -->
+  <style>:root{--brand:#2b6cb0;--bg:#f7fafc;--border:#e2e8f0;font-family:system-ui,sans-serif}body{margin:0}</style>
+</head>
+<body>
+  <app-root></app-root>
+  <script type="module" src="./app.js"></script>
+</body>
+</html>
+`);
+
+// importmap.json(本地开发用)
+write('src/importmap.json', JSON.stringify({
+  _comment: '本地开发用;生产由 FMODE Repose 的 /npm/importmap/lit-v3.json 提供。',
+  imports: {
+    lit: `/npm/lit/v${DEP_VERSIONS.lit}/lit.js`,
+    'lit/': `/npm/lit/v${DEP_VERSIONS.lit}/`,
+    'lit-html': `/npm/lit-html/v${DEP_VERSIONS['lit-html']}/lit-html.js`,
+    'lit-element': `/npm/lit-element/v${DEP_VERSIONS['lit-element']}/lit-element.js`,
+    '@lit/reactive-element': `/npm/@lit/reactive-element/v${DEP_VERSIONS['@lit/reactive-element']}/reactive-element.js`,
+    '@lit/context': `/npm/@lit/context/v${DEP_VERSIONS['@lit/context']}/index.js`,
+    '@lit-labs/signals': `/npm/@lit-labs/signals/v${DEP_VERSIONS['@lit-labs/signals']}/index.js`,
+    'signal-polyfill': `/npm/signal-polyfill/v${DEP_VERSIONS['signal-polyfill']}/dist/index.js`,
+  },
+}, null, 2) + '\n');
+
+// app.js
+write('src/app.js', `/**
+ * app.js —— 应用装配:provide 全局服务 + 挂载路由 + 根组件。不含业务逻辑。
+ */
+import { LitElement, html, css } from 'lit';
+import { provide } from '@lit/context';
+import { authContext } from './services/contexts.js';
+import { authService } from './services/auth-service.js';
+
+export class AppRoot extends LitElement {
+  static styles = css\`:host{display:block;background:var(--bg)}\`;
+  render() {
+    return html\`
+      \${provide(authContext, authService)}
+      <app-shell></app-shell>
+    \`;
+  }
+}
+customElements.define('app-root', AppRoot);
+`);
+
+// services/contexts.js
+write('src/services/contexts.js', `/**
+ * 注入令牌集中定义(令牌必须同一模块实例)。
+ */
+import { createContext } from '@lit/context';
+export const authContext = createContext('${project}.auth');
+`);
+
+// services/auth-service.js
+write('src/services/auth-service.js', `/**
+ * 全局登录态(Signal Service)。纯 ESM Class,无 DOM 依赖,可单测。
+ */
+import { signal, computed } from '@lit-labs/signals';
+
+class AuthService {
+  #user = signal(null);
+  #token = signal(localStorage.getItem('token') || '');
+  get user() { return this.#user; }
+  get isLoggedIn() { return computed(() => this.#user.get() != null); }
+  getToken() { return this.#token.get(); }
+
+  async login(username, password) {
+    // TODO: 接真实接口
+    // const { token, user } = await http.post('/auth/login', { username, password });
+    const token = 'demo-token', user = { name: username };
+    this.#token.set(token); this.#user.set(user);
+    localStorage.setItem('token', token);
+  }
+  logout() { this.#token.set(''); this.#user.set(null); localStorage.removeItem('token'); }
+}
+export const authService = new AuthService();
+`);
+
+// utils/http.js
+write('src/utils/http.js', `/**
+ * 原生 fetch 薄封装。token 通过 configureHttp 注入,避免 utils 反向依赖 services。
+ */
+let getToken = () => '';
+let onUnauthorized = () => {};
+export function configureHttp({ token, unauthorized } = {}) {
+  if (token) getToken = token;
+  if (unauthorized) onUnauthorized = unauthorized;
+}
+export class HttpError extends Error {
+  constructor(status, body) { super('HTTP ' + status); this.status = status; this.body = body; }
+}
+const BASE = '/api';
+async function request(method, path, { body, headers = {}, query } = {}) {
+  let url = BASE + path;
+  if (query) url += '?' + new URLSearchParams(query).toString();
+  const h = { 'Content-Type': 'application/json', ...headers };
+  const t = getToken(); if (t) h.Authorization = 'Bearer ' + t;
+  const res = await fetch(url, { method, headers: h, body: body != null ? JSON.stringify(body) : undefined, credentials: 'same-origin' });
+  if (res.status === 401) { onUnauthorized(); throw new HttpError(401, null); }
+  const ct = res.headers.get('content-type') || '';
+  const data = ct.includes('application/json') ? await res.json() : await res.text();
+  if (!res.ok) throw new HttpError(res.status, data);
+  return data;
+}
+export const http = {
+  get: (p, o) => request('GET', p, o),
+  post: (p, body, o) => request('POST', p, { ...o, body }),
+  put: (p, body, o) => request('PUT', p, { ...o, body }),
+  del: (p, o) => request('DELETE', p, o),
+};
+`);
+
+// utils/router.js —— 子路径部署安全(不要用 new URL(path, baseURI),会丢部署前缀)
+write('src/utils/router.js', `/**
+ * utils/router.js —— 子路径部署安全的导航(只用 History API,干净 URL)。
+ *
+ * ⚠️ 不要写 new URL(path, document.baseURI) 做导航:path 以 '/' 开头时,
+ *   相对解析会丢掉 base 的路径前缀(/proj/users -> /users),子路径部署刷新即 404。
+ *   正确做法:把应用内路径拼到 base 的 pathname 上再解析。
+ *   详见规则仓 .claude/rules/routing-best-practices.md
+ */
+export function basePath() {
+  return new URL(document.baseURI).pathname.replace(/\\/+$/, '');
+}
+export function resolveUrl(path) {
+  const base = basePath();
+  const q = path.indexOf('?'), h = path.indexOf('#');
+  let cut = path.length;
+  if (q !== -1) cut = Math.min(cut, q);
+  if (h !== -1) cut = Math.min(cut, h);
+  let p = path.slice(0, cut);
+  const rest = path.slice(cut);        // 查询串 / hash 原样保留
+  if (!p.startsWith('/')) p = '/' + p;
+  return new URL(base + p + rest, location.origin);
+}
+export function navigate(path, { replace = false } = {}) {
+  const url = resolveUrl(path);
+  if (replace) history.replaceState({}, '', url); else history.pushState({}, '', url);
+  window.dispatchEvent(new PopStateEvent('popstate'));   // History 原生不触发
+}
+export function currentPath() {
+  const base = basePath();
+  let path = location.pathname;
+  if (base && path.startsWith(base)) path = path.slice(base.length);
+  if (!path.startsWith('/')) path = '/' + path;
+  return path.replace(/\\/{2,}/g, '/') || '/';   // 折叠 '//'
+}
+export function currentQuery() {
+  return Object.fromEntries(new URLSearchParams(location.search));
+}
+export function href(path) { return resolveUrl(path); }   // 供真实 <a href>
+`);
+
+// components/app-shell.js —— 不用 @lit-labs/router(子路径部署会污染路由状态)
+write('src/components/app-shell.js', `/**
+ * 布局壳:侧边菜单 + 内容出口。多标签版本见规则仓 tabs-keepalive.md。
+ *
+ * ⚠️ 不用 @lit-labs/router:它装全局 <a> 拦截器且用未剥前缀的 location.pathname
+ *   匹配路由,子路径部署下会污染路由状态。改为订阅 popstate + currentPath()。
+ */
+import { LitElement, html, css } from 'lit';
+import { currentPath, navigate } from '../utils/router.js';
+import './page-home.js';
+
+const ROUTES = [{ path: '/', tag: 'page-home', title: '首页' }];
+
+export class AppShell extends LitElement {
+  static styles = css\`:host{display:block;padding:16px}\`;
+  static properties = { _path: { state: true } };
+
+  #onPop = () => { this._path = currentPath(); };
+
+  connectedCallback() {
+    super.connectedCallback();
+    this._path = currentPath();
+    window.addEventListener('popstate', this.#onPop);
+  }
+  disconnectedCallback() {
+    window.removeEventListener('popstate', this.#onPop);
+    super.disconnectedCallback();
+  }
+
+  render() {
+    const hit = ROUTES.find((r) => r.path === this._path) || ROUTES[ROUTES.length - 1];
+    return html\`
+      <nav>
+        \${ROUTES.map((r) => html\`<button @click=\${() => navigate(r.path)}>\${r.title}</button>\`)}
+      </nav>
+      <main>\${this.#view(hit)}</main>
+    \`;
+  }
+  #view(r) { return document.createElement(r.tag); }
+}
+customElements.define('app-shell', AppShell);
+`);
+
+// components/page-home.js
+write('src/components/page-home.js', `/**
+ * 首页示例组件。无装饰器;用 static properties。
+ */
+import { LitElement, html, css } from 'lit';
+import { SignalWatcher } from '@lit-labs/signals';
+import { ContextConsumer } from '@lit/context';
+import { authContext } from '../services/contexts.js';
+
+export class PageHome extends SignalWatcher(LitElement) {
+  static properties = { _auth: { state: true } };
+  #consumer = new ContextConsumer(this, { context: authContext, subscribe: true, callback: (v) => { this._auth = v; } });
+  static styles = css\`h1{color:var(--brand)}\`;
+  render() {
+    const name = this._auth?.user.get()?.name;
+    return html\`<h1>${project}: \${name || 'hello lit'}</h1>\`;
+  }
+}
+customElements.define('page-home', PageHome);
+`);
+
+// package.json
+write('package.json', JSON.stringify({
+  name: project, private: true, type: 'module',
+  _comment: 'devDependencies 仅用于 IDE 类型提示,不参与部署产物。',
+  scripts: { serve: 'npx --yes serve src', deploy: 'bash scripts/deploy.sh ' + project },
+  devDependencies: {
+    lit: '^' + DEP_VERSIONS.lit, '@lit/context': '^' + DEP_VERSIONS['@lit/context'],
+    '@lit-labs/signals': '^' + DEP_VERSIONS['@lit-labs/signals'],
+  },
+}, null, 2) + '\n');
+
+// scripts/deploy.sh
+write('scripts/deploy.sh', `#!/usr/bin/env bash
+# 无构建部署:同步 src/ 到 OBS + CDN 刷新。凭据由环境/既有脚本提供,不写入仓库。
+set -euo pipefail
+PROJECT="\${1:-${project}}"
+BUCKET="\${OBS_BUCKET:?需设置 OBS_BUCKET}"
+EP="\${OBS_ENDPOINT:-obs.<region>.myhuaweicloud.com}"
+OBS="\${OBSUTIL:-obsutil}"
+SRC="$(cd "$(dirname "$0")/.." && pwd)/src"
+"$OBS" rm "$BUCKET/$PROJECT/index.html" -e="$EP" -f >/dev/null 2>&1 || true
+"$OBS" cp "$SRC/index.html" "$BUCKET/$PROJECT/index.html" -e="$EP" -acl=public-read -f \\
+  -meta="Cache-Control:no-cache#Content-Type:text/html; charset=utf-8"
+"$OBS" sync "$SRC/" "$BUCKET/$PROJECT/" -e="$EP" -acl=public-read -f
+echo "完成。确认 CDN 回退:不存在路径 200 返回 index.html(禁 302)。"
+`);
+
+// .gitignore
+write('.gitignore', 'node_modules/\ndist/\n.DS_Store\n');
+
+console.log(`\n✓ 完成:新建 ${created} 个文件,跳过 ${skipped} 个。`);
+console.log(`\n下一步:`);
+console.log(`  1. 本地预览: cd ${target} && npx serve src`);
+console.log(`  2. 或在 index.html 指向 Repose 公共 importmap 后直接部署`);
+console.log(`  3. 规则细则见 fmode-code-lit 规则仓 .claude/rules/\n`);

+ 60 - 0
dist/skills/fmode-code-lit/scripts/smoke.mjs

@@ -0,0 +1,60 @@
+#!/usr/bin/env node
+/**
+ * smoke.mjs —— 技能自检。
+ * 生成一个临时项目并校验:文件齐全、JS 语法通过、无装饰器。
+ * 用法:node scripts/smoke.mjs
+ */
+import { mkdtempSync, rmSync, readdirSync, statSync, readFileSync } from 'node:fs';
+import { join, dirname } from 'node:path';
+import { tmpdir } from 'node:os';
+import { execFileSync } from 'node:child_process';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const scaffold = join(__dirname, 'scaffold.mjs');
+const newGen = join(__dirname, 'new.mjs');
+
+let fail = 0;
+const check = (name, cond) => { console.log(`${cond ? '✓' : '✗'} ${name}`); if (!cond) fail++; };
+
+function walk(d, out = []) {
+  for (const e of readdirSync(d, { withFileTypes: true })) {
+    const p = join(d, e.name);
+    if (e.isDirectory()) walk(p, out); else out.push(p);
+  }
+  return out;
+}
+
+const dir = mkdtempSync(join(tmpdir(), 'fmode-code-lit-smoke-'));
+try {
+  execFileSync(process.execPath, [scaffold, dir, '--project', 'smoke-app'], { stdio: 'pipe' });
+  const files = walk(dir);
+  check('scaffold 生成 index.html', files.some((f) => f.endsWith('src/index.html')));
+  check('scaffold 生成 importmap.json', files.some((f) => f.endsWith('src/importmap.json')));
+  check('scaffold 生成 app.js', files.some((f) => f.endsWith('src/app.js')));
+  check('scaffold 生成分层目录', ['components', 'services', 'utils'].every((d2) => files.some((f) => f.includes(`/src/${d2}/`))));
+
+  execFileSync(process.execPath, [newGen, 'component', 'smoke-card', '--dir', join(dir, 'src/components')], { stdio: 'pipe' });
+  execFileSync(process.execPath, [newGen, 'service', 'smoke-service', '--dir', join(dir, 'src/services')], { stdio: 'pipe' });
+  check('new component 生成', files.some((f) => f.endsWith('smoke-card.js')) || walk(dir).some((f) => f.endsWith('smoke-card.js')));
+  check('new service 无重复后缀', walk(dir).some((f) => f.endsWith('smoke-service.js')) && !walk(dir).some((f) => f.endsWith('smoke-service-service.js')));
+
+  // 全量语法校验 + 装饰器扫描
+  let syntaxOk = true, decoratorFound = false;
+  for (const f of walk(dir).filter((f) => f.endsWith('.js'))) {
+    try { execFileSync(process.execPath, ['--check', f], { stdio: 'pipe' }); } catch { syntaxOk = false; console.log('  语法失败:', f); }
+    if (/^\s*@(customElement|property|state|consume|provide|query)\b/m.test(readFileSync(f, 'utf8'))) decoratorFound = true;
+  }
+  check('生成物全部通过语法检查', syntaxOk);
+  check('生成物无装饰器语法', !decoratorFound);
+
+  // importmap JSON 合法
+  let mapOk = false;
+  try { JSON.parse(readFileSync(join(dir, 'src/importmap.json'), 'utf8')); mapOk = true; } catch {}
+  check('importmap.json 为合法 JSON', mapOk);
+} finally {
+  rmSync(dir, { recursive: true, force: true });
+}
+
+console.log(fail === 0 ? '\nSMOKE OK' : `\nSMOKE FAILED(${fail})`);
+process.exit(fail === 0 ? 0 : 1);

+ 18 - 0
dist/skills/fmode-code-lit/skill-package-manifest.json

@@ -0,0 +1,18 @@
+{
+  "name": "fmode-code-lit",
+  "version": "1.0.0",
+  "description": "FmodeCode 独立技能包:用 Lit 原生 ESM 无构建方式写前端(零编译/零打包/浏览器直跑),分层规范 + importmap + signals 响应式 + context 注入 + router History 路由 + 多标签 KeepAlive,配套规则仓与 OBS+CDN 部署。",
+  "plugin": "fmode-code-lit",
+  "skills": [
+    "fmode-code-lit"
+  ],
+  "entrySkill": "fmode-code-lit",
+  "npmPackage": "@fmode/skill-code-lit",
+  "smokeCommand": "node scripts/smoke.mjs",
+  "installCommand": "npx @fmode/skill-code-lit@latest install",
+  "workspaceInstallCommand": "npx @fmode/skill-code-lit@latest workspace",
+  "workspaceSkillPath": ".claude/skills/fmode-code-lit/SKILL.md",
+  "globalSkillPath": "~/.claude/skills/fmode-code-lit/SKILL.md",
+  "installHint": "工作区安装:npx @fmode/skill-code-lit@latest workspace,写入 ./.claude/skills/fmode-code-lit/。用户级安装:npx @fmode/skill-code-lit@latest install,写入 ~/.claude/skills/fmode-code-lit/。随后可用 npx @fmode/skill-code-lit@latest scaffold <dir> --project <name> 生成无构建 Lit 项目骨架。规则细则见 fmode-code-lit 规则仓 .claude/rules/。",
+  "summary": "数字人开发基础规则技能:Lit 原生 ESM 无构建前端(含多页面 light DOM 五条死线)"
+}

+ 14 - 0
scripts/build-dist.mjs

@@ -200,6 +200,20 @@ for (const name of names) {
   if (!DRY) {
     const targetDir = path.join(DIST, cfg.target);
     copyDir(skillDir, targetDir, summary);
+
+    // 额外打包目录(如技能的规则仓 .claude/rules,SKILL.md 正文会引用)
+    if (Array.isArray(cfg.extraDirs)) {
+      for (const extra of cfg.extraDirs) {
+        const srcExtra = path.join(synced.dir, extra.from);
+        if (!fs.existsSync(srcExtra)) {
+          warn(`${name} 额外目录缺失: ${extra.from}`);
+          continue;
+        }
+        const destExtra = path.join(targetDir, extra.to || extra.from);
+        copyDir(srcExtra, destExtra, summary);
+      }
+    }
+
     // 用规范化后的 SKILL.md 覆盖(正文保留原文,仅统一 frontmatter)
     fs.writeFileSync(path.join(targetDir, 'SKILL.md'), norm.output, 'utf8');
     // 品牌映射:对外呈现统一为 FmodeAgent / FmodeCode

+ 11 - 4
skill-sources.json

@@ -81,11 +81,18 @@
       "localPath": "/opt/data/git-repos/skill-video-editing"
     },
     "fmode-code-lit": {
-      "repo": "https://github.com/fmodecn/skill-code-lit.git",
-      "path": ".",
+      "repo": "https://git.fmode.cn/fmode/fmode-code-lit.git",
+      "localPath": "/opt/data/git-repos/fmode-code-lit",
+      "path": "skills/fmode-code-lit",
       "target": "skills/fmode-code-lit",
-      "status": "pending-source",
-      "note": "GitHub 源访问超时(clone ETIMEDOUT);待建 Gogs 镜像后改回 repo"
+      "status": "local-repo",
+      "extraDirs": [
+        {
+          "from": ".claude/rules",
+          "to": "references/rules"
+        }
+      ],
+      "note": "规则仓在仓库根 .claude/rules/,技能通过 install-rules.mjs 引用;extraDirs 把它一并打进 dist/references/rules/(对应线上 fmode-skills 的 references/rules 结构)"
     },
     "fmode-code-agent": {
       "repo": "https://git.fmode.cn/fmode/fmode-code-agent.git",