22 KiB
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 undersrc/<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
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:
DOCS_BASE_URL=/docs/ pnpm build:docs
Releasing the component library (changesets):
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:
vueis markedexternal— never bundle Vue, or consumers get duplicate instances.preserveModuleskeeps thesrcstructure so consumers can tree-shake.vite-plugin-dts产出逐文件 d.ts +dist/index.d.ts(re-export barrel),与exports.types对齐;刻意不打包成单文件(那要bundleTypes+@microsoft/api-extractorpeer)。注意 v5 里outDir/rollupTypes这类旧选项名会被静默忽略,改完配置跑pnpm typecheck才看得见。src/index.tsis the public entry: it re-exports every component plus its types, and exports the optionalG3UIinstall plugin for whole-library registration.
Docs site (docs)
- VitePress with the default theme; config lives at
docs/.vitepress/config.mts(srcDir: 'content', sodocs/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.aliaspoints@g3soft/uidirectly atpackages/ui/src, and@g3soft/tokensatpackages/tokens/dist/index.css, so the docs site can develop against the library without building it. Editingpackages/uihot-reloads the docs immediately (tokens still needpnpm --filter @g3soft/tokens build).- Deploy base path comes from
DOCS_BASE_URL; site URL (sitemap hostname) fromDOCS_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 byvitepress-demo-plugin, registered inconfig.mtsviamarkdown.config, withdemoDirpointing atdocs/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.cssimportstokens.css(light/default) anddark.css. Consumed asimport '@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
scopedin component<style>. Isolate with theg3-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/tokensis not loaded. - 组件 API 文档手写:改 props/events/slots 时同步更新
docs/content/ui/**/<组件>.md的 API 表格(没有自动生成器)。 - Examples live at
docs/examples/<component>/<example>.vueand 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 fromsrc/index.ts.
RTL(双向布局)
镜像的原则是跟随行内方向,不是「左右互换」。三层分工:
- 视觉:CSS
[dir='rtl']+ 逻辑属性(padding-inline-start/inset-inline-end/border-start-start-radius/margin-inline-start…)自动翻转。所以组件<style>里 不要写left/right(除非确实是物理锚定,见下)。带符号的位移用 token--g3-dir-sign(LTR1/ RTL-1),例如translateX(calc(#{v('dir-sign', 1)} * 12px))。 - 定位数学:
_utils/position.ts的computePosition接受direction,RTL 下镜像*-start/*-end的水平对齐轴;left/right主方向保持物理(与 floating-ui 一致)。useFloating已透传,所以 Tooltip / Popover / Dropdown / Select / DatePicker 自动跟随。 - 注入:
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 插件);测试里把vuealias 到完整版,才支持字符串模板挂载。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 11uses theallowBuildsmap form inpnpm-workspace.yaml— the v10onlyBuiltDependenciesarray form is inert.esbuildandvue-demimust stay allowed (they run install scripts; pnpm 11 errors on un-allowed build scripts).vueandtypescriptversions are centralized in the workspacecatalog:; each package references"vue": "catalog:"to avoid version drift.