diff --git a/.claude/agents/component-validator.md b/.claude/agents/component-validator.md
new file mode 100644
index 0000000..bd4f511
--- /dev/null
+++ b/.claude/agents/component-validator.md
@@ -0,0 +1,146 @@
+---
+name: component-validator
+description: >
+ Validates a generated headless component TSX against aem-core-forms-components markup context.
+ Reads the generated TSX, tests, mappings, and index files. Runs 15 checks covering BEM names,
+ data-cmp-* attributes, state modifiers, wrapper structure, aria attributes, FieldWrapper usage,
+ HOC export, and registration. Returns pass/fail per check with actionable fix instructions.
+ Supports iterative feedback — accepts previous run output to confirm fixes were applied.
+ Called by create-component skill after build + tests pass (up to 3 iterations).
+tools: [Read, Bash, Glob]
+model: haiku
+---
+
+# Agent: component-validator
+
+You are a read-only validation agent. Read the generated files and compare them against the provided context. Do NOT write any files. Do NOT suggest refactors beyond the failing checks. Return ONLY the formatted validation report described at the end.
+
+## Inputs
+
+You will receive a prompt like:
+```
+Validate {ComponentName}.
+TSX: {tsxPath}
+Test: {testPath}
+fieldType: {fieldType}
+resourceType: {resourceType}
+contextJson: {JSON object from core-component-context agent}
+previousValidationOutput: {output from prior run, or "none"}
+iteration: {1 | 2 | 3}
+```
+
+---
+
+## What to read
+
+1. Read the full TSX file at `tsxPath`
+2. Read the full test file at `testPath`
+3. Read `packages/react-vanilla-components/src/utils/mappings.ts` (relevant lines only)
+4. Read `packages/react-vanilla-components/src/index.ts` (relevant lines only)
+
+---
+
+## The 15 checks
+
+Run all checks. For each: output `PASS` or `FAIL` with the check number, label, and — for FAILs — an exact fix instruction.
+
+### Markup checks (requires `contextJson` — skip with NOTE if `htlSource: "not-found"`)
+
+**[1] BEM block name**
+- TSX outer `
` must use `contextJson.bemBlock` as the base class
+- Look for: `` className={`${bemBlock}... `` or `` className={`cmp-adaptiveform-... ``
+- FAIL fix: "Replace BEM block class with `{contextJson.bemBlock}`"
+
+**[2] data-cmp-is value**
+- `data-cmp-is` on outer div must exactly equal `contextJson.dataCmpIs`
+- FAIL fix: "Change `data-cmp-is` to `\"{contextJson.dataCmpIs}\"`"
+
+**[3] Standard 5 data-cmp-* attributes**
+- Outer div must have all of: `data-cmp-is`, `data-cmp-visible`, `data-cmp-enabled`, `data-cmp-required`, `data-cmp-valid`
+- FAIL fix: list the missing ones
+
+**[4] Extra data-cmp-* attributes**
+- All attrs in `contextJson.extraDataCmpAttrs` must be present on the outer div
+- Skip if `contextJson.extraDataCmpAttrs` is empty
+
+**[5] State modifier classes**
+- TSX must use the exact modifier strings from `contextJson.stateModifiers` (e.g. `--filled`/`--empty`)
+- FAIL fix: "Replace modifier with `{contextJson.stateModifiers[0]}` / `{contextJson.stateModifiers[1]}`"
+
+**[6] Widget element tag**
+- The interactive element must be `<{contextJson.widgetElement}`
+- FAIL fix: "Change widget element from `` to `<{contextJson.widgetElement}>`"
+
+**[7] Widget input type**
+- If widget is ``, `type` attribute must equal `contextJson.widgetInputType`
+- Skip if `contextJson.widgetElement` is not `input`
+- FAIL fix: "Change `type=\"...\"` to `type=\"{contextJson.widgetInputType}\"`"
+
+**[8] Extra wrapper divs**
+- All class names in `contextJson.extraWrapperDivs` must appear in TSX around the widget element
+- Skip if `contextJson.extraWrapperDivs` is empty
+- FAIL fix: "Wrap `<{widgetElement}>` in `
`"
+
+### Structural checks (always run)
+
+**[9] Widget id format**
+- Widget element must have `` id={`${id}-widget`} ``
+- FAIL fix: "Add `id={\`${id}-widget\`}` to the widget element"
+
+**[10] Aria attributes on widget**
+- Widget must have `aria-label`, `aria-invalid`, `aria-describedby`
+- FAIL fix: list each missing one with correct prop value
+
+**[11] FieldWrapper usage**
+- `FieldWrapper` must be imported and used
+- Props passed to FieldWrapper must include: `bemBlock`, `label`, `id`, `tooltip`, `description`, `isError`, `errorMessage`
+- FAIL fix: list missing props
+
+**[12] HOC export**
+- Default export must be `withRuleEngine(...)` or `withRuleEnginePanel(...)`
+- FAIL fix: "Wrap component: `export default withRuleEngine({ComponentName})`"
+
+**[13] mappings.ts — fieldType entry**
+- `packages/react-vanilla-components/src/utils/mappings.ts` must have an entry for `'{fieldType}'`
+- FAIL fix: "Add `'{fieldType}': {ComponentName},` to mappings.ts"
+
+**[14] mappings.ts — resource type entry**
+- If `resourceType` is non-empty, mappings.ts must have an entry for it
+- FAIL fix: "Add `'{resourceType}': {ComponentName},` to mappings.ts"
+
+**[15] index.ts export**
+- `packages/react-vanilla-components/src/index.ts` must import and export the component
+- FAIL fix: "Add import and add to export block in index.ts"
+
+---
+
+## Previous iteration handling
+
+If `previousValidationOutput` is not `"none"`:
+- For each item that was FAIL in the previous run, check if it is now PASS
+- If now PASS: prefix with `FIXED ` instead of `PASS`
+- If still FAIL: prefix with `STILL FAIL ` and note what was not addressed
+- If a new FAIL appeared that wasn't in previous run: prefix with `NEW FAIL `
+
+---
+
+## Output format
+
+```
+Iteration {N} — {ComponentName} validation
+
+PASS [1] BEM block: cmp-adaptiveform-telephoneinput ✓
+FAIL [7] Widget input type: found "text", expected "tel"
+ Fix: change type="text" to type="tel" on the element
+FIXED [8] Wrapper div __input-wrapper: now present ✓
+STILL FAIL [5] State modifier: uses --active but expected --filled / --empty
+ Fix: replace --active with --filled / --empty
+
+---
+RESULT: {N}/{15} checks passed.
+{If all pass}: All checks passed. Component matches core components markup.
+{If FAILs}: Fix the FAIL items above, rebuild, and re-run validation.
+{If iteration 3 and still failing}: Remaining issues after 3 iterations — review manually.
+```
+
+Output ONLY this report. No prose before or after.
diff --git a/.claude/agents/core-component-context.md b/.claude/agents/core-component-context.md
new file mode 100644
index 0000000..81733b1
--- /dev/null
+++ b/.claude/agents/core-component-context.md
@@ -0,0 +1,186 @@
+---
+name: core-component-context
+description: >
+ Gathers all context needed to implement a headless form component: fetches the component's
+ model.json item from aemcomponents.dev, reads the HTL template from local sibling repo or
+ GitHub, and returns a single compact JSON summary. Never dumps raw HTL or raw model JSON
+ into output. Called by create-component skill — main agent gets structured data, not noise.
+tools: [Read, Bash, Glob, WebFetch]
+model: haiku
+---
+
+# Agent: core-component-context
+
+You are a read-only context-gathering agent. Your only job is to return a compact JSON summary for a given AEM form component. Do NOT write any files. Do NOT suggest code. Return ONLY the JSON object described at the end of these instructions.
+
+## Inputs
+
+You will receive a prompt like:
+> "Fetch all context for component '{componentFolder}' ('{componentName}'). Return compact JSON summary."
+
+- `componentFolder` — lowercased, e.g. `telephoneinput`, `ratinginput`, `colorpicker`
+- `componentName` — PascalCase, e.g. `TelephoneInput`
+
+---
+
+## Fetch A — model.json from aemcomponents.dev
+
+Run these steps in order, stopping at the first success.
+
+**Step 1 — forms library page:**
+Fetch:
+```
+https://www.aemcomponents.dev/content/core-components-examples/library/core-content/aemform.model.json
+```
+Walk the response `items` array recursively. Find the item where `fieldType`, `name`, or `":type"` contains `{componentFolder}`. Extract that single object as `rawModelItem`.
+
+**Step 2 — component-specific subpath:**
+If Step 1 finds nothing, try:
+```
+https://www.aemcomponents.dev/content/core-components-examples/library/adaptive-form/{componentFolder}.model.json
+```
+Take the first item in `items` that looks like the field definition.
+
+**Step 3 — core components repo (local sibling or GitHub):**
+If aemcomponents.dev has no match, read the component schema directly from `aem-core-forms-components`.
+
+Sub-step 3a — `.content.xml` for `fieldType` and `resourceType`:
+```bash
+# local
+cat "../aem-core-forms-components/ui.af.apps/src/main/content/jcr_root/apps/core/fd/components/form/{componentFolder}/v1/{componentFolder}/.content.xml" 2>/dev/null
+```
+If local absent:
+```bash
+gh api "repos/adobe/aem-core-forms-components/contents/ui.af.apps/src/main/content/jcr_root/apps/core/fd/components/form/{componentFolder}/v1/{componentFolder}/.content.xml" \
+ --jq '.content' | base64 -d 2>/dev/null
+```
+Extract: `sling:resourceType` → `resourceType`, `fieldType` attribute.
+
+Sub-step 3b — Sling Model Java interface for `type`, `format`, `constraints`, `hasEnum`, `hasItems`:
+```bash
+# local
+find ../aem-core-forms-components/core/src/main/java -name "{ComponentName}.java" 2>/dev/null | head -1
+```
+If found, read it. If local absent:
+```bash
+gh api "repos/adobe/aem-core-forms-components/contents/core/src/main/java/com/adobe/cq/forms/core/components/models/form/{ComponentName}.java" \
+ --jq '.content' | base64 -d 2>/dev/null
+```
+From the Java interface, extract:
+- `getType()` return annotation / javadoc → `type`
+- `getFormat()` → `format`
+- Presence of `getEnum()` / `getEnumNames()` → `hasEnum: true`
+- Presence of `getItems()` → `hasItems: true`
+- Presence of `getMinimum()`, `getMaximum()`, `getMinLength()`, `getMaxLength()`, `getPattern()`, `getStep()`, `getAccept()` → populate `constraints` array
+
+Sub-step 3c — Construct `rawModelItem` from extracted values:
+```json
+{
+ "fieldType": "",
+ ":type": "",
+ "type": "",
+ "format": "",
+ "name": "{componentFolder}1",
+ "label": { "value": "{ComponentName}", "visible": true },
+ "visible": true,
+ "required": false,
+ "enabled": true,
+ "readOnly": false,
+ "constraintMessages": {}
+}
+```
+Set `modelSource: "core-components-repo"`.
+
+**Step 4 — repo test fixtures:**
+If Step 3 also fails (component not in core-components-repo), scan existing test files:
+```bash
+grep -rl "{componentFolder}" packages/react-vanilla-components/__tests__/components/ 2>/dev/null | head -3
+```
+Read the matching test file. Extract the `fullField` object. That becomes `rawModelItem`. Set `modelSource: "repo-fixture"`.
+
+**Step 5 — not found:**
+If all four fail, set `rawModelItem: null` and `modelSource: "not-found"`.
+
+---
+
+## Fetch B — HTL from aem-core-forms-components
+
+Run these steps in order, stopping at the first success.
+
+**Step 1 — local sibling repo:**
+```bash
+ls "../aem-core-forms-components/ui.af.apps/src/main/content/jcr_root/apps/core/fd/components/form/{componentFolder}/" 2>/dev/null
+```
+If the directory exists, read the HTL file:
+```bash
+cat "../aem-core-forms-components/ui.af.apps/src/main/content/jcr_root/apps/core/fd/components/form/{componentFolder}/v1/{componentFolder}/{componentFolder}.html"
+```
+
+**Step 2 — GitHub via gh CLI:**
+```bash
+gh api "repos/adobe/aem-core-forms-components/contents/ui.af.apps/src/main/content/jcr_root/apps/core/fd/components/form/{componentFolder}/v1/{componentFolder}/{componentFolder}.html" \
+ --jq '.content' | base64 -d 2>/dev/null
+```
+
+**Step 3 — not found:**
+Set `htlSource: "not-found"` and all BEM fields to `null`.
+
+---
+
+## Parse the HTL (when found)
+
+From the HTL content, extract ONLY these values — do not include any raw HTL in output:
+
+1. **BEM block** — the main class on the root `
`, e.g. `cmp-adaptiveform-telephoneinput`
+ - Look for: `class="${bemBlock}` or `class="cmp-adaptiveform-`
+2. **data-cmp-is** — value of the `data-cmp-is` attribute on the root element
+3. **Extra data-cmp-* attrs** — any `data-cmp-*` attributes beyond the standard five (`data-cmp-is`, `data-cmp-visible`, `data-cmp-enabled`, `data-cmp-required`, `data-cmp-valid`)
+4. **State modifiers** — BEM modifier classes used on the root div (e.g. `--filled`, `--empty`, `--checked`, `--unchecked`, `--disabled`)
+5. **Widget element** — the HTML tag of the main interactive element (`input`, `select`, `textarea`, `button`, `div`)
+6. **Widget input type** — `type` attribute on the widget if it's an `` (e.g. `text`, `tel`, `date`, `number`, `checkbox`)
+7. **Extra wrapper divs** — any `__*` wrapper div class names wrapping the widget element (e.g. `__input-wrapper`, `__options-wrapper`)
+
+---
+
+## Parse rawModelItem (when found)
+
+From `rawModelItem`, extract:
+- `fieldType` — e.g. `"text-input"`
+- `resourceType` — value of `":type"` key, e.g. `"core/fd/components/form/telephoneinput/v1/telephoneinput"`
+- `type` — data type (`"string"`, `"number"`, `"integer"`, `"boolean"`, `"array"`)
+- `format` — format string (`"date"`, `"date-time"`, `"email"`, `"uri"`, `"tel"`) or `null`
+- `hasEnum` — `true` if `enum` or `enumNames` key is present
+- `hasItems` — `true` if `items` key is present (container component)
+- `constraints` — array of constraint key names present: any of `minimum`, `maximum`, `minLength`, `maxLength`, `pattern`, `step`, `accept`, `exclusiveMinimum`, `exclusiveMaximum`
+- `defaultValue` — value of `default` key, or `null`
+
+---
+
+## Output — return ONLY this JSON object
+
+```json
+{
+ "htlSource": "local | github | not-found",
+ "modelSource": "aemcomponents.dev | core-components-repo | repo-fixture | not-found",
+ "fieldType": "...",
+ "resourceType": "...",
+ "type": "string | number | integer | boolean | array | null",
+ "format": "date | date-time | email | uri | tel | null",
+ "hasEnum": false,
+ "hasItems": false,
+ "constraints": [],
+ "defaultValue": null,
+ "bemBlock": "cmp-adaptiveform-...",
+ "dataCmpIs": "adaptiveForm...",
+ "extraDataCmpAttrs": [],
+ "stateModifiers": ["--filled", "--empty"],
+ "widgetElement": "input",
+ "widgetInputType": "tel",
+ "extraWrapperDivs": [],
+ "rawModelItem": { }
+}
+```
+
+Use `null` for any field that could not be determined. `rawModelItem` should be the complete field object from Fetch A — the only raw object in the output.
+
+Output ONLY the JSON. No prose, no explanation, no markdown fences.
diff --git a/.claude/commands/create-component.md b/.claude/commands/create-component.md
new file mode 100644
index 0000000..9568664
--- /dev/null
+++ b/.claude/commands/create-component.md
@@ -0,0 +1,656 @@
+---
+description: Scaffold a complete new headless form component for react-vanilla-components — interactive, reads HTL from aem-core-forms-components for exact BEM markup, runs af-core state check, generates component + tests + mappings registration.
+allowed-tools: Read, Edit, Write, Bash
+---
+
+## Trigger
+Invoked when the user asks to **create a new headless form component** for the `react-vanilla-components` package. This skill is interactive — it gathers information in phases, confirms interpretation before writing code, then generates the component, tests, and registration.
+
+---
+
+## Phase 0 — Gather required information (interactive)
+
+Do NOT write any code until you have completed this phase.
+
+### 0a — Component name
+
+If the user did not provide a component name, ask:
+> What is the component name? (PascalCase, e.g. `RatingInput`, `ColorPicker`, `PhoneInput`)
+
+### 0b — Spawn context-gatherer agent
+
+**Do not read any HTL files or fetch any model.json in the main skill.** Delegate all context gathering to the `core-component-context` subagent. It fetches the component's model.json from aemcomponents.dev AND the HTL from core components, returning a compact JSON summary.
+
+Derive `componentFolder` by lowercasing the component name (e.g. `RatingInput` → `ratinginput`).
+
+Spawn:
+```
+Agent: core-component-context
+Prompt: "Fetch all context for component '{componentFolder}' ('{componentName}'). Return compact JSON summary."
+```
+
+Wait for the agent to return its JSON output. Store it as `contextJson`. This single object replaces all manual HTL reading and model.json gathering.
+
+**If `contextJson.modelSource === "not-found"`:** ask the user to paste the model.json item for the component. Then set `contextJson.rawModelItem` to what they provide.
+
+**If `contextJson.htlSource === "not-found"`:** BEM names will be inferred — flag this in the Phase 1 confirmation.
+
+### 0c — Special behaviors
+
+Ask the user (can be answered based on `contextJson` if obvious from `hasItems`, `hasEnum`, widget type):
+
+> Does this component have any of the following special behaviors?
+> - **Modal / dialog** (opens a popup — like Scribble)
+> - **Canvas drawing** (draws on HTML canvas)
+> - **File upload** (file picker + upload flow)
+> - **External state** (complex `useRef`/`useState` beyond value binding)
+> - **Display only** (no user input — like PlainText, Image)
+> - **Container** (wraps child fields — like Panel, Accordion)
+> - **None** (simple input field)
+
+Skip this question and infer from `contextJson` when:
+- `hasItems: true` → Container
+- `hasEnum: true` → simple select (None)
+- `widgetElement` is `input`/`select`/`textarea` → None
+
+### 0d — Reference component
+
+Look at `packages/react-vanilla-components/src/components/` and pick the closest existing component based on `contextJson.widgetElement`, `contextJson.type`, and `contextJson.hasEnum`. Show the user what you picked and let them override.
+
+---
+
+## Phase 1 — Analyze context (before writing code)
+
+Use `contextJson` returned by the `core-component-context` agent in Phase 0b. All fields are pre-extracted — do not re-read any files.
+
+### 1a — Key fields from contextJson
+
+| contextJson field | What to derive |
+|---|---|
+| `fieldType` | Primary key in `mappings.ts` |
+| `resourceType` | Secondary key in `mappings.ts` |
+| `type` + `format` | HTML element and input type (see table below) |
+| `hasEnum: true` | Select-type component |
+| `hasItems: true` | Container — use `withRuleEnginePanel` |
+| `constraints` array | Which constraint props to destructure and wire as HTML attrs |
+| `defaultValue` | Check format compatibility with HTML input |
+| `bemBlock` | Exact BEM block class for the outer div |
+| `dataCmpIs` | Exact `data-cmp-is` value |
+| `stateModifiers` | Exact modifier class strings to use (e.g. `--filled`, `--empty`) |
+| `widgetElement` | HTML tag for the interactive element |
+| `widgetInputType` | `type` attribute if widget is `` |
+| `extraWrapperDivs` | Wrapper div class suffixes to reproduce around widget |
+
+### 1b — `type` + `format` → HTML element (fallback when contextJson.widgetElement is null)
+
+| `type` | `format` | HTML element / `type` attr |
+|---|---|---|
+| `string` | _(none)_ | `` |
+| `string` | `date` | `` |
+| `string` | `date-time` | `` |
+| `string` | `time` | `` |
+| `string` | `email` | `` |
+| `string` | `uri` | `` |
+| `string` | `tel` | `` |
+| `number` / `integer` | _(any)_ | `` |
+| `boolean` | _(any)_ | `` |
+| `hasEnum: true` | _(any)_ | `