This file provides guidance to AI coding agents when working with code in this repository. Kept in sync with CLAUDE.md — edit both together.
VEF Framework is a React 19 application framework published to npm under @vef-framework-react/* (with external consumers). It is a pnpm-workspace monorepo built on TypeScript and Vite. Backwards-incompatible changes affect downstream apps, so prefer additive changes and surface breaking ones explicitly.
pnpm playground— Start the playground dev serverpnpm test— Run all testspnpm test:watch— Tests in watch modepnpm test:coverage— Coverage report (informational only; not a CI gate)vitest run path/to/file.test.ts— Run a single test file
pnpm typecheck— Typecheck all packagespnpm typecheck:<pkg>— Typecheck one package (core/components/hooks/shared/dev/starter/form-editor/approval/approval-flow-editor)pnpm lint— ESLint--fixacross the repoeslint --fix <file>— Lint a single file
pnpm build— Build all packagespnpm build:<pkg>— Build one package (core/components/hooks/shared/dev/starter; plusbuild:playground)pnpm clean— Clean all package build outputspnpm clean:modules— Drop allnode_modulesandpnpm-lock.yaml
pnpm deps:check— Check inter-package dependency consistencypnpm deps:check:apply— Apply recommended fixespnpm sync-meta— Sync package metadata across the monorepo
Publishing is CI-triggered by pushing a v* tag (.github/workflows/release.yml) — not by a local pnpm pub. Flow:
pnpm version:patch|minor|major— bump root + every package (--no-git-tag-version, so no commit/tag).- Commit the bump as
chore(release): vX.Y.Z. git tag -a vX.Y.Z -m vX.Y.Z— the tag must equal the rootpackage.jsonversion (CI hard-fails otherwise).git push --follow-tags— thev*tag firesrelease.yml:install --frozen-lockfile→ tag/version check →typecheck→lint→test→build→pnpm publish→ GitHub Release (notes via git-cliff from Conventional Commits).
- Land feature commits before the
chore(release)commit so git-cliff picks them up. - Don't publish with
pnpm pub/pnpm release:*for a real release — they publish from your machine (local npm auth, skipping CI gates) and double-publish if combined with the tag flow; a published version can't be re-published. - A failed CI gate blocks the publish but the tag remains — delete the tag, fix, re-tag.
Scripts: pnpm version:patch|minor|major (bump only) · pnpm release:patch|minor|major (local bump + build + publish, the non-CI path) · pnpm pub (local publish, no bump) · pnpm unpub (roll back, see scripts/unpublish.ts).
- pnpm workspaces; inter-package deps use
workspace:* - Source packages:
packages/*. Demo app:playground/. Plugins:plugins/. - Build orchestration via
pnpm --filter=./packages/* <cmd> - Source resolution: package
exportsinclude a"vef"condition pointing atsrc; the playground's Vite config and the Vitest config resolve through it (Vitest also via explicitsrcpath aliases) to the unbuilt sources for HMR / fast tests without rebuilds.
Packages publish under @vef-framework-react/* as dual ESM/CJS with TypeScript declarations.
| Package | Purpose |
|---|---|
| core | API client (TanStack Query + axios), HttpClient with BusinessError / token refresh, state utilities (Jotai / Zustand / XState), Immer, selector-based contexts, resumable chunked-upload Uploader, SSE client |
| components | antd v6 + Emotion UI library (100+ components), TanStack Form integration, semantic color/scene system, custom motion/typography components |
| hooks | Reusable React hook library (permission / event / code-set / upload / deep-compare / etc.) |
| shared | Pure-function utilities (tree, chrono, color, equal, key, path, string, event, task, format) |
| starter | Ready-to-use layouts and auth components on top of TanStack Router, plus the headless login flow (useLoginFlow) and single sign-on landing page |
| form-editor | Visual form schema editor; linkage expressions are plain JavaScript via new Function. Published under @vef-framework-react/form-editor; treat exported APIs and schema shapes as external surface. Targets forms with 100s of fields. |
| approval-flow-editor | Visual approval flow editor on @xyflow/react v12 (ReactFlow) + elkjs auto-layout (excluded from test scope — visual canvas) |
| approval-form-bridge | Projection bridge from form-editor schemas to the Go approval form contract: projectFormSchema (one walk → backend FormDefinition + flow-editor formFields, conservation errors for unprojectable keyed fields), createApprovalRegistries (approval profile — no switch / daterange / button), validateApprovalSchema save gate |
| approval | Ready-to-use approval engine pages on the Go approval module: runtime pages (initiate / task center / my instances with the action-complete instance detail), management pages (flow list + full-screen designer wizard absorbing form-editor + approval-flow-editor, categories, delegations, admin console), ApprovalProvider host plugins (org pickers), typed API layer mirroring the Go DTOs, and the progress-annotated flow-graph viewer |
| dev | Shared ESLint / Stylelint / Commitlint configs, Vite plugins, TypeScript configs |
- API Client (
packages/core/src/api/client.ts) wrapsHttpClient+QueryClient.createQueryFninjects an invocation-isolatedAbortSignal; its factory runs once per query execution and must keep cross-request state or side effects outside the factory.createMutationFnis constructed once, andexecuteMutation()runs mutations imperatively. - HTTP errors split into
BusinessError(API returned a non-OK business code; carriescode/message/data) and network errors (axios errors for 4xx/5xx, timeouts). - Token refresh coordination (
packages/core/src/http/client.ts): on a 401 with a configuredtokenExpiredCode,HttpClientpublishes one shared refresh promise. Concurrent new requests wait through request-scoped, abort-aware waiters and resume with the renewed token; canceling a waiter or the triggering 401 caller does not cancel the global refresh. After a successful refresh, a live triggering request retries with the renewed token; an automatic refresh failure callsonUnauthenticatedonce even if that caller was canceled.ensureTokenRefreshed(false)suppresses the callback only when it owns the refresh cycle. - Login flow (
packages/starter/src/components/login/use-login-flow.ts): authentication is a loop, not a request — the backend answers a login with either a session or a challenge, and answering a challenge yields the same two outcomes again.useLoginFlowowns that loop headlessly (challenge cycle, error normalization, credential encryption, the store write, navigation), so<Login>and an application's own login screen are consumers rather than reimplementations. It never rejects — failures surface througherror; the store write is not overridable (the route guards read it) whileonAuthenticatedreplaces navigation and the welcome notification. The store write is also the success boundary: a failure after it (a rejectingonAuthenticated, a failed invalidation or navigation) goes toonErroralone, never toerror, which would otherwise render "登录失败" over an authenticated session on a form that can no longer accomplish anything.cancelis likewise ignored whilepending: an answer already submitted will still be applied. A URL-suppliedredirectis narrowed to a rooted internal path before navigation (redirectTo, being the application's own, is not) — on the single sign-on route the search is whatever the originating system appended. Establishing a session drops the previous identity's menus before the layout guard can read them.LoginChallengeOutletis the shared renderer lookup, including the alert shown for a challenge type no renderer is registered for; it renders each renderer as an element with a per-type key, never by calling it — a plain call would run every renderer's hooks in the outlet's one hook list, so a chain such as department selection followed by a forced password change would crash on the second challenge.autoResolveanswers selected challenges without presenting them, keyed by type and deliberately partial where the renderer registry is exhaustive: presenting a challenge is mandatory, answering one on the user's behalf is opt-in per type, so a second factor can never be resolved from ambient data. Each type is attempted once — a silent answer the server rejects falls through to the renderer instead of retrying, which would loop against the backend's brute-force guard. - Single sign-on (
packages/starter/src/components/sso,createSsoRouteOptions): a trust-login handoff is an ordinary login carrying a one-time code instead of a password, so everything after the exchange is the shared login flow — challenge chain included, because the originating system authenticated the user but did not satisfy this application's login policy.useSsoLoginadds only what is specific to a handoff: reading it out of the URL (readHandoff, defaulting to the framework'sapp_id+codeconvention), spending it exactly once per mount, and clearing it from the address bar. Two details are load-bearing there. The clear happens before the exchange, not after: by the time the exchange settles the browser may already be on the destination, where rewriting the URL to its bare pathname would discard that page's own query and hash. AndSsoLoginFlow.searchis a snapshot taken at mount, because@tanstack/historypatcheshistory.replaceState— clearing the URL notifies the router and empties its live search, which a landing page or an auto-resolver still needs to read.statushas a terminal state: a spent handoff that produced neither a session nor a pending challenge (the user cancelled, or the result carried nothing) reportsfailed, since the one-time code is gone and nothing can restart it.publicKeyis accepted here too — a handoff meets the samepassword_changechallenge a password login does, and without the key its renderer would submit the new password in clear text. The route deliberately has nobeforeLoadbouncing an authenticated visitor: a handoff names the user it is for and may arrive at a tab signed in as somebody else, so it always exchanges and replaces the session.<SsoLogin>is a plain default page taking no slots; an application-specific screen is written overuseSsoLoginand passed tocreateSsoRouteOptionsascomponent.<SsoLogin>pairsonResolveChallengewithchallengeRenderersthe way<Login>does — renderers without a dispatcher put a challenge on screen whose submit button can never do anything.createLoginRouteOptionsandcreateSsoRouteOptionsare the same shape: each takes either the default page's props or acomponentreplacing the page, as a discriminated union rather than an optional prop that would leave the others silently dead; the custom-page member forbids every default-page prop explicitly (Partial<Record<keyof …Props, never>>) because excess-property checking catches an inline literal but not a pre-built value, where the surplus props would be dropped in silence. The route keeps what no screen should restate — for login theredirectcontract (defaulted andcatch-guarded) and the already-authenticated guard, for single sign-on the pass-through search. - Path parameters:
:paramNamein the URL is substituted fromparams—/users/:id+{ id: 123 }→/users/123. - Auth skip: set header
X-Skip-Authentication: "1"to bypass Bearer injection for a single request. - Request body encoding (
packages/core/src/http/body-encoding.ts): passbodyEncoding: "base64" | "gzip+base64"topost/put(or setdefaultBodyEncodingon the client) to transport-encode a code-shaped JSON body so it survives middleboxes that false-positive on scripts; the client addsX-Body-Encodingand the server's body-encoding middleware reverses it before parsing. The integration adapter/system saves and the dry-run consoles opt intogzip+base64."none"opts a single request out of a client default; multipart/binary bodies are never encoded; responses ride native gzip, so nothing changes there. The encoded body must reach the wire byte for byte, so the request carriestransformRequest: [body => body]: the content type staysapplication/json(that is what it decodes back into, and the server guards/apion it), and axios reads exactly that pair as "a JSON payload to serialize" — for a string it triesJSON.parse, base64 is not JSON, so it falls through toJSON.stringifyand wraps the payload in double quotes, which the server refuses with a 400 on every transport-encoded request.client.node.test.tspins it against a real HTTP server, because the mocked-instance specs inclient.test.tsassert what the client hands axios and never run its transforms. - File preview bridge: the framework bundles no viewer library.
components/file-previewdefines theFilePreviewTarget/FilePreviewHandlercontract; the app installs a preview host viaFilePreviewProvider, and<Upload>(and everything built on it) dispatches non-image files to it as normalized targets (toFilePreviewTargetreadsUploadedFileMeta— stamped byFileUploadon upload and byUploadFieldon hydrate). Images keep the built-inImagemodal; without an accepting provider the framework warns and never navigates directly to a URL whose authentication requirements it cannot prove. Private files are fetched or downloaded by the host throughHttpClient.requestFile(url)/HttpClient.download(url)(Bearer + 401 refresh +Content-Dispositionfilename). Reference integration: playground's@file-viewer/reacthost atplayground/src/components/file-viewer-preview-host.tsx, mounted around the authenticated layout inpages/_layout/route.tsx(try it atsys/file-preview-demo); the viewer's renderer preset is registered byfileViewerRenderers()inplayground/vite.config.ts. - State management: Jotai for atomic state, Zustand for stores (with
createStore/createPersistedStoremiddleware stack incore/store), XState for complex machines (withuseActor+ selector incore/state-machine). Pick by complexity. - Forms: TanStack Form is wrapped by
packages/components/src/form/*and surfaced throughFormModal/FormDrawer/Crud. - Linkage expressions:
form-editorconditions, assignment expressions, and script actions are all plain JavaScript compiled throughnew Function(engine/linkage/default-evaluator.ts) withfield/$form,$vars,$user,$node, and aDate$nowin scope; hosts swap in their own runtime viaLinkageEvaluators. Expression inputs are CodeMirror (CodeEditorfrom components) with completion off.approval-flow-editorcondition expressions use the Go engine's own syntax — edited as plain text (no highlighting), validated and evaluated on the backend; itsCONDITION_OPERATORSvocabulary lives inapproval-flow-editor/src/types.ts. - Global context injection:
FormRenderer'sevaluationContext($user/$node/$varsoverrides) carries host runtime context into every expression, script, and — via a$-rootedsourceKeylike$user.departmentId— visual leaf condition; the designer'scontextSourcesprop (LinkageContextSource[]) declares those paths for the condition builder's pick list (design-time metadata only).approval-flow-editormirrors this withEditorPlugins.globalSubjects(FormFieldDefinition[]), layered between the built-in applicant subjects and form fields; the Go engine resolves them fromInstance.Globals, a server-side snapshot supplied by the host'sInstanceGlobalsResolverat instance start (never from the client request — globals steer routing). - Editor perf at scale: the form/flow editors target 100s of fields — keep per-keystroke render and per-frame drag cheap (structural sharing in
packages/form-editor/src/engine/schema/mutate.ts, memoized canvas rows, and don't let$vars/ expression-scope changes bust cellmemo). Discrete-action tree walks (drop / duplicate) are not hot paths; don't pre-optimize them.
- Vite for all packages via
defineBuildConfig()(scripts/build-config.ts) - Auto-externalizes deps/peerDeps; Emotion CSS-in-JS transform;
.d.tsvia unplugin-dts - Requires Node 22+. pnpm version is auto-tracked from
package.json#packageManager(CI usespnpm/action-setup@v4).
Vitest 4 + jsdom + @testing-library/react. Test files: ./packages/**/*.test.{ts,tsx}. Global setup at ./scripts/test-setup.ts (browser-API mocks, localStorage polyfill, jsdom virtual-console filter). The root vitest.config.ts enables globals: true, so describe / it / beforeEach / expect / vi are available without imports.
Conventions below follow Testing Library, Kent C. Dodds' Testing Trophy, and the practices of React Aria / Radix / Mantine / TanStack. Existing specs are being aligned; new specs follow these from day one.
- Test behavior, not implementation. Assert on what a caller observes, not internal state, private fields, or refactor-fragile structure.
- Confidence over coverage. Prefer integration-level tests (render → interact → assert). Coverage is a side-effect of good tests.
- One concept per
it. Unrelated assertions belong in separateitblocks. - No flaky tests. No performance assertions, time-of-day logic, or real-clock
setTimeoutwaits.
Specs are colocated next to the source: <name>.test.ts(x) when the source is <name>.ts(x), or index.test.ts(x) when the source is index.ts(x). No __tests__/ directories.
Reach for these patterns before inventing new ones:
- Pure utility —
packages/shared/src/utils/tree.test.ts - Hook with
apiClientinjection —packages/hooks/src/use-has-fetching/index.test.ts(passesapiClienttorenderHook, builds aqueryFnviaapiClient.createQueryFn) - Component (antd + permission) —
packages/components/src/permission-gate/index.test.tsx - Component (form submit lifecycle) —
packages/components/src/form-modal/index.test.tsx - Component (async UI gated by
Promise.withResolvers) —packages/components/src/action-button/index.test.tsx(loading state under a deferred onClick) vi.hoisted+vi.mockfor a CJS package —packages/core/src/http/client.test.ts(axios)vi.mockfor a typed SDK module —packages/core/src/sse/client.test.ts- In-repo dependency mock at the package boundary —
packages/components/src/file-upload/index.test.tsx(mockscore/Uploader) - Fake-driver scripted backend —
packages/core/src/storage/uploader.test.ts - Manual
defer<T>()for deterministic async ordering —packages/core/src/http/client.test.ts,packages/core/src/storage/uploader.test.ts
globals: trueis on — new specs omitdescribe/it/expect/viimports. Existing explicit imports are kept; do not bulk-rename old specs purely to drop them.- Component and hook specs go through the package's
test-utils.tsx(packages/components/test-utils.tsx,packages/hooks/test-utils.tsx) —render/renderHookfrom that module installConfigProvider/AppContextProvider/ApiClientProvider. Bypassing the wrapper makes antd and permission-aware code misfire. - Pull
screen,waitFor,act,within, etc. from the sametest-utils.tsx(it re-exports* from "@testing-library/react"). - Specs that touch
useApiClient/useMutation/useQuerypassapiClienttorender/renderHook; the wrapper installsApiClientProvider(which providesQueryClientProvider). UsecreateTestApiClient()for an isolated per-test instance — its defaultbaseUrlishttp://vef-test.invalidso any leaked real network call fails loudly. packages/starter/test-utils.tsxdiffers from the others: it mounts what is under test as the only route of a router (renderRouterHook/renderInRouter), becauseRouterProviderrenders a route tree rather than its children, so a Testing Librarywrappercannot host one. It renders underStrictModeon purpose — double-invoked effects are what a "run this once" guard has to survive. The default memory history never toucheswindow.location, which also means it never writes there: anything asserting on the address bar must passhistory: "browser", or the assertion holds regardless of what the code does.- Packages without a local
test-utils.tsx(currentlycore,shared, andform-editor) import directly from@testing-library/react. They have no provider requirements —core/state-machine/index.test.ts,core/context/disabled.test.tsx, andcore/context/context-selector.test.tsxare the canonical examples.
describe(module name)→ optionaldescribe("when <condition>")→it("<observable outcome>"). Descriptions read as sentences:it("renders the dialog"),it("throws when X is missing"). Do not prefix with "should" — third-person verb or imperative statement only.- Separate happy path, edge cases, and errors into distinct
describeblocks. - One
itper case — do not useit.each(it collapses semantically distinct cases under one description and obscures which row failed). - Prefer accessible queries:
getByRole/findByRole>getByLabelText>getByText>getByTestId. See Testing Library priority. queryBy*is only for absence assertions (expect(queryBy...).not.toBeInTheDocument()).findBy*already waits — do not wrap it inwaitFor.- Don't query by CSS class unless the class is a documented contract (e.g.
vef-btn-loading).
userEventonly. FreshuserEvent.setup()per test.userEventfires the full pointer/focus chain and flushes microtasks;fireEventdoes not, which silently breaks antd popover / popconfirm transitions. The repo currently has zerofireEventusage — even antd's hidden<Upload>input is driven byuser.upload(input, file)(seepackages/components/src/file-upload/index.test.tsx). If a new spec genuinely needsfireEvent, justify the exception in the PR description.await findBy*for elements appearing async;await waitFor(() => expect(...))for non-element state.Promise.withResolvers()(or a hand-rolleddefer<T>()) gates async flow under test (action-button,http/client,uploader).- Timer-driven code:
vi.useFakeTimers()/vi.useRealTimers(); flush viavi.runAllTimers(),vi.runAllTimersAsync(), orvi.advanceTimersByTime(ms). Usevi.useFakeTimers({ shouldAdvanceTime: true })when the code under test mixes a realawaitwith fake intervals. vi.spyOn(target, "method")overvi.fnwhenever you want the original implementation to still run (e.g. silencingconsole.warnwhile observing calls — seesilenceConsoleinhttp/client.test.ts). Reach forvi.fnwhen constructing a replacement from scratch.- Never
await new Promise(r => setTimeout(r, ms))— it's flaky, slow, and not what user-visible behavior depends on. Use microtask flushes (await Promise.resolve()) for ordering and fake timers for delay-sensitive code.
- Plain
expect().to*+ jest-dom matchers (toBeInTheDocument,toHaveTextContent,toHaveAttribute,toBeDisabled,toHaveValue). No custom matchers. - Snapshot tests only for stable structural output (e.g. generated schema). Never for rendered DOM.
- Mock the network layer (
axios/fetch/ event-source) and side-effect entry points of third-party SDKs. vi.hoistedis required when avi.mockfactory needs to reference an outer variable — Vitest hoistsvi.mockto file-top, andvi.hoistedis the only legal way to share state with the factory. Seehttp/client.test.ts.- Browser APIs (
ResizeObserver,IntersectionObserver,matchMedia,localStorage) are already mocked globally inscripts/test-setup.ts— do not duplicate. - Do not mock internal modules. Test through the public API. The fake-driver pattern in
uploader.test.tsis canonical: construct a typed fake of the boundary, script its responses, record calls for assertion. - A dependency from another in-repo package counts as a boundary — mocking it is fine when that dependency has its own thorough coverage (e.g.
components/file-uploadmockingcore/Uploader). - Per-test mock isolation: the project does not run
test.concurrent, so two patterns coexist legitimately:- Boundary mocks created once (
vi.hoistedinstances, module-scopedvi.fn()likemockHasPermissioninpermission-gate) — cleared inbeforeEachviamockClear()/mockReset(). Required forvi.mockfactories; acceptable elsewhere. - State-carrying fakes (call counters, captured payloads, in-memory queues like
use-deep-memo'sfactoryCallCount) — must be re-created insidebeforeEach. If a counter or buffer survives between tests, isolation is gone.
- Boundary mocks created once (
- Reset shared state in
afterEachviavi.restoreAllMocks()/vi.clearAllTimers().
renderHookfrom the package's localtest-utils.tsx. The legacy@testing-library/react-hooksis not used.actwrapping is implicit for Testing-Library-triggered updates — wrap manually only when the React warning explicitly asks.- Read latest value via
result.currentat each assertion point. Don't destructure into a stale binding.
- Pure re-export modules:
core/common,core/dnd,core/immer,core/motion,core/state - Pure antd pass-through components (no behavior beyond a wrapper)
- Pure style / animation components
- Excluded from coverage (existing specs still run in
pnpm test):starter,dev,plugins,playground, and the visual canvas inapproval-flow-editor.form-editorIS in test scope (heavily tested) — just without an aggregate threshold yet. - Generated artifacts (
dist/,.d.ts)
- Package-level thresholds enforced via
vitest.config.tsforshared/hooks/core. Values reflect the measured baseline minus ~5% buffer; raised stage by stage as new specs land.componentsandform-editorare coverage-measured but have no aggregate threshold by design — tracked per-component/feature. - CI gates on
pnpm typecheck && pnpm lint && pnpm test.pnpm test:coverageruns as an informational artifact only. - New components and hooks ship with a spec. Exceptions justified in the PR description.
- When modifying any
packages/{shared,hooks,core,components,form-editor}/**source, update the corresponding spec. Before renaming exports or changing prop types, search for specs asserting the old contract. - Use
test:prefix for test-only commits (Conventional Commits, enforced by commitlint).
- Single-line only. One Conventional Commits header (
type(scope): subject); no blank line, no body — enforced by commitlint'sbody-empty/footer-emptyrules (rootcommitlint.config.tsand@vef-framework-react/dev'sdefineCommitlintConfig). Pass exactly one-m(keep under ~100 chars). Flag breaking changes with the header!(feat!:,feat(scope)!:) — theBREAKING CHANGE:footer is rejected. Extra rationale goes in the PR description or chat, never the commit body. test:prefix marks test-only commits (commitlint-enforced).
- Filenames: kebab-case. React components: PascalCase.
- No leading-underscore identifiers (e.g.
_handleSubmit). A_prefix is reserved solely for intentionally-unused params/vars (the ESLint^_ignore pattern) — never as a naming style for real, used bindings. - Comments, JSDoc, and inline docs: English only.
- When wrapping a same-named component from another package, alias the imported inner component as
XxxInternal. - Imports sorted by perfectionist plugin: type imports first, then external, then internal (alphabetical within group).
- Strict mode; TypeScript 6+. Avoid
any—no-explicit-anyis off (a convention, not lint-enforced), so prefer precise types / generics and narrow casts rather than reaching forany - Prefer
interfaceovertypefor object shapes - Array types:
T[]for simple,Array<T>for complex (array-simple rule) - Max 5 function parameters
- Type safety is not negotiable: prefer an exhaustive
switch(withassert-never/exhaustive()) overas/ widening casts on discriminated unions — never loosen types with a cast where a switch type-checks.
- No class components, no
defaultProps, prefer hooks - No unnecessary
useMemo/useCallback - JSX props sorted:
key/reffirst, then alphabetical, callbacks last
@stylistic/eslint-plugin: 2-space indent, double quotes, semicolons, 120-char line width.
- Antd styles live in the
antdCSS layer; VEF styles already have higher priority — don't use!importantor&&to override antd. - Use
globalCssVarsfrom@vef-framework-react/componentsfor theme variables. - Many CSS vars (
colorText,colorBorder,colorBgContainer) auto-adapt to light/dark — no.darkoverrides needed. - Use
html.dark &only for properties that genuinely differ between themes (gradients, shadows, glass effects).
- Pre-commit (
lint-staged): runseslint --fixon staged.js/.ts/.tsx/.json/.mdfiles - Pre-push (
.husky/pre-push): runspnpm typecheck && pnpm testbefore any push
.github/workflows/test.yml runs on every PR and push to main:
- Gating:
pnpm typecheck→pnpm lint→pnpm test - Informational:
pnpm test:coverage(uploaded as artifact but not gating)
.github/workflows/release.yml runs on a pushed v* tag — re-runs the gates, then pnpm publish and a GitHub Release (see Release).
Every package is React 19 — apply these by default when writing or reviewing any component / hook (not only for big refactors):
/vercel-react-best-practices— performance & re-render patterns (memoization, derived state, effect deps, large lists)/vercel-composition-patterns— component API design (compound components, avoid boolean-prop sprawl, lift state, React 19 APIs)/ant-design— antd 6.x decision guide (component selection, theming/tokens, performance, CRUD / ProComponents); the UI layer is antd v6 + Emotion/frontend-design— visual design, styling, UI aesthetics/web-design-guidelines— UI code review (performance, forms, animations; accessibility is not prioritized for this project)- ReactFlow v12 (
@xyflow/react) best practices when working inapproval-flow-editor(its only consumer) - No
tanstack-formskill exists — forms go through the@vef-framework-react/componentsform wrapper (packages/components/src/form/*)