diff --git a/README.md b/README.md
index ecd0704..8954bc2 100644
--- a/README.md
+++ b/README.md
@@ -88,27 +88,38 @@ When embedded in Express, the MCP endpoint is also available over HTTP at `/__ng
MCP clients see these with an underscore, as `ng-devtools_get-routes`.
-| Tool | Description |
-| ---------------------------------- | ----------------------------------------------------------- |
-| `ng-devtools:get-routes` | List Angular routes from source |
-| `ng-devtools:get-components` | Discover components and directives, with inputs and outputs |
-| `ng-devtools:get-signals` | Signal declarations from source |
-| `ng-devtools:get-providers` | DI providers from source |
-| `ng-devtools:build-meta` | Angular/TS versions, SSR status |
-| `ng-devtools:highlight` | Highlight a component in the page |
-| `ng-devtools:inspect-signals` | Signal graph a connected page reported |
-| `ng-devtools:inspect-providers` | Injector tree a connected page reported |
-| `ng-devtools:get-ngrx-store` | Scan source for NgRx store patterns |
-| `ng-devtools:inspect-forms` | Forms on the page with every field's state and errors |
-| `ng-devtools:explain-form-invalid` | Which fields make a form invalid, and why |
-| `ng-devtools:inspect-route` | The current route with params, data, guards and resolvers |
-| `ng-devtools:explain-navigation` | Recent navigations and why each succeeded or not |
-| `ng-devtools:list-routes` | Live route config; match a URL; audit guard protection |
-| `ng-devtools:lint-routes` | Route config mistakes, with fixes |
-| `ng-devtools:router-config` | Router options, features and strategies in effect |
-| `ng-devtools:export-navigation` | Markdown repro of a navigation |
-| `ng-devtools:explain-render-mode` | ServerRoute and render mode for a URL |
-| `ng-devtools:navigate` | Navigate, abort, replay, probe, instrument (dev only) |
+| Tool | Description |
+| ------------------------------------ | ----------------------------------------------------------- |
+| `ng-devtools:get-routes` | List Angular routes from source |
+| `ng-devtools:get-components` | Discover components and directives, with inputs and outputs |
+| `ng-devtools:get-signals` | Signal declarations from source |
+| `ng-devtools:get-providers` | DI providers from source |
+| `ng-devtools:build-meta` | Angular/TS versions, SSR status |
+| `ng-devtools:highlight` | Highlight a component in the page |
+| `ng-devtools:inspect-signals` | Signal graph a connected page reported |
+| `ng-devtools:inspect-providers` | Injector tree a connected page reported |
+| `ng-devtools:get-ngrx-store` | Scan source for NgRx store patterns |
+| `ng-devtools:inspect-forms` | Forms on the page with every field's state and errors |
+| `ng-devtools:explain-form-invalid` | Which fields make a form invalid, and why |
+| `ng-devtools:explain-field` | One field: error sources, skip reasons, binding, source |
+| `ng-devtools:explain-submit` | What submit will do, and why it might do nothing |
+| `ng-devtools:form-payload` | What the form sends: value vs raw value, unvalidated fields |
+| `ng-devtools:form-history` | Change timeline with origin (user, code, devtools) |
+| `ng-devtools:form-diff` | Net change since a marker |
+| `ng-devtools:lint-forms` | Form bugs and model-aware accessibility checks |
+| `ng-devtools:explain-custom-control` | How a field is bound, and what is wrong with the binding |
+| `ng-devtools:export-form` | JSON snapshot or test fixture |
+| `ng-devtools:wait-for-form` | Wait until settled, valid, not pending or submitted |
+| `ng-devtools:form-action` | Set, touch, revalidate, reset, submit, focus, snapshot |
+| `ng-devtools:fill-form` | Fill several fields through the inputs |
+| `ng-devtools:inspect-route` | The current route with params, data, guards and resolvers |
+| `ng-devtools:explain-navigation` | Recent navigations and why each succeeded or not |
+| `ng-devtools:list-routes` | Live route config; match a URL; audit guard protection |
+| `ng-devtools:lint-routes` | Route config mistakes, with fixes |
+| `ng-devtools:router-config` | Router options, features and strategies in effect |
+| `ng-devtools:export-navigation` | Markdown repro of a navigation |
+| `ng-devtools:explain-render-mode` | ServerRoute and render mode for a URL |
+| `ng-devtools:navigate` | Navigate, abort, replay, probe, instrument (dev only) |
#### Forms
@@ -118,8 +129,12 @@ The Forms tab and the forms tools read Signal Forms, reactive forms and template
- `ng-devtools:explain-form-invalid` is the tool to reach for first: without arguments it lists every form that is invalid or waiting on async validation, with each failing field's current value, the validator that failed, its message and whether it was touched. Pass `form` (an id like `form-1`, or part of a label like `SignupComponent`) to explain one form.
- `ng-devtools:inspect-forms` lists the forms with their status and error counts. Pass `form` for a field tree, plus `path` (e.g. `address.city`), `onlyInvalid` or `includeValues: false` to narrow it down.
- Both tools note when the page last reported, so an agent can tell when the data is stale.
+- Each error says where it comes from: a validator, a template attribute, a cross-field rule (and on which ancestor), async, parse, a server/submission error, or `setErrors()`. `explain-field` adds why validation is skipped (hidden, disabled, readonly), typed-but-uncommitted values (`updateOn`, `debounce`), stale validity after validator changes, the binding, whether the error text is visible, and the file and line of the form and its rules.
+- Agents can loop: inspect, act (`form-action`, `fill-form`), `wait-for-form`, then `form-diff` from the marker they had. Writes need a development build; `reset`, `submit` and `restore` need `confirm: true`.
+- The Forms tab has Fields (with filters and per-field actions), Timeline, Submit and Lint views. Pick a field on the page to select it, or open a form from its component in the Components tab.
+- Timeline recording (a checkbox in the Timeline view, or `form-action` with `instrument`) adds the calling code of each change, validator changes, async validation times and component renders per keystroke. Array items are tracked by identity, so moves show as moves.
-Form values leave the page: they are sent to the devtools server, shown in the Forms tab and returned to agents. Values of password fields, fields with a password, one-time-code or credit-card `autocomplete`, and fields whose name looks secret (password, token, card, cvv and similar) are replaced with `[redacted]`. Other values are sent as they are, so keep real credentials out of forms you inspect, and don't expose the dev server beyond localhost.
+Form values leave the page: they are sent to the devtools server, shown in the Forms tab and returned to agents. Values of password fields, fields with a password, one-time-code or credit-card `autocomplete`, fields inside `.sentry-mask`, `.rr-mask`, `[data-private]` or `[data-ng-devtools="mask"]`, and fields whose name contains a secret word (password, token, card, cvv, apiKey and similar) are replaced with `[redacted]`, and those values are also removed from error messages. `[data-ng-devtools="unmask"]` opts a field back in; `window.__NG_DEVTOOLS_FORMS__ = { mask: ['iban'], unmask: ['passport'] }` does the same by key. DevTools never writes secret fields. Other values are sent as they are, so keep real credentials out of forms you inspect, and don't expose the dev server beyond localhost.
#### Router
diff --git a/app/src/app.ts b/app/src/app.ts
index 9698ecc..de0c495 100644
--- a/app/src/app.ts
+++ b/app/src/app.ts
@@ -64,7 +64,7 @@ type Tab = 'dashboard' | 'components' | 'routes' | 'signals' | 'injectors' | 'st
{{ text() || 'Loading…' }}
+ {{ message() }}
+ `, + styles: ` + ${FORMS_STYLES} + :host { + display: grid; + gap: 8px; + padding: 10px; + border: 1px solid #3f3f46; + border-radius: 8px; + } + h3 { + margin: 0; + color: #e4e4e7; + font-size: 14px; + font-family: ui-monospace, monospace; + } + .row { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; + } + .sr-only { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + } + `, +}) +export class FormsFieldDetail { + form = input.requiredConnecting…
@@ -142,153 +142,316 @@ function countFields(node: FormFieldNode): number { > - - -| Field | -Value | -Status | -State | -Errors | -
|---|---|---|---|---|
| - - {{ row.node.type }} - | -
- @if (row.node.type === 'control') {
- {{ row.node.value | json }}
- @if (row.node.defaultValue !== undefined) {
-
- resets to
- }
- }
- {{ row.node.defaultValue | json }}
- |
- - @if (row.node.materialized === false) { - not created yet - } @else { - {{ - row.node.status - }} - } - | -- @if (row.node.touched) { - touched - } - @if (row.node.dirty) { - dirty - } - @if (row.node.required) { - required - } - @if (row.node.readonly) { - readonly - } - @if (row.node.hidden) { - hidden - } - @if (row.node.updateOn) { - updates on {{ row.node.updateOn }} - } - @if (row.node.debouncing) { - debouncing - } - @if (row.node.validators?.sync) { - validators - } - @if (row.node.validators?.async) { - async validator - } - @for (rule of constraintList(row.node); track rule) { - {{ rule }} - } - @if (row.node.accessor) { - {{ row.node.accessor }} - } - @for (reason of row.node.disabledReasons ?? []; track $index) { - disabled: {{ reason }} - } - | -
- @for (error of row.node.errors; track $index) {
-
- {{ errorText(row.node, error) }}
-
- }
- {{ error.kind }}
- |
-
| - {{ row.node.truncated }} more fields under - {{ row.node.path || 'the form' }} not shown - | -||||
| No field path matches "{{ filter() }}". | -||||
{{ message() }}
-{{ event.path || '(form)' }}
- {{ event.type }}
- @if (event.detail) {
- {{ event.detail }}
+ | Field | +Value | +Status | +State | +Errors | +
|---|---|---|---|---|
| + + {{ row.node.type }} + | +
+ @if (row.node.type === 'control') {
+ {{ row.node.value | json }}
+ @if (row.node.uncommitted !== undefined) {
+
+ typed
+ }
+ @if (row.node.defaultValue !== undefined) {
+ {{ row.node.uncommitted | json }}, not in the model yet
+
+ resets to
+ }
+ }
+ {{ row.node.defaultValue | json }}
+ |
+ + @if (row.node.materialized === false) { + not created yet + } @else { + {{ + row.node.status + }} + } + | ++ @if (row.node.touched) { + touched + } + @if (row.node.dirty) { + {{ + row.node.changed === false ? 'dirty, unchanged' : 'dirty' + }} + } + @if (row.node.skipped) { + not validated ({{ row.node.skipped }}) + } + @if (row.node.stale?.length) { + stale: {{ row.node.stale!.join(', ') }} + } + @if (row.node.dom?.drift !== undefined) { + view out of sync + } + @if (row.node.redacted) { + redacted ({{ row.node.redacted }}) + } + @if (row.node.required) { + required + } + @if (row.node.readonly) { + readonly + } + @if (row.node.hidden) { + hidden + } + @if (row.node.updateOn) { + updates on {{ row.node.updateOn }} + } + @if (row.node.debouncing) { + debouncing + } + @if (row.node.validators?.sync) { + validators + } + @if (row.node.validators?.async) { + async validator + } + @for (rule of constraintList(row.node); track rule) { + {{ rule }} + } + @if (row.node.accessor) { + {{ row.node.accessor }} + } + @for (reason of row.node.disabledReasons ?? []; track $index) { + disabled: {{ reason }} + } + | +
+ @for (error of row.node.errors; track $index) {
+
+ {{ errorText(row.node, error) }}
+
+ }
+ @if (row.node.errors.length && row.node.dom?.errorShown === false) {
+ {{ error.kind }}
+ @if (error.source) {
+ {{ sourceText(error) }}
+ }
+ not shown to the user
+ }
+ |
+
| + {{ row.node.truncated }} more fields under + {{ row.node.path || 'the form' }} not shown + | +||||
| + @if (filter()) { + No field path matches "{{ filter() }}". + } @else { + No field matches the selected filters. + } + | +||||
No changes yet. Type into the form to see them here.
- } + } +{{ submit() || 'Loading…' }}
+ {{ payload() || 'Loading…' }}
+ Checking…
+ } @else if (!findings()!.length) { +No problems found. For generic accessibility, run axe on the page.
+ } @else { +{{ f.rule }}
+ @if (f.path) {
+ at {{ f.path }}
+ }
+ {{ event.path || '(form)' }}
+ {{ event.type }}
+ @if (event.outcome) {
+ {{
+ event.outcome
+ }}
+ }
+
+ @if (event.prev !== undefined) {
+ {{ event.prev }} →
+ }
+ {{ event.detail }}
+
+ @if (event.count && event.count > 1) {
+ ×{{ event.count }}
+ }
+ @if (event.origin) {
+ {{ event.origin }}
+ }
+ @if (event.ms !== undefined) {
+ 1000 ? 'warn' : ''"
+ >pending {{ event.ms }}ms
+ }
+ @if (event.renders) {
+ 20 ? 'warn' : ''"
+ >{{ event.renders }} renders: {{ (event.rendered ?? []).join(', ') }}
+ }
+ @if (event.caller) {
+ from {{ event.caller }}
+ }
+ No changes yet. Type into the form to see them here.
+ } + `, + styles: ` + ${FORMS_STYLES} + :host { + display: grid; + gap: 8px; + } + .chips { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin: 0; + padding: 0; + border: 0; + color: #d4d4d8; + font-size: 13px; + } + .sr-only { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + } + .events { + display: grid; + gap: 4px; + margin: 0; + padding: 0; + list-style: none; + font-size: 13px; + } + .events li { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: baseline; + color: #d4d4d8; + } + .events li[data-type='submit'] { + border-left: 3px solid var(--accent); + padding-left: 6px; + } + time { + color: #a1a1aa; + font-variant-numeric: tabular-nums; + } + code { + color: #c4b5fd; + } + .event-type { + color: #93c5fd; + } + .detail { + overflow-wrap: anywhere; + } + .record { + color: #d4d4d8; + font-size: 13px; + } + .caller { + flex-basis: 100%; + padding-left: 16px; + color: #a1a1aa; + font-family: ui-monospace, monospace; + font-size: 12px; + overflow-wrap: anywhere; + } + `, +}) +export class FormsTimeline { + events = input.required