Skip to content

chore(release): sync v1.7.0 to master - #61

Closed
vannt-dev wants to merge 70 commits into
masterfrom
develop
Closed

chore(release): sync v1.7.0 to master#61
vannt-dev wants to merge 70 commits into
masterfrom
develop

Conversation

@vannt-dev

Copy link
Copy Markdown
Owner

Summary

  • sync the completed v1.7.0 release from develop to master
  • includes dirty-baseline rebasing, accessible validation errors, form-level message catalogs, and async field options
  • carries the generated 1.7.0 package versions and changelogs

Verification

  • Release workflow passed all quality gates
  • all four packages are published on npm at 1.7.0
  • GitHub Release v1.7.0 is published

vannt-dev and others added 30 commits August 4, 2026 23:52
…ations (#30)

* ci: optimize workspace build script and release workflow verification

* feat(core): export default HTML5 field types in FieldTypeMap

* feat(react): implement default HTML5 built-in fallback renderers

* feat(vue): implement default HTML5 built-in fallback renderers

* feat(angular): implement default HTML5 built-in fallback renderers

* docs: update READMEs and examples to showcase default zero-config renderers

* test: add test coverage for default password, email, and textarea renderers in React and Vue

* ci: upgrade Node.js version from 20 to 22 across GitHub workflows

* test(angular): expand test coverage for default HTML5 fallback renderers
…ing (#31)

* feat(core): add Zod and Yup schema validation helpers and extend default renderers map

* feat: add useDynamicForm, schema adapters, extended renderers, wizard engine, and DevTools

* docs: update READMEs and example app with enterprise features

* fix(core): parse schemas synchronously and make adapter target explicit

Why:
zodValidator checked safeParseAsync before safeParse. Real Zod schemas expose
both, so the async branch always won and the validator returned a Promise.
validateField treats a Promise as "no errors", and useDynamicForm only ever
calls the synchronous validateFields - so a Zod-validated form silently
reported valid for invalid data.

yupValidator had a related bug: Yup throws a plain Error (not a
ValidationError) when a test is async, and that internal message
"Validation test of type ... returned a Promise" was surfaced to the user as
a form error.

The payload rule was also a guess - `value !== undefined ? value : data` -
which picked the field value for a form-level schema, and differed from
standardSchemaValidator.

What:
- Prefer sync parsing (safeParse / validateSync), falling back to the async
  path only when the schema genuinely requires it.
- Distinguish a Yup ValidationError from its async-test error, and retry
  asynchronously instead of leaking the internal message.
- Replace the payload heuristic with an explicit SchemaValidatorOptions
  `{ field, target: 'form' | 'field' }`, applied identically by all three
  adapters. The `zodValidator(schema, 'email')` shorthand still works.
- Add zod and yup as core devDependencies; the adapters were previously only
  exercised against hand-rolled fakes that hid both bugs.

How to test:
npm run test --workspace=@dynamic-field-kit/core

* feat: give useDynamicForm the same surface in every framework

Why:
The three form-state APIs had drifted. React was missing isSubmitting and
isSubmitted, which Vue and Angular both exposed, so a React consumer had no
way to disable a submit button while a submission was in flight. Angular's
handleSubmit executed immediately instead of returning a handler like React
and Vue, and never called preventDefault - so it could not be bound to a
native form submit at all.

What:
- React: add isSubmitting / isSubmitted, set around handleSubmit in a
  try/finally so a throwing onValid still clears the flag, and reset both.
- Angular: handleSubmit(onValid, onInvalid) now returns an async handler
  that calls preventDefault, matching React and Vue.

These APIs are unreleased (published angular is 1.4.0 without the store), so
no published consumer is affected.

How to test:
npm run test --workspace=@dynamic-field-kit/react
npm run test --workspace=@dynamic-field-kit/angular

* test: cover DevTools, extended renderers and group helpers

Why:
The new feature work landed largely untested and pushed every package below
its coverage floor, which is a hard CI gate: core 68/63/79/68, react
functions 77.7, vue 84/74/72/84, angular 83/71/85/83. On develop core sat at
88/85/100/88, so this was a regression introduced by the feature branch.

What:
Tests for the code that shipped without any - group array helpers
(move/swap/insert plus focusFirstInvalidField under jsdom), wizard index
clamping and the empty-steps case, the DevTools overlay across all four tabs
in all three frameworks, and the extended HTML5 renderers (date/time/
datetime-local/switch/file, option id/name fallbacks, range).

Coverage is now core 89.95/85.04/96.96/90.33, react 97.34/86.95/95.65/97.34,
vue 98.87/87.01/94.02/98.87, angular 91.30/75.56/92.40/91.22 - all above
their floors. Suite grew from 249 to 394 tests.

How to test:
npm run test --workspace=@dynamic-field-kit/<pkg> -- --coverage

* docs: document adapter targets and add the missing changeset

Why:
The three feature commits on this branch added public API to all four
packages without a changeset, so the work would have been merged and never
released. The README also promised "Integrated zodValidator" without saying
that adapters parse synchronously, which is the difference between a form
that validates and one that silently passes.

What:
- Add a minor changeset covering core, react, vue and angular.
- Document the schema adapter contract: form vs field target, the field-name
  shorthand, and when a schema forces validateFieldsAsync.
- Note that all three frameworks share one hook surface.

How to test:
npx changeset status

* fix(core): add "switch" to FieldTypeMap

Why:
Both the react and vue defaultRenderersMap register a `switch` renderer, but
FieldTypeMap had no `switch` entry - so `type: 'switch'` failed to typecheck
and the shipped renderer was unreachable from TypeScript. The example app hit
exactly this: `Type '"switch"' is not assignable to type 'FieldTypeKey'`.

What:
Add `switch: boolean` to FieldTypeMap, and a type test asserting every key the
default renderer maps register is a usable field type, so the two cannot drift
apart again.

How to test:
npm run test:types --workspace=@dynamic-field-kit/core

* fix: stop shipping angular sources and drop a non-existent example prop

Why:
Two problems that CI cannot currently see, because the example apps are not
workspaces and are not referenced by any workflow, and because the angular
package has no `files` field.

The example passed `onBlurField` to MultiFieldInput, which has no such prop -
it manages blur internally. The page therefore did not compile.

The angular tarball shipped src/ and test/ alongside dist/ - 61 files where
react ships 8.

What:
- example: remove the `onBlurField` prop so the page compiles.
- angular: add `files: ["dist"]`. Tarball drops 61 -> 33 files, dist/ only.

Note: useDynamicForm's handleBlur/touched cannot currently be wired into
MultiFieldInput at all. Worth a follow-up on whether the component should
expose a blur hook.

How to test:
cd example/react-app && npx next build
cd packages/angular && npm pack --dry-run

* ci: gate releases on the same checks as PRs, and cover what CI could not see

Why:
Publishing was effectively ungated. Release triggered on push to master, did
not depend on CI, and ran a thinner set of checks - no lint, no format, no
coverage floors, no verify scripts. The two workflows are independent, as the
2026-08-04 history shows: CI succeeded on master at 16:13:10 while Release
failed at 16:13:09 on the same commit. The reverse - publishing while CI is
red - was equally possible.

Three more blind spots:
- The example apps are not workspaces and were referenced by no workflow, so
  nothing compiled them. Both bugs fixed in the previous two commits (a
  missing FieldTypeMap entry and a non-existent prop) were sitting in an
  example that had never been built in CI.
- No check that a PR touching a package adds a changeset. The three feature
  commits on this branch had none.
- No dependency audit. postcss reached production deps through vue.

What:
- Extract every gate into a reusable quality-gates.yml (workflow_call). ci.yml
  and release.yml both call it, so the two can no longer drift and release
  blocks on `needs: [gates]`.
- Add an examples job building all three demo apps against the built dist.
- Add a changeset job on pull_request, and an npm audit step for production
  dependencies.
- Pin every job to .nvmrc via node-version-file. The workflows hardcoded Node
  22 while .nvmrc said 24.
- Add CODEOWNERS - master has require_code_owner_reviews enabled, which does
  nothing without this file - and a PR template.
- Widen lint-staged globs to cover .cjs/.mjs/.yaml so pre-commit stops letting
  through files that `prettier --check .` then fails on in CI.
- Configure commit.template from `prepare`; the template file existed but was
  never wired up.
- Override postcss to ^8.5.25. `npm audit fix` cannot resolve it here because
  of the known ng-packagr peer conflict. Production audit is now clean, so the
  new audit step passes.
- changesets baseBranch master -> develop, matching where PRs actually land.

How to test:
npm run lint && npm run format-check && npm run typecheck
npm audit --omit=dev --audit-level=high
npx changeset status --since=origin/develop
cd example/<app> && npm ci && npm run build

* fix(ci): drop the postcss override that desynchronised package-lock

Why:
The override broke `npm ci` on Linux: "Missing: yaml@2.9.0 from lock file".
A bare `postcss` key rewrites every postcss in the tree, including
@angular-devkit/build-angular's nested copy, whose subtree resolves
differently on Linux than on Windows. npm then had to re-resolve packages the
lockfile had no entries for, so every CI job failed at install.

The override was also unnecessary. vue's compiler-sfc asks for postcss
^8.5.8, and the advisories cover <=8.5.22 - so 8.5.25 was always inside the
range npm was allowed to pick. The vulnerability existed only because the
lockfile pinned 8.5.12. Refreshing that pin is the whole fix; no override is
needed to hold it there.

What:
Remove the overrides block. The lockfile keeps postcss 8.5.25, which is a
resolution npm reaches on its own, and `npm audit --omit=dev` reports zero
vulnerabilities.

How to test:
npm ci && npm audit --omit=dev --audit-level=high

* fix(ci): declare Node 22 in .nvmrc, the version CI actually runs

Why:
Pointing the workflows at .nvmrc was meant to remove drift, but .nvmrc said
24 while every workflow had hardcoded 22 - so the "fix" silently upgraded CI
by a major version, and install broke everywhere with:

  npm error Missing: yaml@2.9.0 from lock file

Node 22 ships npm 10.x; Node 24.18 ships npm 11.16. lint-staged declares
`yaml` as an optionalDependency, and npm 11.16 requires an entry for it in
the lockfile where npm 10 does not. develop's lockfile has never had that
entry, so this was latent, not caused by anything in this branch.

Regenerating the lockfile on Windows is not the fix: npm 11.16 prunes every
other platform's optional binaries while doing it, dropping 26 entries
including @lmdb/lmdb-linux-x64 and @napi-rs/nice-linux-x64-gnu, which is
exactly what Linux CI needs.

What:
Set .nvmrc to 22 so the declared version matches the one that is actually
tested and that the committed lockfile supports. The workflows keep reading
.nvmrc, so there is still a single source of truth.

Moving to Node 24 is a real upgrade and needs its own PR: the lockfile has to
be regenerated on Linux under npm 11.16 so it keeps the cross-platform
optional binaries and gains the yaml entry.

How to test:
npm ci

* fix(ci): make the examples job actually install what the demos need

Why:
Two failures in the new job.

example/angular-app gitignores its package-lock.json, so in a fresh checkout
there is no lockfile: `cache-dependency-path` could not resolve it ("Some
specified paths were not resolved") and `npm ci` had nothing to install from.

example/react-app failed with module-not-found on
@dynamic-field-kit/core from packages/react/dist/index.mjs. The `file:` deps
resolve to the real packages/ directories, so Node walks up from there to the
workspace root looking for core - and the job never ran a root install, so it
was not there. It passed locally only because a root node_modules already
existed.

What:
- Run `npm ci` at the workspace root before installing each example.
- Use `npm install` for the examples so the lockfile-less angular app works;
  react and vue still honour their committed lockfiles.
- Drop cache-dependency-path so the cache keys off the root lockfile, which
  always exists.

How to test:
rm -rf example/react-app/node_modules
npm ci && cd example/react-app && npm install && npm run build

* ci: skip the changeset check on changesets' own release PR

The release PR consumes changesets and bumps versions, so it changes packages
while correctly having no changeset left. Requiring the check without this
would deadlock every release.

* feat(ci): release by picking a bump from the Actions UI

Why:
Releases were being cut by hand - #25 edited versions directly and #27 then
had to delete consumed changeset files manually. Changesets was installed but
half-used, so the version bump was manual work followed by a commit.

What:
Release is now workflow_dispatch only, with inputs:

  bump      patch | minor | major
  packages  core,react,vue,angular  (empty = all)
  message   the CHANGELOG entry
  dry_run   version and print, publish nothing

scripts/create-changeset.js turns those inputs into a real changeset, so the
run goes: quality gates -> changeset version -> lockfile sync -> build ->
commit -> changeset publish. No version is edited by hand. Packages stay
independently versioned, and changesets already committed are consumed in the
same run with the largest bump per package winning.

The push-to-master trigger is gone. It opened a "version packages" PR through
changesets/action, which is a second, competing release path - the same
overlap that produced the manual cleanup in #27.

Run it on develop, not master: required status checks apply to direct pushes,
so the Actions bot cannot push the release commit to master. Versions reach
master through the usual develop -> master PR.

Verified locally against the real changeset on this branch: core 1.3.0 ->
1.4.0, react/vue/angular 1.4.0 -> 1.5.0, internal deps rewritten to ^1.4.0,
CHANGELOGs generated. The lockfile sync step was checked under npm 10 (what
Node 22 ships, per .nvmrc) and keeps every platform's optional binaries -
npm 11 prunes them, which is what broke install earlier on this branch.

How to test:
Actions > Release > Run workflow, on develop, with dry_run enabled.

* feat(core): let the wizard engine actually change step

Why:
The engine shipped canGoNext and canGoPrev - it could say whether moving was
allowed, but there was no function to move. Callers had to rebuild state with
createWizardState(steps, i + 1), which resets completedSteps to [] every
time. completedSteps was written in exactly one place, its initialiser, and
read nowhere: declared state that nothing maintained.

The README advertises a "Multi-Step Form Wizard Engine", so this was the gap
between the claim and what the code could do.

What:
- goNext / goPrev / goToStep, all returning new state and leaving the input
  untouched. goNext records the step it leaves in completedSteps, so the set
  is now maintained rather than decorative.
- markStepCompleted and isStepCompleted for driving a step indicator.
- goNext at the last step and goPrev at the first return the *same* state
  object, so callers can compare identity to detect a no-op.

goNext does not validate. Validation stays explicit through validateStep, so
a wizard can allow moving on from an incomplete step if it wants to.

How to test:
npm run test --workspace=@dynamic-field-kit/core

* feat(angular): show the error count in DevTools, matching react and vue

The react and vue overlays put a red count badge on the collapsed button and
render the errors tab as "errors (N)". Angular had neither, so the one
framework where you cannot see errors at a glance was the one whose overlay
looked identical otherwise.

* feat: report field blur from MultiFieldInput in all three frameworks

Why:
useDynamicForm ships handleBlur, touched and validateOnBlur, but there was no
way to connect them to MultiFieldInput - the component that actually renders
the form. The example app tried, with onBlurField, and did not compile.

Worse, blur plumbing existed only in react. Vue and Angular had none at any
level: their FieldInput never passed onBlur down, and Vue's DynamicInput did
not declare it - so the vue default renderers accepted an onBlur prop that
could never arrive.

What:
- react: MultiFieldInput takes an optional onBlurField, called alongside the
  touched tracking it already did internally.
- vue: onBlur and touched threaded through DynamicInput -> FieldInput ->
  MultiFieldInput, plus internal touched tracking to match react.
- angular: FieldInput emits onBlurField from a `focusout` listener - it
  bubbles, so any renderer works without declaring a blur output of its own.
  MultiFieldInput re-emits it and exposes isTouched().
- example: restore onBlurField, now that the prop exists.

How to test:
npm run test --workspace=@dynamic-field-kit/{react,vue,angular}
cd example/react-app && npm run build

* docs: document the v1.4 APIs and add a runnable wizard demo

Why:
The v1.4 features were a bullet list with no examples, and several shipped
APIs appeared nowhere in the README at all - the wizard navigation, the group
array helpers, defaultRenderersMap/getDefaultRenderer, and the blur wiring.
The built-in renderer list was also stale: it named 7 types when the packages
ship 14.

What:
- Sections with real examples for form state, the wizard, DevTools and the
  group array helpers, each with a table of the exported surface.
- Correct the renderer list, and document what each default emits.
- A "Runnable Examples" section mapping each demo page to what it shows.
- New /wizard page in the react example: step indicator driven by
  completedSteps, per-step validateStep, goNext/goPrev. Linked from the other
  two pages.

Every identifier named in the README was checked against the built dist, and
the example app compiles in CI.

How to test:
cd example/react-app && npm run dev  # then visit /wizard

* ci: publish the example apps to GitHub Pages

Why:
There was no link to hand someone who wants to see the library work. The
example apps only ran locally, and running them means cloning the repo and
building four packages first.

What:
- deploy-pages.yml builds all three demos and publishes them under one site:
  /react, /vue, /angular, plus a landing page. It runs on develop and on
  demand, and only when packages/ or example/ changed.
- Each app takes its base path from an env var, so local dev is untouched:
  Next reads PAGES_BASE_PATH, Vite reads it as `base`, Angular gets
  --base-href on the command line.
- Next now emits a static export with trailingSlash, so /wizard resolves to
  wizard/index.html on a plain file host. A .nojekyll file stops Pages from
  stripping _next.
- README gains per-package npm badges and demo links. The repo's About link
  now points at the demos, so npm needed a home in the README.

Also deletes example/react-app/next.config.ts. Next resolves next.config.js
first, so the .ts file - and the `reactCompiler: true` in it - had never
taken effect. Verified by adding output:'export' to the .js and watching out/
appear.

And ignores example/ and smoke/ in .eslintignore. `npm run lint` only covers
packages/*/src, so CI never linted them, but the pre-commit hook did - against
a config written for library source, which rejects a `require` in a Next
config and cannot resolve an example's own dependencies.

Live at https://vannt-dev.github.io/dynamic-field-kit/ once this is on develop.

How to test:
Actions > Deploy demos to Pages > Run workflow
…#32)

* feat(example): shared demo nav with a link back to all demos

Each React page duplicated its own <nav>, and none of the three apps offered a
way back to the Pages landing page - once you were in /react you were stuck
there. DemoNav centralises the links and adds an absolute 'Tất cả demo' link;
absolute because the landing page only exists on the deployed site, one level
above each app's base path.

* feat(example): show each demo's source beside it

Why:
A demo you cannot read the code of only proves the library runs - it does not
show how. Linking to GitHub means leaving the page, and a hand-written snippet
drifts from the code that is actually running.

What:
Each route splits into a Server Component page and a client `demo.tsx`. The
page reads its own demo's source with fs and passes it to DemoShell, which
renders it in a toggleable side panel with a copy button.

Because every route is statically exported, the read happens at build time and
the text is baked into the HTML - no runtime fetch, and the snippet is by
construction the code that rendered the form next to it.

Tried `?raw` imports first: Turbopack compiles them inside a private folder but
fails on a real route, so fs in a Server Component is the approach that works.

How to test:
cd example/react-app && npm run build   # then open out/wizard/index.html

* feat(example): bring the vue and angular demos up to the react one

Why:
Neither demo referenced a single v1.4 feature. Grepping both for
useDynamicForm, createDynamicFormStore, createWizardState,
DynamicFormDevTools, radio, range, switch or onBlurField returned zero hits,
while the react app had three pages covering all of it. Anyone opening the
Vue or Angular demo saw a library two releases out of date.

Neither had a way back to the Pages landing page either.

What:
- Vue: EnterpriseDemo.vue (useDynamicForm, extended renderers, blur wiring,
  DevTools) and WizardDemo.vue, as two new tabs.
- Angular: the same pair as standalone components, using
  createDynamicFormStore and its signals, plus the (onBlurField) output.
- Both gain the '← Tất cả demo' link, absolute for the same reason as react.
- Both show the demo's source beside it, matching the react panel. Vue uses
  Vite's native `?raw`; Angular's builder has no equivalent, so
  scripts/embed-demo-sources.js generates a module from the real files and is
  wired to prestart/prebuild, which keeps the panel from ever going stale.

Label is '‹/› Xem code', not '</>': Angular's template parser reads the `</`
inside an interpolated string as a closing tag and fails to compile.

How to test:
cd example/<app> && npm install && npm run build

* docs: document the v1.4 APIs in every package README

Why:
None of the four package READMEs mentioned the v1.4 surface. Grepping each for
useDynamicForm, createDynamicFormStore, createWizardState, goNext,
DynamicFormDevTools, zodValidator, onBlurField or defaultRenderersMap returned:
core 0, react 2 (bare names in a list), vue 0, angular 0. Someone installing
from npm and reading the package page saw a library two releases out of date.

All four also linked a "Demo app" repo that is not where the demos live.

What:
- core: schema adapters (form vs field target, sync vs async), the wizard state
  machine with a table of every export, and the group array helpers. The
  "What this package provides" list now covers them too.
- react / vue / angular: a form-state section with the full member table in each
  framework's idiom - props, refs, signals - plus default renderers and
  DevTools. Each notes how blur is wired, since that differs: a prop in react
  and vue, a focusout-driven @output in angular.
- Point every "Live demo" link at the deployed Pages site.

Every identifier written into these files was checked against the built dist.

How to test:
npm run format-check

* chore: stop tracking docs/superpowers

.gitignore already excludes .superpowers/ as "scratch/planning docs, not
subject to version control", but docs/superpowers/ held the same kind of
content - eight internal plan and spec files - and was tracked, so it showed up
on the repo page next to the real documentation.

Untracked rather than deleted: the files stay on disk, and remain in history if
they are ever needed.

* docs: add a patch changeset for the README updates

README.md is in the published tarball - npm includes it regardless of the
`files` field - so the v1.4 documentation only reaches the package pages
through a release. Patch rather than --empty for that reason.
The Pages deploy failed with:

  Can't resolve './demo-sources' in example/angular-app/src/app

The step called `npx ng build` directly to pass --base-href, which skips npm's
`prebuild` hook. That hook runs scripts/embed-demo-sources.js, which generates
src/app/demo-sources.ts - a file that is gitignored and therefore absent from a
fresh checkout.

The CI examples job did not catch it because it runs plain `npm run build`,
where the hook does fire.

`npm run build -- --base-href …` forwards the args to `ng build` and keeps the
hook. Verified locally by deleting demo-sources.ts first: the generator ran and
the emitted index.html carries <base href="/dynamic-field-kit/angular/">.
…ing (#33)

* fix(deps): move dev-only deps out of root dependencies and patch nanoid

The `npm audit --omit=dev --audit-level=high` gate in quality-gates.yml
started failing on GHSA-2v37-7h3g-55p8 (nanoid <3.3.18) without any code
change on develop. nanoid reaches the tree only through
vue -> @vue/compiler-sfc -> postcss, which is build-time only.

Two things were wrong:

- The root is private and publishes nothing, so react, react-dom and vue
  belong in devDependencies. They exist purely to resolve the packages'
  peers while building and testing, and keeping them in `dependencies`
  is what put vue's build-time tree in front of an `--omit=dev` audit.
- nanoid was left vulnerable in the toolchain we actually run. Pin it
  forward with an override so it is patched, not merely excluded from
  the audit's scope.

All four packages declare `dependencies: {}`, and no workflow installs
with --omit=dev or --production, so nothing else changes resolution.

Lockfile regenerated with npm@10, not the locally installed npm 11 which
prunes other platforms' optional binaries: entry count is unchanged at
1606 with no additions or removals, and all 100 linux entries intact.

* fix(scripts): report bundle sizes from package entry points

show-sizes.js guessed formats from file extensions, assuming index.js is
CJS and index.mjs is ESM. That holds for core and react but inverts for
vue, which is `"type": "module"` and so ships ESM as dist/index.js and
CommonJS as dist/index.cjs. The report therefore printed vue's ESM
bundle under a CJS label and never printed its real CJS bundle at all.
Angular was missing entirely, having never been added to the list.

Resolve each entry from the package's own manifest instead
(exports['.'].import/require, falling back to module/main), which also
handles ng-packagr's ESM-only output where main and module point at the
same fesm2022 bundle -- reporting that file twice would invent a CJS
build that is not shipped.

Before                    After
  vue (CJS): 36.31 KB       vue (ESM): 36.31 KB
  (no vue ESM)              vue (CJS): 39.37 KB
  (no angular)              angular (ESM): 54.47 KB

The collector is now exported and covered by scripts/show-sizes.test.js,
which builds throwaway workspaces on a real filesystem rather than
mocking fs, plus two regression tests against the repo's own packages.
Root vitest.config.mjs scopes that suite to scripts/** so it cannot
touch the per-package coverage floors, and quality-gates.yml runs it
after the build step, where a dist exists for those two tests to read.

* chore: ignore vite's transient config bundle

Running the angular suite locally leaves an orphaned
packages/angular/vitest.config.ts.timestamp-<n>-<hash>.mjs behind when
vitest does not get to clean it up, which on Windows is often. The file
was ignored by neither .gitignore nor .prettierignore, so it showed up
as untracked and, because `prettier --check .` walks untracked files,
turned the next format-check red for reasons unrelated to any change.

CI never hit this because format-check runs before any test, so the
artifact only ever bit local runs and risked being committed by accident.

* fix(vue): dev-install vue instead of borrowing it from the root

packages/vue peer-depends on vue but never declared it in
devDependencies, so its build, typecheck and test runs resolved vue only
because the workspace root happened to hoist one. packages/react and
packages/angular already dev-install their own peers; vue was the
outlier, and it became load-bearing on the root's declaration right as
that root entry moved to devDependencies.

Extend verify-framework-deps.js, which already gates this class of
manifest drift in CI, with the rule that every peer must also appear in
devDependencies. The script now exports its check so it can be tested,
and scripts/verify-framework-deps.test.js covers both rules against
throwaway workspaces plus the repo's real manifests.

Running the new rule against this repo reported exactly one violation,
which is the one fixed here:

  vue: peer-depends on vue but does not declare it in devDependencies,
  so it builds and tests against whatever the workspace root happens to
  hoist

Lockfile regenerated with npm@10: 1606 entries unchanged with nothing
added or removed, all 100 linux entries intact. The diff is `"dev": true`
markers appearing on vue's transitive tree, which is now dev-only
everywhere it is reachable.

No changeset: devDependencies are not installed by consumers, so no
published package changes behaviour or its dependency contract.

* fix(scripts): make the cross-framework import check actually run

isSourceFile() tested paths against /\.(ts|tsx|js|jsx)$/. In a regex
literal `\` matches a literal backslash, not a dot, so the pattern
required a backslash followed by any character and then `ts`/`tsx`/etc
at end of string. No path in the repo can satisfy that, so the predicate
returned false for every file, the walk callback returned early every
time, and `violations` was always empty.

The gate has therefore been reporting "OK: No cross-framework imports
found" unconditionally since it was written -- it would have passed a
package importing another framework wholesale. Verified by appending
`import { DynamicInput } from '@dynamic-field-kit/react'` to
packages/vue/src/index.ts: before this change the script printed OK and
exited 0; after it, it exits 1 and names the file and line.

Fix the escape, and restructure the script the way show-sizes.js and
verify-framework-deps.js already are: export the check, guard the CLI
behind require.main so importing it does not call process.exit, and
derive the scanned roots from a package list rather than three
hand-written paths. scripts/check-cross-framework-imports.test.js covers
import and require forms, nested directories, line numbers, that core
stays allowed, and that non-source files are skipped.

Running the now-working check against this repo finds no violations, so
nothing in packages/*/src had drifted while the gate was blind.

* chore(lint): extend the lint gate to scripts/

`npm run lint` only covered packages/*/src, so nothing under scripts/
was ever linted in CI. The pre-commit hook does lint it, because
lint-staged matches *.js anywhere, which meant the two gates disagreed:
a script could sit in the repo with errors that would block the next
person who happened to touch it.

Add scripts to the lint script and clear the errors that surfaced:

- build-changed.js: import order
- check-cross-framework-imports.js: prefer-const, curly (already
  rewritten in the previous commit)
- diagnose-hoist.js: execSync's return value was assigned to an unused
  binding. It runs with stdio: 'inherit', so output goes straight to our
  stdout and there is nothing to capture; drop the assignment.

All three are behaviour-preserving.

* chore(deps): drop root deps that the packages now declare themselves

With packages/vue dev-installing vue, the root's react, react-dom and
vue entries are redundant: every workspace that needs them declares them
(packages/react, packages/vue, smoke). Leaving them meant the root's
manifest implied ownership of dependencies it does not use, and a
package could silently go back to borrowing from the root without
verify-framework-deps.js noticing, since resolution would still succeed.

@types/react moves to packages/react rather than being dropped. It is
genuinely needed -- packages/react is a .tsx package and its `tsc -p
tsconfig.json --noEmit` gate resolves React's types through it -- but it
was only ever declared at the root, so that gate depended on hoisting
too. Verified by running typecheck after the move.

Root devDependencies are now exactly the workspace-wide tooling:
changesets, commitlint, eslint and its plugins, husky, lint-staged,
prettier, tsup, typescript, vitest.

Lockfile regenerated with npm@10: 1606 entries unchanged with nothing
added or removed, all 100 linux entries intact.

* fix(example): give the react and vue demos real page titles

Both demos shipped their scaffold defaults to the live site. The React
demo -- the one the package READMEs link to most -- rendered a browser
tab and link preview reading "Create Next App", described as "Generated
by create next app". The Vue demo's title was "vue-app". Only the
Angular demo had a real one.

Match Angular's existing "Dynamic Field Kit - <Framework> Example" and
give each a description naming what the demo actually shows. Verified in
the built output: all three now emit their own title and no stale
create-next-app description survives.

* docs: close the README gaps and link the demo sub-routes

Cross-checking every symbol the four READMEs tell you to import against
the built .d.ts found no stale API -- all 58 resolve -- but 25 public
exports were never mentioned anywhere. All four packages now describe
their full surface.

The substantive gap was async validation. `validateField` and
`validateFields` are synchronous and cannot await, so a `validate` hook
returning a Promise is discarded and the field reads as valid. Both
`useDynamicForm` and `createDynamicFormStore` use the sync path
throughout, including in handleSubmit, so async rules never surface
unless the app calls `validateFieldsAsync` itself. Core's adapter
section already said async schemas "must be validated through
validateFieldsAsync", but no README showed how, and the async functions
appeared in no adapter export list at all. Core now has a "Sync vs async
validation" section, and each adapter repeats the caveat and links to it.

Also documented:

- core: `Properties`, `ValidatorFn`, `FieldValidatorResult` and
  `FieldValidatorFunction`, which appear in signatures throughout the
  README but had no definition; and `FormStep` next to `WizardState`.
- react/vue/angular: `validateFieldAsync`, `validateFieldsAsync`,
  `resolveOptions` and `validators`, grouped under a heading that marks
  them as core re-exports rather than adapter exports.
- angular: `layoutRegistry`, `LayoutRegistry`, `ColumnLayout`,
  `RowLayout`, `GridLayout` and `BaseInputComponent`. It was the only
  adapter documenting no layout registry, and its registry is the one
  real difference between the three -- it holds standalone components,
  not render functions -- so it needed its own example rather than a
  pointer at the React one.

Demo links: the package READMEs each pointed only at their framework's
landing page. React now links its enterprise-features and wizard routes,
core links all three framework demos plus the wizard it documents, and
vue/angular note that their wizard is a tab rather than a separate URL.
All links verified to serve real pages.

Angular's `## What it exports` becomes `## Exports`, matching react/vue.

* perf: let consumer bundlers drop unused parts of the adapters

Components were declared as bare top-level calls -- defineComponent({...})
in vue, React.memo(...) in react. A bundler cannot prove such a call is
side-effect free, so it evaluates it even when the result is unused. That
pinned every default renderer and every component into an app's bundle
regardless of how little of the package it imported.

Annotating those calls /* @__PURE__ */ makes them droppable. Measured
with esbuild, minified, framework external:

  react, one core helper       11,149 -> 3,258 B  (-71%)
  react, DynamicInput only     11,150 -> 6,830 B  (-39%)
  react, everything            17,601 -> unchanged
  vue,   one core helper       17,379 -> 13,934 B (-20%)
  vue,   DynamicInput only     17,379 -> 13,934 B (-20%)
  vue,   everything            20,530 -> unchanged

"Everything" staying flat is the expected result: nothing is droppable
when it is all reachable. react gains more than vue because its default
renderers are already plain arrow functions, so only the two memo() calls
were holding them; vue's renderers are each a defineComponent() call.

The published dist grows marginally (react +0.03 KB, vue +0.29 KB) since
the annotations are comments carried into the bundle. That is the trade:
a slightly bigger file on npm for a materially smaller bundle in the app.

Also declare "sideEffects": false on core. It has no top-level execution
-- the only module-scope work is `new FieldRegistry()` assigned to an
export -- so the claim is accurate. It buys nothing under esbuild, which
already tree-shakes core to 907 B for a single import, but it is correct
metadata for bundlers that trust the flag over their own analysis. The
adapters cannot claim it: their entry side-effect-imports the default
layouts to register them.

No behaviour change -- these are comments and a packaging flag. Full
suite green: core/react/vue/angular tests, smoke, typecheck, type tests,
lint, and the three verify scripts.

* perf(vue): stop MultiFieldInput pinning the package into every bundle

MultiFieldInput renders itself recursively for repeatable groups and
reached itself through a module-scope assignment:

  let multiFieldInputSelfRef: Component;
  const MultiFieldInput = defineComponent({ ... });
  multiFieldInputSelfRef = MultiFieldInput;

That last line is a bare top-level assignment -- a side effect no
bundler is allowed to drop. It anchored MultiFieldInput -> FieldInput ->
DynamicInput -> getDefaultRenderer -> every default renderer into any
app that imported the package at all, which is why the previous commit's
/* @__PURE__ */ annotations only recovered 3.4 KB of a 17 KB floor: the
components were pure but still reachable.

Replace it with a hoisted function declaration returning MultiFieldInput.
A function body is not evaluated until called, so nothing is retained
until a group actually renders. The explicit `Component` return type does
the job the forward-declared `let` was there for -- it stops TypeScript
having to infer MultiFieldInput from inside its own initializer.

Measured with esbuild, minified, vue external:

  one core helper        13,934 -> 2,479 B  (-82%)
  DynamicInput only      13,934 -> 9,196 B  (-34%)
  MultiFieldInput only   13,944 -> 13,958 B (+14 B)
  everything             20,530 -> 20,543 B (+13 B)

Apps using the whole surface pay 13-14 bytes for the wrapper. Against the
original baseline, before the PURE annotations, a vue app importing only
DynamicInput goes 17,379 -> 9,196 B, a 47% reduction.

react and angular were checked for the same pattern and have none.

No behaviour or type change: dist/index.d.ts is byte-identical to before,
and the vue suite passes unchanged at 115 tests, including the
repeatable-group recursion that exercises this path. Full repo suite
green.

* chore(angular): state the target ng-packagr actually emits

tsconfig.json claimed `target: ES2019`, but ng-packagr ignores it: the
published fesm2022 bundle uses ES2022 class fields (`value;` declarations,
define semantics). So the config described an emit that never shipped,
and tsconfig.spec.json had to override target to ES2022 with a comment
explaining it was matching reality -- an override that only existed to
work around the wrong value here.

Set the real target on the base config and let the spec config inherit
it. Verified: the published fesm2022 bundle is byte-identical before and
after, which is what confirms the old value was inert.

Also drop `emitDecoratorMetadata`. Angular carries its own DI metadata on
the generated `ɵfac`, and grepping the built bundle for `__metadata`,
`Reflect.metadata` and `design:paramtypes` returns zero hits -- the flag
produced nothing while implying a reflect-metadata dependency that does
not exist.

* test(angular): cover option resolution, responsive layout and group bounds

Angular's branch coverage sat at 75.8% against a 75 floor -- 0.8 points
of headroom, so the next uncovered `if` anyone added would have turned
CI red for whoever touched the file next rather than whoever wrote it.
The other three packages all had double-digit headroom, so this was
angular's gap, not a floor set too high.

The uncovered branches were real behaviour, not unreachable code:

- FieldInput.resolvedOptions (33% covered) decides between an explicit
  `options` input, a static array on the field description, and a
  dynamic options callback -- which it deliberately withholds, because
  resolving it needs form data only MultiFieldInput has. Passing the raw
  function down would make a renderer try to iterate a function. None of
  those three paths was tested. Asserted through a renderer that prints
  what it received, so the tests pin what reaches the renderer rather
  than what the getter returns.
- MultiFieldInput's responsive layout (66%): mobile/desktop resolution,
  the custom breakpoint (it queries `max-width: breakpoint - 1`, so a
  viewport exactly at the breakpoint is desktop), the resize handler
  only scheduling a re-render when the mobile state actually flips, and
  the no-matchMedia fallback that keeps the component usable in jsdom.
  Also grid column/gap defaults and the min/max item guards.
- DynamicFormDevTools.errorCount (50%): the `this.errors || {}` guard,
  without which a null errors input takes the host app's render down.

Coverage: branches 75.8% -> 84.8% (54 -> 34 missed of 223), lines 91.4%
-> 95.4%, functions 92.7% -> 93.9%. Headroom over the floor goes from
+0.8 to +9.8, in line with core 85.9 / react 86.8 / vue 87.3. Suite goes
from 74 to 91 tests.

FieldInput and DynamicFormDevTools reach 100% branch coverage and
MultiFieldInput 92.9%, which is the evidence these tests hit the
branches they were written for rather than padding the number.

* perf: stop publishing sourcemaps

tsup emitted sourcemaps with sourcesContent, so each .map carried a full
copy of the TypeScript source. That is what made them usable at all --
`files` publishes only `dist`, so a map pointing at ../src/*.ts would
resolve to nothing -- but it also made them roughly half of every
tarball, installed by every consumer.

  core   157.0 -> 83.0 KB unpacked (-47%), 33.3 -> 17.5 KB packed
  react  207.6 -> 90.1 KB unpacked (-57%), 46.8 -> 19.2 KB packed
  vue    250.4 -> 109.4 KB unpacked (-56%), 47.6 -> 20.5 KB packed

Nothing that reaches an application bundle changes; sourcemaps never do.
What changes is install size, against the ability to step into the
library's TypeScript while debugging a consuming app.

Calling it out plainly: this is a trade, not a free win. The maps were
not broken or dead weight -- I checked, and sourcesContent made them
fully self-contained. Each tsup.config.ts now carries that reasoning
beside a `sourcemap: false` that is one word from restoring them.

Verified no dangling //# sourceMappingURL comments survive in any dist,
so nothing 404s looking for a map that is no longer shipped. angular is
unaffected; ng-packagr's published output does not carry them.

* ci: move actions off the deprecated Node 20 runtime

Every run was annotating 10 warnings: actions/checkout@v4,
setup-node@v4, upload-artifact@v4, download-artifact@v4 and
codecov-action@v4 all declare `runs.using: node20`, which GitHub
deprecated and now force-runs on Node 24 anyway.

Bumped to the current majors, each verified against how this repo
actually uses it rather than assumed:

  checkout             v4 -> v7
  setup-node           v4 -> v7
  upload-artifact      v4 -> v7
  download-artifact    v4 -> v8
  codecov-action       v4 -> v7
  upload-pages-artifact v3 -> v5
  deploy-pages         v4 -> v5

Two breaking changes in that range needed checking:

- setup-node v6 limits *automatic* package-manager caching to npm. All
  seven call sites pass `cache: 'npm'` explicitly, so nothing relies on
  the detection that changed.
- download-artifact v5 changed path behaviour for downloads **by ID**.
  Both call sites download by `name`, so the layout the verify and
  examples jobs depend on (`packages/<pkg>/dist`) is unaffected.

Every input in use was confirmed to still exist in the new majors
(`node-version-file`, `cache`, `name`, `path`, `retention-days`,
`if-no-files-found`, `files`, `flags`, `fail_ci_if_error`), as was
deploy-pages' `page_url` output that the Pages environment reads.

upload-pages-artifact and deploy-pages were not in the warning list
because the Pages workflow only runs on push to develop, not on a PR.
Including them anyway: deploy-pages@v4 is node20, and
upload-pages-artifact@v3 wraps upload-artifact@v4, so both would have
warned on the next deploy.

All four workflows re-parsed as valid YAML; the diff is version tags
only.
…g a problem (#34)

* chore(angular): upgrade ng-packagr to 19 to match the Angular it builds

ng-packagr sat at 17.3.0 while the package builds against Angular 19,
and its peer ranges said so plainly:

  peer @angular/compiler-cli: ^17.0.0     installed: 19.2.20
  peer typescript:            >=5.2 <5.5  installed: 5.6.3

Both violated, which is why installing in packages/angular needed
--legacy-peer-deps. It worked -- the emitted bundle carries
`ɵɵngDeclareComponent ... version: "19.2.20"` -- but on a combination
ng-packagr never claimed to support.

19.2.2 matches exactly: compiler-cli ^19.x and typescript >=5.5 <5.9.
The install now completes with plain `npm install`, no
--legacy-peer-deps, and drops 36 transitive packages (ng-packagr 17's
cacache/tar/minipass/esbuild-wasm chain) for 4.

The published artifact is smaller, and the part consumers load is
unchanged:

  fesm2022 bundle   byte-identical
  index.d.ts        identical
  public-api.d.ts   identical
  tarball           317.1 -> 165.9 KB unpacked, 75.8 -> 33.7 KB packed
  files             33 -> 19

The difference is the per-file `esm2022/` output, which ng-packagr
stopped emitting in 18 because the fesm2022 bundle is what the Angular
linker consumes. Its `esm2022` and `esm` export conditions go with it;
`types` and `default` remain, which is what every Angular 19+ library
ships.

Angular suite passes unchanged at 91 tests, typecheck and the three
verify scripts pass, and the angular demo app still builds against the
package through its `file:` dependency.

Lockfile regenerated with npm@10: all 100 linux entries intact.

* chore(deps): take in-range updates across the workspace

`npm update` only, so every package.json is untouched and nothing moved
outside a range this repo already declared. Direct dependencies that
advanced:

  @analogjs/vite-plugin-angular      1.16.1 -> 1.22.5
  @analogjs/vitest-angular           1.16.1 -> 1.22.5
  @angular/* (7 packages)           19.2.20 -> 19.2.25
  @commitlint/cli                     21.2.1 -> 21.2.2
  @commitlint/config-conventional     21.2.0 -> 21.2.2
  @testing-library/user-event         14.6.1 -> 14.6.4
  @types/node                       20.19.38 -> 20.19.43
  @types/react                       19.2.14 -> 19.2.18
  @vue/test-utils                      2.4.6 -> 2.4.11
  lint-staged                         17.2.0 -> 17.3.0
  react, react-dom                    19.2.4 -> 19.2.8
  vue                                 3.5.32 -> 3.5.41

Two entries in the lockfile diff look like major jumps and are not.
`rimraf` at the hoist root goes 3.0.2 -> 6.1.3, but core and angular
already declared ^6.1.3 -- the 3.0.2 that used to sit there was a
transitive from flat-cache, which now keeps its own nested copy. `vite`
appears at the root as 6.4.3, pulled in by the newer @analogjs; the vite
that packages/react declares stays at 5.4.21 under its own node_modules,
matching its ^5.4.0, and vitest keeps its nested 5.4.21 too.

Linux optional binaries went 100 -> 115 entries, an increase from the new
transitive platform packages -- not the pruning that npm 11 causes, which
is why this was regenerated with npm@10 as usual.

Verified after a clean `npm@10 ci`: build, typecheck, core type tests,
all four package suites, the published-package smoke suite, test:scripts,
lint, format-check, audit, the three verify scripts, and all three
example app builds. Bundle sizes are unchanged bar a few bytes from the
newer tsup/esbuild.

* chore(core): put core on the same vitest as the rest of the workspace

core was pinned to vitest 0.34 while react, vue and angular ran 1.6, and
that single split was the cause of two workarounds the repo had been
carrying:

- 0.34 has no `--typecheck` CLI flag, so `test:types` had to use the
  0.34-only `vitest typecheck --run` subcommand.
- The hoisted `@vitest/coverage-v8@1.6.1` is incompatible with 0.34
  (reports 0% or errors), so core alone used
  `@vitest/coverage-istanbul@0.34.6` as its own nested devDependency.

Moving core to ^1.6.0 with `@vitest/coverage-v8@^1.6.0` removes both.
core now resolves the hoisted vitest 1.6.1 with no nested copy at all,
and the istanbul provider is gone.

Two config shapes had to change with it, and both were verified live
rather than assumed -- a silently-ignored gate is worse than no gate:

- `test:types` becomes `vitest --run --typecheck.only`, which reproduces
  the old scope exactly (1 file, 9 tests, types.test-d.ts only).
  Appending `const broken: number = 'nope'` to types.test-d.ts makes it
  exit 1 with `TypeCheckError: Type 'string' is not assignable to type
  'number'`.
- The coverage floor moves from 0.34's flat `coverage.lines/statements/
  functions/branches` keys to 1.x's `coverage.thresholds` wrapper. This
  is the risky half: leaving the flat keys would have made vitest 1.x
  ignore them and the floor would have vanished without a word. Raising
  statements to 99 makes the run exit 1 with `ERROR: Coverage for
  statements (95.12%) does not meet global threshold (99%)`, so the new
  shape is being read.

Switching provider moves the reported numbers, since v8 and istanbul
count differently: core now reports 95.12 statements / 88.69 branches /
100 functions / 95.12 lines against the unchanged 85/75/85/85 floor,
where istanbul reported 91.1 lines / 85.9 branches.

Verified after a clean npm@10 ci: build, typecheck, type tests, all four
suites both plain and with --coverage, smoke, test:scripts, lint,
format-check and the three verify scripts.

* docs(changeset): record the angular packaging change and core's non-release

ng-packagr 19 changes what the angular tarball contains, so that needs a
patch. core's move to vitest 1.6 touched only devDependencies and test
config, neither of which ships, so it gets an empty changeset rather than
a version bump that would publish an identical tarball.

* chore(lint): migrate to eslint 9 flat config

eslint 8.57.1 is end of life -- `npm ci` printed "This version is no
longer supported" on every install. Moving to 9 means flat config, since
eslint 9 reads neither .eslintrc.cjs nor .eslintignore.

Both files are replaced by eslint.config.mjs, translated rule for rule.
.eslintignore's contents become the global `ignores`, comment and all:
the example apps and smoke workspace stay excluded because the lint
script never covered them, while the pre-commit hook matches *.js
anywhere and would otherwise lint them against a config written for
library source.

Dependency changes fall out of it:

- @typescript-eslint/{eslint-plugin,parser} 5 -> the `typescript-eslint`
  meta package at 8.67.0, which is what supports flat config. The parser
  is still declared explicitly: eslint-plugin-import resolves it by name
  through eslint-module-utils, and without the declaration 71 of the
  first run's 77 errors were that require failing.
- eslint-config-airbnb-base and eslint-plugin-node are removed. Neither
  appeared in the old config's `extends` or `plugins` -- they had been
  installed but unused.
- eslint-plugin-prettier is removed rather than upgraded. v5 requires
  prettier >=3, which would have dragged the prettier 2 -> 3 reformat
  into this change. It is redundant here anyway: `npm run format-check`
  is already its own CI gate and lint-staged runs `prettier --write`
  before eslint, so linting formatting was a slower second copy of a
  check that already exists. eslint-config-prettier stays, at 10.1.8, to
  keep turning off rules that fight the formatter.
- Stayed on eslint 9 rather than 10 deliberately: eslint-plugin-import
  2.32 declares peers only up to ^9, and this branch opens by removing a
  peer violation, not adding one.

eslint 9 and typescript-eslint 8 then surfaced six real things:

- typescript-eslint 8 flags unused catch bindings by default, which v5
  did not. scripts/build-changed.js had three; they become optional
  catch bindings.
- eslint 9 reports unused disable directives by default. Three were
  stale: a no-empty-interface disable in core/src/types.ts guarding an
  interface that is not empty and a rule v8 has since renamed; an
  import/no-unresolved disable in vue's DynamicInput (narrowed to the
  import/order it still needs); and a no-explicit-any disable in vue's
  defaultRenderers for a rule this config sets to off.

A clean lint run is also what a flat config with a wrong `files` glob
produces, so the rules were checked against a probe file rather than
assumed. On a .ts file, curly, eqeqeq, semi, max-len, prefer-const and
@typescript-eslint/no-unused-vars all fire; on a second probe,
import/order and import/no-unresolved fire. example/, smoke/ and *.d.ts
are still ignored.

Verified after a clean npm@10 ci: build, lint, format-check, typecheck,
core type tests, all four suites, smoke, test:scripts, audit, the three
verify scripts, and all three example app builds. The eslint@8
end-of-life warning is gone from npm ci.

* docs(changeset): widen the no-release note to core, react and vue

The eslint 9 migration touched core and vue sources too, but only by
removing stale eslint-disable comments. Built dist for all three is
byte-identical to develop, checked by building both revisions and
diffing, so the empty changeset still describes the truth.
Four pieces of package config had drifted out of sync with what the repo
actually does. None of them ship - `files` publishes `dist` alone, and the
built `dist` for all four packages is byte-identical to develop.

* Drop the hand-release scripts from all four packages.

  `release:patch|minor|major` (and angular's `publish:dist`) ran
  `npm version && npm publish` directly. That contradicts `release.yml`,
  which says in its header comment "Nothing is versioned by hand" and
  exists precisely so a release goes through `changeset version`, gets a
  CHANGELOG entry, a release commit and a per-package tag. Running one of
  these scripts would have published a version with none of that, and
  left `changeset publish` unable to tag the commit it never made.

* Make the per-package `lint` scripts true.

  vue and angular declared `"lint": "echo \"skip lint for Vue\""`, which
  reads as "this package is not linted". It is: the root `lint` script
  passes `packages/vue/src` and `packages/angular/src` to eslint on every
  CI run. All four packages now run `eslint src`, matching what core
  already did, and react gains the script it was missing. All four pass.

* Drop `@vitest/coverage-v8` from packages/angular.

  Angular's `vitest.config.ts` sets `provider: 'istanbul'` and CI runs it
  with `--coverage`, so the v8 provider was installed and never loaded.
  `@vitest/coverage-istanbul` stays.

* Raise angular's branch coverage floor 70 -> 75.

  The other three packages have sat at 75 since the floor was introduced;
  angular was the odd one out. It measures 84.75% (189/223), so this is
  slack that was already there, and it keeps ~10 points of headroom.
  Verified the floor still bites: at 90 the run exits 1 with
  "ERROR: Coverage for branches (84.75%) does not meet global threshold".

* Point core's `@types/node` at the Node it is built on.

  It declared `^20.0.0` while `.nvmrc` pins 22, so the types described an
  older runtime than the one CI and every contributor runs. Now `^22.0.0`,
  resolving to 22.20.1 nested under packages/core.

Lockfile refreshed with npm@10, not the npm 11 on this machine: 106 linux
platform entries before and after, no optional binaries pruned.

Verified: lint, format-check, build, typecheck, core type tests, all four
suites with --coverage (117/116/115/91), the published-package smoke suite,
test:scripts, audit, and the three verify scripts.
* chore(lint): lint the test files too, and fix the 13 errors that were hiding

`npm run lint` listed four `src` directories and `scripts`, so no test
file has ever been linted in CI. lint-staged does lint them - it matches
`*.{ts,tsx,js,jsx}` anywhere - which means the errors were real and
merely invisible until a commit happened to stage the file holding one.
Reformatting the repo is exactly the kind of change that stages all of
them at once, so this had to be dealt with first.

Thirteen errors had accumulated:

  5  import/order                 core, react, angular specs
  6  no-unused-vars               react layout renderers, vue mock renderers
  1  no-unused-vars ('vi')        react FieldRegistryProvider
  1  max-len (135 > 120)          vue FieldGroupInput template string

The import/order ones are eslint --fix output. The unused ones take the
`_` prefix the config already allows and the repo already uses
(`ngOnChanges(_changes)`, `computeValue: (_data, rootData)`). The long
line is a Vue inline template split across two concatenated strings; the
template it produces is character-for-character the same.

The scope fix is `eslint packages scripts` rather than a longer list of
directories. The flat config already ignores dist, node_modules, coverage,
`*.d.ts`, `example/` and `smoke/`, so pointing it at `packages` picks up
src, test, and each package's vitest/tsup config, and a directory added
later is covered without anyone remembering to extend a list. Per-package
scripts become `eslint .` for the same reason.

That pulled in one more file than before: `packages/vue/vitest.config.ts`
imports `vitest/config`, which eslint-plugin-import's node resolver cannot
follow because it does not read `exports` maps. `^vitest` joins `^@angular`,
`^vue` and `^@dynamic-field-kit` in the `import/no-unresolved` ignore list,
which exists for exactly this.

Verified the widened gate bites: an unused const appended to
packages/core/test/registry.test.ts makes `npm run lint` exit 1, which it
would have passed before this change.

Verified: lint, format-check, typecheck, core type tests, all four suites
with --coverage (117/116/115/91).

* chore(deps): move to prettier 3 and take its trailing-comma default

prettier 2.8.8 is two majors behind and has had no release since April
2023. Nothing was broken by it - this is deliberate debt payment, kept in
its own commit because it touches 105 files and none of the changes mean
anything.

The diff splits in two, and the split is worth stating:

* 19 files change because prettier 3 formats them differently - markdown,
  and a few expressions where it now parenthesises `??` inside a ternary.

* 86 more change only because prettier 3 flipped the `trailingComma`
  default from "es5" to "all". This takes the new default rather than
  pinning the old one, and writes `"trailingComma": "all"` into
  .prettierrc.json explicitly, so it reads as a decision instead of an
  inherited default. The payoff is quieter diffs from here: adding an
  argument or a property stops showing up as a change to the line above
  it. Reverting is one word plus a `prettier --write .`.

Shipped output is unchanged wherever it can be. core, react and vue build
a byte-identical `dist` - tsup/esbuild reprints from the AST, so source
formatting never reaches the bundle. angular's `fesm2022` differs by 36
lines because ng-packagr carries formatting through: the two cosmetic
changes above, plus a `</pre\n>` continuation pulled onto one line. The
`<pre>` content is identical either way - it ends at `</` in both - so the
DevTools output does not move, and the angular suite agrees at 91 tests.

Verified: format-check, lint, build, typecheck, core type tests, all four
suites with --coverage (117/116/115/91), the published-package smoke
suite, test:scripts, and the three verify scripts.

* chore: keep the prettier 3 reformat out of git blame

A 105-file formatting commit sits on top of every line it touched, so
`git blame` on any of them points here instead of at whoever last
changed the code. GitHub reads .git-blame-ignore-revs automatically;
locally it takes

  git config blame.ignoreRevsFile .git-blame-ignore-revs

* chore(deps): eslint 10, which meant replacing eslint-plugin-import

eslint 9.39.5 -> 10.9.1. The upgrade is not a version bump: eslint 10
cannot be installed alongside eslint-plugin-import at all.

  eslint-plugin-import@2.32.0 (latest)
  peer eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9

There is no ^10 in that range and no newer release. The options were to
force the install, to drop the import rules, or to move to the maintained
fork. Forcing it would put --legacy-peer-deps back into this repo, which
#34 had just finished removing, and the import rules are worth keeping -
`import-x/order` is the reason imports in this repo are sorted at all. So:
eslint-plugin-import -> eslint-plugin-import-x@4.17.1, which declares
`eslint: ^8.57.0 || ^9.0.0 || ^10.0.0`. Plain `npm install` resolves, no
flags.

Also bumped for the same peer reason: @eslint/js 9 -> 10, globals 16 -> 17,
typescript-eslint and its parser 8.67 -> 8.68.

Three things in eslint.config.mjs had to change beyond renaming
`import/*` to `import-x/*`:

* The resolver. import-x 4 dropped the string-keyed `import/resolver`
  map for a resolver object, so `{ node: { extensions: [...] } }` became
  `'import-x/resolver-next': [importX.createNodeResolver({ extensions })]`.
  createNodeResolver is the plugin's own, so no eslint-import-resolver-*
  package is needed, and it reads `exports` maps - which is why
  `^vitest` could have stayed out of the no-unresolved ignore list, had
  it not already been there for the old resolver.

* `importX.flatConfigs.typescript` is dropped. It wants
  eslint-import-resolver-typescript installed, and without it every rule
  reports "typescript with invalid interface loaded as resolver". What
  that preset actually did here was turn off four rules TypeScript
  already covers, so those four are turned off directly instead, with
  the reason written next to them: import-x resolves
  `@dynamic-field-kit/core` to its built `dist/index.mjs`, where a
  type-only export has no runtime binding, so `import-x/named` reported
  all 52 `import type { FieldDescription }` sites as missing.

* `/* eslint-disable import/order */` at the top of
  packages/vue/src/components/DynamicInput.ts is deleted rather than
  renamed. eslint 10 reports an unknown rule name in a disable comment,
  which is how this got noticed - and the file's imports satisfy
  import-x/order on their own, so the suppression had been dead for a
  while. Same species as the stale disables #34 removed.

Verified the rules still bite, not merely pass: an import of a
nonexistent module added to a test file makes `npm run lint` exit 1 with
import-x/no-unresolved.

dist is byte-identical for all four packages - the deleted comment is a
comment, and tsup strips it either way.

Verified: lint, format-check, build, typecheck, core type tests, all four
suites with --coverage (117/116/115/91), the smoke suite, test:scripts,
audit, and the three verify scripts. Lockfile refreshed with npm@10; linux
platform entries 106 -> 118, none lost.

* chore(deps): vitest 1.6 -> 4.1, which drags vite 8 in with it

vitest 1.6.1 shipped in May 2024 and is three majors behind. Moving is
not a one-line bump, because vitest 4 declares `vite: ^6 || ^7 || ^8`,
and packages/react pinned vite ^5.4.0. So the upgrade is a stack:

  vitest                        1.6.1  -> 4.1.11   (all 4 packages + root + smoke)
  @vitest/coverage-v8           1.6.1  -> 4.1.11   (core, react, vue)
  @vitest/coverage-istanbul     1.6.1  -> 4.1.11   (angular)
  vite                          5.4.21 -> 8.2.2    (react)
  @vitejs/plugin-react          4.7.0  -> 6.1.1    (react, smoke - peers vite ^8)
  @analogjs/vite-plugin-angular 1.22.5 -> 2.7.1    (angular - peers vite ^6||^7||^8)
  @analogjs/vitest-angular      1.22.5 -> 2.7.1
  jsdom                         24.1.3 -> 29.1.1   (react, vue, angular, smoke)
  @vue/test-utils               2.4.11 -> 2.5.0

Angular stays on 19: analog 2.7.1 lists `@angular-devkit/build-angular`
^17 through ^22, so the plugin upgrade needed by vitest 4 does not force
the framework upgrade with it. Plain `npm install`, no flags.

No config changes were needed. `coverage.thresholds`, `globals`,
`environment: 'jsdom'`, `setupFiles`, `globalSetup` and core's
`vitest --run --typecheck.only` all mean the same thing in 4 as in 1.6.

That last one is worth stating, because a silently-ignored config is the
failure mode this repo has been bitten by before. Verified live, per
provider, that the floors still fail the run rather than passing quietly:

  core   statements 85 -> 99 : exit 1, "Coverage for statements (91.01%)..."
  react  statements 85 -> 99 : exit 1, "Coverage for statements (92.64%)..."
  vue    branches   75 -> 90 : exit 1, "Coverage for branches (82.7%)..."
  angular branches  75 -> 90 : exit 1, "Coverage for branches (84.75%)..."

Measured coverage under the new providers, all clearing their floors of
statements/lines/functions 85 and branches 75:

  core     91.01 / 86.74 / 97.33 / 91.32
  react    92.64 / 85.24 / 96.55 / 93.51
  vue      95.91 / 82.70 / 96.36 / 95.75
  angular  95.62 / 84.75 / 93.90 / 95.48

vue's branch coverage is the thinnest at 7.7 points of headroom.

The lockfile loses 27 `@esbuild/linux-*` entries, which looks exactly like
the npm 11 pruning this repo was burned by - it is not. It was refreshed
with npm@10 as always, and the entries disappear because vite 8 replaced
esbuild with rolldown, so three nested vite trees stopped depending on
esbuild at all. Every tree that still uses it keeps its full set: 45
`@esbuild/linux-*` entries remain across @angular/build, ng-packagr, tsup
and the root vite. 24 entries arrive for rolldown, unrs-resolver and
lightningcss.

Verified: lint, format-check, build, typecheck, core type tests (9, no
type errors), all four suites (117/116/115/91), the published-package
smoke suite, test:scripts (19), audit, and the three verify scripts.

* feat(ci): prove the React peer range, and fix the Angular one

Both adapters declared support they had never tested.

* react declares `react: ^18.0.0 || ^19.0.0`, but the workspace installs
  React 19 and the suite has only ever run against it. React 18 was half
  of a promise with nothing behind it.

* angular declares `@angular/core: ">=13 <22"`. That lower bound is
  simply wrong. The published fesm2022 is partial-compilation output and
  each declaration in it records the Angular version needed to link it;
  the highest here is `minVersion: "14.0.0"`. An Angular 13 app would
  install this package happily and then fail in the linker. Now `>=14`.
  The upper bound stays at `<22` - a v19-compiled partial bundle links
  forward, which is the direction Angular guarantees.

The react half gets a script rather than a CI matrix leg, because a
matrix leg does not work. Installing React 18 into this workspace leaves
npm hoisting one React to the root and nesting another, and the render
then dies with "A React Element from an older version of React was
rendered" - an artifact of the install layout, not a real
incompatibility. Three different pinning strategies (workspace devDeps,
root `overrides`, root devDeps) each produced a different 18/19 split.

So scripts/verify-react-peer-range.js does what a consumer does instead:
`npm pack` core and react, install the tarballs into a throwaway project
outside the workspace next to one exact React major, and server-render a
form with both a registered renderer and a built-in one.
`renderToString` is the deliberate choice - no jsdom, and it is the one
render path whose API is identical across 18 and 19.

Result: React 18 works. The range was honest, it just had no evidence.

  react ^18:  react 18.3.1 rendered: 89 chars
  react ^19:  react 19.2.8 rendered: 89 chars

Verified the check fails rather than passing vacuously: adding '17' to
MAJORS exits 1, because npm's own peer resolution refuses the install.

It runs in the `verify` job, as its own step after the offline
verification scripts, since it is the only one that installs from the
network and so the only one that can go red for reasons unrelated to the
code.

Verified: lint, format-check, build, all four suites (117/116/115/91),
the smoke suite, and the three existing verify scripts.

* chore(deps): put the whole repo on one TypeScript, and take in-range updates

The repo was compiling itself with two different TypeScript versions:
the root declared `~5.5.0`, packages/angular `~5.6.0`. So the `.d.ts`
that consumers get for core, react and vue was emitted by 5.5, while
angular's specs and ng-packagr build ran on 5.6 - and nothing made that
deliberate, it was just two bumps that never met.

Both are now `~5.8.3`, which resolves to a single hoisted copy;
packages/angular no longer carries a nested one. 5.8 is the ceiling, not
a preference: ng-packagr 19 peers `typescript >=5.5 <5.9`, so 5.9 and
the 7.x line are out until the Angular toolchain moves. typescript-eslint
8.68 accepts it (`>=4.8.4 <6.1.0`).

Also ran `npm update`, so in-range dependencies advanced without any
package.json range being touched: zod 4.4.3 -> 4.5.4, @vue/test-utils
and vue, @testing-library/react and user-event, lint-staged, and the
transitive tree behind them.

Compiled output is unchanged. `dist/index.*` and every `.d.ts` across
all four packages is byte-identical, and so is angular's fesm2022
bundle. The single file that moves is angular's fesm2022 sourcemap,
where 5.8 encodes the mappings slightly differently.

Lockfile refreshed with npm@10. linux platform entries 106 -> 135, with
54 `@esbuild/linux-*` still present across the trees that use esbuild.

Verified: lint, format-check, build, typecheck, core type tests (9, no
type errors), all four suites with --coverage (117/116/115/91), the
smoke suite, test:scripts (19), audit, and all four verify scripts
including the React peer-range check.

* fix(angular): stop publishing a 70.7 KB sourcemap nobody knew was there

When core, react and vue stopped shipping sourcemaps, angular was written
off in that changeset as unaffected: "ng-packagr's published output does
not carry them". It does. `npm pack --dry-run` in packages/angular lists

  72.4kB dist/fesm2022/dynamic-field-kit-angular.mjs.map

and the map has `sourcesContent` with 12 embedded TypeScript files - the
same shape as the tsup maps that were dropped for being half of every
tarball. It has been published with every angular release since.

  Unpacked  165.9 KB -> 97.1 KB  (-41%)
  Tarball    33.7 KB -> 18.2 KB  (-46%)
  Files           19 -> 18

Same trade as the other three, and worth naming as a trade: the map
worked, and this costs the ability to step into the library's TypeScript
from a consuming app.

ng-packagr has no `sourcemap: false`, so scripts/strip-sourcemaps.js runs
as a `postbuild` step and deletes both the map and the trailing
`//# sourceMappingURL=` comment. Deleting only the map - or excluding it
at publish time with a `files` negation, which was the first attempt -
leaves every consumer's devtools fetching a URL that 404s.

The script is deliberately narrow. It only strips a sourceMappingURL
comment anchored to the end of a file, so a URL that appears inside the
code survives; that case has a test. Six tests in
scripts/strip-sourcemaps.test.js, which take test:scripts from 19 to 25.

The bundle consumers load is otherwise identical - it differs by that one
comment line, and the angular suite passes unchanged at 91 tests.

Verified: lint, format-check, build, typecheck, all four suites
(117/116/115/91), the smoke suite, test:scripts (25), and all four verify
scripts.

* chore(example): refresh the demo lockfiles, which described a repo from three releases ago

Both example apps link the workspace packages through `file:` dependencies,
so their lockfiles carry a snapshot of what those packages declared at the
time. That snapshot had drifted badly:

  ../../packages/core  version   1.2.0  -> 1.3.0
                       vitest    ^0.34  -> ^4.1.11
                       coverage  istanbul -> v8
                       missing   yup, zod

Nothing was broken by it - CI installs the examples with `npm install`, not
`npm ci`, so a stale lockfile is re-resolved rather than enforced, which is
also why nobody noticed. But it made the files actively misleading to read.

Regenerated by installing each example. All three demo apps build:
next 16 (react), vite (vue), and the Angular CLI production build against
packages/angular/dist.
The file listed 91778d3, the prettier 3
reformat commit. #44 was squash-merged, so that commit is not reachable
from develop - the whole branch landed as e4f8dbd.

git tolerates an unresolvable rev in this file rather than failing, so
nothing was broken. But the entry named a commit that does not exist,
and the mechanism it was meant to drive no longer has a target: the
reformat is now fused with the vitest 4 and eslint 10 migrations in a
single commit, and ignoring that commit in blame would hide the
authorship of real changes.

So the list is emptied and the header explains why, rather than deleting
the file - the convention is still worth having for the next
formatting-only commit, which should be merged without squashing.
The form state hooks, schema adapters, wizard engine, DevTools and extended
renderers were labelled 'v1.4+' in the root README, the three demo apps and the
changeset that documents them. They have never been released: adapters are on
npm at 1.4.0 without them, and core at 1.3.0. The release that ships them puts
every package at 1.5.0, so the label pointed at a version where the features do
not exist.

Nothing published changes here - the package READMEs, which are what npm shows,
carry no such label.
core is a whole minor behind the three adapters - 1.3.0 against 1.4.0 - because
it had fewer releases early on, not because the packages diverged. They are
released together every time: release.yml writes a changeset for every package
unless its `packages` input names a subset.

A `linked` group makes every package released in the same pass take the same
version, so the pending changesets now produce 1.5.0 across the board (core
skips 1.4.0) instead of core 1.4.0 against adapters 1.5.0. Verified by running
`changeset version` on a throwaway clone with and without the group.

Deliberately `linked` and not `fixed`: an angular-only patch must not bump and
republish three packages whose tarballs did not change.

The peer ranges stay at ^1.3.0, and `onlyUpdatePeerDependentsWhenOutOfRange`
keeps them there. Every core symbol the three adapters import - all 26, checked
against the exports of the published core@1.3.0 tarball - has existed since
1.3.0, so ^1.3.0 is accurate; none of them touches the wizard engine, the schema
validators or the new group helpers.

config.json cannot carry comments, so the reasoning lives in .changeset/README.md.
…ver mentioned

Three things were wrong on the package's npm page:

- The 'pin versions explicitly' example still said core@^1.0.12 and
  angular@^1.2.3, three release lines behind what npm serves.
- Nine public exports appeared nowhere in the README: FieldInputProps,
  DynamicFormOptions, FieldTypeKey, LayoutConfig, ColumnLayoutConfig,
  RowLayoutConfig, GridLayoutConfig, BaseLayoutConfig and
  ResponsiveLayoutConfig. Checked by parsing dist/public-api.d.ts and grepping
  each name: 30 exports, 21 documented. core (50), react (28) and vue (28) are
  each at 100%, so angular was the only one with a gap.
- 'Legacy setup (Angular 14 and earlier with NgModule)' names versions the
  package cannot run on - the peer floor is >=14, because the published
  fesm2022 records minVersion 14.0.0. It is the NgModule path that is legacy,
  not Angular 14.
…hree releases ago

All three example READMEs still said the app renders two fields, 'name' as text
and 'age' as number, and listed a Main Files section naming only the entry
component. Each app has had three or four demos for a while: the basic schema,
the feature tour (dynamic options, validators, async validation,
appearCondition / disabledCondition), the enterprise demo (useDynamicForm /
createDynamicFormStore, HTML5 renderers, DevTools) and the wizard - most of them
with a source panel beside the running form.

Each README now maps tab or route -> source file -> what it demonstrates, and
carries the two things that actually trip someone up locally:

- The demos depend on the packages through file: paths that resolve to dist, so
  the workspace has to be built before npm install here.
- The angular demo must be built through npm, not the ng binary: prebuild /
  prestart generate the gitignored src/app/demo-sources.ts, and skipping the
  hook fails the build on a missing module. This is the same trap 4bee6eb fixed
  in CI.
All four package.json files say "license": "MIT" and the root README says
MIT (c) vannt-dev, but there was no LICENSE file anywhere in the repo - so the
published tarballs carried the claim without the terms. react@1.4.0 on npm
contains exactly README.md, dist/ and package.json.

npm always includes a LICENSE at the package root regardless of `files`, so a
copy in each package plus one at the repo root is enough. Verified with
`npm pack --dry-run`: all four tarballs now list LICENSE (core/react/vue 7
files, angular 19).
packages/angular/package.json has said "sideEffects": false since the package
was created, but src/layout/defaultLayouts.ts ends with three module-scope
layoutRegistry.register() calls. That is the same side effect react and vue
list their layout modules for; angular was simply missed.

The flag is now ["**/fesm2022/*.mjs"]. It is a glob rather than a path
because ng-packagr copies the field verbatim into dist/package.json, where
everything resolves one directory lower, and a bundler reads whichever
package.json is nearest the module it is looking at - dist/package.json for the
fesm bundle, the root one for the package as a whole. The glob is correct from
both.

Nothing observable changes today, which is why this is a claim and not a bug:
installing angular@1.4.0 from npm and bundling with esbuild while importing only
DynamicInput already kept register("column"), register("row") and
register("grid") - the fesm2022 output is one module and the app is using it.
Rebuilt and re-bundled with the new flag: same three calls, and the angular
suite passes at 91 tests.
Every package ships two sets of declarations but declared a single `types`
target for both, so `arethetypeswrong` on the built tarballs reported core and
react "masquerading as CJS" and vue "masquerading as ESM" - an ESM import
resolved to the CommonJS declaration file, and the reverse. Each `exports` map
now declares `types` per condition. react also had no top-level `types` at all;
it resolved only because TypeScript falls back to the file next to `main`.

Angular did not merely mismatch, it failed outright. Its entry is an `.mjs`
bundle, so TypeScript reads its declarations in ESM mode, where the
extensionless `export * from './public-api'` is error TS2834 - and that is in
`dist/index.d.ts`, the first file a consumer reaches, so the package could not
be imported at all on node16 resolution.

Fixing the source imports would not have helped: ng-packagr writes that file
itself and stamps it "Generated bundle index. Do not edit." So the extensions
are added to the build output instead, by a new `postbuild` step. It resolves
each specifier against the emitted tree, so a directory import becomes
`./layout/index.js` rather than a broken `./layout.js`, and leaves anything it
cannot resolve alone rather than inventing a path. Angular also gains an
`exports` map and `"type": "module"`, which is what it has always been.

`show-sizes.js` read `exports['.'].import` as a path, which the nested
condition objects turned into "entry.replace is not a function" - a CI step.
It now unwraps a condition to its target.
…y lacked

All four packages ship `README` and `LICENSE` but nothing else the npm page
uses: no `homepage`, so the page links to no documentation; no `bugs`, so it
links to no issue tracker; and no `repository.directory`, so the repository
link points at the monorepo root rather than the package. `CHANGELOG.md` now
ships too - it is generated on release and was being written for no one.

angular was shipping its README and LICENSE twice, once at the root and once
more where ng-packagr had copied them into `dist`.

`publishConfig.provenance` signs each tarball and links it back to the workflow
run that built it, which needs `id-token: write` on the release workflow. npm
fails the publish outright if it cannot mint that token, so the permission and
the manifests have to land together.
`changeset publish` writes a git tag per package, so 1.5.0 landed as four tags
that all point at the same commit and say the same thing:
`@dynamic-field-kit/{core,react,vue,angular}@1.5.0`. With all four packages on
a single version line by design, that is noise on the tag list and on the
releases page.

Its tagging is now off, and the workflow writes one annotated `v<version>` tag
instead, named after core - the anchor of the version line. The exact version
of each package goes in the tag body, which is what keeps the tag honest in the
one case where the names could drift apart: `linked`, unlike `fixed`, lets an
angular-only patch bump angular without touching the other three.
The check fires on every pull request, so the `develop -> master` sync that
follows a release fails it: that PR carries work whose changesets were consumed
by the release itself, and there is nothing left to add. Adding one would mean
releasing the same work a second time.

It now runs only on PRs into develop, which is the branch where unreleased work
accumulates and so the only branch where a missing changeset is a real defect.
The existing exemption for the release PR changesets opens stays as it was.
Each package carried three keywords, two of which were its own name. npm ranks
search partly on keywords, so these could realistically only be found by
someone who already knew what to type. The repository has had the right
vocabulary as GitHub topics since the start - dynamic-forms, form-builder,
form-engine, form-validation, schema-driven, headless, and the framework names
- and npm never saw any of it.

Each package now carries that vocabulary plus the terms its own users would
search, including the schema libraries it genuinely adapts: zod, yup, valibot
and Standard Schema. Deliberately not json-schema, which is a different thing
and not supported.

`homepage` pointed at the package README on GitHub, which is the same text npm
already renders on the package page from the shipped README - so the link led
back to where the reader already was. It now points at the live demo, where the
forms run. `repository` still carries the source.
…g it

Why: `FieldRendererProps` was a type nobody enforced. Each adapter hand-wrote
the object it handed the registered renderer, so the three lists drifted:
React dropped `placeholder`, `min`, `max`, `step`, `accept` and `multiple`;
Vue dropped `required`, `id`, `dirty` and the aria flags; Angular dropped
`touched`, `dirty` and `id`. Setting `placeholder` on a `FieldDescription`
therefore did nothing at all on React and Vue - no error, no warning, the
value simply vanished - and a renderer written for one framework could not be
ported to another, which is the opposite of the point of a shared schema.

Field ids had a second, unrelated problem: adapters built them as
`dfk-field-${name}`, from the field name alone. Two forms holding a field of
the same name emitted the same DOM id twice, which is invalid HTML and leaves
every `label[for]` pointing at two inputs.

What:
- `FIELD_RENDERER_PROP_KEYS` names the contract; `buildFieldRendererProps`
  produces the whole bag once, resolving disabled/readOnly/options, validating
  (skipping disabled fields, whose errors a user cannot act on) and setting the
  aria flags.
- `makeFieldId(field, prefix)` returns `field.id` when set, else
  `${prefix}-${name}`, so callers can namespace ids per form instance.
- `FieldDescription` gains `id`, to pin one field's id outright.
- `ariaDescribedBy` is deliberately left unset: no adapter renders the
  description or error node, so pointing it at an id that may not exist would
  be worse than omitting it.

How to test: `npm run test --workspace=@dynamic-field-kit/core`
Why: three of the reported bugs were one design flaw. Touched state had two
independent trackers that never met - `useDynamicForm`'s, and a private one
inside `MultiFieldInput` that only blur could set, which was the one renderers
actually saw. So `setFieldTouched` from an `onInvalid` handler changed nothing
visible, submitting a form nobody had focused showed no errors at all (the
button looked broken), and `reset()` could not clear touched left behind by an
earlier round, so a form that stays mounted across submits kept showing the
previous errors. Separately, ids came from the field name alone, so two forms
holding the same field name emitted duplicate DOM ids.

What:
- `MultiFieldInput` accepts `touched` as a controlled prop, making
  `useDynamicForm` the single source of truth exactly as `properties`/`onChange`
  already were for data, plus `onTouchedChange` and a `form` shorthand that
  wires data, change, blur and touched in one prop.
- Omitting `touched` keeps the old internal tracker, so nothing breaks; a
  forwardRef handle exposes `resetTouched`/`setFieldTouched`/`getTouched` for
  that mode, so clearing it no longer needs a `key` remount.
- `handleSubmit` calls the new `touchAll()` before validating; `resetTouched()`
  and `setTouched` round out the hook.
- Ids are namespaced per instance via `useId` (SSR-safe, and its delimiters are
  stripped so the result works as a CSS selector). `idPrefix` pins them -
  `idPrefix="dfk-field"` reproduces the old ids.
- `FieldInput` builds its props through core's `buildFieldRendererProps`, and
  `DynamicInput` spreads them rather than re-listing each one, which is how
  `placeholder`, `min`, `max`, `step`, `accept` and `multiple` went missing.

How to test: `npm run test --workspace=@dynamic-field-kit/react`
Why: the Vue adapter carried the same architecture as React, so it carried the
same three bugs - a private touched tracker the form store could not drive or
reset, and ids built from the field name alone that collide when two forms hold
the same field. On top of that its renderer prop list had drifted the other
way: `required`, `id`, `dirty` and the aria flags never reached a renderer, and
neither did `placeholder`.

What:
- `MultiFieldInput` gains a controlled `touched` prop, `onTouchedChange`, a
  `form` shorthand (which unwraps the composable's refs), and exposes
  `resetTouched`/`setFieldTouched`/`getTouched` for the uncontrolled mode.
- `useDynamicForm` gains `touchAll()`/`resetTouched()`, and `handleSubmit`
  touches everything before validating.
- Ids are namespaced per instance from the component uid; `idPrefix` pins them.
- `properties` defaults to undefined rather than `{}` so the `form` shorthand
  can tell "not passed" from "passed empty".
- `FieldInput` builds props through core's `buildFieldRendererProps`, and
  `DynamicInput` declares every contract key - an undeclared key is a
  fallthrough attribute, not a prop, which is why they were unreachable.
- `className` is delivered as `class`, and only as `class`. Forwarding
  `className` too lets it fall through to a renderer's root element, where Vue
  assigns `el.className`; an undefined value becomes `''` and wipes the class
  the renderer set on itself, which broke the bundled radio group.

How to test: `npm run test --workspace=@dynamic-field-kit/vue`
Why: this adapter was the worst off. `touched` was not in `DynamicInput`'s
KNOWN_PROPS and not an input on `BaseInputComponent`, so no Angular renderer
could ever receive it - `MultiFieldInput.isTouched()` existed but nothing in
the template called it, making it dead code. An Angular renderer therefore had
no way to do "only show the error once the user leaves the field" without
reimplementing blur tracking from scratch. `dirty` and `id` were missing the
same way, and ids were not emitted at all. The store's `touched` signal was
likewise disconnected from what rendered.

What:
- `BaseInputComponent`/`FieldInputProps` and `DynamicInput`'s KNOWN_PROPS now
  carry the whole contract: `touched`, `dirty`, `id`, the aria flags and
  `min`/`max`/`step`/`accept`/`multiple`. The HTML5 fallbacks set `id` too.
- `FieldInput` resolves everything through core's `buildFieldRendererProps`,
  once per change-detection pass rather than once per binding, and takes `data`
  so cross-field validation and dynamic options resolve against the real form.
  Explicitly bound inputs still override, so mounting it directly still works.
- `MultiFieldInput` gains a controlled `touched` input, a `touchedChange`
  output, public `resetTouched()`/`setFieldTouched()`, per-field `dirty`, and
  per-instance id namespacing with an `idPrefix` override.
- `createDynamicFormStore` gains `touchAll()`/`resetTouched()`, and
  `handleSubmit` touches everything before validating.
- Drops `getResolvedOptions`/`getDisabled`/`getReadOnly`/`getError` from
  `MultiFieldInput`: the template no longer calls them, and a second copy of
  that logic beside the shared one is how the adapters drifted apart to begin
  with. The core equivalents are already re-exported from this package.

The FieldInput spec's "withholds a dynamic options callback" case is updated
rather than kept: it encoded the old limitation that FieldInput had no form
data. It does now, so the callback resolves, matching React and Vue.

How to test: `npm run test --workspace=@dynamic-field-kit/angular`
…prop

Why: core now owns the renderer prop list, but each adapter still has to carry
those keys across its own component boundary - React through DynamicInput's
Props interface, Vue through its declared props (an undeclared key becomes a
fallthrough attribute, not a prop) and its forwarding call, Angular through
KNOWN_PROPS plus a matching @input. Nothing stopped one of them from quietly
dropping a key again, which is precisely how six props went missing on React,
four on Vue and four on Angular without a single test failing.

What: `scripts/check-renderer-prop-parity.js` parses the contract out of core
and probes each adapter's source for every key, with one documented exception -
Vue's `class` in place of `className`. Wired into the quality-gates verify job
and available as `npm run lint:renderer-parity`.

How to test: `node scripts/check-renderer-prop-parity.js`, then delete a key
from any adapter's list and watch it exit 1 naming the adapter and the prop.
… ownership

Why: the READMEs described a `FieldRendererProps` that no adapter actually
delivered in full - the snippets predated `touched`, `dirty`, `error`, the aria
flags and the numeric/file props - and said nothing about which adapter forwards
what. They also still implied touched could be driven through `setFieldTouched`
alone, the pattern that quietly did nothing.

What:
- Core and root READMEs carry the real interface, say that parity is enforced by
  a build check rather than convention, and name the one deliberate deviation
  (Vue's `class`) and the one prop no adapter fills in (`ariaDescribedBy`).
- Each adapter README documents the `form` shorthand as the recommended wiring,
  the controlled `touched` prop, `touchAll`/`resetTouched`, the ref/handle for
  the uncontrolled mode, and a "Field ids" section covering `idPrefix` and
  `FieldDescription.id`.
- Changeset spells out the one behaviour change: generated ids are no longer
  the literal `dfk-field-*`.

How to test: `npm run format-check`
Angular 19 -> 21 across the package, the demo app and the root, plus
TypeScript 5.9.3, zone.js 0.16, @testing-library/jest-dom 7 and
@types/node 26. Three things had to change for the bump to actually work.

Angular 21 ships every entry point through `exports` alone, with no `main`
or `types`. `tsconfig.base.json` still asks for node10 resolution, which
cannot read an `exports` map, so the Angular package stopped resolving its
own dependencies: `tsc -p packages/angular` reported 16 x TS2307, and every
spec died in `test/setup.ts` on `@angular/core/testing`. Both the
Angular package tsconfig and the demo app's now use `moduleResolution:
"Bundler"`, which is what the Angular CLI itself uses - the demo app failed
the same way, on `@angular/common/http` and `@angular/core/primitives/di`.

ng-packagr 21 emits the type declarations as `dist/types/<name>.d.ts`
instead of `dist/index.d.ts`, so the manifest's `types` pointed at a file
the build no longer produces - the package would have published with types
that resolve to nothing. `types` and `exports` now name the file that
actually ships. `scripts/verify-package-entrypoints.js` fails the build on
any manifest path that the build does not emit, so this cannot come back
quietly; it runs in the quality-gates verify job next to the other checks.

The lockfile was regenerated with npm 10 (npm 11 prunes other platforms'
optional binaries on Windows). The previous one had ten entries under
packages/angular/node_modules with no `resolved`/`integrity` at all and no
entry for `injection-js`, which ng-packagr 21 requires - `npm ci` refused
it outright and the Angular build died on the missing module. Every
platform binary the Linux runners need is still present.

The two `vitest.config.ts` files that Vite warned about are renamed to
`.mts`; the contents are unchanged and nothing referenced them by path.
vannt-dev and others added 28 commits September 4, 2026 22:10
Matches the React adapter: the baseline was a const snapshot taken in
setup() and never reassigned. It now tracks the first non-undefined
properties, or comes from initialProperties / form.baselineValues.

The first-seen tracker is a ref rather than a plain binding because the
baseline is a computed, which only re-evaluates on reactive reads.
Completes the three-adapter fix. This adapter turned out to be the least
affected: init() only records a baseline when properties is set, so values
arriving after mount were already handled correctly - the late-load test
added here passed before the fix. What was broken is that the initialised
guard pinned the baseline permanently, so a store reset could never move
it. The new initialProperties input is that escape hatch; pass
store.baselineValues() into it.

The private field is renamed to firstSeenProperties to free the name for
the input, matching React and Vue.
It was hard-coded undefined, so focusFirstInvalidField - which selects
[aria-invalid=true] - had nothing to find for consumers whose renderers
followed the official recipe. makeErrorId defines the convention once,
in one place, for all three adapters.
1.6.0 moved placeholder, min, max, step, accept and multiple to the top
level of FieldDescription. Values left behind in props are overwritten by
the resolved contract and vanish with no throw and no warning - the one
upgrade hazard a consumer cannot diagnose from the outside.

Fires once per field+key and only outside production.
The default renderers forwarded aria-describedby but never rendered the
error they were handed, so the reference had nothing to point at. Emitted
as a fragment sibling - no wrapper element, so layout is unchanged - and
only where no custom renderer is registered, so consumers rendering their
own message do not get a second copy.
Mirrors the React adapter's markup exactly, returned as a fragment array
so no wrapper element appears.

This adapter declares error as [String, Array], unlike React where core
only ever supplies an array, so the message is normalised before use -
indexing a raw string would have rendered its first character.
Completes the three-adapter error node, so aria-describedby resolves on
every adapter instead of dangling.

Two adapter-specific notes. The template uses *ngIf, not the @if block:
the peer range starts at Angular 16 and block control flow is 17+. And
the condition asks the registry directly rather than reading a flag set
in render(), which runs in ngAfterViewInit - by then this template's
bindings are already checked for the pass, and under OnPush nothing
would mark them dirty again.
Also re-exports makeErrorId from all three adapters - check-docs-api-
references caught that the recipe imported it from the react package,
which did not have it. Angular re-exported none of the renderer-prop
helpers, so it gains buildFieldRendererProps, makeFieldId and
FIELD_RENDERER_PROP_KEYS alongside, matching react and vue.
Validation messages could only be set per field, per form, by passing a
string to each validator - so translating a form meant touching every
field description.

t lives on the existing ValidationContext rather than a new parameter:
FieldDescription.validate already takes that context as its fourth
argument, so there was no free slot and no need to invent one. An async
validator gets the resolver for free as a result.
Each validator computed its message when the field description was built,
so a catalog could never reach it. Resolution moves inside the returned
closure, with an explicitly passed string still winning over any catalog -
every existing call site behaves identically.

validators.matches lands in the same change because its default message
needs that machinery; every consumer was hand-writing the same
(value, data) => value !== data.other for confirm-password fields.
validateField and validateFields gain a trailing optional context, so a
catalog reaches the validators - including inside repeatable groups,
where the recursive call now forwards it.

validateFieldsAsync needed no change: its options bag already is the
ValidationContext and was already threaded recursively, so t flows there
as soon as a caller supplies it.
Both validateFieldsAsync call sites spread the context before setting
signal, rather than replacing the options object - dropping the signal
there would silently disable run cancellation.
debounceMs was declared in FieldDescription, published in the .d.ts and
read by no implementation anywhere - setting it did nothing. It now
debounces this loader.

Everything hard lives here rather than three times over in the adapters:
debounce, abort of a superseded run, a run counter that discards an
out-of-order response even when the signal is ignored, and shallow deps
comparison. An AbortError is deliberately not an error state - being
superseded is normal and would otherwise flash a failure on every
keystroke of a search box.

options takes one signature, not a union of sync and async shapes: a
union defeats TypeScript's contextual inference, so every existing
options: (data) => ... would have started erroring under noImplicitAny.
Returning a promise is what makes a loader async. resolveOptions now
returns undefined for those, so no renderer is handed a Promise.
optionsStatus and optionsError join FIELD_RENDERER_PROP_KEYS, so
lint:renderer-parity now requires all three adapters to forward them -
it currently fails on angular, which the next commits fix.

onOptionsQuery is deliberately not in that list: it is a callback,
attached alongside onValueChange and onBlur, and putting it there would
make the parity script check the wrong kind of thing.
Two things this needed beyond wiring the loader in.

FieldInput's memo comparator only compares this field's own slice of the
data, so a field whose optionsDeps read *another* field would never
re-render to notice the change - a country/city pair would load once and
never again. Async-options fields now compare the whole data object.

resolveOptions now drops a promise returned by a loader that detection
missed, and warns with the fix. constructor.name === 'AsyncFunction'
does not survive a memoiser, a spy or a transpiler helper, and handing
the renderer a pending promise as its option list is worse than an empty
one. This mirrors what the validate path already does.
Mirrors the React adapter. The watch is deep on the whole data object
because optionsDeps can read another field's value, and the loader - not
the component - decides whether anything it cares about changed.
Completes the three-adapter loader; lint:renderer-parity now reports 23
props instead of 21.

The loader callback calls markForCheck: these components are OnPush, so
an async arrival happens outside any event the view is checked for and
the options would otherwise load and never appear. onOptionsQuery is
declared on BaseInputComponent only - redeclaring it on DynamicInput is
a TS4114-class error under useDefineForClassFields.
The root README covered the new API; the per-package ones did not mention
any of it. Each adapter README now documents baselineValues,
getDirtyValues, the messages catalog, initialProperties, async options and
the default renderers' new error node, and the core README carries the
full catalog key table and the async options reference the adapters link
to.

One paragraph in the core README had become actively wrong: it still said
ariaDescribedBy is the one prop no adapter fills in. It now explains what
replaced that and why the old reasoning, though sound, left
focusFirstInvalidField doing nothing.
Seven defects in code added by this branch, each with a test that fails
without the fix.

react: the options loader was built in the render body and disposed by
the effect cleanup, but the ref was never cleared - StrictMode's
mount/cleanup/mount left every field holding a disposed loader, so async
options sat at 'loading' forever in any development build. It is now
created lazily and re-created after disposal.

angular: onOptionsQuery never reached a renderer. applyProps iterates
KNOWN_PROPS, which deliberately excludes callbacks, so search-remote was
dead on this adapter despite being documented. Callbacks now have their
own pass, covering the component, the sync and the fallback paths.

angular: the HTML5 fallback never set aria-invalid or aria-describedby,
so the new error node had nothing pointing at it and
focusFirstInvalidField still found nothing - the exact failure the
migration guide claims is fixed. Applied once after the fallback builds,
rather than in each of its four branches.

react: the default error node rendered error[0] of a bare string, which
is its first character. Vue and Angular already normalised; React did
not, and DynamicInput is publicly exported with error typed
string | string[].

core: a retry after a failed load emitted status 'loading' while still
carrying the previous optionsError. The loading transition now clears it.

core: isAsyncOptions returned true for optionsMode: 'async' on a field
whose options is a static array, and fetchNow then threw "load is not a
function" out of a lifecycle hook. It now requires a callable first.

angular: swapping a field from async to synchronous options left the
stale optionsState winning in buildFieldRendererProps, serving the old
list forever with the loader never disposed.

The one finding not fixed is the baseline when properties starts as {}
rather than undefined: {} is a real value, and a form that opens blank
cannot be told apart from one still waiting on a fetch. Characterised by
two tests and documented as a caveat with initialProperties as the
escape hatch.
Changing a field's name swaps the loader (adapters key each field by
name, so it remounts); changing only the options closure on a same-named
field does not.

That asymmetry is deliberate and now has a test saying so. Rebuilding on
closure identity would refetch in a loop for the very common case of a
fields array built inline in a component body, which gets a fresh closure
on every render.
@vannt-dev

Copy link
Copy Markdown
Owner Author

Superseded by #62. master and develop had equivalent v1.6.0 trees but divergent rebase histories, so the direct branch PR conflicted. #62 syncs the exact released v1.7.0 tree onto master with a linear parent.

@vannt-dev vannt-dev closed this Sep 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant