diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4ca7cf7d..a2223e9a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
### Breaking Changes
- **components:** `.sf-btn` nested in `.sf-card` no longer auto-shrinks its label to `--sf-text-s`. Buttons in cards now render at their own size like anywhere else; add `.sf-btn--s` for a compact card action. The orphaned `--sf-card-btn-font-size` token is removed. See `docs/migration.md`.
+- **forms:** form fields no longer auto-colour on native `:user-invalid` / `:user-valid` — a field no longer looks "broken" the instant the browser considers it invalid, before your app or the user submits anything. The explicit `.sf-is-invalid` / `.sf-is-valid` (and `.sf-is-warning` / `.sf-is-info` / `.sf-is-danger`) state classes are unaffected and remain the way to colour a field's validation state. See `docs/migration.md`.
## [0.7.15] - 2026-07-11
diff --git a/docs/migration.md b/docs/migration.md
index 22327a43..5b179bae 100644
--- a/docs/migration.md
+++ b/docs/migration.md
@@ -67,6 +67,32 @@ the size tier on that card:
.sf-card--compact .sf-btn { --sf-btn-font-size--size: var(--sf-text-s); }
```
+### Form fields no longer auto-colour on native `:user-invalid` / `:user-valid` (breaking)
+
+`optional/forms.css` used to flip `--sf-field-border-color` to `--sf-color-danger`
+/ `--sf-color-success` automatically whenever the browser's own constraint
+validation (`:user-invalid` / `:user-valid`) fired — so a `required` or
+`pattern`-constrained field could render "broken" (red border) as soon as it was
+touched, before the user submitted anything or your app ran its own validation.
+This was the same category of surprise as the card/button rule above — the
+framework silently overriding an element's appearance based on browser-internal
+state you didn't opt into — so it has been removed.
+
+**What changed for you:** native constraint validation no longer changes a
+field's border colour by itself. The explicit state classes in `core/states.css`
+— `.sf-is-invalid` / `.sf-is-error`, `.sf-is-valid` / `.sf-is-success`,
+`.sf-is-warning`, `.sf-is-info`, `.sf-is-danger` — are unaffected and remain the
+one way to colour a field's validation state; add the class when *your* code
+(not the browser) decides the field is invalid:
+
+```html
+
+```
+
+The underlying `--sf-field-border-color` token and its consumers (resting
+border, focus, autofill) are unchanged — only the automatic pseudo-class
+trigger is gone.
+
### `--sf-touch-target` decoupled from `--sf-size-l`; size scale regularised (breaking)
`--sf-touch-target` used to be `var(--sf-size-l)`, which coupled the WCAG 2.5.5
diff --git a/optional/forms.css b/optional/forms.css
index 988a1dbc..91d972c0 100644
--- a/optional/forms.css
+++ b/optional/forms.css
@@ -86,19 +86,6 @@
}
/* stylelint-enable selector-pseudo-class-no-unknown */
- /* Native validation states */
- input:user-invalid,
- select:user-invalid,
- textarea:user-invalid {
- --sf-field-border-color: var(--sf-color-danger);
- }
-
- input:user-valid,
- select:user-valid,
- textarea:user-valid {
- --sf-field-border-color: var(--sf-color-success);
- }
-
/* Numeric inputs */
input[type="number"] {
font-variant-numeric: var(--sf-font-numeric, tabular-nums);
diff --git a/tests/forms.spec.js b/tests/forms.spec.js
index a31ca6c0..dfe1cde9 100644
--- a/tests/forms.spec.js
+++ b/tests/forms.spec.js
@@ -3,8 +3,8 @@
// previously with no dedicated spec (only touched incidentally by a11y.spec).
// Asserts the computed styling contract for text inputs, textarea, select, the
// disabled state, and the --sf-field-border-color token indirection that the
-// :user-invalid / :user-valid states pivot on. Loads the optimal bundle (which
-// includes optional/forms.css) in both themes.
+// explicit .sf-is-invalid / .sf-is-valid state classes (core/states.css) set.
+// Loads the optimal bundle (which includes optional/forms.css) in both themes.
import { test, expect } from '@playwright/test';
import { renderWithBundle, NO_TRANSITIONS_STYLE } from './render-helpers.js';
@@ -63,7 +63,7 @@ for (const theme of ['light', 'dark']) {
await mount(page, ``);
const cs = await computed(page, ['border-top-color']);
// The border resolves through --sf-field-border-color (the same hook
- // :user-invalid/:user-valid flip to danger/success).
+ // .sf-is-invalid/.sf-is-valid flip to danger/success — core/states.css).
expect(cs['border-top-color']).toBe('rgb(1, 2, 3)');
});