171 lines
16 KiB
Markdown
171 lines
16 KiB
Markdown
# 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.
|