Files
workspace/code/g3soft-libs/CODEBUDDY.md
T
2026-10-09 22:05:02 +08:00

214 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CODEBUDDY.md
This file provides guidance to CodeBuddy Code when working with code in this repository.
## Overview
G3Soft frontend infrastructure — a pnpm monorepo holding a self-built **Vue 3 component library** plus a **VitePress documentation site**.
- `packages/ui` (`@g3soft/ui`) — component library, published to a private npm registry. One directory per component under `src/<name>/`.
- `packages/tokens` (`@g3soft/tokens`) — design tokens as plain CSS (`--g3-*` variables), shared by the library and the docs site.
- `docs` (`@g3soft/docs`) — VitePress docs site (default theme). `private: true`, never published. 组件文档(示例 + API)全部手写在 `docs/content/ui/<分类>/<组件>.md`。
Requires Node >= 20.19 and pnpm 11 (see `packageManager` in root `package.json`).
## Commands
```bash
pnpm install
pnpm dev # start docs site (http://localhost:5173); live-reloads packages/ui via alias
pnpm build # build all packages under packages/** (component lib -> dist)
pnpm build:docs # build docs static site -> docs/.vitepress/dist
pnpm preview:docs # preview the built docs site
```
Deploying docs to a subpath:
```bash
DOCS_BASE_URL=/docs/ pnpm build:docs
```
Releasing the component library (changesets):
```bash
pnpm changeset # record the change
pnpm version # bump versions + generate CHANGELOG
pnpm release # build + publish to the private registry
```
`packages/ui` runs `publint` via `prepublishOnly` to validate package structure (`exports`/`files` consistency).
**Testing:** 三层测试(vitest + happy-dom / Playwright)见下方「测试与自检」:`pnpm test`、`pnpm test:e2e`、`pnpm verify`。
### Running a single component's checks
没有 per-component 的 lint 脚本。类型检查用 `pnpm --filter @g3soft/ui typecheck`(`vue-tsc --noEmit`)—— **别指望 `pnpm build` 兜底**:它会把 TS 错误打出来,却照样 exit 0 并产出 d.ts(实测漏声明两个 prop 时构建仍报「✓ built in 7s」)。单跑一个测试文件:`pnpm --filter @g3soft/ui exec vitest run src/date/date-utils.test.ts`。
## Architecture
### Two independent pipelines
The docs site and the component library are deliberately decoupled:
| | Docs site | Component package |
|---|---|---|
| Published | No (`private: true`) | Yes (private npm registry) |
| Versioning | None | changesets (`ignore: ["@g3soft/docs"]`) |
| Build | `pnpm build:docs` | `pnpm build` |
| Output | `docs/.vitepress/dist` (static) | `packages/ui/dist` (ESM + d.ts) |
| Dep boundary | VitePress lives only in the `docs` package | only `vue` (peer) + `@g3soft/tokens` |
VitePress-related deps appear **only** in `docs/package.json`; the library's dependency tree has none of it. changesets' `ignore` keeps the docs site out of version tags.
### Component library build (`packages/ui`)
`vite.config.ts` builds ESM + type declarations. Critical points:
- `vue` is marked `external` — never bundle Vue, or consumers get duplicate instances.
- `preserveModules` keeps the `src` structure so consumers can tree-shake.
- `vite-plugin-dts` 产出**逐文件 d.ts + `dist/index.d.ts`(re-export barrel)**,与 `exports.types` 对齐;刻意不打包成单文件(那要 `bundleTypes` + `@microsoft/api-extractor` peer)。注意 v5 里 `outDir` / `rollupTypes` 这类**旧选项名会被静默忽略**,改完配置跑 `pnpm typecheck` 才看得见。
- `src/index.ts` is the public entry: it re-exports every component plus its types, and exports the optional `G3UI` install plugin for whole-library registration.
### Docs site (`docs`)
- VitePress with the **default theme**; config lives at `docs/.vitepress/config.mts` (`srcDir: 'content'`, so `docs/content/` is the site root).
- Navigation/sidebar are declared in `config.mts` (`themeConfig.nav` / `themeConfig.sidebar`), replacing Docus's `.navigation.yml` + `navigation.sub`. Search is VitePress's built-in local search (no native module).
- `vite.resolve.alias` points `@g3soft/ui` **directly at `packages/ui/src`**, and `@g3soft/tokens` at `packages/tokens/dist/index.css`, so the docs site can develop against the library without building it. Editing `packages/ui` hot-reloads the docs immediately (tokens still need `pnpm --filter @g3soft/tokens build`).
- Deploy base path comes from `DOCS_BASE_URL`; site URL (sitemap hostname) from `DOCS_SITE_URL` — never hardcoded.
- `docs/.vitepress/theme/` holds the theme: `index.ts` (extends default theme, imports tokens CSS), `Layout.vue` (syncs VitePress dark mode → `data-g3-theme`). Demo 容器由 `vitepress-demo-plugin` 提供(见下)。
- Examples are embedded with `<demo vue="<组件>/<示例>.vue" />` (provided by `vitepress-demo-plugin`, registered in `config.mts` via `markdown.config`, with `demoDir` pointing at `docs/examples`).
### 组件文档写作约定(手写,不生成)
组件文档在 `docs/content/ui/<分类>/<组件>.md` 里**手写全文**:正文说明 + `<demo vue="<组件>/<示例>.vue" />` + 手写的 API 表格(Props / Events / Slots / Exposed / 类型定义 / 样式变量 / 实现说明)。
约定:
- **一个功能一个示例**:示例文件按功能命名(`basic` / `variant` / `loading` / `remote` …),不要写「大杂烩」示例;示例内用注释说明「这个功能怎么用」。
- 示例里**不要在 setup 顶层使用 `window` / `document`**(文档站是 SSR 预渲染的),需要浏览器 API 时放到 `onMounted`。
- 改组件的 props / events / slots 时,同步改对应 md 的 API 表格——没有生成器兜底,忘改就是文档错误。
- 分类目录:`usage` / `general` / `form` / `overlay` / `feedback` / `data`;侧边栏分组在 `docs/.vitepress/config.mts` 的 `themeConfig.sidebar` 里维护——**新增组件页时忘加这一条,页面就不会出现在导航里**。
### Design tokens (`packages/tokens`)
- `src/index.css` imports `tokens.css` (light/default) and `dark.css`. Consumed as `import '@g3soft/tokens'`.
- Dark theme activates via `html[data-g3-theme='dark']` (`document.documentElement.dataset.g3Theme = 'dark'`).
- Re-theming/customer customization = overriding same-named `--g3-*` variables, no component changes.
## Conventions (must follow)
- **No `scoped` in component `<style>`.** Isolate with the `g3-` prefix + BEM naming so consumers can override.
- **Never hardcode colors or sizes** — always reference `--g3-*` variables, and give every variable a fallback value (e.g. `var(--g3-color-primary, #18181b)`) so components render correctly even when `@g3soft/tokens` is not loaded.
- **组件 API 文档手写**:改 props/events/slots 时同步更新 `docs/content/ui/**/<组件>.md` 的 API 表格(没有自动生成器)。
- **Examples** live at `docs/examples/<component>/<example>.vue` and are embedded in Markdown as `<demo vue="<component>/<example>.vue" />`. 一个功能一个示例;API 表格直接写在 md 里。
- New components get a `src/<name>/` directory (component `.vue`, `types.ts`, `index.ts`) and must be re-exported from `src/index.ts`.
## RTL(双向布局)
镜像的原则是**跟随行内方向**,不是「左右互换」。三层分工:
1. **视觉**:CSS `[dir='rtl']` + 逻辑属性(`padding-inline-start` / `inset-inline-end` /
`border-start-start-radius` / `margin-inline-start` …)自动翻转。所以组件 `<style>` 里
**不要写 `left` / `right`**(除非确实是物理锚定,见下)。带符号的位移用 token
`--g3-dir-sign`(LTR `1` / RTL `-1`),例如 `translateX(calc(#{v('dir-sign', 1)} * 12px))`。
2. **定位数学**:`_utils/position.ts` 的 `computePosition` 接受 `direction`,RTL 下镜像
`*-start` / `*-end` 的**水平对齐轴**;`left` / `right` 主方向保持物理(与 floating-ui 一致)。
`useFloating` 已透传,所以 Tooltip / Popover / Dropdown / Select / DatePicker 自动跟随。
3. **注入**:`useDirection()` 三级链 —— 就近 `ConfigProvider.direction` → `getGlobalConfig().direction`
→ `documentElement` 的方向(客户端首次调用读取 + `MutationObserver` 跟随)→ `ltr`。
宿主 `<html dir="rtl">` 零配置生效;SSR 场景用 `ConfigProvider`(首帧就对)。
**刻意保持物理语义、不镜像**:Drawer / Modal / Tabs 的 `placement`(字面 API)、浮层箭头与
`left: 50%` 居中、Notification 停靠边。`Grid` 的 `push` / `pull` 已改为沿行内方向位移
(与 Bootstrap 的物理语义不同,迁移时留意)。
**方向性图标**:只翻面 Chevron / Arrow 系列,统一在 `src/_styles/rtl.scss` 里按 lucide 自带的
`lucide-*` 类名处理。**禁止用 `scaleX(-1)` 整块镜像** —— 文字会变成反字。
**键盘**:水平方向键统一走 `horizontalArrowStep(key, direction)`(`_utils/direction.ts`)换算
「行内方向」步进 —— 直接把 `ArrowRight` 当「下一个」会让 RTL 下键盘走向与画面相反。
已接入 Tabs / Tree / CalendarPanel / Splitter;Radio / Segmented 是原生 radio,交给浏览器。
**验证**:几何镜像只能放 E2E(`e2e/rtl.spec.ts`)—— happy-dom 没有布局引擎。playground 用
`?dir=rtl` 进入(也提供右上角开关),同一断言在 LTR / RTL **各测一次**:单侧通过可能是巧合,
两侧夹住才是「镜像」。
## 浮层陷阱(踩过,勿重犯)
**`<component :is="X">` 的 `X` 必须是稳定引用。** `useUnwrappedTrigger` 返回的 `InjectedTrigger` 是一个恒定对象(内容由 render 内部读 computed)。早期实现写成 `computed(() => ({ render: () => vnode }))`,每次依赖变化都产出新组件对象,而 Vue patch 用 `n1.type === n2.type` 判断能否复用 —— 对象身份变了就卸载旧组件、挂载新组件。unwrapped 模式的触发器常常是 `<input>`(Select / DatePicker / Dropdown),DOM 一重建焦点就丢、随即派发 focusout,focusout 处理器又改状态触发下一次重算:**重建 → 失焦 → 改状态 → 重建**自我驱动,主线程被打满,表现为「点击 Select 后整机卡死」。规矩:`:is` 的值不能是每次求值都新建的对象/函数。
**不要在 ResizeObserver 回调链里无谓写样式。** `useFloating` 观察浮层自身用于跟随重算,写 `min-width` 前要先比较旧值(见 `updatePosition`),否则样式写入与观察回调会互相喂数据形成反馈环。
**`v-if` 不要和 `v-for` 写在同一元素上。** Vue 3 里 `v-if` 优先级更高,会在 `v-for` 建立作用域之前求值,模板中引用的循环变量此时是 `undefined`(实测直接抛 `Cannot read properties of undefined (reading 'key')` 打断整个渲染)。把 `v-for` 提到外层 `<template>` 上。
**组件子节点是 `{ default: () => [...] }`,不是纯文本。** 声明式容器组件(如要收集 `<G3DropdownItem>编辑</G3DropdownItem>` 文案的 Dropdown)不能只读 `label` prop,也不能假设 children 是字符串:编译器把组件子节点编译成插槽对象,文本藏在 VNode 里。取值要走「字符串 → 数组 → isVNode 递归 → default() 工厂」这条链。
**改 `types.ts` 里的 props 类型后要重启 dev server。** 运行时 props 声明由编译器解析 `defineProps<Props>()` 的 `types.ts` 生成,Vite 会缓存这一步:把必填改成可选后热更不生效,表现是「类型已改但仍报 Missing required prop」。硬刷新不够,重启 `pnpm dev` 才会重新解析。
**改过 `docs/.vitepress/config.mts` 之后必须重启 `pnpm dev`,否则所有含代码块的页面在 dev 下全 500。** VitePress 1.6.4 的 `handleHotUpdate` 一旦命中 config(或其依赖)就走 `resolveUserConfig` → `disposeMdItInstance()`(把 md-it 与 shiki highlighter 一起销毁)→ `recreateServer()`,而重建后仍可能留着指向已销毁实例的引用,之后每次 `highlight()` 都抛。报错为 `[plugin:vitepress] Shiki instance has been disposed`,堆栈 `ensureNotDisposed → getLoadedLanguages → loadLanguage → highlight`。两个容易误判的点:
- 消息里带的具体 `.md` 路径只是**第一个撞上的文件**,不是那个文件的错;没有代码块的页面(如 `/guide/**`)照样 200。
- 排查别直接 GET 页面 —— VitePress dev **不做 SSR**,只会返回 SPA 壳(无正文、无报错)。要请求 Vite 转换后的模块:`http://localhost:3100/ui/general/button.md?import`,正常是 200 + JS,坏掉时是 500 + 上述报错。
生产构建不受影响(SSG 一次性跑完,没有「销毁后重建」这一步)。
## 测试与自检(含 AI 使用方式)
三层,按「跑得多快 → 能拦什么」递进,命令都在仓库根执行:
| 层 | 命令 | 范围 |
|---|---|---|
| L0 类型 | `pnpm typecheck` | `vue-tsc --noEmit`:源码 + SFC 模板类型。**`pnpm build` 只打印类型错误、不 gate**,所以必须单独跑这条 |
| L1 纯函数 + L2 组件 | `pnpm test` | vitest + happy-dom:日期/校验/定位/分层/树算法 + 组件契约,秒级 |
| L3 真浏览器 | `pnpm test:e2e` | Playwright:弹层层级、命中测试、拖拽、焦点陷阱、DOM 重建检测 |
| 一把梭 | `pnpm verify` | tokens build → ui **typecheck** → ui build → 单测 → E2E |
- 单测与源码 colocate:`src/**/*.test.ts`;组件测试补丁在 `packages/ui/tests/setup.ts`(happy-dom 缺 ResizeObserver / matchMedia / PointerEvent)。
- `packages/ui/vitest.config.ts` **刻意不复用** `vite.config.ts`(那份带 lib 构建与 dts 插件);测试里把 `vue` alias 到完整版,才支持字符串模板挂载。
- **`packages/ui/tests/docs-examples.test.ts` 校验文档示例的 prop 契约**:用 `vue/compiler-sfc` 解析 `docs/examples/**/*.vue` 的模板 AST,把传给 G3 组件的 attribute 与组件**运行时 props**(`defineProps<Props>()` 生成的那份)对账 —— 专治「prop 名写错、Vue 静默吞掉」这类隐形失败:`direction="vertical"` 曾在 39 处静默失效、`allow-clear` 在 Select 上无效、`placeholder` 在 Select / DatePicker 上无效。放行 `class` / `style` / `data-*` / `aria-*` / `v-on` / `v-slot` 等指令、按组件登记的 `PASS_THROUGH`(落到根元素确实生效的属性)、以及带 `v-bind="obj"` 的元素(宁漏勿错)。已知待修项记在 `KNOWN_PENDING`,**计数必须精确匹配**:新增笔误会失败,修好后也要同步删条目。
- E2E 宿主页面是 `packages/ui/playground`:**新增可交互元素必须挂 `data-testid`**,用例只按 testid 找元素(不依赖文案与结构)。
- 面向工具/AI 的产物:`packages/ui/test-results/unit.json`、`test-results/e2e.json`;失败自动留 screenshot / trace 于同目录。
- 单跑一个用例:`pnpm --filter @g3soft/ui exec vitest run src/date/date-utils.test.ts`、`pnpm --filter @g3soft/ui exec playwright test e2e/layer.spec.ts`
- 手测与探索:`pnpm playground`(http://localhost:5200),可直接用浏览器工具看真实渲染。
**清产物目录和跑构建/测试必须拆成两条命令,且不要用相对路径。** Playwright 启动时会递归删 `packages/ui/test-results`,VitePress 构建会清 `docs/.vitepress/dist`(`emptyOutDir`)与 `docs/.vitepress/.temp`;IDE 的 safe-delete 守卫会拦截这些批量删除,报 `[safe-delete][SAFE_DELETE_BULK_GUARD_ERROR]`(helper 拿不到)或 `SAFE_DELETE_BULK_CONFIRM_REQUIRED`(超阈值),整轮命令直接失败。要点:
- 阈值 **500 个文件**,且计数**按轮次累加**。「手动删 456 个 + 同一条命令里跑构建(VitePress 自己再删 44 个)」会凑满 500 被拦 —— 清理与构建必须分开执行。
- PowerShell 的 `Remove-Item` **同样受该守卫限制**(早期笔记写「不受限制」是错的)。
- 单次删除超过阈值时按目录分批,每轮 < 500 即可通过。
- 典型表现:`docs/.vitepress/dist` 残留 500+ 文件时,`pnpm build:docs` 会在 `vite:prepare-out-dir` 阶段失败,看起来像代码问题,其实不是。
- ⚠️ **`cd X; Get-ChildItem | Remove-Item` 里的 `cd` 失败不会中断后续语句**,于是删除会在**当前目录**(通常是仓库根)执行。实测:`cd docs/.vitepress/dist`(目录本就不存在)失败后,紧跟的 `Get-ChildItem -File | Remove-Item` 把**仓库根的文件**删了 —— `package.json` / `pnpm-workspace.yaml` / `pnpm-lock.yaml` / `.gitignore` / `CODEBUDDY.md` / `README.md` / `.codebuddy/` / `.changeset/` 全丢(目录因体量大被守卫挡住才幸存),最后靠 `git checkout -- <路径>` 逐个恢复。**清理一律写绝对路径 + `-LiteralPath`,并在删除前用 `Test-Path` 断言目标存在。**
清理命令(单独一条跑,绝对路径):`Remove-Item -LiteralPath 'D:\...\packages\ui\test-results' -Recurse -Force`。
**E2E 里用 `page.mouse.*` 之前先 `scrollIntoViewIfNeeded()`。** mouse 事件按视口坐标派发,元素在视口外时事件会落到别的元素上,表现为「拖拽/点击完全没反应」,极易误判成组件 bug;同理,`force: true` 只是跳过可点击性检查,事件仍按坐标派发,被遮罩盖住的按钮要点它请用 `dispatchEvent('click')`。
**组件 vnode 收集要处理三种形态**:`label` 这类 camelCase prop、`item-key` 这类 **kebab-case**(`vnode.props` 里保留原样)、以及 `<Comp danger />` 这类**空值布尔属性**(值是 `''` 而非 `true`,用 `if (raw.x)` 会误判为 false,必须 `raw.x != null && raw.x !== false`)。Dropdown / Tabs 都因此踩过坑。
**必须放 E2E 的行为**:层叠上下文与命中测试(`elementFromPoint`)、弹层定位与翻转、拖拽 / 缩放、焦点陷阱、DOM 重建计数、跨组件层级比较。happy-dom 没有布局引擎,这类断言在单测里只会「假装通过」。
**多方向 / 多尺寸的组件,每个方向都要进回归。** Drawer 只验过默认的 `right`,「上下方向宽度不是 100%」就潜伏了很久:容器用 flex 的 `justify-content` / `align-items` 做吸附时,两条轴各只管一个方向,上下面板在主轴(水平)方向没有约束,宽度缩到内容宽。现在按方向定边(上下 `left/right: 0`、左右 `top/bottom: 0`,与 fms 一致),并由 `e2e/drawer.spec.ts` 锁住四方向的几何。
**组件结构元素要显式重置 `margin` / `padding` / `border`。** Drawer 面板是 `<section>`(与 fms 一致),内部还有 `<header>` / `<footer>`,宿主页面里任何 `section { margin: 16px }` 这类**元素选择器**都会命中:实测 `margin-bottom: 16px` 会把「定边撑开」的高度吃掉 16px(720 → 704),表现为「抽屉撑不满」;border 在 border-box 下同样吃高度。组件用类选择器声明这些值为 0(特异性高于元素选择器)即可盖住。同理,**playground / 文档站自己的容器样式要用子选择器**(`.page > section`),不要写裸 `section` —— 这个坑就是这么踩到的(见 `playground/index.html` 注释)。
**CSS 变量写进 `color-mix()` 必须包 `var()`。** `color-mix(in srgb, --g3-color-primary 12%, transparent)` 里少了 `var()`,整个函数解析失败、变量值为空 —— 表现是「树/树节点选中没有背景色」这种看起来像 JS 没生效的样式 bug。SCSS 里要写 `var(#{fn.css-var('color-primary')})`。**并且改完 `packages/tokens/src/scss` 必须 `pnpm --filter @g3soft/tokens build`**:`import '@g3soft/tokens'` 加载的是 `dist/index.css`,只改 src 不重新构建,页面拿到的还是旧值。
**过渡不要包 `width` / `height`;首次定位前不要开过渡。** Segmented 的滑块一度把宽高也放进 `transition`:首次测量时它正从 `100%` 收缩动画中,肉眼是一闪,断言读到的是 104px 而不是 38px。现在宽高瞬时生效、只过渡 `transform` / `opacity`(与 fms 一致),并且首次定位在 `transition: none` 下完成(`thumbReady` 下一帧才置位),否则滑块会从左上角滑到选中项。
**只固定该固定的东西。** Splitter 拖拽曾把**所有**面板都写成 px:那些原本靠 flex 自动分配的面板会被一并冻结成「按下那一刻的实测宽度」,表现为「一拖就整体跳一下」。只更新分隔条两侧的两块(`applySizes(sizes, index)`)。
**Tree 每个节点都要占住箭头列(含叶子)。** 缩进模型是「`gutter + indent * level` + 箭头列」,箭头列必须恒定存在:叶子也要渲染等宽的占位箭头(`visibility: hidden`),否则叶子会少一列宽度、落回父节点文字左侧,整棵树看起来层级错乱。曾按「同层兄弟里是否含可展开节点」决定叶子是否占位(`placeholderKeys`),对「父有箭头、子全为叶」的情形会算错,已废弃。参考 naive-ui:叶子渲染 `--hide` 的 switcher(`visibility: hidden`),宽度等于缩进量。
**给 Teleport / 多根组件挂 `data-testid` 会触发 Vue 的 attrs 继承警告**,挂在包裹 `<div>` 上(E2E 用 `getByTestId` 定位包裹层即可)。删掉组件上的 testid 前先确认没有 E2E 依赖它。
**E2E 里这几个写法会「假失败」**:① hover 后立刻 `evaluate` 读 `border-color`,读到的是过渡中间值(215 而非 24)—— 用会重试的 `toHaveCSS`;② 跨实例取 locator(页面上有多组同组件时 `.first()` 可能来自别的实例)—— 先锁定父容器再 `locator`;③ `page.mouse` 用视口坐标且**不会自动滚动**,元素在视口外时拖拽直接落空(`scrollIntoViewIfNeeded` 先滚);④ `offsetWidth` 是整数、`boundingBox()` 是亚像素,尺寸比较要留 1px 容差。
**不要并行跑 `pnpm build` 与 `pnpm test:e2e`。** 两者都会拉起 Vite 并抢占 `packages/ui/dist`,`vite:prepare-out-dir` 会失败报「Build failed with 1 error」,而单独跑又是好的,极易误判成代码问题。
## Version constraints (do not relax)
`pnpm-workspace.yaml` documents these deliberately — read it before touching dependency versions:
- **`pnpm 11` uses the `allowBuilds` map form** in `pnpm-workspace.yaml` — the v10 `onlyBuiltDependencies` array form is inert. `esbuild` and `vue-demi` must stay allowed (they run install scripts; pnpm 11 errors on un-allowed build scripts).
- `vue` and `typescript` versions are centralized in the workspace `catalog:`; each package references `"vue": "catalog:"` to avoid version drift.