Files
workspace/code/g3soft-libs/CODEBUDDY.md
T
2026-10-08 22:38:34 +08:00

171 lines
16 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 **Docus 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`) — Docus/Nuxt docs site. `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:3000); live-reloads packages/ui via alias
pnpm build # build all packages under packages/** (component lib -> dist)
pnpm build:docs # build docs static site -> docs/.output/public
pnpm preview:docs # preview the built docs site
```
Deploying docs to a subpath:
```bash
NUXT_APP_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:** there is no test runner configured in this repo (no vitest/jest, no test files). Do not assume `pnpm test` exists.
### Running a single component's checks
There is no per-component test/lint script. The closest equivalent is `pnpm --filter @g3soft/ui build` (runs `vite build`, which type-checks declarations via `vite-plugin-dts`).
## 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/.output/public` (static) | `packages/ui/dist` (ESM + d.ts) |
| Dep boundary | Docus/Nuxt/Tailwind live only in the `docs` package | only `vue` (peer) + `@g3soft/tokens` |
Nuxt-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` with `rollupTypes` emits a single `dist/index.d.ts`, matched by `exports.types`.
- `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`)
- `extends: ['docus']` — all navigation/search/theme comes from the Docus Nuxt Layer.
- `vite.resolve.alias` points `@g3soft/ui` and `@g3soft/tokens` **directly at `packages/*/src`**, so the docs site can develop against the library without building it. Editing `packages/ui` hot-reloads the docs immediately.
- `site.url` is required (sitemap, llms.txt, OG images) — missing it breaks prerender with a 500.
- Deploy base path comes from `NUXT_APP_BASE_URL` (Nuxt's env override for `app.baseURL`), never hardcoded.
- `docs/app/components/content/` holds components usable directly inside Markdown: `Demo.vue` (renders a runnable example + source).
### 组件文档写作约定(手写,不生成)
组件文档在 `docs/content/ui/<分类>/<组件>.md` 里**手写全文**:正文说明 + `:::demo{name="<组件>/<示例>"}:::` + 手写的 API 表格(Props / Events / Slots / Exposed / 类型定义 / 样式变量 / 实现说明)。
约定:
- **一个功能一个示例**:示例文件按功能命名(`basic` / `variant` / `loading` / `remote` …),不要写「大杂烩」示例;示例内用注释说明「这个功能怎么用」。
- 示例里**不要在 setup 顶层使用 `window` / `document`**(文档站是 SSR 预渲染的),需要浏览器 API 时放到 `onMounted`。
- 改组件的 props / events / slots 时,同步改对应 md 的 API 表格——没有生成器兜底,忘改就是文档错误。
- 分类目录:`1.usage` / `2.general` / `3.form` / `4.overlay` / `5.feedback` / `6.data`,每个分类有 `.navigation.yml`。
### 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.
### Windows: dev-server port selection
`docs/package.json` runs `node scripts/dev.mjs` for `dev` (not `nuxt dev` directly). On Windows, Node sets `SO_REUSEADDR`, so **two processes can bind the same `host:port`**. Nuxt's built-in "is the port free?" check works by *trying to bind* a probe socket — which therefore succeeds even when another service already listens on 3000, so `pnpm dev` silently lands on 3000 (no 3000→3001 fallback). `scripts/dev.mjs` sidesteps this by testing the port with a real TCP *connection* (`connect` to `127.0.0.1`/`::1`) and walking upward from `DOCS_PORT`/`PORT`/3000 until it finds a free one, then passing `--port`. `devServer.host: ''` (empty string — deliberately not `'0.0.0.0'`) makes listhen listen on all interfaces *and* print a usable `Local: http://localhost:<port>/` plus the LAN `Network:` URL; with `'0.0.0.0'` the Local line would be an unopenable `http://0.0.0.0:<port>/`.
## 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 referenced in Markdown as `::demo{name="<component>/<example>"}`. 一个功能一个示例;API 表格直接写在 md 里。
- New components get a `src/<name>/` directory (component `.vue`, `types.ts`, `index.ts`) and must be re-exported from `src/index.ts`.
## 浮层陷阱(踩过,勿重犯)
**`<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` 才会重新解析。
## 测试与自检(含 AI 使用方式)
三层,按「跑得多快 → 能拦什么」递进,命令都在仓库根执行:
| 层 | 命令 | 范围 |
|---|---|---|
| L1 纯函数 + L2 组件 | `pnpm test` | vitest + happy-dom:日期/校验/定位/分层/树算法 + 组件契约,秒级 |
| L3 真浏览器 | `pnpm test:e2e` | Playwright:弹层层级、命中测试、拖拽、焦点陷阱、DOM 重建检测 |
| 一把梭 | `pnpm verify` | tokens build → 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 到完整版,才支持字符串模板挂载。
- 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),可直接用浏览器工具看真实渲染。
**跑 E2E 前先清 `packages/ui/test-results`。** Playwright 启动时会递归删除上次产物,而 IDE 的 safe-delete 守卫会拦截 node 进程的批量删除,直接报 `[safe-delete][SAFE_DELETE_BULK_GUARD_ERROR]` 让整轮测试跑不起来。用 PowerShell 清理(不受该守卫限制):`Remove-Item 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 的叶子缩进占位按兄弟判定。** 同一批兄弟里存在可展开节点时,叶子才渲染等宽占位(`placeholderKeys`);整层都是叶子还空一格就是白占位置。懒加载下「未加载过」的节点算可展开。
**给 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:
- **`nuxt` is pinned to exactly `4.4.8` in `docs/package.json`.** Do not widen to `^4.4.8` or upgrade to 4.5+. Nuxt 4.5+ pulls Vite 8 (Rolldown), which cannot resolve Nitro's `file:///D:/...` virtual modules on Windows, breaking `#nitro-internal-virtual/*` and causing every page to 500 (`Either manifest or precomputed data must be provided`). 4.4.8 → Vite 7 (Rollup) and matches Docus's official starter.
- **`pnpm 11` uses the `allowBuilds` map form** in `pnpm-workspace.yaml` — the v10 `onlyBuiltDependencies` array form is inert. `better-sqlite3` (Docus FTS5 search native module), `vue-demi`, and `esbuild` must stay allowed.
- `vue` and `typescript` versions are centralized in the workspace `catalog:`; each package references `"vue": "catalog:"` to avoid version drift.