Files
2026-10-08 22:38:34 +08:00

16 KiB
Raw Permalink Blame History

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

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:

NUXT_APP_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: 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.