Ver Fonte

feat: 双方向规范 —— core(纯 ESM 核心库)+ wc(Web Component 组件库)

规则仓从「只教写页面」升级为双方向:既写页面,也指导跨前端框架的
最底层 ESM 核心库与跨框架组件库的开发。同一套无构建铁律,三个尺度。

分层(core / wc / page):
  [core] 纯 ESM 逻辑库,零浏览器依赖,Node 可 import 单测
     ▲
  [wc]   Web Component 组件库,仅浏览器交互,跨框架 <my-x> 直接用
     ▲
  [page] Lit 无构建页面,组装 core 与 wc
core 不认识 DOM,wc 不认识业务,page 组装一切。

新增规则篇:
- .claude/rules/core-library.md
  无浏览器依赖铁律(禁 window/document/localStorage/fetch/lit)、
  只 export 纯逻辑、零副作用、Node 可测(node:test,不依赖 jsdom)、
  I/O 注入(now/id/transport)、无构建单文件发布(保持多文件 ESM 优先,
  必要时仅在发布流水线打一次 esbuild,不进业务仓)、
  semver + Repose 版本目录化 + 单一出口 exports
- .claude/rules/web-component-library.md
  customElements 注册(判重)、static properties 无装饰器、
  跨框架契约(attribute/property + composed 事件 + slot + CSS 变量 + ::part)、
  Lit/Vue/React/原生四种宿主用法对照、依赖注入 transport、
  样式隔离与主题、Repose 分发(lit 不内置,走宿主 importmap)、
  验收(必须在一个非 Lit 宿主冒烟)

增量修订(不破坏现有页面规范):
- architecture.md  新增「一·五 双方向分层」总图 + 扩展方向补 2 条
- components.md    开篇区分页面组件 vs 组件库;新增「七、升级为组件库组件」
- CLAUDE.md        规则索引 11→13 条 + §2.1 分层一句话 + §8 写库动手清单
- README.md        分层图 + 「何时用 core/wc/page」决策表 + 示例索引

示例(examples/core-wc/,可浏览器跑、零构建、无装饰器):
- core/   纯 ESM 报价计算(注入 now/id),package.json type:module sideEffects:false
- wc/     <quote-calc>(逻辑来自 core,I/O 由宿主注入 transport)
          + browser-transport.js(唯一碰 fetch 的地方)+ index.js(仅注册)
          + plain.html(纯 HTML 宿主,证明跨框架)
- page/   Lit 页面组合 wc,监听事件、注入 transport
- test/   node:test 单测 6 项(不依赖浏览器)
- scripts/check.mjs          静态体检:core 零 DOM / 无装饰器 / import 后缀 / 注册幂等
- scripts/browser-verify.mjs 真实浏览器验收 11 项(不能只 curl)

技能同步:
- new.mjs 新增 core / wc 两种生成器(生成物无装饰器、core 可 Node 直跑)
- SKILL.md 描述/触发词/速查表补 core・wc 两方向,明确选型与铁律

验证:
- core 单测 6/6 通过;scripts/check.mjs 7/7 通过
- 真实 Chromium 131 跑 browser-verify.mjs:11/11 通过
  (Lit 宿主与纯 HTML 宿主用同一 quote-calc.js;Shadow DOM 隔离、
   composed 事件穿透、CSS 变量主题、重复 import 幂等均实测)
- 生成的 core 模块 Node import 直跑且注入生效;技能 smoke 通过
LiuYuYang há 3 horas atrás
pai
commit
fa05c77ba4

+ 38 - 0
.claude/rules/architecture.md

@@ -18,6 +18,38 @@
 - **路由**:自写 `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 依赖**,否则复用不成立。
+
+
 ## 二、分层职责表(越界即返工)
 
 | 层 | 目录 | 允许 | 禁止 |
@@ -28,6 +60,10 @@
 
 > 判定口诀:**能被单测、不 import Lit 的,就不是组件**。服务必须能在 Node 里 `import` 后直接跑。
 
+> 三分层版的口诀(见 [一·五](#一五分方向分层core--wc--page新增)):
+> **core 不认识 DOM,wc 不认识业务,page 组装一切。** service 若连 `localStorage` / `fetch` 都想抽走,就成 core;
+> 若沾 DOM,就留在 page。
+
 ## 三、运行流程
 
 ```
@@ -65,3 +101,5 @@
 - 通用弹窗 / 表格 / 表单 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)。

+ 21 - 0
.claude/rules/components.md

@@ -2,6 +2,10 @@
 
 > 组件只做一件事:**把数据渲染成 DOM,并把用户事件转成对 Service 的调用**。
 > 复杂业务逻辑、请求、状态计算 —— 全部下沉到 Service。
+>
+> **本文件讲「页面层组件」**(服务于本应用的 UI)。若这个组件要**跨项目 / 跨框架**复用,
+> 它是**组件库(wc)**,契约更严、逻辑必须来自 [core](./core-library.md) —— 见
+> [web-component-library.md](./web-component-library.md)。分层总图见 [architecture.md 一·五](./architecture.md)。
 
 ## 〇、无构建环境的第一约束:**禁用装饰器**
 
@@ -130,3 +134,20 @@ export class PageDashboard extends SignalWatcher(LitElement) {
 - **用 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 的页面(本文件其余章节讲的就是它)。

+ 211 - 0
.claude/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 只能留在页面)

+ 266 - 0
.claude/rules/web-component-library.md

@@ -0,0 +1,266 @@
+# Web Component 组件库规范(wc:仅浏览器交互层)
+
+> 组件库 = 一批**用 `customElements.define()` 注册的自定义元素**,内部封装 **DOM / 交互 / 样式**,
+> 逻辑**全部来自核心库 [core](./core-library.md)**。产物是标准 Web Component ——
+> 在 Lit、Vue、React、Angular、原生 HTML 里都能**直接写 `<my-x>`**,无需跑任何前端框架。
+>
+> 与页面内组件([components.md](./components.md))的区别:页面组件服务于「一个应用」;
+> **组件库组件服务于「多个应用 / 多个框架」**,因此契约更严、实现更克制。
+
+## 一、定位与边界
+
+| 项 | 组件库(wc) | 页面内组件(page) |
+|----|--------------|--------------------|
+| 消费方 | 多项目、多框架,非本项目也能用 | 仅本应用 |
+| 契约 | 语义属性 / DOM 事件 / slot / CSS 自定义属性,**稳定** | 可随应用演进 |
+| 业务逻辑 | ❌ 一律来自 core,组件只做**接线** | 可在页面 Service 中 |
+| 样式 | Shadow DOM 强隔离,主题走 CSS 变量 | 可宽松 |
+| 依赖 | `core` + `lit`(仅 `LitElement`/`html`/`css`) | `core` + 页面 `services/` |
+
+```
+fmode-code-agent(core,纯 ESM)
+        ▲ 只 import 纯逻辑
+  <designer-chat>(wc,LitElement,仅 DOM/交互/样式)
+        ▲ 任意框架直接 <designer-chat>
+  页面(page:Lit / Vue / React / 原生 HTML …)
+```
+
+## 二、铁律(违反即返工)
+
+1. **无装饰器**:与 [components.md 第〇节](./components.md) 一致,`static properties` + `customElements.define()`。组件库要让**任何无构建页面**直接跑,装饰器会让源码 `SyntaxError`。
+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` 等 |
+
+## 十、红线清单
+
+- ❌ 组件里写业务逻辑 / 请求 / 协议解析 → 下沉 core,由宿主注入 I/O
+- ❌ 依赖宿主的 CSS 类名 / 全局样式 / 全局 window 变量
+- ❌ 暴露内部状态写入方法(`el.setMessages()`),只暴露语义属性 + 语义事件
+- ❌ 事件漏 `composed: true` → 穿过 Shadow DOM 后宿主收不到
+- ❌ 无前缀标签名(`<chat>` 与别的库撞车)
+- ❌ `customElements.define` 不判重 → 二次加载 `NotSupportedError`
+- ❌ 组件库内置 lit 副本 → 宿主多实例 lit,见 [pitfalls.md](./pitfalls.md)
+- ❌ 装饰器
+
+## 相关
+
+- [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) —— 分发与多版本冲突

+ 29 - 1
CLAUDE.md

@@ -9,6 +9,7 @@
 1. **源码即产物**:不引入任何构建/打包器(rollup / esbuild / vite / webpack)。写好的 `.js` 直接上传 OBS,浏览器原生 ESM 执行。
 2. **依赖不落地项目**:`lit`、`@lit-labs/*` 等运行时依赖一律从 **FMODE Repose** 公共 importmap 引入,**禁止**把依赖源码拷进业务仓、禁止业务仓 `import` 裸 `node_modules`。
 3. **分层不可越界**:组件(渲染)→ 服务(业务与状态)→ 工具(无状态函数)。组件禁止直接 `fetch`、禁止写复杂业务逻辑;服务禁止触碰 DOM。
+   > 跨项目复用时升一级为三分层:**core**(纯 ESM,零 DOM)→ **wc**(Web Component,零业务)→ **page**(组装)。见 [§2.1](#21-分层一句话core--wc--page)。
 
 ## 1. 规则索引
 
@@ -26,12 +27,29 @@
 | 10 | [pitfalls.md](./.claude/rules/pitfalls.md) | CORS / 版本冲突 / 302 陷阱 / base href 等坑位 | 排查问题 |
 | 11 | [modules/](./.claude/rules/modules/) | 功能模块 / 通用能力 / 业务模块 划分最佳实践 | 模块拆分 |
 | 12 | [routing-best-practices.md](./.claude/rules/routing-best-practices.md) | **子路径部署路由实战**:base 三处同源 / `new URL` 丢前缀 / 弃用 `@lit-labs/router` / CDN 刷新假成功 / 验收不能只 curl | 做路由 / 子路径部署 / 上线验收 |
-| 13 | [retrospective.md](./docs/retrospective.md) | 对标 Angular zoneless 的技术复盘 | 理解选型理由 |
+| 13 | [core-library.md](./.claude/rules/core-library.md) | **核心库(core)**:纯 ESM 零浏览器依赖、只 export 纯逻辑、Node 可测、无构建打包为单文件、版本管理 | 抽逻辑层 / 跨框架复用 / 写库 |
+| 14 | [web-component-library.md](./.claude/rules/web-component-library.md) | **组件库(wc)**:customElements 注册、属性/事件/Slot/`::part`、跨框架用法、依赖 core、样式隔离、无装饰器 | 写 Web Component / 组件跨框架复用 |
+| 15 | [retrospective.md](./docs/retrospective.md) | 对标 Angular zoneless 的技术复盘 | 理解选型理由 |
 
 ## 2. 架构一句话
 
 浏览器打开 URL → CDN 回退规则把不存在的路径 **200(非 302)** 返回 `index.html` → `index.html` 加载 **公共 importmap**(指向 FMODE Repose)→ 加载 `app.js` → `import` lit 依赖 → 初始化 Store / 路由 / 根组件 → 渲染。全程**零编译**。
 
+## 2.1 分层一句话(core / wc / page)
+
+```
+[core] 纯 ESM 逻辑库    不认识 DOM / Lit,Node 可 import 单测
+   ▲ import
+[wc]   Web Component 组件库  认识 DOM,不认识业务;跨框架 <my-x> 直接用
+   ▲ import
+[page] Lit 无构建页面    组装 core 与 wc,持有路由 / 布局 / 服务
+```
+
+**core 不认识 DOM,wc 不认识业务,page 组装一切。** 换框架还想复用逻辑 → 下沉 core;
+换框架还想复用 UI → 上提 wc;只服务本项目 → 停在 page。
+(`fmode-code-agent` = core;`designer-chat` 组件 = wc;后台页 = page。)
+细则见 [architecture.md 一·五](./.claude/rules/architecture.md)。
+
 ## 3. 默认技术选型
 
 | 关注点 | 选择 | 备注 |
@@ -85,3 +103,13 @@
 6. 部署脚本:`obsutil sync` + CDN refresh
 
 > 具体脚手架:用全局技能 `fmode-code-lit`(`npx fmode-code-lit@latest workspace`)一键生成。
+
+## 8. 写「库」时的动手清单(core / wc)
+
+**抽 core(逻辑层)**:把纯逻辑(状态机 / 校验 / 协议 / 计算)搬到独立包 → `grep` 确认无 `window`/`document`/`fetch`/`lit` →
+注入 I/O(`now`/`random`/`transport`)→ `node --test` 单测 → `src/index.js` 单一出口 → semver + Repose 版本目录。
+见 [core-library.md](./.claude/rules/core-library.md)。
+
+**抽 wc(组件库)**:逻辑一律来自 core → `static properties` + `customElements.define()`(**判重**)→
+只暴露语义属性 / `composed` 事件 / slot / CSS 变量 + `::part` → 标签加统一前缀 → 在**一个非 Lit 宿主**里冒烟。
+见 [web-component-library.md](./.claude/rules/web-component-library.md)。

+ 30 - 1
README.md

@@ -11,10 +11,13 @@
 |--------|------|------|
 | 规则总纲 | [`CLAUDE.md`](./CLAUDE.md) | AI 进入本仓/引用本仓时的第一入口,索引全部规则 |
 | 分层规则 | [`.claude/rules/`](./.claude/rules/) | 架构、目录、组件、服务、DI、路由、多标签、部署、坑位逐篇规范 |
+| **核心库规范** | [`.claude/rules/core-library.md`](./.claude/rules/core-library.md) | 纯 ESM 核心库:零浏览器依赖 / 只 export 纯逻辑 / Node 可测 / 无构建打包 / 版本管理 |
+| **组件库规范** | [`.claude/rules/web-component-library.md`](./.claude/rules/web-component-library.md) | Web Component 组件库:customElements 注册、属性/事件/Slot、**跨框架**用法、依赖核心库、样式隔离 |
 | **路由实战** | [`.claude/rules/routing-best-practices.md`](./.claude/rules/routing-best-practices.md) | 子路径部署路由最佳实践:base 三处同源 / `new URL` 丢前缀 / 弃用 `@lit-labs/router` / CDN 刷新假成功 / 不能只 curl 验收 |
 | 模块划分 | [`.claude/rules/modules/`](./.claude/rules/modules/) | 功能模块 / 通用能力 / 业务模块 的拆分最佳实践 |
 | 源稿 | [`.claude/_lit-dev-manual.md`](./.claude/_lit-dev-manual.md) | 用户提供的《Lit 原生 ESM 无构建开发最佳实践》原始规范 |
-| 可运行示例 | [`examples/hello-lit/`](./examples/hello-lit/) | 零构建、可直接上 OBS+CDN 的最小多标签后台示例 |
+| 可运行示例(页面) | [`examples/hello-lit/`](./examples/hello-lit/) | 零构建、可直接上 OBS+CDN 的最小多标签后台示例 |
+| **可运行示例(三层)** | [`examples/core-wc/`](./examples/core-wc/) | **core 纯 ESM + wc Web Component + page Lit 页面**,含 Node 单测与跨框架(纯 HTML)演示 |
 | 全局技能 | [`skills/fmode-code-lit/`](./skills/fmode-code-lit/) | 给 AI 的 Lit 项目初始化技能(触发词:lit 项目 / 组件 / 无构建前端) |
 | 复盘 | [`docs/retrospective.md`](./docs/retrospective.md) | 纯原生 ESM × Lit 3.x,对标 Angular zoneless |
 
@@ -22,6 +25,32 @@
 
 > **源码即产物。写完的 `.js` 不需要编译,浏览器原生 ESM 直接跑;依赖走 FMODE Repose 统一托管,业务码走 OBS+CDN,保存刷新即预览。**
 
+## 两个方向:页面 & 库
+
+本仓同时覆盖**两个开发方向**,共用同一套无构建铁律:
+
+```
+[core] 纯 ESM 核心库   零浏览器依赖、Node 可测 —— 跨前端框架最底层
+   ▲ import
+[wc]   Web Component 组件库   仅浏览器交互、零业务逻辑 —— <my-x> 跨框架直接用
+   ▲ import
+[page] Lit 无构建页面   组合 core 与 wc,持有路由 / 布局 / 服务
+```
+
+**core 不认识 DOM,wc 不认识业务,page 组装一切。**
+
+| 你想做的事 | 往哪写 | 规则篇 |
+|-----------|--------|--------|
+| 一段逻辑要在 **Node、浏览器、多个前端框架** 里都能用 | **core** | [core-library.md](./.claude/rules/core-library.md) |
+| 一个 UI 要让 **Lit / Vue / React / 原生 HTML** 都能 `<my-x>` 用 | **wc** | [web-component-library.md](./.claude/rules/web-component-library.md) |
+| 只做**本应用**的页面、路由、布局 | **page** | [components.md](./.claude/rules/components.md) · [architecture.md](./.claude/rules/architecture.md) |
+
+> 例:`fmode-code-agent` 的逻辑层 = **core**(纯 ESM);`<designer-chat>` = **wc**(组件);
+> 后台页面 = **page**(组合)。换前端框架时,**core 一行不改**;换 UI 库时,**core 与 wc 都不改**。
+>
+> 别为了分层而分层 —— 只服务本项目时停在 **page** 即可;一旦「换框架还要复用」,逻辑必须下沉 core。
+
+
 ## 技术底座
 
 - **运行时**:浏览器原生 ESM `<script type="module">` + Import Map

+ 105 - 0
examples/core-wc/README.md

@@ -0,0 +1,105 @@
+# core-wc —— core / wc / page 三分层最小示例
+
+**同一份逻辑,三层产物**:一个纯 ESM 核心库(Node 可测)、一个 Web Component 组件库(跨框架)、
+一个 Lit 无构建页面(组合)。零构建、零打包、**无装饰器**。
+
+```
+core/                    [core] 纯 ESM 逻辑库 —— 零浏览器依赖
+  index.js               # 唯一公共出口
+  quote.js               # 报价计算(纯函数 + 注入的 now/id)
+  package.json           # type: module / sideEffects: false / exports 单一入口
+wc/                      [wc]  组件库 —— 仅浏览器交互
+  quote-calc.js          # <quote-calc>(LitElement,逻辑全部来自 core)
+  browser-transport.js   # 浏览器 I/O 适配器(唯一碰 fetch 的地方)
+  index.js               # 入口:只做注册
+  plain.html             # 跨框架演示:纯 HTML 直接用 <quote-calc>
+page/                    [page] 页面层 —— 组合
+  app.js                 # Lit 页面:注入 transport + 监听事件 + 组合组件
+  index.html             # <base> + importmap
+test/
+  quote.test.js          # core 单测(node:test,不依赖浏览器/jsdom)
+scripts/
+  check.mjs              # 静态体检:core 零 DOM / 无装饰器 / import 后缀 / 注册幂等
+```
+
+## 跑起来
+
+### 1. core 单测(不需要浏览器)
+
+```bash
+cd examples/core-wc
+node --test test/*.test.js
+```
+
+### 2. 静态体检
+
+```bash
+node scripts/check.mjs
+```
+
+断言四条红线:`core/` 不含 `window`/`document`/`fetch`、`core/` 不 import lit、
+全仓无装饰器、`customElements.define` 均有 `get()` 判重。
+
+### 3. 浏览器(页面 + 跨框架)
+
+需要站点根下存在 `core-wc/` 前缀(页面里写的是 `<base href="/core-wc/page/">`)。
+
+```bash
+# 方式 A:指向 FMODE Repose(内网可达)
+#   index.html / plain.html 保持 <script type="importmap" src="/npm/importmap/lit-v3.json">
+cd /opt/data/git-repos/fmode-code-lit
+npx --yes serve examples -l 3000
+# 打开 http://localhost:3000/core-wc/page/index.html   (Lit 页面)
+# 打开 http://localhost:3000/core-wc/wc/plain.html     (纯 HTML 宿主)
+
+# 方式 B:本地离线(无需内网)—— 用 npm 装的 lit
+cd examples/core-wc
+npm install                                   # 只装 lit,仅本地预览用
+# 把 page/index.html 与 wc/plain.html 的 <base> 改成 "/",
+# 并把 importmap 那行换成内联(lit 内部还 import @lit/reactive-element,必须一并映射):
+#   <script type="importmap">{"imports":{
+#     "lit":"/node_modules/lit/index.js","lit/":"/node_modules/lit/",
+#     "lit-html":"/node_modules/lit-html/lit-html.js","lit-html/":"/node_modules/lit-html/",
+#     "lit-element":"/node_modules/lit-element/lit-element.js","lit-element/":"/node_modules/lit-element/",
+#     "@lit/reactive-element":"/node_modules/@lit/reactive-element/reactive-element.js",
+#     "@lit/reactive-element/":"/node_modules/@lit/reactive-element/"
+#   }}</script>
+npx --yes serve . -l 3000
+# 打开 http://localhost:3000/page/index.html 与 http://localhost:3000/wc/plain.html
+```
+
+> `<base href>` 与目录结构有关:上面的路径按「站点根 = `examples/`」写。若直接从
+> `examples/core-wc/` 起服务,把 `<base>` 相应改为 `/page/` 与 `/wc/`。
+
+### 4. 真实浏览器验收(本仓纪律:不能只 curl)
+
+```bash
+npm i -D playwright && npx playwright install chromium
+python3 -m http.server 4000 &            # 站点根 = examples/
+node scripts/browser-verify.mjs http://127.0.0.1:4000/core-wc
+```
+
+覆盖 11 项:组件注册 / property 渲染 / Shadow DOM 样式隔离 / 事件穿透 `composed` /
+core 计算结果 / Lit 页面宿主 / 纯 HTML 宿主 / 重复 import 幂等 / 主题变量。
+(`/favicon.ico` 的 404 是无 favicon 的必然噪音,脚本已排除。)
+
+
+## 演示点
+
+- **core 零浏览器依赖**:`core/quote.js` 里没有 `window`/`document`/`fetch`,不 import lit;
+  `now`/`id` 通过参数注入 → `node --test` 直接断言确定值。
+- **wc 只做接线**:`<quote-calc>` 不自己算钱、不自己 fetch;算钱调 core,网络由宿主注入 `transport`。
+- **契约跨框架**:只暴露 `items`/`discount`/`disabled` 属性、`quote-done`/`quote-error` 事件
+  (`composed: true` 穿透 Shadow DOM)、`<slot name="title">`、`--qc-accent` 变量。
+- **跨框架运行**:`page/index.html`(Lit 宿主)与 `wc/plain.html`(纯 HTML)用的是**同一个**
+  `quote-calc.js`,无需任何适配。
+- **样式隔离**:页面 `body { background }` 之类全局样式进不去组件;换主题只改 `--qc-accent`。
+- **无装饰器**:`static properties` + `customElements.define()`(判重)。
+
+## 与规则仓的对应
+
+| 目录 | 规则篇 |
+|------|--------|
+| `core/` | [.claude/rules/core-library.md](../../.claude/rules/core-library.md) |
+| `wc/` | [.claude/rules/web-component-library.md](../../.claude/rules/web-component-library.md) |
+| `page/` | [.claude/rules/components.md](../../.claude/rules/components.md) · [architecture.md](../../.claude/rules/architecture.md) |

+ 5 - 0
examples/core-wc/core/index.js

@@ -0,0 +1,5 @@
+/**
+ * core/index.js —— 核心库唯一公共出口。
+ * 只 export 纯逻辑;internal/ 下的实现不对外暴露。
+ */
+export { quote, round2, formatMoney } from './quote.js';

+ 17 - 0
examples/core-wc/core/package.json

@@ -0,0 +1,17 @@
+{
+  "name": "core-quote",
+  "version": "1.0.0",
+  "type": "module",
+  "description": "示例核心库(core):纯 ESM 报价计算,零浏览器依赖,Node 可测。",
+  "exports": {
+    ".": "./index.js"
+  },
+  "sideEffects": false,
+  "files": [
+    "index.js",
+    "quote.js"
+  ],
+  "scripts": {
+    "test": "node --test ../test/*.test.js"
+  }
+}

+ 69 - 0
examples/core-wc/core/quote.js

@@ -0,0 +1,69 @@
+/**
+ * core/quote.js —— 核心库(core):纯 ESM 报价计算。
+ *
+ * 铁律:不碰 window / document / localStorage / fetch,不 import lit。
+ * 所有不确定输入(时间、id、费率)都从参数注入 → 可在 Node 里确定化单测。
+ */
+
+/**
+ * @typedef {{ sku: string, price: number, qty: number }} Item
+ * @typedef {{
+ *   taxRate?: number, discount?: number, currency?: string,
+ *   now?: () => number, id?: () => string,
+ * }} QuoteOptions
+ */
+
+/**
+ * 计算一张报价单。
+ * @param {Item[]} items
+ * @param {QuoteOptions} [opts]
+ */
+export function quote(items, opts = {}) {
+  const {
+    taxRate = 0.06,
+    discount = 0,
+    currency = '¥',
+    now = () => Date.now(),
+    id = () => `q-${Math.random().toString(36).slice(2, 10)}`,
+  } = opts;
+
+  if (!Array.isArray(items)) throw badInput('E_ITEMS', 'items 必须是数组');
+  for (const it of items) {
+    if (!(it.price >= 0) || !(it.qty >= 1) || !Number.isInteger(it.qty) || !it.sku) {
+      throw badInput('E_ITEM', `无效条目:${JSON.stringify(it)}`);
+    }
+  }
+  if (discount < 0 || discount > 1) throw badInput('E_DISCOUNT', 'discount 必须在 [0, 1]');
+
+  const subtotal = items.reduce((s, it) => s + it.price * it.qty, 0);
+  const discountAmount = round2(subtotal * discount);
+  const net = round2(subtotal - discountAmount);
+  const tax = round2(net * taxRate);
+  const total = round2(net + tax);
+
+  return {
+    id: id(),
+    at: now(),
+    currency,
+    lines: items.map((it) => ({ sku: it.sku, qty: it.qty, amount: round2(it.price * it.qty) })),
+    subtotal: round2(subtotal),
+    discount: discountAmount,
+    net,
+    tax,
+    total,
+  };
+}
+
+/** 两位小数四舍五入(避免 0.1 + 0.2 类浮点尾巴)。 */
+export function round2(n) {
+  return Math.round((n + Number.EPSILON) * 100) / 100;
+}
+
+/** 展示用格式化;金额格式化本身也是纯函数(金额展示与业务格式耦合,常被复用)。 */
+export function formatMoney(amount, currency = '¥') {
+  return `${currency}${Number(amount).toFixed(2)}`;
+}
+
+function badInput(code, message) {
+  return Object.assign(new Error(message), { code });
+}

+ 16 - 0
examples/core-wc/importmap.json

@@ -0,0 +1,16 @@
+{
+  "_comment": "本地开发用 importmap(与 examples/hello-lit/src/importmap.json 版本一致)。生产由 FMODE Repose 的 /npm/importmap/lit-v3.json 提供。用 npx serve examples/core-wc 起服务后,页面里的 src 改成指向本文件(见 README)。",
+  "imports": {
+    "lit": "/npm/lit/v3.3.3/lit.js",
+    "lit/": "/npm/lit/v3.3.3/",
+    "lit-html": "/npm/lit-html/v3.3.3/lit-html.js",
+    "lit-html/": "/npm/lit-html/v3.3.3/",
+    "lit-element": "/npm/lit-element/v4.2.2/lit-element.js",
+    "lit-element/": "/npm/lit-element/v4.2.2/",
+    "@lit/reactive-element": "/npm/@lit/reactive-element/v2.1.2/reactive-element.js",
+    "@lit/reactive-element/": "/npm/@lit/reactive-element/v2.1.2/",
+    "@lit/context": "/npm/@lit/context/v1.1.6/index.js",
+    "@lit-labs/signals": "/npm/@lit-labs/signals/v0.3.0/index.js",
+    "signal-polyfill": "/npm/signal-polyfill/v0.2.2/dist/index.js"
+  }
+}

+ 14 - 0
examples/core-wc/package.json

@@ -0,0 +1,14 @@
+{
+  "name": "core-wc-example",
+  "private": true,
+  "type": "module",
+  "description": "fmode-code-lit 三分层最小示例:core(纯 ESM)+ wc(Web Component)+ page(Lit 页面)",
+  "scripts": {
+    "test": "node --test test/*.test.js",
+    "check": "node scripts/check.mjs",
+    "serve": "npx --yes serve . -l 3000"
+  },
+  "devDependencies": {
+    "lit": "^3.3.3"
+  }
+}

+ 71 - 0
examples/core-wc/page/app.js

@@ -0,0 +1,71 @@
+/**
+ * page/app.js —— 页面层(page):Lit 无构建页面,组合 <quote-calc>(wc)。
+ *
+ * 页面层才持有「业务页面状态 + 接口地址 + 布局」。组件库只提供 <quote-calc>。
+ */
+import { LitElement, html, css } from 'lit';
+import { quote } from '../core/index.js';
+import { createBrowserTransport } from '../wc/browser-transport.js';
+import '../wc/index.js';                 // 注册 <quote-calc>
+
+export class AppRoot extends LitElement {
+  static properties = { _log: { state: true } };
+
+  static styles = css`
+    :host { display: block; max-width: 720px; margin: 32px auto; padding: 0 16px; }
+    h1 { font-size: 20px; }
+    .demo { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
+    pre { background: #f7fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 10px; overflow: auto; max-height: 260px; }
+    .hint { color: #718096; font-size: 12px; }
+  `;
+
+  constructor() {
+    super();
+    this._log = [];
+    this._items = [
+      { sku: 'FM-PRINT-A3', price: 12.5, qty: 20 },
+      { sku: 'FM-FRAME-W', price: 88, qty: 2 },
+    ];
+  }
+
+  firstUpdated() {
+    // 宿主注入 I/O:换成 createBrowserTransport({ endpoint: '/api/quote' }) 即走真实接口
+    this._calc = this.renderRoot.querySelector('quote-calc');
+    // 演示:本地 transport(用 core 算),模拟一次网络往返
+    this._calc.transport = {
+      call: async (input) => {
+        await new Promise((r) => setTimeout(r, 150));
+        return quote(input.items, { discount: input.discount || 0 });
+      },
+    };
+  }
+
+  render() {
+    return html`
+      <h1>core → wc → page 示例</h1>
+      <p class="hint">
+        core(纯 ESM,Node 可测)提供报价计算;&lt;quote-calc&gt;(Web Component)只做交互;
+        本页面组合二者。另开 <code>plain.html</code> 可看到同一组件在纯 HTML(无框架)里运行。
+      </p>
+      <div class="demo">
+        <quote-calc .items=${this._items} discount="0.05"
+                    @quote-done=${(e) => this.#logEvent('quote-done', e.detail)}
+                    @quote-error=${(e) => this.#logEvent('quote-error', e.detail)}>
+          <span slot="title">Lit 页面里的报价组件</span>
+        </quote-calc>
+        <div>
+          <strong>事件日志</strong>
+          <pre>${this._log.join('\n') || '(点击「计算报价」后出现)'}</pre>
+        </div>
+      </div>
+    `;
+  }
+
+  #logEvent(name, detail) {
+    const line = `[${name}] ${JSON.stringify(detail)}`;
+    this._log = [line, ...this._log].slice(0, 20);
+  }
+}
+
+// 注册幂等:同一页面重复加载不抛 NotSupportedError
+if (!customElements.get('app-root')) customElements.define('app-root', AppRoot);

+ 30 - 0
examples/core-wc/page/index.html

@@ -0,0 +1,30 @@
+<!doctype html>
+<html lang="zh-CN">
+<head>
+  <meta charset="utf-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1" />
+  <title>core / wc / page 示例 · fmode-code-lit</title>
+
+  <!-- 部署到 <域名>/<prefix>/core-wc/ 时用这个;本地根目录起服务时改用下面的相对版本 -->
+  <base href="/core-wc/page/" />
+
+  <!--
+    公共 importmap(FMODE Repose)。
+    本地开发:把下面一行换成 <script type="importmap" src="../importmap.json"><\/script>
+    注意:importmap 里的路径要么是绝对 /npm/...,要么用 <base> 解析好的相对路径 —— 见 ../importmap.json 注释。
+  -->
+  <script type="importmap" src="/npm/importmap/lit-v3.json"></script>
+
+  <style>
+    :root { font-family: system-ui, -apple-system, "Segoe UI", sans-serif; }
+    body { margin: 0; background: #f7fafc; }
+    app-root { display: block; }
+    /* 页面全局样式不会影响组件内部(Shadow DOM 隔离);主题只经 CSS 变量 */
+    quote-calc { --qc-accent: #6b46c1; }
+  </style>
+</head>
+<body>
+  <app-root></app-root>
+  <script type="module" src="./app.js"></script>
+</body>
+</html>

+ 89 - 0
examples/core-wc/scripts/browser-verify.mjs

@@ -0,0 +1,89 @@
+#!/usr/bin/env node
+/**
+ * scripts/browser-verify.mjs —— 真实浏览器验收(本仓纪律:不能只 curl)。
+ *
+ * 覆盖:组件注册 / property 渲染 / Shadow DOM 样式隔离 / 事件穿透 composed /
+ *       core 计算结果 / Lit 页面宿主 / 纯 HTML 宿主 / 重复 import 幂等 / 主题变量。
+ *
+ * 前置:
+ *   1) 站点根 = examples/(页面里 <base href="/core-wc/page/">)
+ *      python3 -m http.server 4000   (在 examples/ 下)
+ *   2) 本地无 Repose 时,用 npm 装的 lit 并把两个 html 的 importmap 换成本地 node_modules
+ *      (见 ../README.md「方式 B」)
+ *   3) npm i -D playwright && npx playwright install chromium
+ *
+ * 用法:node scripts/browser-verify.mjs [baseURL]
+ */
+import { chromium } from 'playwright';
+
+const BASE = process.argv[2] || 'http://127.0.0.1:4000/core-wc';
+let fail = 0;
+const ok = (n, c, d = '') => { console.log(`${c ? '✓' : '✗'} ${n}${!c && d ? `\n    ${d}` : ''}`); if (!c) fail++; };
+
+// favicon.ico 404 是无 favicon 时的必然噪音,不算失败
+const isNoise = (m) => /favicon/i.test(m.location()?.url || '') || /favicon/i.test(m.text());
+
+const browser = await chromium.launch({
+  executablePath: process.env.CHROME_PATH || undefined,
+  args: ['--no-sandbox', '--disable-dev-shm-usage'],
+});
+
+// ---------- 1) 纯 HTML 宿主(无框架)----------
+const page = await browser.newPage();
+const errors = [];
+page.on('console', (m) => { if (m.type() === 'error' && !isNoise(m)) errors.push(m.text()); });
+page.on('pageerror', (e) => errors.push('pageerror: ' + e.message));
+
+await page.goto(`${BASE}/wc/plain.html`, { waitUntil: 'networkidle' });
+ok('plain.html 无控制台报错', errors.length === 0, errors.join('\n    '));
+ok('quote-calc 已注册', await page.evaluate(() => !!customElements.get('quote-calc')));
+
+const lines = await page.locator('quote-calc').evaluate((el) => el.shadowRoot.querySelectorAll('table[part=lines] tr').length);
+ok('property 传值渲染出行(2 行)', lines === 2, `实际 ${lines}`);
+
+const iso = await page.evaluate(() => {
+  const s = document.createElement('style'); s.textContent = 'button{background:red !important}';
+  document.head.appendChild(s);
+  return getComputedStyle(document.querySelector('quote-calc').shadowRoot.querySelector('button')).backgroundColor;
+});
+ok('Shadow DOM 隔离(外部样式改不动内部按钮)', iso !== 'rgb(255, 0, 0)', `实际 ${iso}`);
+
+const detail = await page.evaluate(async () => {
+  const el = document.querySelector('quote-calc');
+  return new Promise((res) => { el.addEventListener('quote-done', (e) => res(e.detail)); el.shadowRoot.querySelector('button').click(); });
+});
+ok('quote-done 事件穿透 Shadow DOM 且带 detail', !!detail?.result, JSON.stringify(detail));
+ok('core 计算结果正确(82 小计 / 8.2 折扣)', detail?.result?.subtotal === 82 && detail?.result?.discount === 8.2, JSON.stringify(detail?.result));
+
+// ---------- 2) Lit 页面宿主 ----------
+const errs2 = []; const page2 = await browser.newPage();
+page2.on('console', (m) => { if (m.type() === 'error' && !isNoise(m)) errs2.push(m.text()); });
+page2.on('pageerror', (e) => errs2.push('pageerror: ' + e.message));
+await page2.goto(`${BASE}/page/index.html`, { waitUntil: 'networkidle' });
+ok('page/index.html 无控制台报错', errs2.length === 0, errs2.join('\n    '));
+ok('Lit 页面里 app-root + quote-calc 均已注册',
+  await page2.evaluate(() => !!customElements.get('app-root') && !!customElements.get('quote-calc')));
+
+await page2.locator('quote-calc').evaluate((el) => el.shadowRoot.querySelector('button').click());
+await page2.waitForTimeout(400);
+const log = await page2.locator('app-root').evaluate((el) => el.shadowRoot.querySelector('pre').textContent);
+ok('Lit 页面收到事件并写入日志', log.includes('quote-done'), log.slice(0, 120));
+
+// ---------- 3) 幂等 ----------
+const dup = await page.evaluate(async () => {
+  try { await import('/wc/index.js'); await import('/wc/index.js'); return 'ok'; }
+  catch (e) { return 'err: ' + e.message; }
+});
+ok('重复 import 组件库不抛 define 冲突', dup === 'ok', dup);
+
+// ---------- 4) 主题变量 ----------
+const themed = await page.evaluate(() => {
+  const el = document.querySelector('quote-calc');
+  el.style.setProperty('--qc-accent', 'rgb(47, 133, 90)');
+  return getComputedStyle(el.shadowRoot.querySelector('button')).backgroundColor;
+});
+ok('CSS 变量 --qc-accent 可穿透主题', themed === 'rgb(47, 133, 90)', `实际 ${themed}`);
+
+await browser.close();
+console.log(fail === 0 ? '\nBROWSER OK' : `\nBROWSER FAILED(${fail})`);
+process.exit(fail ? 1 : 0);

+ 95 - 0
examples/core-wc/scripts/check.mjs

@@ -0,0 +1,95 @@
+#!/usr/bin/env node
+/**
+ * scripts/check.mjs —— examples/core-wc 静态体检。
+ *
+ * 断言三件事(对应规则仓 core-library / web-component-library 的红线):
+ *  1. core/ 零浏览器依赖、不 import lit;
+ *  2. wc/ 无装饰器;
+ *  3. 全仓 .js 语法通过 + 相对 import 都带 .js。
+ *
+ * 用法:node scripts/check.mjs
+ */
+import { readdirSync, readFileSync, statSync } from 'node:fs';
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { execFileSync } from 'node:child_process';
+
+const root = join(dirname(fileURLToPath(import.meta.url)), '..');
+
+let fail = 0;
+const check = (name, ok, detail = '') => {
+  console.log(`${ok ? '✓' : '✗'} ${name}${!ok && detail ? `\n    ${detail}` : ''}`);
+  if (!ok) fail++;
+};
+
+function walk(dir, out = []) {
+  for (const e of readdirSync(dir, { withFileTypes: true })) {
+    if (e.name === 'node_modules' || e.name.startsWith('.')) continue;
+    const p = join(dir, e.name);
+    if (e.isDirectory()) walk(p, out);
+    else if (p.endsWith('.js')) out.push(p);
+  }
+  return out;
+}
+
+const files = walk(root);
+
+/** 去掉注释与字符串字面量,避免把文档里提到的 window/fetch 误判为代码。 */
+function strip(src) {
+  return src
+    .replace(/\/\*[\s\S]*?\*\//g, '')      // 块注释
+    .replace(/(^|[^:])\/\/[^\n]*/g, '$1')  // 行注释(避开 https://)
+    .replace(/'(?:[^'\\]|\\.)*'/g, "''")   // 单引号串
+    .replace(/"(?:[^"\\]|\\.)*"/g, '""');  // 双引号串
+}
+
+// 1. core 零浏览器依赖
+const DOM_GLOBALS = /\b(window|document|localStorage|sessionStorage|navigator|HTMLElement|CustomEvent)\b|\bfetch\s*\(/;
+const coreFiles = files.filter((f) => f.includes('/core/'));
+check('core/ 目录非空', coreFiles.length > 0);
+let domHits = [];
+for (const f of coreFiles) {
+  const src = strip(readFileSync(f, 'utf8'));
+  const m = src.match(DOM_GLOBALS);
+  if (m) domHits.push(`${f}: ${m[0]}`);
+}
+check('core/ 无浏览器全局(window/document/fetch/…)', domHits.length === 0, domHits.join('\n    '));
+
+const litHits = coreFiles.filter((f) => /from\s+['"]lit(-html|-element)?['"]/.test(strip(readFileSync(f, 'utf8'))));
+check('core/ 不 import lit', litHits.length === 0, litHits.join('\n    '));
+
+// 2. wc 无装饰器
+const DECORATOR = /^\s*@(customElement|property|state|consume|provide|query|queryAssignedElements)\b/m;
+const decoHits = files.filter((f) => DECORATOR.test(strip(readFileSync(f, 'utf8'))));
+check('全仓无装饰器语法', decoHits.length === 0, decoHits.join('\n    '));
+
+// 3. 语法 + 相对 import 带 .js
+let syntaxBad = [];
+const suffixBad = [];
+for (const f of files) {
+  try { execFileSync(process.execPath, ['--check', f], { stdio: 'pipe' }); }
+  catch { syntaxBad.push(f); }
+  const src = strip(readFileSync(f, 'utf8'));
+  for (const m of src.matchAll(/\bfrom\s+['"](\.[^'"]*)['"]/g)) {
+    if (!/\.js$/.test(m[1]) && !/\{\}/.test(m[1])) suffixBad.push(`${f}: ${m[1]}`);
+  }
+  // 裸标识符 import(非相对、非 node:)必须能进 importmap
+  for (const m of src.matchAll(/\bfrom\s+['"]([^.'"][^'"]*)['"]/g)) {
+    const spec = m[1];
+    if (spec.startsWith('node:')) continue;
+    if (!/^(lit|lit-html|lit-element|@lit\/|@lit-labs\/|signal-polyfill|\/)/.test(spec)) {
+      suffixBad.push(`${f}: 裸标识符 ${spec} 未列入 importmap 白名单`);
+    }
+  }
+}
+check('全部 .js 通过 node --check', syntaxBad.length === 0, syntaxBad.join('\n    '));
+check('相对 import 带 .js 且裸标识符在 importmap 内', suffixBad.length === 0, suffixBad.join('\n    '));
+
+// 4. 注册幂等
+const defs = files.filter((f) => /customElements\.define\(/.test(readFileSync(f, 'utf8')));
+const guarded = defs.filter((f) => /customElements\.get\(/.test(readFileSync(f, 'utf8')));
+check('customElements.define 均有 get() 判重', defs.length > 0 && defs.length === guarded.length,
+  defs.filter((f) => !guarded.includes(f)).join('\n    '));
+
+console.log(fail === 0 ? '\nCHECK OK' : `\nCHECK FAILED(${fail})`);
+process.exit(fail === 0 ? 0 : 1);

+ 51 - 0
examples/core-wc/test/quote.test.js

@@ -0,0 +1,51 @@
+/**
+ * test/quote.test.js —— core 单测:node --test test/
+ * 不依赖浏览器、不依赖 jsdom;所有不确定输入经参数注入。
+ */
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { quote, round2, formatMoney } from '../core/index.js';
+
+const fixed = { now: () => 0, id: () => 'q-test' };
+
+test('合计与税:四舍五入到分', () => {
+  const r = quote([{ sku: 'A', price: 19.99, qty: 3 }], { taxRate: 0.06, ...fixed });
+  assert.equal(r.subtotal, 59.97);
+  assert.equal(r.tax, 3.6);        // 59.97 * 0.06 = 3.5982 → 3.60
+  assert.equal(r.total, 63.57);
+  assert.equal(r.id, 'q-test');
+  assert.equal(r.at, 0);
+});
+
+test('折扣先减后计税', () => {
+  const r = quote([{ sku: 'A', price: 100, qty: 2 }], { discount: 0.1, taxRate: 0.1, ...fixed });
+  assert.equal(r.subtotal, 200);
+  assert.equal(r.discount, 20);
+  assert.equal(r.net, 180);
+  assert.equal(r.tax, 18);
+  assert.equal(r.total, 198);
+});
+
+test('行小计等于单价 × 数量', () => {
+  const r = quote([{ sku: 'A', price: 0.1, qty: 3 }], fixed);
+  assert.deepEqual(r.lines, [{ sku: 'A', qty: 3, amount: 0.3 }]);   // 0.1*3 浮点尾巴被抹平
+});
+
+test('非法输入抛带 code 的错误', () => {
+  assert.throws(() => quote('x'), (e) => e.code === 'E_ITEMS');
+  assert.throws(() => quote([{ sku: 'A', price: -1, qty: 1 }]), (e) => e.code === 'E_ITEM');
+  assert.throws(() => quote([{ sku: 'A', price: 1, qty: 1.5 }]), (e) => e.code === 'E_ITEM');
+  assert.throws(() => quote([], { discount: 2 }), (e) => e.code === 'E_DISCOUNT');
+});
+
+test('缺省 id / now 也能跑(未注入时用默认实现)', () => {
+  const r = quote([{ sku: 'A', price: 1, qty: 1 }]);
+  assert.match(r.id, /^q-/);
+  assert.equal(typeof r.at, 'number');
+});
+
+test('工具函数', () => {
+  assert.equal(round2(1.005), 1.01);
+  assert.equal(formatMoney(12.5), '¥12.50');
+  assert.equal(formatMoney(12.5, '$'), '$12.50');
+});

+ 34 - 0
examples/core-wc/wc/browser-transport.js

@@ -0,0 +1,34 @@
+/**
+ * wc/browser-transport.js —— 浏览器 I/O 适配器。
+ *
+ * 组件库(wc)不自己 fetch:网络属于「宿主环境」。
+ * 这里提供一个默认 transport 给 Lit 宿主用;React/Vue 宿主可换成自己的实现。
+ * 注意:这个文件**才是**碰 fetch 的地方 —— core 里一个字都不许有。
+ */
+export function createBrowserTransport({ endpoint = '/api/quote', headers = {} } = {}) {
+  return {
+    /** @param {{ items: any[], discount?: number }} input */
+    async call(input) {
+      const res = await fetch(endpoint, {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json', ...headers },
+        body: JSON.stringify(input),
+      });
+      if (!res.ok) {
+        const err = Object.assign(new Error(`HTTP ${res.status}`), { code: 'E_HTTP', status: res.status });
+        throw err;
+      }
+      return res.json();
+    },
+  };
+}
+
+/** 无后端时的本地演示 transport:直接用 core 计算,行为与真实一致。 */
+export function createEchoTransport(quoteFn) {
+  return {
+    async call(input) {
+      await new Promise((r) => setTimeout(r, 120));   // 模拟网络延迟,好观察 loading
+      return quoteFn(input.items, { discount: input.discount || 0 });
+    },
+  };
+}

+ 5 - 0
examples/core-wc/wc/index.js

@@ -0,0 +1,5 @@
+/**
+ * wc/index.js —— 组件库入口:只做「import + 注册」,无渲染、无请求。
+ * 宿主(任意框架)import 本文件即可获得 <quote-calc>。
+ */
+import './quote-calc.js';

+ 43 - 0
examples/core-wc/wc/plain.html

@@ -0,0 +1,43 @@
+<!doctype html>
+<html lang="zh-CN">
+<head>
+  <meta charset="utf-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1" />
+  <title>跨框架演示:纯 HTML 直接用 &lt;quote-calc&gt;</title>
+  <base href="/core-wc/wc/" />
+  <!-- 本地开发改用 <script type="importmap" src="../importmap.json"><\/script> -->
+  <script type="importmap" src="/npm/importmap/lit-v3.json"></script>
+  <style>
+    body { font-family: system-ui, sans-serif; margin: 24px; background: #f7fafc; }
+    quote-calc { max-width: 420px; --qc-accent: #2f855a; }
+    p.note { color: #718096; max-width: 520px; }
+  </style>
+</head>
+<body>
+  <h1>纯 HTML 宿主(无任何框架)</h1>
+  <p class="note">
+    与本目录外的 Lit 页面用的是**同一个** <code>quote-calc.js</code> 和同一个 core。
+    这里没有 Lit 应用、没有路由、没有构建,只用标准 Web Component。
+  </p>
+
+  <quote-calc discount="0.1">
+    <span slot="title">纯 HTML 里的报价组件</span>
+  </quote-calc>
+
+  <script type="module">
+    import '../wc/index.js';  // 注册 <quote-calc>
+
+    const el = document.querySelector('quote-calc');
+
+    // 复杂值走 property(attribute 只能传字符串)
+    el.items = [
+      { sku: 'PLAIN-01', price: 30, qty: 2 },
+      { sku: 'PLAIN-02', price: 5.5, qty: 4 },
+    ];
+
+    // 宿主可监听语义事件(composed,从 Shadow DOM 穿透出来)
+    el.addEventListener('quote-done', (e) => console.log('quote-done', e.detail));
+    el.addEventListener('quote-error', (e) => console.warn('quote-error', e.detail));
+  </script>
+</body>
+</html>

+ 127 - 0
examples/core-wc/wc/quote-calc.js

@@ -0,0 +1,127 @@
+/**
+ * wc/quote-calc.js —— 组件库(wc):<quote-calc> 自定义元素。
+ *
+ * 定位:仅浏览器交互层。逻辑(算钱)来自 core `quote()`;I/O 由宿主注入 transport。
+ * 契约(跨框架就靠这几样):
+ *   属性 : items(Array, property)、discount(Number, attribute)、disabled(Boolean, attribute)
+ *   事件 : quote-done / quote-error(bubbles + composed,穿透 Shadow DOM)
+ *   插槽 : <span slot="title"> 覆盖标题
+ *   主题 : CSS 变量 --qc-accent / --qc-bg;对外结构用 ::part()
+ *
+ * 无构建铁律:无装饰器 —— static properties + customElements.define()(判重)。
+ * 不 import 任何业务 services/routes;不直接 fetch。
+ */
+import { LitElement, html, css } from 'lit';
+import { quote, formatMoney } from '../core/index.js';   // ← 核心库(core)
+
+export class QuoteCalc extends LitElement {
+  static properties = {
+    items: { type: Array },                 // 复杂值走 property(.items=${...})
+    discount: { type: Number },             // 简单值可走 attribute(discount="0.1")
+    disabled: { type: Boolean, reflect: true },
+    _result: { state: true },               // 内部渲染状态
+    _busy: { state: true },
+    _error: { state: true },
+  };
+
+  static styles = css`
+    :host {
+      display: block;
+      --qc-accent: #2b6cb0;
+      --qc-bg: #fff;
+      --qc-border: #e2e8f0;
+      font: 14px/1.5 system-ui, sans-serif;
+    }
+    .card { background: var(--qc-bg); border: 1px solid var(--qc-border); border-radius: 10px; padding: 14px; }
+    h3 { margin: 0 0 10px; font-size: 15px; }
+    button { background: var(--qc-accent); color: #fff; border: 0; border-radius: 6px; padding: 7px 14px; cursor: pointer; }
+    button[disabled] { opacity: .5; cursor: not-allowed; }
+    table { width: 100%; border-collapse: collapse; margin-top: 10px; }
+    td { padding: 4px 0; }
+    td.r { text-align: right; }
+    .total { font-weight: 700; border-top: 1px solid var(--qc-border); }
+    .err { color: #c53030; margin-top: 8px; }
+  `;
+
+  #transport = null;
+
+  constructor() {
+    super();
+    this.items = [];
+    this.discount = 0;
+    this.disabled = false;
+    this._result = null;
+    this._busy = false;
+    this._error = '';
+  }
+
+  /** 宿主注入 I/O(如 createBrowserTransport());未注入时本地用 core 计算。 */
+  set transport(t) { this.#transport = t; }
+  get transport() { return this.#transport; }
+
+  render() {
+    const busy = this._busy || this.disabled;
+    const cur = this._result?.currency ?? '¥';
+    return html`
+      <div class="card">
+        <h3><slot name="title">报价</slot></h3>
+
+        <table part="lines">
+          ${(this.items || []).map((it) => html`
+            <tr>
+              <td>${it.sku} × ${it.qty}</td>
+              <td class="r">${formatMoney(it.price * it.qty, cur)}</td>
+            </tr>
+          `)}
+        </table>
+
+        <p>
+          <button ?disabled=${busy} @click=${this.#onCalc}>${busy ? '计算中…' : '计算报价'}</button>
+          <span class="disc">折扣:${Math.round((this.discount || 0) * 100)}%</span>
+        </p>
+
+        ${this._result ? html`
+          <table part="result">
+            <tr><td>小计</td><td class="r">${formatMoney(this._result.subtotal, cur)}</td></tr>
+            <tr><td>优惠</td><td class="r">-${formatMoney(this._result.discount, cur)}</td></tr>
+            <tr><td>税</td><td class="r">${formatMoney(this._result.tax, cur)}</td></tr>
+            <tr class="total"><td>合计</td><td class="r">${formatMoney(this._result.total, cur)}</td></tr>
+          </table>
+        ` : ''}
+
+        ${this._error ? html`<p class="err" part="error" role="alert">${this._error}</p>` : ''}
+      </div>
+    `;
+  }
+
+  async #onCalc() {
+    this._error = '';
+    this._busy = true;
+    try {
+      // 未注入 transport → 本地用 core(演示模式);真实宿主会注入 transport
+      const result = this.#transport
+        ? await this.#transport.call({ items: this.items, discount: this.discount })
+        : quote(this.items, { discount: this.discount });
+
+      this._result = result;
+      /** 子 → 父:语义事件,composed 穿透 Shadow DOM */
+      this.dispatchEvent(new CustomEvent('quote-done', {
+        detail: { result },
+        bubbles: true,
+        composed: true,
+      }));
+    } catch (err) {
+      this._error = err.message || '计算失败';
+      this.dispatchEvent(new CustomEvent('quote-error', {
+        detail: { code: err.code, message: this._error },
+        bubbles: true,
+        composed: true,
+      }));
+    } finally {
+      this._busy = false;
+    }
+  }
+}
+
+// 注册幂等:同一页面重复加载不抛 NotSupportedError
+if (!customElements.get('quote-calc')) customElements.define('quote-calc', QuoteCalc);

+ 32 - 6
skills/fmode-code-lit/SKILL.md

@@ -1,7 +1,7 @@
 ---
 name: fmode-code-lit
-description: "用 Lit 原生 ESM 无构建方式写前端(零编译/零打包/浏览器直跑)。触发词:lit 项目、lit 组件、WebComponent、无构建前端、原生 ESM、importmap、@lit-labs/signals、@lit-labs/context、多标签 KeepAlive、FMODE OBS+CDN 前端部署。适用场景:(1) 新建 lit 无构建项目并生成目录/importmap/骨架 (2) 写 LitElement 组件/Service/路由 (3) 给已有项目补齐前端开发规则 (.claude/rules) (4) 部署到 OBS+CDN 并配 History 回退。产出遵循 fmode-code-lit 规则仓的分层与命名规范。"
-description_en: "Build front-ends the Lit native-ESM no-build way (zero compile, zero bundling, runs directly in the browser). Triggers: lit project, lit component, WebComponent, no-build frontend, native ESM, importmap, @lit-labs/signals, @lit-labs/context, multi-tab keep-alive, FMODE OBS+CDN front-end deploy. Use for: (1) scaffolding a new lit no-build project with dirs/importmap/skeleton, (2) writing LitElement components/services/routing, (3) adding front-end dev rules (.claude/rules) to an existing project, (4) deploying to OBS+CDN with History fallback. Output follows the fmode-code-lit rules repo's layering and naming conventions."
+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 规则仓的分层与命名规范。"
+description_en: "Build front-ends the Lit native-ESM no-build way (zero compile, zero bundling, runs directly in the browser). Triggers: lit project, lit component, WebComponent, web component library, pure-ESM core library, cross-framework components, no-build frontend, native ESM, importmap, @lit-labs/signals, @lit-labs/context, multi-tab keep-alive, FMODE OBS+CDN front-end deploy. Use for: (1) scaffolding a new lit no-build project with dirs/importmap/skeleton, (2) writing LitElement components/services/routing, (3) extracting a pure-ESM core library (zero DOM, Node-testable) or a cross-framework Web Component library (wc), (4) adding front-end dev rules (.claude/rules) to an existing project, (5) deploying to OBS+CDN with History fallback. Output follows the fmode-code-lit rules repo's layering and naming conventions."
 schema_version: 1.0.0
 level: system
 category: development
@@ -52,6 +52,28 @@ tags: [未来飞马, 智能体技能, 超级技能, 开发级, 前端, Lit, WebC
 2. **依赖不落地项目**:`lit`、`@lit/*`、`@lit-labs/*` 由 **importmap** 指向 FMODE Repose,禁止把依赖拷进业务仓。
 3. **分层不越界**:组件→服务→工具。组件禁 `fetch`/复杂业务;服务禁 DOM。
 
+## 两个方向:页面 & 库(同一套铁律)
+
+```
+[core] 纯 ESM 核心库   零浏览器依赖、Node 可测 — 跨前端框架最底层
+   ▲ import
+[wc]   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 铁律**:逻辑来自 core;网络由宿主注入 `transport`;只暴露语义属性 + `composed` 事件 + slot + CSS 变量;标签加统一前缀;`customElements.define` **判重**。
+
+
 ## ⚠️ 无构建环境的硬约束(最容易踩)
 
 **禁用一切装饰器语法**:`@customElement` / `@property` / `@state` / `@consume` / `@provide` / `@query`。
@@ -95,12 +117,14 @@ my-admin/
 node <skill_dir>/scripts/install-rules.mjs .      # 写入 ./.claude/rules/ + CLAUDE.md 片段
 ```
 
-### 3) 生成单个组件 / 服务
+### 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 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) 部署
@@ -116,6 +140,8 @@ 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`,`customElements.define`(**判重**),标签带统一前缀;逻辑来自 core |
 | 相对导入 | **必须带 `.js` 后缀** |
 | 全局状态 | `@lit-labs/signals` 的 signal + `SignalWatcher` |
 | 组件本地状态 | `static properties`(`state: true`) |

+ 91 - 3
skills/fmode-code-lit/scripts/new.mjs

@@ -1,11 +1,13 @@
 #!/usr/bin/env node
 /**
- * new.mjs —— 生成单个 Lit 无构建单元(组件 / 服务 / 页面)。
+ * 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(跨框架组件库)
  *
  * 生成物一律无装饰器,遵循规则仓命名与分层规范。
  */
@@ -22,10 +24,12 @@ 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> <name> [--dir <dir>] [--force]`);
+  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)) {
@@ -40,7 +44,7 @@ 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' ? null : (fileBase.includes('-') ? fileBase : 'x-' + fileBase);
+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';
 
@@ -101,6 +105,90 @@ class ${pascal} {
 
 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。
+ *
+ * 契约:语义属性 + 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} —— 页面组件(路由挂载)。