You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@struts.apache.org by lu...@apache.org on 2026/08/27 15:17:02 UTC

(struts) branch main updated: WW-5695 Derive HTML5 constraint attributes from validators, deprecate the JS validator (#1865)

This is an automated email from the ASF dual-hosted git repository.

lukaszlenart pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/struts.git


The following commit(s) were added to refs/heads/main by this push:
     new c752860e3 WW-5695 Derive HTML5 constraint attributes from validators, deprecate the JS validator (#1865)
c752860e3 is described below

commit c752860e320f6f26822f78bee146ee410144860b
Author: Lukasz Lenart <lu...@apache.org>
AuthorDate: Thu Aug 27 17:16:57 2026 +0200

    WW-5695 Derive HTML5 constraint attributes from validators, deprecate the JS validator (#1865)
    
    * WW-5695 docs(validation): design HTML5 constraint validation and retire the JS validator
    
    Design covering the replacement of the generated JavaScript client-side
    validator with native HTML5 constraint attributes derived from the action's
    validators.
    
    Governing rule is never to false-reject: a constraint is emitted only when the
    browser cannot reject input the server would accept. That rules out changing an
    input's type, since type="number" refuses "1234,50" which locale-aware
    conversion accepts, and browser email/url regexes diverge from the framework's.
    Struts only adds constraints safe for the type already present.
    
    Also records why WW-2975 is superseded rather than fixed: constraints riding on
    each input remove the central tagNames list its root cause depends on.
    
    Covers WW-5694 (deprecate, 7.4.0), WW-5695 (constraints, 7.4.0) and WW-5696
    (remove, 8.0.0). The implementation plan drawn from this covers 7.4.0 only.
    
    Co-Authored-By: Claude Opus 5 <no...@anthropic.com>
    
    * WW-5695 docs(validation): correct the UIBean hook point and control-type resolution
    
    Reading the code turned up two errors in the approved design.
    
    evaluateExtraParams() is the last statement of evaluateParams(), and that is
    where TextField sets attributes.type. The hook cannot sit next to the tagNames
    block as written, because at that point no text field has a resolved type and
    every one of them would look like OTHER. It moves to the end of the method.
    
    attributes.type is set by TextField and nothing else on the input path, so the
    control type cannot come from the attribute map alone. Adds a getControlType()
    component hook with four overrides; Checkbox, Radio, File and Hidden fall
    through to OTHER, which emits nothing and is correct for all four.
    
    Co-Authored-By: Claude Opus 5 <no...@anthropic.com>
    
    * WW-5695 docs(validation): add the 7.4.0 implementation plan
    
    Nine tasks covering WW-5694 (deprecation, task 1, separately mergeable) and
    WW-5695 (tasks 2-9). WW-5696's 8.0.0 removal is out of scope.
    
    Syncs three points where writing the code diverged from the design:
    
    - constraintsFor takes the action as a third parameter; getMessage needs it to
      resolve the i18n text behind the data-msg-* attributes.
    - The validator list is memoised on Form component fields rather than the
      attributes map, which is exposed to templates.
    - Temporal min/max is deferred. DateRangeFieldValidator emits nothing for now;
      honouring it needs per-control ISO formatting worth doing deliberately.
    
    Co-Authored-By: Claude Opus 5 <no...@anthropic.com>
    
    * WW-5694 refactor(validation): deprecate JavaScript client-side validation
    
    Marks the form tag's validate attribute and the machinery behind it for
    removal in 8.0.0. Annotations and documentation only; no behaviour change.
    
    Follows the WW-5510 precedent: annotate both the component and the JSP tag,
    and put the notice inside the START SNIPPET block so the website picks it up.
    
    Co-Authored-By: Claude Opus 5 <no...@anthropic.com>
    
    * WW-5694 docs(validation): scope the deprecation banner to the validate attribute
    
    The javadoc SNIPPET block published on struts.apache.org read as a
    blanket deprecation of <s:form> itself, with no replacement mentioned.
    Reword and move the banner to the end of the javadoc snippet so it
    explicitly scopes the deprecation to the validate attribute and the
    client-side JS behind it; the form tag itself is not deprecated.
    
    Co-Authored-By: Claude Opus 5 <no...@anthropic.com>
    
    * WW-5695 feat(components): add HtmlControlType
    
    Models the kind of form control a UIBean renders, so constraint derivation can
    ask which HTML5 attributes are legal rather than string-matching a type
    attribute. Models the control, not the attribute, because textarea and select
    have no type yet still accept required.
    
    from() never throws; unknown input becomes OTHER, which supports nothing.
    
    Co-Authored-By: Claude Opus 5 <no...@anthropic.com>
    
    * WW-5695 docs(validation): require the licence header on every new file in the plan
    
    The plan's code blocks omit the ASF header for brevity, and a task reviewer
    caught an implementer transcribing that omission into a real test file.
    
    apache-rat-plugin:check runs at prepare-package, so mvn test -DskipAssembly —
    the command every task in the plan uses to verify itself — never runs the
    licence check. A missing header passes every task-level gate and fails CI.
    
    Co-Authored-By: Claude Opus 5 <no...@anthropic.com>
    
    * WW-5695 chore(components): add the missing licence header to HtmlControlTypeTest
    
    The task brief's code block omitted the ASF licence header; every sibling
    test in this package carries it, and apache-rat:check (bound to
    prepare-package) would fail on it in a full build. mvn test -DskipAssembly
    never reaches that phase, so the earlier green run was blind to it.
    
    Co-Authored-By: Claude Opus 5 <no...@anthropic.com>
    
    * WW-5695 feat(components): add ECMAScript-safe regex detection
    
    Decides whether a Java regex can become an HTML5 pattern attribute without
    changing meaning. Allowlist by design: a denylist would violate the
    never-false-reject rule the first time it missed a construct, and a regex the
    browser reads differently is a rejection the user cannot get past.
    
    Co-Authored-By: Claude Opus 5 <no...@anthropic.com>
    
    * WW-5695 fix(validation): drop \s and \S from the ECMAScript-safe escape allowlist
    
    A task reviewer found a real semantic divergence the design had certified as
    portable. Java's \s is ASCII-only by default; ECMAScript's is always the wider
    Unicode set. For a rule as ordinary as ^\S+$, a value containing NBSP satisfies
    Java's \S and fails the browser's — so the server accepts input the browser
    silently refuses to submit, which is exactly the failure this allowlist exists
    to prevent.
    
    \d and \w are safe: both engines are ASCII-only for those by default and
    JavaScript never widens them, so the fix is scoped to s/S.
    
    The spec listed \s among the safe escapes and the plan's ALLOWED_ESCAPES
    transcribed it. Both corrected, plus a regression test. The governing rule
    outranks its own example list.
    
    Co-Authored-By: Claude Opus 5 <no...@anthropic.com>
    
    * WW-5695 fix(components): drop \s and \S from the portable-escape allowlist
    
    Java's \s is ASCII-only by default; ECMAScript's is the wider Unicode
    whitespace set (NBSP and friends). ^\S+$ therefore accepted a value
    containing NBSP on the server while the browser's pattern attribute
    rejected it silently - the exact false-true failure this class exists to
    prevent. \d and \w stay allowed: both engines are ASCII-only for those and
    JavaScript never widens them.
    
    Co-Authored-By: Claude Opus 5 <no...@anthropic.com>
    
    * WW-5695 feat(components): map validators onto HTML5 constraint attributes
    
    Adds HtmlConstraintProvider and its conservative default implementation, which
    never sets or changes an input's type: type=number would reject 1234,50 that
    locale-aware conversion accepts, and browser email/url grammars differ from the
    framework's validators. Range constraints therefore land only on a control the
    developer already made numeric.
    
    Registered as a swappable bean so applications wanting a best-effort mapping
    can replace the policy.
    
    Co-Authored-By: Claude Opus 5 <no...@anthropic.com>
    
    * WW-5695 fix(components): close three false-reject and injection gaps in HtmlConstraintProvider
    
    Review found two Critical defects inherited from the task brief and three
    Important gaps:
    
    - addPattern ignored RegexFieldValidator's trim=true default: the server
      matches the trimmed value while HTML pattern matches the raw one, so
      "abc " would pass server-side and be blocked client-side. Now guarded on
      isTrimed(), and EmailValidator/CreditCardValidator (whose matching diverges
      from their raw regex) are excluded outright.
    - required was emitted wherever RequiredFieldValidator's null-or-empty check
      is looser than the browser's required: an empty text input, a select with
      an empty header option, and an unticked checkbox (CheckboxInterceptor
      substitutes "false") all pass server-side but would be blocked client-side.
      Split into addRequiredString (safe on any text-entry control, since
      requiredstring rejects blank too) and addRequiredField (safe only on RADIO
      and FILE, the only controls that omit the parameter entirely when empty).
    - The bean was registered in struts-beans.xml but never aliased to its
      container-default name, so an @Inject HtmlConstraintProvider would not
      resolve. Added STRUTS_HTML_CONSTRAINT_PROVIDER to StrutsConstants, its
      default.properties entry, and the alias() call in
      StrutsBeanSelectionProvider, following the UrlRenderer model.
    - Added regression coverage: the temporal-range early return, the new
      trim/EmailValidator/CreditCardValidator pattern exclusions, and the -1
      length sentinel, none of which had a covering test before.
    
    Co-Authored-By: Claude Opus 5 <no...@anthropic.com>
    
    * WW-5695 feat(components): add Form.getFieldValidators with per-render caching
    
    getValidators re-runs the action-mapping lookup and the validator-manager
    resolution on every call, so a twenty-field form would do twenty of them. The
    new method resolves once per form render and filters per field.
    
    Memoised on component fields rather than the attributes map: a Form component
    is built per render, and the attributes map is exposed to templates.
    
    Co-Authored-By: Claude Opus 5 <no...@anthropic.com>
    
    * WW-5695 docs(validation): make the plan's memoisation test actually test memoisation
    
    A task reviewer found the plan's own testRepeatedCallsAreConsistent vacuous:
    resolution is deterministic, so comparing result sizes across two calls passes
    identically against an implementation with no cache at all. Memoisation is the
    entire purpose of getFieldValidators, so nothing would have caught it silently
    regressing to a full resolution per field.
    
    Replaced with a test that counts resolutions across two different field names,
    and recorded the second half of the harness trap: createMocks() never sets a
    config on the MockActionProxy, so the validator manager NPEs without an
    explicit setConfig.
    
    Co-Authored-By: Claude Opus 5 <no...@anthropic.com>
    
    * WW-5695 test(components): make FormFieldValidatorsTest prove memoisation
    
    testRepeatedCallsAreConsistent only compared list sizes across two calls to
    the same field, which a non-memoised implementation would also satisfy since
    resolution is deterministic -- it never observed how many times resolution
    actually ran.
    
    Replace it with a test that mocks ActionValidatorManager and asserts
    getValidators(...) is invoked exactly once across lookups of two different
    fields on the same Form instance, which is the actual behaviour
    getFieldValidators promises. Verified the new test fails (2 invocations
    instead of 1) when the resolveActionValidators() early-return is temporarily
    neutralised, then passes again with it restored.
    
    Co-Authored-By: Claude Opus 5 <no...@anthropic.com>
    
    * WW-5695 feat(components): resolve the HTML control type per component
    
    attributes.type is set by TextField and nothing else on the input path, so the
    control type cannot come from the attribute map alone. Adds getControlType()
    with four overrides; Checkbox, Radio, File and Hidden fall through to OTHER,
    which emits nothing and is correct for all four.
    
    Co-Authored-By: Claude Opus 5 <no...@anthropic.com>
    
    * WW-5695 feat(components): derive constraint attributes during tag evaluation
    
    Adds struts.ui.html5.constraints, default false, and wires UIBean to the
    constraint provider behind it.
    
    The hook sits at the end of evaluateParams rather than beside the tagNames
    block, because evaluateExtraParams is where TextField resolves attributes.type
    and it runs last; hooking earlier would make every text field look like OTHER.
    
    Co-Authored-By: Claude Opus 5 <no...@anthropic.com>
    
    * WW-5695 test(components): pin the constraint hook's evaluateExtraParams ordering
    
    Adds a discriminating test: a stringlength validator on an explicit
    type="number" control must not emit minlength, since minlength is only
    legal once TextField.evaluateExtraParams() has resolved the control type.
    The prior two tests could not tell a correctly-placed hook from one hoisted
    to the tagNames block, because an untyped field resolves to TEXT either way.
    
    Also rewords the javadoc/properties comment for struts.ui.html5.constraints
    to stop committing to a specific future version number; the release version
    is chosen at release time, not baked into 7.4.0-SNAPSHOT.
    
    Co-Authored-By: Claude Opus 5 <no...@anthropic.com>
    
    * WW-5695 feat(html5): render derived constraint attributes
    
    Included from common-attributes.ftl so every html5 input picks the attributes
    up without per-template edits. Values rely on FreeMarker's auto-escaping
    (HTMLOutputFormat + ENABLE_IF_DEFAULT_AUTO_ESCAPING_POLICY, set globally by
    FreemarkerManager): pattern and the data-msg-* text are author-controlled and
    land inside an attribute, and the ?html builtin is rejected at parse time
    under this configuration ("legacy escaping ... not allowed when
    auto-escaping is on with a markup output format") because ${...} is already
    escaped. Verified against the actual template with a standalone FreeMarker
    render using the same Configuration: '"><script>&' comes out as
    &quot;&gt;&lt;script&gt;&amp;.
    
    Covers the regression that matters most - requiredLabel draws an asterisk and
    must never emit a required attribute.
    
    Co-Authored-By: Claude Opus 5 <no...@anthropic.com>
    
    * WW-5695 docs(validation): drop the ?html builtin from the template guidance
    
    The design and plan both prescribed ${value?html} for the constraints
    template. That is a parse error in this repo: FreemarkerManager sets
    ENABLE_IF_DEFAULT_AUTO_ESCAPING_POLICY with HTMLOutputFormat, so values are
    escaped by configuration and FreeMarker rejects ?html as a double-escape.
    Zero templates in the tree use it.
    
    The escaping requirement itself stands and is easy to mistake for absent when
    reading the template, so both documents now say why there is no visible escape
    and warn against disabling auto-escaping for this file.
    
    Co-Authored-By: Claude Opus 5 <no...@anthropic.com>
    
    * WW-5695 fix(html5): stop constraints.ftl leaking a newline into every input
    
    FreeMarker only strips a line's trailing newline when that line contains
    nothing but FTL tags. The <#if>/<#list>/</#if> block was on one physical
    line together with the interpolated attribute text, so it failed that test
    and the newline was emitted on every html5 input render - including when
    struts.ui.html5.constraints is off, breaking the "changes nothing when off"
    guarantee. Reformatted to the multi-line idiom already used by the sibling
    accesskey block in common-attributes.ftl and by dynamic-attributes.ftl, with
    </#list> and </#if> alone on their own lines.
    
    Added testRendersExactMarkupWhenTheConstantIsOff, an assertEquals on the
    full rendered string (not a contains check), which is the only kind of
    assertion that can catch a stray whitespace byte; the existing suite's
    normalize() strips whitespace entirely and could not have caught this.
    Verified it discriminates: reverting constraints.ftl to the single-line form
    made it fail on the trailing newline, restoring the multi-line form made it
    pass again.
    
    Co-Authored-By: Claude Opus 5 <no...@anthropic.com>
    
    * WW-5695 fix(components): close false-reject and duplicate-attribute gaps in HTML5 constraints
    
    Final whole-branch review found five issues in the constraint-derivation path
    added for the html5 theme's HTML5 constraint validation:
    
    - A fractional min (e.g. minInclusive=6000.1 from a double validator) becomes
      the HTML step base on type="number"/"range", and with the default step="1"
      the browser then rejects whole numbers the server accepts. addRange and
      addDoubleRange now only emit min when the bound is integral; max is
      unaffected since it does not participate in the step base.
    - addRequiredField only matched RADIO/FILE, but no component ever returned
      either control type, making it dead code. Radio and File now override
      getControlType(), the only two controls where an unselected/empty
      submission omits the parameter entirely and so agrees with the server.
      Checkbox and Hidden deliberately stay OTHER: CheckboxInterceptor
      substitutes "false" for an unticked box, so required there would
      false-reject.
    - A derived constraint could duplicate a developer-set attribute (maxlength
      from a declared tag attribute, min/max from a dynamic one on a numeric
      textfield), producing invalid markup with the attribute repeated.
      addConstraintAttributes now drops any derived key already present as a
      declared or dynamic attribute, except "required" against the declared
      half: requiredLabel stores an unrelated boolean under that same key to
      draw a label asterisk, and must never suppress a genuine required
      constraint.
    - Form.getFieldValidators reaches AnnotationActionValidatorManager, which
      dereferences the current ActionInvocation unconditionally - a path that
      used to need the opt-in validate="true" and now runs for every html5 form.
      Rendering outside action scope, a null validator, or a broken ${} in a
      validator message unbalancing the value stack in
      ValidatorSupport.getMessage would all turn a working page into a 500 for
      a purely decorative feature. addConstraintAttributes now catches broadly
      and logs the field name.
    - Two provider tests could never fail: the CreditCardValidator and
      EmailValidator exclusion tests returned early at earlier guards
      (case-sensitivity, then isTrimed()) before ever reaching the exclusion
      they claimed to cover. Both now set caseSensitive/trim so they actually
      exercise it.
    
    Also adds tests for the integral-min guard, the maxlength duplicate
    suppression, and pins the data-msg-* escaping against a message containing
    a quote and an angle bracket.
    
    Co-Authored-By: Claude Opus 5 <no...@anthropic.com>
    
    * WW-5695 docs(design): correct the spec and plan to match the post-fix behaviour
    
    The design spec's mapping table and the plan's Task 4/6 code listings still
    described the pre-fix behaviour: required and requiredstring both emitting
    unconditionally, no EmailValidator/CreditCardValidator pattern exclusion, no
    regex trim gate, and Radio/File deliberately left without a getControlType()
    override. These are now the only remaining wrong description of the
    feature and the input to the follow-up ticket WW-5696, so bring both back
    in line with StrutsHtmlConstraintProvider as it stands, including the new
    integral-min guard and duplicate-attribute suppression. Narrative sections
    are left alone; only the tables and code blocks are corrected, with notes
    marking what changed and why.
    
    Co-Authored-By: Claude Opus 5 <no...@anthropic.com>
    
    * WW-5695 refactor(components): address SonarQube findings
    
    No behaviour change; the core suite stays at 3257 tests, 0 failures.
    
    Split EcmaScriptSafeRegex.isSafe into a positioning loop and an isPortable
    predicate. The allowlist is the security-relevant half, and it now reads as
    one method answering one question, which is the thing a reviewer has to
    audit. Dropping the ++i mutation out of the loop header makes the "an escape
    consumes two characters" rule explicit rather than a side effect buried in a
    condition.
    
    Also: @deprecated Javadoc on the four members deprecated in this branch, each
    naming the html5 replacement; a constant for the repeated actionClass and
    required literals; renamed the local in TextField.getControlType that shadowed
    the field of the same name but held the evaluated value rather than the raw
    OGNL expression; and three test nits.
    
    FormTag.populateParams gets @SuppressWarnings("removal") -- the tag has to
    keep forwarding validate until both sides go in 8.0.0.
    
    Two findings are deliberately not taken. S1133 ("remove this deprecated code
    someday") fires on every @Deprecated and deprecation is the point of the
    branch. S8924 (static import for verify) is wrong here: AbstractUITagTest
    inherits verify(URL), which shadows a static import and fails to compile --
    noted in a comment so the next reader does not retry it.
    
    Co-Authored-By: Claude Opus 5 <no...@anthropic.com>
    
    * WW-5695 fix(components): correct three constraint-derivation defects found in review
    
    Drop \b and \B from the portable-escape allowlist. Their meaning is not stable
    across the JDKs Struts supports: up to Java 18 the boundary was decided by
    Character.isLetterOrDigit, so it was Unicode-aware while \w stayed ASCII, and
    JDK 19 made the two consistent. Pattern.matches("^\\bäiti\\b$", "äiti") is
    therefore true on Java 17 and false on Java 21, while ECMAScript's always-ASCII
    boundary rejects it in every browser. On the Java 17 baseline that is a false
    reject, and no version check could rescue it - one validation.xml would have to
    mean two different things depending on the JVM.
    
    Resolve the action from the ActionInvocation rather than stack.peek(). The top
    of the stack is not the action whenever something has been pushed over it:
    ModelDrivenInterceptor pushes the model, an <s:iterator> around the field pushes
    the current element. ValidationInterceptor validates invocation.getAction(), and
    ValidatorSupport.getMessage builds its DelegatingValidatorContext from whatever
    it is handed, so peeking resolved data-msg-* keys against the wrong resource
    bundle.
    
    Restore the value stack in a finally. Derivation runs against the request-scoped
    stack - DefaultActionValidatorManager.getValidators hands each validator
    ActionContext.getValueStack() - and getMessage's pops sit outside any finally,
    so a message that fails to resolve leaves frames behind. Because the catch here
    deliberately swallows the failure to keep a decorative feature from costing the
    page, every tag rendered afterwards would then have resolved its OGNL against
    the wrong root, with nothing in the output to say so.
    
    Each of the three regression tests was confirmed to fail against the unfixed
    code, for its own reason.
    
    Co-Authored-By: Claude Opus 5 <no...@anthropic.com>
    
    ---------
    
    Co-authored-by: Claude Opus 5 <no...@anthropic.com>
---
 .../java/org/apache/struts2/StrutsConstants.java   |   16 +
 .../struts2/components/EcmaScriptSafeRegex.java    |  124 ++
 .../java/org/apache/struts2/components/File.java   |    5 +
 .../java/org/apache/struts2/components/Form.java   |   72 +-
 .../struts2/components/HtmlConstraintProvider.java |   44 +
 .../apache/struts2/components/HtmlControlType.java |   78 +
 .../org/apache/struts2/components/Password.java    |    5 +
 .../java/org/apache/struts2/components/Radio.java  |    5 +
 .../java/org/apache/struts2/components/Select.java |    5 +
 .../components/StrutsHtmlConstraintProvider.java   |  208 +++
 .../org/apache/struts2/components/TextArea.java    |    5 +
 .../org/apache/struts2/components/TextField.java   |    6 +
 .../java/org/apache/struts2/components/UIBean.java |  129 ++
 .../config/StrutsBeanSelectionProvider.java        |    2 +
 .../org/apache/struts2/views/jsp/ui/FormTag.java   |    7 +
 .../org/apache/struts2/default.properties          |    9 +
 core/src/main/resources/struts-beans.xml           |    2 +
 .../resources/template/html5/common-attributes.ftl |    1 +
 .../{common-attributes.ftl => constraints.ftl}     |    6 +-
 .../template/xhtml/form-close-validate.ftl         |    7 +
 .../apache/struts2/TestConfigurationProvider.java  |    9 +
 .../struts2/components/ConstraintAction.java}      |   43 +-
 .../components/ConstraintAttributesTest.java       |  170 ++
 .../apache/struts2/components/ControlTypeTest.java |   74 +
 .../components/EcmaScriptSafeRegexTest.java        |   99 ++
 .../components/FormFieldValidatorsTest.java        |   95 +
 .../struts2/components/HtmlControlTypeTest.java    |   78 +
 .../StrutsHtmlConstraintProviderTest.java          |  312 ++++
 .../views/jsp/ui/Html5ConstraintRenderingTest.java |  131 ++
 .../components/ConstraintAction-validation.xml     |   43 +
 .../2026-08-24-html5-constraint-validation.md      | 1828 ++++++++++++++++++++
 ...026-08-24-html5-constraint-validation-design.md |  422 +++++
 32 files changed, 4032 insertions(+), 8 deletions(-)

diff --git a/core/src/main/java/org/apache/struts2/StrutsConstants.java b/core/src/main/java/org/apache/struts2/StrutsConstants.java
index 9c1245248..ffc135f0b 100644
--- a/core/src/main/java/org/apache/struts2/StrutsConstants.java
+++ b/core/src/main/java/org/apache/struts2/StrutsConstants.java
@@ -203,6 +203,14 @@ public final class StrutsConstants {
      */
     public static final String STRUTS_UI_STATIC_CONTENT_PATH = "struts.ui.staticContentPath";
 
+    /**
+     * Whether the html5 theme emits HTML5 constraint attributes derived from the action's validators.
+     * Defaults to {@code false}; the default is expected to flip in a future major release.
+     *
+     * @since 7.4.0
+     */
+    public static final String STRUTS_UI_HTML5_CONSTRAINTS = "struts.ui.html5.constraints";
+
     /**
      * Whether WebJars support is enabled (serving and URL building)
      */
@@ -218,6 +226,14 @@ public final class StrutsConstants {
      */
     public static final String STRUTS_UI_ESCAPE_HTML_BODY = "struts.ui.escapeHtmlBody";
 
+    /**
+     * The {@link org.apache.struts2.components.HtmlConstraintProvider} implementation used to derive
+     * HTML5 constraint attributes from an action's validators.
+     *
+     * @since 7.4.0
+     */
+    public static final String STRUTS_HTML_CONSTRAINT_PROVIDER = "struts.htmlConstraintProvider";
+
     /**
      * The maximum size of a multipart request (file upload)
      */
diff --git a/core/src/main/java/org/apache/struts2/components/EcmaScriptSafeRegex.java b/core/src/main/java/org/apache/struts2/components/EcmaScriptSafeRegex.java
new file mode 100644
index 000000000..2614c5c57
--- /dev/null
+++ b/core/src/main/java/org/apache/struts2/components/EcmaScriptSafeRegex.java
@@ -0,0 +1,124 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.struts2.components;
+
+/**
+ * Decides whether a Java regular expression can be handed to a browser as an HTML5 {@code pattern}
+ * attribute without changing meaning.
+ * <p>
+ * This is an allowlist by design. A denylist of Java-only constructs would violate the
+ * never-false-reject rule the first time it missed one, because a missed construct becomes a pattern
+ * the browser interprets differently and the user cannot get past. Anything not provably common to
+ * both engines is rejected, and the field simply gets no client-side check.
+ *
+ * @since 7.4.0
+ */
+public final class EcmaScriptSafeRegex {
+
+    /**
+     * Escapes with identical meaning in both engines.
+     * <p>
+     * {@code \s} and {@code \S} are deliberately absent. Java's {@code \s} is ASCII-only by default
+     * while ECMAScript's is the wider Unicode set, so {@code ^\S+$} accepts a value containing NBSP
+     * on the server and rejects it in the browser. {@code \d} and {@code \w} are safe — both engines
+     * are ASCII-only for those, and JavaScript never widens them.
+     * <p>
+     * {@code \b} and {@code \B} are absent for a sharper reason: their meaning is not even stable
+     * across the JDKs Struts supports. Up to Java 18 the boundary was decided by
+     * {@code Character.isLetterOrDigit}, making it Unicode-aware while {@code \w} stayed ASCII;
+     * JDK 19 resolved that inconsistency. So {@code ^\bäiti\b$} matches {@code äiti} on Java 17 and
+     * not on Java 21, while ECMAScript — whose boundary is always ASCII-word based — rejects it in
+     * every browser. On the Java 17 baseline that is a false reject, and no version check could fix
+     * it: one {@code validation.xml} would have to mean two different things depending on the JVM.
+     */
+    private static final String ALLOWED_ESCAPES = "dDwWnrtf\\.*+?()[]{}|^$/-";
+
+    private EcmaScriptSafeRegex() {
+    }
+
+    public static boolean isSafe(String regex) {
+        if (regex == null || regex.isEmpty()) {
+            return false;
+        }
+        boolean inCharClass = false;
+        int i = 0;
+        while (i < regex.length()) {
+            char current = regex.charAt(i);
+            if (!isPortable(regex, i, current, inCharClass)) {
+                return false;
+            }
+            if (current == '[') {
+                inCharClass = true;
+            } else if (current == ']') {
+                inCharClass = false;
+            }
+            // an escape consumes the character it escapes, which must not be scanned again
+            i += (current == '\\') ? 2 : 1;
+        }
+        return !inCharClass;
+    }
+
+    /**
+     * Whether the construct starting at {@code index} means the same thing to both engines. This is
+     * the whole allowlist: anything that reaches {@code default} is a character with no special
+     * meaning in either engine, or one whose meaning is shared.
+     */
+    private static boolean isPortable(String regex, int index, char current, boolean inCharClass) {
+        switch (current) {
+            case '\\':
+                return isAllowedEscape(regex, index);
+            case '[':
+                // Java allows nested classes and POSIX names; ECMAScript allows neither
+                return !inCharClass && !regex.startsWith("[:", index);
+            case '&':
+                // Java character-class intersection
+                return !inCharClass || !isFollowedBy(regex, index, '&');
+            case '(':
+                return isPortableGroup(regex, index);
+            case '*', '+', '?', '}':
+                // possessive quantifier
+                return !isFollowedBy(regex, index, '+');
+            default:
+                return true;
+        }
+    }
+
+    private static boolean isAllowedEscape(String regex, int index) {
+        return index + 1 < regex.length() && ALLOWED_ESCAPES.indexOf(regex.charAt(index + 1)) >= 0;
+    }
+
+    /**
+     * Only non-capturing groups and lookahead are portable; named groups, lookbehind, atomic groups
+     * and inline flags are not. A plain capturing group is always fine.
+     */
+    private static boolean isPortableGroup(String regex, int index) {
+        if (!isFollowedBy(regex, index, '?')) {
+            return true;
+        }
+        if (index + 2 >= regex.length()) {
+            return false;
+        }
+        char kind = regex.charAt(index + 2);
+        return kind == ':' || kind == '=' || kind == '!';
+    }
+
+    private static boolean isFollowedBy(String regex, int index, char expected) {
+        return index + 1 < regex.length() && regex.charAt(index + 1) == expected;
+    }
+}
diff --git a/core/src/main/java/org/apache/struts2/components/File.java b/core/src/main/java/org/apache/struts2/components/File.java
index e9317afba..58bfe42ce 100644
--- a/core/src/main/java/org/apache/struts2/components/File.java
+++ b/core/src/main/java/org/apache/struts2/components/File.java
@@ -62,6 +62,11 @@ public class File extends UIBean {
         return TEMPLATE;
     }
 
+    @Override
+    protected HtmlControlType getControlType() {
+        return HtmlControlType.FILE;
+    }
+
     public void evaluateParams() {
         super.evaluateParams();
 
diff --git a/core/src/main/java/org/apache/struts2/components/Form.java b/core/src/main/java/org/apache/struts2/components/Form.java
index 7db1477c3..cb7a4f0ab 100644
--- a/core/src/main/java/org/apache/struts2/components/Form.java
+++ b/core/src/main/java/org/apache/struts2/components/Form.java
@@ -77,6 +77,9 @@ import java.util.Set;
  * </li>
  * </ol>
  * <p>
+ * <strong>The client-side JS <code>validate</code> attribute is deprecated since 7.4.0 — use the html5 theme's
+ * constraint attributes instead. Removed in 8.0.0.</strong>
+ * </p>
  * <!-- END SNIPPET: javadoc -->
  *
  * <p><b>Examples</b></p>
@@ -98,6 +101,8 @@ public class Form extends ClosingUIBean {
     public static final String OPEN_TEMPLATE = "form";
     public static final String TEMPLATE = "form-close";
 
+    private static final String ATTR_ACTION_CLASS = "actionClass";
+
     private int sequence = 0;
 
     protected String onsubmit;
@@ -119,6 +124,10 @@ public class Form extends ClosingUIBean {
     protected UrlRenderer urlRenderer;
     protected ActionValidatorManager actionValidatorManager;
 
+    private List<Validator> cachedActionValidators;
+    private String cachedActionName;
+    private boolean actionValidatorsResolved;
+
     public Form(ValueStack stack, HttpServletRequest request, HttpServletResponse response) {
         super(stack, request, response);
     }
@@ -238,7 +247,12 @@ public class Form extends ClosingUIBean {
      * @param actionName   the actioName to check for
      * @param namespace    the namespace to check for
      * @param actionMethod the method to ckeck for
+     * @deprecated since 7.4.0, for removal in 8.0.0. The generated client-side validator only ever
+     * covered fields rendered by a nested Struts tag (WW-2975). Use the {@code html5} theme with
+     * {@code struts.ui.html5.constraints=true}, which derives native HTML5 constraint attributes
+     * per field instead.
      */
+    @Deprecated(since = "7.4.0", forRemoval = true)
     protected void evaluateClientSideJsEnablement(String actionName, String namespace, String actionMethod) {
 
         // Only evaluate if Client-Side js is to be enable when validate=true
@@ -268,8 +282,17 @@ public class Form extends ClosingUIBean {
         }
     }
 
+    /**
+     * Looks up the validators for a field, for the deprecated client-side JavaScript validator.
+     *
+     * @param name the field name to look up
+     * @return the validators applying to the field, never null
+     * @deprecated since 7.4.0, for removal in 8.0.0. Use {@link #getFieldValidators(String)}, which
+     * is generically typed and resolves the action's validators once per form rather than per field.
+     */
+    @Deprecated(since = "7.4.0", forRemoval = true)
     public List getValidators(String name) {
-        Class actionClass = (Class) getAttributes().get("actionClass");
+        Class actionClass = (Class) getAttributes().get(ATTR_ACTION_CLASS);
         if (actionClass == null) {
             return Collections.EMPTY_LIST;
         }
@@ -300,6 +323,47 @@ public class Form extends ClosingUIBean {
         return validators;
     }
 
+    /**
+     * Returns the validators declared for a single field, resolving the action's validator list at
+     * most once per form render.
+     *
+     * @since 7.4.0
+     */
+    public List<Validator> getFieldValidators(String name) {
+        resolveActionValidators();
+        if (cachedActionValidators.isEmpty()) {
+            return Collections.emptyList();
+        }
+        Class actionClass = (Class) getAttributes().get(ATTR_ACTION_CLASS);
+        List<Validator> validators = new ArrayList<>();
+        findFieldValidators(name, actionClass, cachedActionName, cachedActionValidators, validators, "");
+        return validators;
+    }
+
+    private void resolveActionValidators() {
+        if (actionValidatorsResolved) {
+            return;
+        }
+        actionValidatorsResolved = true;
+        cachedActionValidators = Collections.emptyList();
+
+        Class actionClass = (Class) getAttributes().get(ATTR_ACTION_CLASS);
+        if (actionClass == null) {
+            return;
+        }
+        ActionMapping mapping = actionMapper.getMappingFromActionName(findString(action));
+        if (mapping == null) {
+            mapping = actionMapper.getMappingFromActionName((String) getAttributes().get("actionName"));
+        }
+        if (mapping == null) {
+            return;
+        }
+        cachedActionName = mapping.getName();
+        String methodName = isValidateAnnotatedMethodOnly(cachedActionName) ? mapping.getMethod() : null;
+        cachedActionValidators =
+            actionValidatorManager.getValidators(actionClass, cachedActionName, methodName);
+    }
+
     private boolean isValidateAnnotatedMethodOnly(String actionName) {
         RuntimeConfiguration runtimeConfiguration = configuration.getRuntimeConfiguration();
         String actionNamespace = getNamespace(stack);
@@ -507,8 +571,14 @@ public class Form extends ClosingUIBean {
         this.namespace = namespace;
     }
 
+    /**
+     * @deprecated since 7.4.0, for removal in 8.0.0. The generated client-side validator only ever
+     * covered fields rendered by a nested Struts tag (WW-2975). Use the {@code html5} theme with
+     * {@code struts.ui.html5.constraints=true} instead.
+     */
     @StrutsTagAttribute(description = "Whether client side/remote validation should be performed. Only" +
         " useful with theme xhtml/ajax", type = "Boolean", defaultValue = "false")
+    @Deprecated(since = "7.4.0", forRemoval = true)
     public void setValidate(String validate) {
         this.validate = validate;
     }
diff --git a/core/src/main/java/org/apache/struts2/components/HtmlConstraintProvider.java b/core/src/main/java/org/apache/struts2/components/HtmlConstraintProvider.java
new file mode 100644
index 000000000..6fdeaa52b
--- /dev/null
+++ b/core/src/main/java/org/apache/struts2/components/HtmlConstraintProvider.java
@@ -0,0 +1,44 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.struts2.components;
+
+import org.apache.struts2.validator.Validator;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Maps a field's validators onto the HTML attributes a theme should render for it.
+ * <p>
+ * The default implementation is deliberately conservative — see {@link StrutsHtmlConstraintProvider}.
+ * Applications wanting a best-effort mapping (an {@code email} validator becoming
+ * {@code type="email"}, say) should register their own implementation instead.
+ *
+ * @since 7.4.0
+ */
+public interface HtmlConstraintProvider {
+
+    /**
+     * @param validators the field's validators; may be null or empty
+     * @param control    the kind of control being rendered
+     * @param action     the action instance, used to resolve i18n validator messages; may be null
+     * @return attribute name to value; never null, possibly empty
+     */
+    Map<String, String> constraintsFor(List<Validator> validators, HtmlControlType control, Object action);
+}
diff --git a/core/src/main/java/org/apache/struts2/components/HtmlControlType.java b/core/src/main/java/org/apache/struts2/components/HtmlControlType.java
new file mode 100644
index 000000000..6e69617b9
--- /dev/null
+++ b/core/src/main/java/org/apache/struts2/components/HtmlControlType.java
@@ -0,0 +1,78 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.struts2.components;
+
+import java.util.EnumSet;
+import java.util.Locale;
+import java.util.Set;
+
+/**
+ * The kind of HTML form control a {@link UIBean} renders, used to decide which HTML5 constraint
+ * attributes are legal on it.
+ * <p>
+ * This models the <em>control</em> rather than the {@code type} attribute, because {@code textarea}
+ * and {@code select} have no {@code type} attribute yet still accept {@code required}.
+ *
+ * @since 7.4.0
+ */
+public enum HtmlControlType {
+
+    TEXT, SEARCH, TEL, PASSWORD, EMAIL, URL,
+    NUMBER, RANGE,
+    DATE, MONTH, WEEK, TIME, DATETIME_LOCAL,
+    CHECKBOX, RADIO, FILE, HIDDEN, SELECT,
+    TEXTAREA,
+    OTHER;
+
+    private static final Set<HtmlControlType> TEXT_ENTRY = EnumSet.of(TEXT, SEARCH, TEL, PASSWORD, EMAIL, URL);
+    private static final Set<HtmlControlType> NUMERIC = EnumSet.of(NUMBER, RANGE);
+    private static final Set<HtmlControlType> TEMPORAL = EnumSet.of(DATE, MONTH, WEEK, TIME, DATETIME_LOCAL);
+
+    /**
+     * Resolves a raw {@code type} attribute value. Never throws: the attribute is OGNL-evaluated, so at
+     * runtime it can be any string. Anything unrecognised becomes {@link #OTHER}, which supports no
+     * constraints at all — so an unknown control degrades to emitting nothing.
+     */
+    public static HtmlControlType from(String type) {
+        if (type == null) {
+            return OTHER;
+        }
+        String normalised = type.trim().toUpperCase(Locale.ROOT).replace('-', '_');
+        if (normalised.isEmpty()) {
+            return OTHER;
+        }
+        try {
+            return valueOf(normalised);
+        } catch (IllegalArgumentException e) {
+            return OTHER;
+        }
+    }
+
+    public boolean supportsPattern() {
+        return TEXT_ENTRY.contains(this);
+    }
+
+    public boolean supportsLength() {
+        return TEXT_ENTRY.contains(this) || this == TEXTAREA;
+    }
+
+    public boolean supportsRange() {
+        return NUMERIC.contains(this) || TEMPORAL.contains(this);
+    }
+}
diff --git a/core/src/main/java/org/apache/struts2/components/Password.java b/core/src/main/java/org/apache/struts2/components/Password.java
index 471f7dbd2..4bd75c1a1 100644
--- a/core/src/main/java/org/apache/struts2/components/Password.java
+++ b/core/src/main/java/org/apache/struts2/components/Password.java
@@ -64,6 +64,11 @@ public class Password extends TextField {
         return TEMPLATE;
     }
 
+    @Override
+    protected HtmlControlType getControlType() {
+        return HtmlControlType.PASSWORD;
+    }
+
     public void evaluateExtraParams() {
         super.evaluateExtraParams();
 
diff --git a/core/src/main/java/org/apache/struts2/components/Radio.java b/core/src/main/java/org/apache/struts2/components/Radio.java
index d0d3eb146..bc6bf7606 100644
--- a/core/src/main/java/org/apache/struts2/components/Radio.java
+++ b/core/src/main/java/org/apache/struts2/components/Radio.java
@@ -74,4 +74,9 @@ public class Radio extends ListUIBean {
         return true;
     }
 
+    @Override
+    protected HtmlControlType getControlType() {
+        return HtmlControlType.RADIO;
+    }
+
 }
diff --git a/core/src/main/java/org/apache/struts2/components/Select.java b/core/src/main/java/org/apache/struts2/components/Select.java
index f1a8e5b6d..237775fce 100644
--- a/core/src/main/java/org/apache/struts2/components/Select.java
+++ b/core/src/main/java/org/apache/struts2/components/Select.java
@@ -97,6 +97,11 @@ public class Select extends ListUIBean {
         return TEMPLATE;
     }
 
+    @Override
+    protected HtmlControlType getControlType() {
+        return HtmlControlType.SELECT;
+    }
+
     public void evaluateExtraParams() {
         super.evaluateExtraParams();
 
diff --git a/core/src/main/java/org/apache/struts2/components/StrutsHtmlConstraintProvider.java b/core/src/main/java/org/apache/struts2/components/StrutsHtmlConstraintProvider.java
new file mode 100644
index 000000000..79790aabb
--- /dev/null
+++ b/core/src/main/java/org/apache/struts2/components/StrutsHtmlConstraintProvider.java
@@ -0,0 +1,208 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.struts2.components;
+
+import org.apache.struts2.validator.Validator;
+import org.apache.struts2.validator.validators.CreditCardValidator;
+import org.apache.struts2.validator.validators.DoubleRangeFieldValidator;
+import org.apache.struts2.validator.validators.EmailValidator;
+import org.apache.struts2.validator.validators.RangeValidatorSupport;
+import org.apache.struts2.validator.validators.RegexFieldValidator;
+import org.apache.struts2.validator.validators.RequiredFieldValidator;
+import org.apache.struts2.validator.validators.RequiredStringValidator;
+import org.apache.struts2.validator.validators.StringLengthFieldValidator;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Default {@link HtmlConstraintProvider}.
+ * <p>
+ * Governed by one rule: never false-reject. A constraint is emitted only when the browser cannot
+ * reject input the server would accept. In particular this implementation <em>never sets or changes
+ * an input's {@code type}</em> — switching a field to {@code type="number"} would reject
+ * {@code 1234,50}, which the framework's locale-aware conversion accepts in a comma-decimal locale,
+ * and the browsers' {@code email}/{@code url} grammars differ from the framework's validators.
+ * Range constraints are therefore emitted only on a control the developer already made numeric.
+ *
+ * @since 7.4.0
+ */
+public class StrutsHtmlConstraintProvider implements HtmlConstraintProvider {
+
+    /**
+     * The HTML5 boolean attribute; its canonical serialisation repeats the attribute name as the value.
+     */
+    private static final String REQUIRED = "required";
+
+    @Override
+    public Map<String, String> constraintsFor(List<Validator> validators, HtmlControlType control, Object action) {
+        Map<String, String> attributes = new LinkedHashMap<>();
+        if (validators == null || validators.isEmpty() || control == null) {
+            return attributes;
+        }
+        for (Validator validator : validators) {
+            addConstraints(attributes, validator, control);
+            addMessage(attributes, validator, action);
+        }
+        return attributes;
+    }
+
+    protected void addConstraints(Map<String, String> attributes, Validator validator, HtmlControlType control) {
+        if (validator instanceof RequiredStringValidator) {
+            addRequiredString(attributes, control);
+        } else if (validator instanceof RequiredFieldValidator) {
+            addRequiredField(attributes, control);
+        } else if (validator instanceof StringLengthFieldValidator lengthValidator) {
+            addLength(attributes, lengthValidator, control);
+        } else if (validator instanceof RegexFieldValidator regexValidator) {
+            addPattern(attributes, regexValidator, control);
+        } else if (validator instanceof DoubleRangeFieldValidator doubleValidator) {
+            addDoubleRange(attributes, doubleValidator, control);
+        } else if (validator instanceof RangeValidatorSupport<?> rangeValidator) {
+            addRange(attributes, rangeValidator, control);
+        }
+    }
+
+    /**
+     * {@code requiredstring} fails on null, empty and (by default) blank, so the browser's
+     * {@code required} can only reject what the server would also reject. Safe on any text-entry control.
+     */
+    protected void addRequiredString(Map<String, String> attributes, HtmlControlType control) {
+        if (!control.supportsLength()) {
+            return;
+        }
+        attributes.put(REQUIRED, REQUIRED);
+    }
+
+    /**
+     * {@code required} fails only on null, an empty array or an empty collection. A control that submits
+     * an empty string rather than omitting the parameter therefore passes server-side while the browser
+     * blocks it — an empty text input, a select with an empty-valued header option, and an unticked
+     * checkbox (CheckboxInterceptor substitutes "false") are all in that group. Only RADIO and FILE omit
+     * the parameter entirely when empty, so only they agree with the browser.
+     */
+    protected void addRequiredField(Map<String, String> attributes, HtmlControlType control) {
+        if (control != HtmlControlType.RADIO && control != HtmlControlType.FILE) {
+            return;
+        }
+        attributes.put(REQUIRED, REQUIRED);
+    }
+
+    protected void addLength(Map<String, String> attributes, StringLengthFieldValidator validator, HtmlControlType control) {
+        // with trim=true the server measures the trimmed value, so a maxlength taken from it would
+        // stop the user typing input the server would have accepted
+        if (!control.supportsLength() || validator.isTrim()) {
+            return;
+        }
+        if (validator.getMinLength() > -1) {
+            attributes.put("minlength", String.valueOf(validator.getMinLength()));
+        }
+        if (validator.getMaxLength() > -1) {
+            attributes.put("maxlength", String.valueOf(validator.getMaxLength()));
+        }
+    }
+
+    protected void addPattern(Map<String, String> attributes, RegexFieldValidator validator, HtmlControlType control) {
+        // HTML pattern accepts no flags, so a case-insensitive rule cannot be expressed at all
+        if (!control.supportsPattern() || !validator.isCaseSensitive()) {
+            return;
+        }
+        // trim defaults to true, and the server matches the trimmed value while pattern matches the
+        // raw one: "[a-z]+" would accept "abc " server-side and be blocked by the browser
+        if (validator.isTrimed()) {
+            return;
+        }
+        // Both extend RegexFieldValidator but do not match their regex against the raw value:
+        // CreditCardValidator strips all whitespace first, and both carry grammars the browser
+        // does not share. Neither is expressible as a pattern.
+        if (validator instanceof EmailValidator || validator instanceof CreditCardValidator) {
+            return;
+        }
+        String regex = validator.getRegex();
+        if (EcmaScriptSafeRegex.isSafe(regex)) {
+            attributes.put("pattern", regex);
+        }
+    }
+
+    protected void addRange(Map<String, String> attributes, RangeValidatorSupport<?> validator, HtmlControlType control) {
+        if (!isNumericRange(control)) {
+            // Temporal controls support ranges too, but min/max there need per-control ISO
+            // formatting (date -> yyyy-MM-dd, month -> yyyy-MM, week -> yyyy-'W'ww, time -> HH:mm).
+            // Deliberately deferred; DateRangeFieldValidator therefore emits nothing for now.
+            return;
+        }
+        // min is guarded by isIntegral; see the comment on that method. The shipped Integer/Short/Long
+        // range validators always pass it, but a custom RangeValidatorSupport<Double> would not.
+        Object min = validator.getMin();
+        if (isIntegral(min)) {
+            putIfPresent(attributes, "min", min);
+        }
+        putIfPresent(attributes, "max", validator.getMax());
+    }
+
+    protected void addDoubleRange(Map<String, String> attributes, DoubleRangeFieldValidator validator, HtmlControlType control) {
+        if (!isNumericRange(control)) {
+            // Temporal controls support ranges too, but min/max there need per-control ISO
+            // formatting (date -> yyyy-MM-dd, month -> yyyy-MM, week -> yyyy-'W'ww, time -> HH:mm).
+            // Deliberately deferred; DateRangeFieldValidator therefore emits nothing for now.
+            return;
+        }
+        // exclusive bounds have no HTML equivalent; omitting them leaves the browser more
+        // permissive than the server, which is the safe direction
+        Double minInclusive = validator.getMinInclusive();
+        if (isIntegral(minInclusive)) {
+            putIfPresent(attributes, "min", minInclusive);
+        }
+        putIfPresent(attributes, "max", validator.getMaxInclusive());
+    }
+
+    private boolean isNumericRange(HtmlControlType control) {
+        return control.supportsRange() && (control == HtmlControlType.NUMBER || control == HtmlControlType.RANGE);
+    }
+
+    /**
+     * A fractional {@code min} moves the HTML step base off zero, and with the default {@code step="1"}
+     * the browser then rejects whole numbers the server accepts. {@code max} does not participate in the
+     * step base, so only {@code min} needs this guard.
+     */
+    private boolean isIntegral(Object value) {
+        if (!(value instanceof java.lang.Number number)) {
+            return false;
+        }
+        double asDouble = number.doubleValue();
+        return !Double.isNaN(asDouble) && !Double.isInfinite(asDouble) && asDouble == Math.floor(asDouble);
+    }
+
+    protected void addMessage(Map<String, String> attributes, Validator validator, Object action) {
+        if (action == null) {
+            return;
+        }
+        String message = validator.getMessage(action);
+        if (message != null && !message.isEmpty()) {
+            attributes.put("data-msg-" + validator.getValidatorType(), message);
+        }
+    }
+
+    private void putIfPresent(Map<String, String> attributes, String name, Object value) {
+        if (value != null) {
+            attributes.put(name, String.valueOf(value));
+        }
+    }
+}
diff --git a/core/src/main/java/org/apache/struts2/components/TextArea.java b/core/src/main/java/org/apache/struts2/components/TextArea.java
index 7f3babf56..41856b270 100644
--- a/core/src/main/java/org/apache/struts2/components/TextArea.java
+++ b/core/src/main/java/org/apache/struts2/components/TextArea.java
@@ -62,6 +62,11 @@ public class TextArea extends UIBean {
         return TEMPLATE;
     }
 
+    @Override
+    protected HtmlControlType getControlType() {
+        return HtmlControlType.TEXTAREA;
+    }
+
     public void evaluateExtraParams() {
         super.evaluateExtraParams();
 
diff --git a/core/src/main/java/org/apache/struts2/components/TextField.java b/core/src/main/java/org/apache/struts2/components/TextField.java
index 726c4fc5b..e72772dee 100644
--- a/core/src/main/java/org/apache/struts2/components/TextField.java
+++ b/core/src/main/java/org/apache/struts2/components/TextField.java
@@ -72,6 +72,12 @@ public class TextField extends UIBean {
         return TEMPLATE;
     }
 
+    @Override
+    protected HtmlControlType getControlType() {
+        Object resolvedType = getAttributes().get("type");
+        return resolvedType == null ? HtmlControlType.TEXT : HtmlControlType.from(String.valueOf(resolvedType));
+    }
+
     protected void evaluateExtraParams() {
         super.evaluateExtraParams();
 
diff --git a/core/src/main/java/org/apache/struts2/components/UIBean.java b/core/src/main/java/org/apache/struts2/components/UIBean.java
index adac94dba..42d7cbbcd 100644
--- a/core/src/main/java/org/apache/struts2/components/UIBean.java
+++ b/core/src/main/java/org/apache/struts2/components/UIBean.java
@@ -25,9 +25,12 @@ import org.apache.struts2.util.ValueStack;
 import jakarta.servlet.http.HttpServletRequest;
 import jakarta.servlet.http.HttpServletResponse;
 import jakarta.servlet.http.HttpSession;
+import org.apache.commons.lang3.BooleanUtils;
 import org.apache.commons.lang3.ObjectUtils;
 import org.apache.logging.log4j.LogManager;
 import org.apache.logging.log4j.Logger;
+import org.apache.struts2.ActionContext;
+import org.apache.struts2.ActionInvocation;
 import org.apache.struts2.StrutsConstants;
 import org.apache.struts2.StrutsException;
 import org.apache.struts2.components.template.Template;
@@ -531,6 +534,9 @@ public abstract class UIBean extends Component {
 
     protected CspNonceReader cspNonceReader;
 
+    protected HtmlConstraintProvider htmlConstraintProvider;
+    protected boolean html5ConstraintsEnabled;
+
     @Inject(StrutsConstants.STRUTS_UI_TEMPLATEDIR)
     public void setDefaultTemplateDir(String dir) {
         this.defaultTemplateDir = dir;
@@ -561,6 +567,16 @@ public abstract class UIBean extends Component {
         this.cspNonceReader = cspNonceReader;
     }
 
+    @Inject
+    public void setHtmlConstraintProvider(HtmlConstraintProvider htmlConstraintProvider) {
+        this.htmlConstraintProvider = htmlConstraintProvider;
+    }
+
+    @Inject(value = StrutsConstants.STRUTS_UI_HTML5_CONSTRAINTS, required = false)
+    public void setHtml5ConstraintsEnabled(String html5ConstraintsEnabled) {
+        this.html5ConstraintsEnabled = BooleanUtils.toBoolean(html5ConstraintsEnabled);
+    }
+
     @Override
     public boolean end(Writer writer, String body) {
         evaluateParams();
@@ -903,6 +919,108 @@ public abstract class UIBean extends Component {
         }
 
         evaluateExtraParams();
+
+        // must run after evaluateExtraParams(): that is where TextField resolves attributes.type,
+        // and the control type decides which constraints are legal
+        addConstraintAttributes(form);
+    }
+
+    /**
+     * Derives HTML5 constraint attributes for this field from the action's validators.
+     * <p>
+     * This reaches {@link Form#getFieldValidators(String)}, which resolves the action's validators via
+     * {@code AnnotationActionValidatorManager}, which in turn dereferences the current
+     * {@code ActionInvocation} unconditionally. Before this feature that path only ran under the opt-in
+     * {@code validate="true"}; with constraint derivation gated only by
+     * {@code struts.ui.html5.constraints}, every {@code html5}-themed form now runs it, including one
+     * rendered outside action scope (a direct JSP include from a plain servlet, say) — which would NPE.
+     * A stray {@code null} in the validator list, and a broken {@code ${}} in a validator message
+     * failing in {@code ValidatorSupport.getMessage}, land in the same call. This feature is purely
+     * decorative — a missing constraint attribute costs nothing, a 500 costs the page — so the broad
+     * catch here is deliberate rather than a mistake. Swallowing the failure is only safe because
+     * {@link #restoreStackDepth(int)} undoes whatever that failure left on the value stack.
+     *
+     * @since 7.4.0
+     */
+    protected void addConstraintAttributes(Form form) {
+        if (!html5ConstraintsEnabled || form == null || htmlConstraintProvider == null) {
+            return;
+        }
+        String fieldName = (String) getAttributes().get("name");
+        if (fieldName == null) {
+            return;
+        }
+        int stackDepth = stack.getRoot().size();
+        try {
+            Map<String, String> constraints = htmlConstraintProvider.constraintsFor(
+                form.getFieldValidators(fieldName), getControlType(), resolveAction());
+            if (constraints.isEmpty()) {
+                return;
+            }
+            constraints = new LinkedHashMap<>(constraints);
+            constraints.keySet().removeIf(this::isAlreadyRendered);
+            if (!constraints.isEmpty()) {
+                addParameter("constraints", constraints);
+            }
+        } catch (Exception e) {
+            LOG.warn("Failed to derive HTML5 constraint attributes for field [{}], skipping", fieldName, e);
+        } finally {
+            restoreStackDepth(stackDepth);
+        }
+    }
+
+    /**
+     * The object server-side validation runs against, which is what the derived {@code data-msg-*}
+     * messages must be resolved against too: {@code ValidatorSupport.getMessage} builds a
+     * {@code DelegatingValidatorContext} from it, and that decides which resource bundle the message
+     * key is looked up in.
+     * <p>
+     * Deliberately not {@code stack.peek()}. The top of the stack is not the action whenever
+     * something has been pushed over it — {@code ModelDrivenInterceptor} pushes the model, and an
+     * {@code <s:iterator>} wrapping the field pushes the current element — so peeking would resolve
+     * messages against a model or a list element while {@code ValidationInterceptor} validated the
+     * action.
+     *
+     * @return the action, or null when rendering outside action scope, in which case the provider
+     * simply derives no message attributes
+     */
+    private Object resolveAction() {
+        ActionInvocation invocation = ActionContext.of(stack.getContext()).getActionInvocation();
+        return invocation == null ? null : invocation.getAction();
+    }
+
+    /**
+     * Pops whatever constraint derivation left behind. {@code ValidatorSupport.getMessage} pushes the
+     * action and the validator onto this same request-scoped stack — {@code
+     * DefaultActionValidatorManager.getValidators} hands it {@code ActionContext.getValueStack()} —
+     * and its matching pops are not in a {@code finally}. A message that fails to resolve (a bad
+     * {@code MessageFormat} pattern, an unresolvable {@code ${}}) would therefore leave frames on the
+     * stack, and because the catch above deliberately swallows the failure, every tag rendered after
+     * this one would silently resolve its OGNL against the wrong root.
+     */
+    private void restoreStackDepth(int depth) {
+        while (stack.getRoot().size() > depth) {
+            stack.pop();
+        }
+    }
+
+    /**
+     * True when the developer already supplied this attribute explicitly — as a declared tag attribute
+     * (e.g. {@code maxlength}) or a dynamic one (e.g. {@code min} on a numeric textfield, which is not a
+     * declared attribute of any component) — so a derived constraint of the same name must not be
+     * rendered a second time. The developer's own value always wins.
+     * <p>
+     * {@code required} is deliberately excluded from the declared-attribute half of this check:
+     * {@code requiredLabel} stores an unrelated boolean under the same {@code attributes.required} key,
+     * purely to draw a label asterisk in the xhtml theme, and that must never suppress a genuine
+     * {@code required} constraint derived from a {@code required}/{@code requiredstring} validator. A
+     * {@code required} attribute the developer typed by hand as a dynamic attribute still wins.
+     */
+    private boolean isAlreadyRendered(String attributeName) {
+        if (dynamicAttributes.containsKey(attributeName)) {
+            return true;
+        }
+        return !"required".equals(attributeName) && getAttributes().containsKey(attributeName);
     }
 
     /**
@@ -968,6 +1086,17 @@ public abstract class UIBean extends Component {
         }
     }
 
+    /**
+     * The kind of HTML control this component renders, used to decide which HTML5 constraint
+     * attributes are legal on it. Defaults to {@link HtmlControlType#OTHER}, which supports no
+     * constraints — so a component that does not override this emits none.
+     *
+     * @since 7.4.0
+     */
+    protected HtmlControlType getControlType() {
+        return HtmlControlType.OTHER;
+    }
+
     protected void evaluateExtraParams() {
     }
 
diff --git a/core/src/main/java/org/apache/struts2/config/StrutsBeanSelectionProvider.java b/core/src/main/java/org/apache/struts2/config/StrutsBeanSelectionProvider.java
index f37cb4752..b0dda639b 100644
--- a/core/src/main/java/org/apache/struts2/config/StrutsBeanSelectionProvider.java
+++ b/core/src/main/java/org/apache/struts2/config/StrutsBeanSelectionProvider.java
@@ -29,6 +29,7 @@ import org.apache.struts2.StrutsConstants;
 import org.apache.struts2.text.TextProvider;
 import org.apache.struts2.text.TextProviderFactory;
 import org.apache.struts2.UnknownHandlerManager;
+import org.apache.struts2.components.HtmlConstraintProvider;
 import org.apache.struts2.components.UrlRenderer;
 import org.apache.struts2.components.date.DateFormatter;
 import org.apache.struts2.conversion.ConversionAnnotationProcessor;
@@ -424,6 +425,7 @@ public class StrutsBeanSelectionProvider extends AbstractBeanSelectionProvider {
         alias(MultiPartRequest.class, StrutsConstants.STRUTS_MULTIPART_PARSER, builder, props, Scope.PROTOTYPE);
         alias(FreemarkerManager.class, StrutsConstants.STRUTS_FREEMARKER_MANAGER_CLASSNAME, builder, props);
         alias(UrlRenderer.class, StrutsConstants.STRUTS_URL_RENDERER, builder, props);
+        alias(HtmlConstraintProvider.class, StrutsConstants.STRUTS_HTML_CONSTRAINT_PROVIDER, builder, props);
         alias(ActionValidatorManager.class, StrutsConstants.STRUTS_ACTIONVALIDATORMANAGER, builder, props);
         alias(ValueStackFactory.class, StrutsConstants.STRUTS_VALUESTACKFACTORY, builder, props);
         alias(ReflectionProvider.class, StrutsConstants.STRUTS_REFLECTIONPROVIDER, builder, props);
diff --git a/core/src/main/java/org/apache/struts2/views/jsp/ui/FormTag.java b/core/src/main/java/org/apache/struts2/views/jsp/ui/FormTag.java
index 3bdb48379..b64670247 100644
--- a/core/src/main/java/org/apache/struts2/views/jsp/ui/FormTag.java
+++ b/core/src/main/java/org/apache/struts2/views/jsp/ui/FormTag.java
@@ -54,6 +54,7 @@ public class FormTag extends AbstractClosingTag {
     }
 
     @Override
+    @SuppressWarnings("removal") // must keep forwarding `validate` until it is removed in 8.0.0
     protected void populateParams() {
         super.populateParams();
         Form form = ((Form) component);
@@ -93,6 +94,12 @@ public class FormTag extends AbstractClosingTag {
         this.namespace = namespace;
     }
 
+    /**
+     * @deprecated since 7.4.0, for removal in 8.0.0. The generated client-side validator only ever
+     * covered fields rendered by a nested Struts tag (WW-2975). Use the {@code html5} theme with
+     * {@code struts.ui.html5.constraints=true} instead.
+     */
+    @Deprecated(since = "7.4.0", forRemoval = true)
     public void setValidate(String validate) {
         this.validate = validate;
     }
diff --git a/core/src/main/resources/org/apache/struts2/default.properties b/core/src/main/resources/org/apache/struts2/default.properties
index a14c84bcc..a35001534 100644
--- a/core/src/main/resources/org/apache/struts2/default.properties
+++ b/core/src/main/resources/org/apache/struts2/default.properties
@@ -171,11 +171,20 @@ struts.ui.theme.expansion.token=~~~
 ### Sets the default template type. Either ftl, vm, or jsp
 struts.ui.templateSuffix=ftl
 
+### Whether the html5 theme emits HTML5 constraint attributes (required, minlength,
+### maxlength, pattern, min, max) derived from the action's validators.
+### Defaults to false so existing html5-theme forms render unchanged; the default is
+### expected to flip in a future major release.
+struts.ui.html5.constraints=false
+
 ### Sets a global flag which will escape html body of Anchor, Submit and Component tag
 ### You can control this flag per tag, e.g.: <s:a ... escapeHtmlTag="true">...</s:a>
 ### and this take precedence over the global flag
 # struts.ui.escapeHtmlBody=true
 
+### The HtmlConstraintProvider implementation used to derive HTML5 constraint attributes
+struts.htmlConstraintProvider=struts
+
 ### Configuration reloading
 ### This will cause the configuration to reload struts.xml when it is changed
 # struts.configuration.xml.reload=false
diff --git a/core/src/main/resources/struts-beans.xml b/core/src/main/resources/struts-beans.xml
index 84f0919dc..8ad4dff8b 100644
--- a/core/src/main/resources/struts-beans.xml
+++ b/core/src/main/resources/struts-beans.xml
@@ -145,6 +145,8 @@
 
     <bean type="org.apache.struts2.components.UrlRenderer" name="struts"
           class="org.apache.struts2.components.ServletUrlRenderer"/>
+    <bean type="org.apache.struts2.components.HtmlConstraintProvider" name="struts"
+          class="org.apache.struts2.components.StrutsHtmlConstraintProvider"/>
     <bean type="org.apache.struts2.views.util.UrlHelper" name="struts"
           class="org.apache.struts2.views.util.DefaultUrlHelper"/>
 
diff --git a/core/src/main/resources/template/html5/common-attributes.ftl b/core/src/main/resources/template/html5/common-attributes.ftl
index 424316dad..d8730c23a 100644
--- a/core/src/main/resources/template/html5/common-attributes.ftl
+++ b/core/src/main/resources/template/html5/common-attributes.ftl
@@ -21,3 +21,4 @@
 <#if attributes.accesskey?has_content>
  accesskey="${attributes.accesskey}"<#rt/>
 </#if>
+<#include "/${attributes.templateDir}/${attributes.expandTheme}/constraints.ftl" /><#rt/>
diff --git a/core/src/main/resources/template/html5/common-attributes.ftl b/core/src/main/resources/template/html5/constraints.ftl
similarity index 84%
copy from core/src/main/resources/template/html5/common-attributes.ftl
copy to core/src/main/resources/template/html5/constraints.ftl
index 424316dad..ad86c25b0 100644
--- a/core/src/main/resources/template/html5/common-attributes.ftl
+++ b/core/src/main/resources/template/html5/constraints.ftl
@@ -18,6 +18,8 @@
  * under the License.
  */
 -->
-<#if attributes.accesskey?has_content>
- accesskey="${attributes.accesskey}"<#rt/>
+<#if attributes.constraints??>
+<#list attributes.constraints as attributeName, attributeValue>
+ ${attributeName}="${attributeValue}"<#rt/>
+</#list>
 </#if>
diff --git a/core/src/main/resources/template/xhtml/form-close-validate.ftl b/core/src/main/resources/template/xhtml/form-close-validate.ftl
index 8e8661568..a815105a1 100644
--- a/core/src/main/resources/template/xhtml/form-close-validate.ftl
+++ b/core/src/main/resources/template/xhtml/form-close-validate.ftl
@@ -19,6 +19,13 @@
  */
 -->
 <#--
+DEPRECATED since Struts 7.4.0, removed in 8.0.0 (WW-5694 / WW-5696).
+
+JavaScript client-side validation is superseded by native HTML5 constraint
+attributes in the html5 theme (WW-5695). This template, form-validate.ftl and
+validation.js are all removed in 8.0.0.
+-->
+<#--
 START SNIPPET: supported-validators
 Only the following validators are supported:
 * required validator
diff --git a/core/src/test/java/org/apache/struts2/TestConfigurationProvider.java b/core/src/test/java/org/apache/struts2/TestConfigurationProvider.java
index 34d1ecd5b..c6fabf742 100644
--- a/core/src/test/java/org/apache/struts2/TestConfigurationProvider.java
+++ b/core/src/test/java/org/apache/struts2/TestConfigurationProvider.java
@@ -38,6 +38,7 @@ import org.apache.struts2.interceptor.TokenInterceptor;
 import org.apache.struts2.interceptor.TokenSessionStoreInterceptor;
 import org.apache.struts2.interceptor.parameter.ParametersInterceptor;
 import org.apache.struts2.result.ServletDispatcherResult;
+import org.apache.struts2.components.ConstraintAction;
 import org.apache.struts2.views.jsp.ui.DoubleValidationAction;
 
 import java.util.HashMap;
@@ -94,6 +95,13 @@ public class TestConfigurationProvider implements ConfigurationProvider {
             .addInterceptor(new InterceptorMapping("validation", validationInterceptor))
             .build();
 
+        ActionConfig constraintActionConfig = new ActionConfig.Builder("", "constraintAction", ConstraintAction.class.getName())
+            .addResultConfig(new ResultConfig.Builder(Action.SUCCESS, ServletDispatcherResult.class.getName())
+                    .addParam("location", "success.jsp")
+                    .build())
+            .addInterceptor(new InterceptorMapping("validation", validationInterceptor))
+            .build();
+
         ActionConfig testActionConfig = new ActionConfig.Builder("", "", TestAction.class.getName())
             .addResultConfig(new ResultConfig.Builder(Action.SUCCESS, ServletDispatcherResult.class.getName())
                     .addParam("location", "success.jsp")
@@ -119,6 +127,7 @@ public class TestConfigurationProvider implements ConfigurationProvider {
             .addActionConfig(EXECUTION_COUNT_ACTION_NAME, executionCountActionConfig)
             .addActionConfig(TEST_ACTION_NAME, testActionConfig)
             .addActionConfig("doubleValidationAction", doubleValidationActionConfig)
+            .addActionConfig("constraintAction", constraintActionConfig)
             .addActionConfig(TOKEN_ACTION_NAME, tokenActionConfig)
             .addActionConfig(TOKEN_SESSION_ACTION_NAME, tokenSessionActionConfig)
             .addActionConfig("testActionTagAction", new ActionConfig.Builder("", "", TestAction.class.getName())
diff --git a/core/src/main/resources/template/html5/common-attributes.ftl b/core/src/test/java/org/apache/struts2/components/ConstraintAction.java
similarity index 51%
copy from core/src/main/resources/template/html5/common-attributes.ftl
copy to core/src/test/java/org/apache/struts2/components/ConstraintAction.java
index 424316dad..2a1f9b4f3 100644
--- a/core/src/main/resources/template/html5/common-attributes.ftl
+++ b/core/src/test/java/org/apache/struts2/components/ConstraintAction.java
@@ -1,4 +1,3 @@
-<#--
 /*
  * Licensed to the Apache Software Foundation (ASF) under one
  * or more contributor license agreements.  See the NOTICE file
@@ -17,7 +16,41 @@
  * specific language governing permissions and limitations
  * under the License.
  */
--->
-<#if attributes.accesskey?has_content>
- accesskey="${attributes.accesskey}"<#rt/>
-</#if>
+package org.apache.struts2.components;
+
+import org.apache.struts2.ActionSupport;
+import org.apache.struts2.interceptor.parameter.StrutsParameter;
+
+public class ConstraintAction extends ActionSupport {
+
+    private String username;
+    private String comment;
+    private String bio;
+
+    public String getUsername() {
+        return username;
+    }
+
+    @StrutsParameter
+    public void setUsername(String username) {
+        this.username = username;
+    }
+
+    public String getComment() {
+        return comment;
+    }
+
+    @StrutsParameter
+    public void setComment(String comment) {
+        this.comment = comment;
+    }
+
+    public String getBio() {
+        return bio;
+    }
+
+    @StrutsParameter
+    public void setBio(String bio) {
+        this.bio = bio;
+    }
+}
diff --git a/core/src/test/java/org/apache/struts2/components/ConstraintAttributesTest.java b/core/src/test/java/org/apache/struts2/components/ConstraintAttributesTest.java
new file mode 100644
index 000000000..7a76f1d7b
--- /dev/null
+++ b/core/src/test/java/org/apache/struts2/components/ConstraintAttributesTest.java
@@ -0,0 +1,170 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.struts2.components;
+
+import org.apache.struts2.StrutsConstants;
+import org.apache.struts2.TestConfigurationProvider;
+import org.apache.struts2.mock.MockActionProxy;
+import org.apache.struts2.views.jsp.AbstractUITagTest;
+import org.apache.struts2.views.jsp.ui.FormTag;
+import org.apache.struts2.views.jsp.ui.TextFieldTag;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+public class ConstraintAttributesTest extends AbstractUITagTest {
+
+    private FormTag form;
+
+    public void testNoConstraintsWhenTheConstantIsOff() throws Exception {
+        initDispatcherWith("false");
+
+        assertNull(renderFieldAndReturnConstraints(null));
+    }
+
+    public void testConstraintsWhenTheConstantIsOn() throws Exception {
+        initDispatcherWith("true");
+
+        Map<String, String> constraints = renderFieldAndReturnConstraints(null);
+        assertNotNull("expected constraints to be populated", constraints);
+        assertEquals("3", constraints.get("minlength"));
+    }
+
+    /**
+     * Pins the hook to running after {@code evaluateExtraParams()}. A {@code stringlength} validator on
+     * a control the browser treats as numeric must not emit {@code minlength} at all — that attribute
+     * is not legal there. This can only resolve correctly if the control type ({@code type="number"},
+     * resolved by {@code TextField.evaluateExtraParams()}) is already known when the constraint hook
+     * fires. Untyped text fields resolve to {@code TEXT} either way, so
+     * {@link #testConstraintsWhenTheConstantIsOn()} alone cannot distinguish a correctly-placed hook
+     * from one hoisted up to the {@code tagNames} block.
+     */
+    public void testConstraintsRespectAnExplicitInputType() throws Exception {
+        initDispatcherWith("true");
+
+        Map<String, String> constraints = renderFieldAndReturnConstraints("number");
+
+        assertTrue("expected minlength to be suppressed for a numeric control",
+            constraints == null || !constraints.containsKey("minlength"));
+    }
+
+    /**
+     * The action handed to the provider must be the one server-side validation ran against —
+     * {@code ValidationInterceptor} validates {@code invocation.getAction()} — because
+     * {@code ValidatorSupport.getMessage} builds its {@code DelegatingValidatorContext} from that
+     * object, and the context decides which resource bundle a {@code data-msg-*} key resolves in.
+     * The top of the value stack is not that action whenever something has been pushed over it:
+     * {@code ModelDrivenInterceptor} pushes the model, an {@code <s:iterator>} around the field
+     * pushes the current element. The marker pushed here stands in for both.
+     */
+    public void testActionComesFromTheInvocationNotTheTopOfTheStack() throws Exception {
+        initDispatcherWith("true");
+
+        TextFieldTag field = startField(null);
+        List<Object> captured = new ArrayList<>();
+        // not named `action`: that would shadow the inherited field this test asserts against
+        ((UIBean) field.getComponent()).setHtmlConstraintProvider((validators, control, derivedFrom) -> {
+            captured.add(derivedFrom);
+            return Collections.emptyMap();
+        });
+        Object pushedOverTheAction = new Object();
+        stack.push(pushedOverTheAction);
+
+        finishField(field);
+
+        assertEquals("expected the provider to be consulted once", 1, captured.size());
+        assertNotSame("messages must not resolve against whatever sits on top of the stack",
+            pushedOverTheAction, captured.get(0));
+        assertSame(action, captured.get(0));
+    }
+
+    /**
+     * Derivation runs against the request-scoped value stack — {@code
+     * DefaultActionValidatorManager.getValidators} hands each validator {@code
+     * ActionContext.getValueStack()} — and {@code ValidatorSupport.getMessage} pushes the action and
+     * the validator onto it with the matching pops outside any {@code finally}. A message that fails
+     * to resolve therefore leaves frames behind, and since the failure is deliberately swallowed,
+     * every tag rendered afterwards would silently resolve its OGNL against the wrong root. The
+     * provider below stands in for that, failing the same way at the same point.
+     */
+    public void testTheValueStackIsRestoredWhenDerivationFails() throws Exception {
+        initDispatcherWith("true");
+
+        TextFieldTag field = startField(null);
+        ((UIBean) field.getComponent()).setHtmlConstraintProvider((validators, control, derivedFrom) -> {
+            stack.push(new Object());
+            throw new IllegalStateException("message resolution failed midway");
+        });
+        int depthBeforeRendering = stack.getRoot().size();
+
+        finishField(field);
+
+        assertEquals("a swallowed failure must not leave the stack dirty for later tags",
+            depthBeforeRendering, stack.getRoot().size());
+    }
+
+    @SuppressWarnings("unchecked")
+    private Map<String, String> renderFieldAndReturnConstraints(String type) throws Exception {
+        TextFieldTag field = startField(type);
+
+        Map<String, Object> attributes =
+            ((UIBean) field.getComponent()).getAttributes();
+
+        finishField(field);
+
+        return (Map<String, String>) attributes.get("constraints");
+    }
+
+    private TextFieldTag startField(String type) throws Exception {
+        form = new FormTag();
+        form.setPageContext(pageContext);
+        form.setAction("constraintAction");
+        form.setNamespace("");
+        form.doStartTag();
+
+        TextFieldTag field = new TextFieldTag();
+        field.setPageContext(pageContext);
+        field.setName("username");
+        if (type != null) {
+            field.setType(type);
+        }
+        field.doStartTag();
+        return field;
+    }
+
+    private void finishField(TextFieldTag field) throws Exception {
+        field.doEndTag();
+        form.doEndTag();
+    }
+
+    private void initDispatcherWith(String constraintsEnabled) {
+        initDispatcher(new HashMap<String, String>() {{
+            put("configProviders", TestConfigurationProvider.class.getName());
+            put(StrutsConstants.STRUTS_UI_HTML5_CONSTRAINTS, constraintsEnabled);
+        }});
+        createMocks();
+        // createMocks() never sets a config on the MockActionProxy it builds; without one,
+        // AnnotationActionValidatorManager.buildValidatorKey NPEs dereferencing proxy.getConfig().
+        ((MockActionProxy) actionProxy).setConfig(
+            configuration.getRuntimeConfiguration().getActionConfig("", "constraintAction"));
+    }
+}
diff --git a/core/src/test/java/org/apache/struts2/components/ControlTypeTest.java b/core/src/test/java/org/apache/struts2/components/ControlTypeTest.java
new file mode 100644
index 000000000..c43cfe6b0
--- /dev/null
+++ b/core/src/test/java/org/apache/struts2/components/ControlTypeTest.java
@@ -0,0 +1,74 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.struts2.components;
+
+import org.apache.struts2.views.jsp.AbstractUITagTest;
+
+public class ControlTypeTest extends AbstractUITagTest {
+
+    public void testTextFieldDefaultsToText() {
+        TextField textField = new TextField(stack, request, response);
+        assertEquals(HtmlControlType.TEXT, textField.getControlType());
+    }
+
+    public void testTextFieldHonoursAnExplicitType() {
+        TextField textField = new TextField(stack, request, response);
+        textField.addParameter("type", "number");
+        assertEquals(HtmlControlType.NUMBER, textField.getControlType());
+    }
+
+    public void testTextFieldFallsBackForAnUnknownType() {
+        TextField textField = new TextField(stack, request, response);
+        textField.addParameter("type", "supercolor");
+        assertEquals(HtmlControlType.OTHER, textField.getControlType());
+    }
+
+    public void testPasswordIsAlwaysPassword() {
+        Password password = new Password(stack, request, response);
+        assertEquals(HtmlControlType.PASSWORD, password.getControlType());
+    }
+
+    public void testTextAreaIsTextarea() {
+        TextArea textArea = new TextArea(stack, request, response);
+        assertEquals(HtmlControlType.TEXTAREA, textArea.getControlType());
+    }
+
+    public void testSelectIsSelect() {
+        Select select = new Select(stack, request, response);
+        assertEquals(HtmlControlType.SELECT, select.getControlType());
+    }
+
+    public void testRadioIsRadio() {
+        Radio radio = new Radio(stack, request, response);
+        assertEquals(HtmlControlType.RADIO, radio.getControlType());
+    }
+
+    public void testFileIsFile() {
+        File file = new File(stack, request, response);
+        assertEquals(HtmlControlType.FILE, file.getControlType());
+    }
+
+    public void testControlsWithoutAnOverrideAreUnknown() {
+        // CheckboxInterceptor substitutes "false" for an unticked box, so the server accepts what
+        // a browser "required" would block — that is a real false reject, and the reason Checkbox
+        // and Hidden deliberately have no getControlType() override.
+        assertEquals(HtmlControlType.OTHER, new Checkbox(stack, request, response).getControlType());
+        assertEquals(HtmlControlType.OTHER, new Hidden(stack, request, response).getControlType());
+    }
+}
diff --git a/core/src/test/java/org/apache/struts2/components/EcmaScriptSafeRegexTest.java b/core/src/test/java/org/apache/struts2/components/EcmaScriptSafeRegexTest.java
new file mode 100644
index 000000000..743ccc4c3
--- /dev/null
+++ b/core/src/test/java/org/apache/struts2/components/EcmaScriptSafeRegexTest.java
@@ -0,0 +1,99 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.struts2.components;
+
+import org.junit.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class EcmaScriptSafeRegexTest {
+
+    @Test
+    public void acceptsPortableConstructs() {
+        assertThat(EcmaScriptSafeRegex.isSafe("[a-z]+")).isTrue();
+        assertThat(EcmaScriptSafeRegex.isSafe("\\d{3}-\\d{4}")).isTrue();
+        assertThat(EcmaScriptSafeRegex.isSafe("(foo|bar)?baz")).isTrue();
+        assertThat(EcmaScriptSafeRegex.isSafe("^\\w+@\\w+\\.\\w{2,6}$")).isTrue();
+        assertThat(EcmaScriptSafeRegex.isSafe("(?:ab)+")).isTrue();
+        assertThat(EcmaScriptSafeRegex.isSafe("a(?=b)")).isTrue();
+        assertThat(EcmaScriptSafeRegex.isSafe("a(?!b)")).isTrue();
+    }
+
+    @Test
+    public void rejectsJavaOnlyEscapes() {
+        assertThat(EcmaScriptSafeRegex.isSafe("\\p{Alpha}+")).isFalse();
+        assertThat(EcmaScriptSafeRegex.isSafe("\\A\\d+\\z")).isFalse();
+        assertThat(EcmaScriptSafeRegex.isSafe("\\Qliteral\\E")).isFalse();
+        assertThat(EcmaScriptSafeRegex.isSafe("\\h+")).isFalse();
+    }
+
+    @Test
+    public void rejectsPossessiveQuantifiers() {
+        assertThat(EcmaScriptSafeRegex.isSafe("\\d++")).isFalse();
+        assertThat(EcmaScriptSafeRegex.isSafe("a*+")).isFalse();
+        assertThat(EcmaScriptSafeRegex.isSafe("a?+")).isFalse();
+        assertThat(EcmaScriptSafeRegex.isSafe("a{2,3}+")).isFalse();
+    }
+
+    @Test
+    public void rejectsNonPortableGroups() {
+        assertThat(EcmaScriptSafeRegex.isSafe("(?<name>a)")).isFalse();
+        assertThat(EcmaScriptSafeRegex.isSafe("(?<=a)b")).isFalse();
+        assertThat(EcmaScriptSafeRegex.isSafe("(?>a)")).isFalse();
+        assertThat(EcmaScriptSafeRegex.isSafe("(?i)abc")).isFalse();
+    }
+
+    @Test
+    public void rejectsJavaCharacterClassFeatures() {
+        assertThat(EcmaScriptSafeRegex.isSafe("[[:alpha:]]")).isFalse();
+        assertThat(EcmaScriptSafeRegex.isSafe("[a-z&&[^aeiou]]")).isFalse();
+    }
+
+    @Test
+    public void rejectsUnusableInput() {
+        assertThat(EcmaScriptSafeRegex.isSafe(null)).isFalse();
+        assertThat(EcmaScriptSafeRegex.isSafe("")).isFalse();
+        assertThat(EcmaScriptSafeRegex.isSafe("abc\\")).isFalse();
+        assertThat(EcmaScriptSafeRegex.isSafe("[abc")).isFalse();
+    }
+
+    @Test
+    public void rejectsWhitespaceClassesWhoseMeaningDiffersBetweenEngines() {
+        // Java's \s is ASCII-only by default; ECMAScript's includes NBSP and friends, so
+        // ^\S+$ accepts a value containing NBSP on the server and rejects it in the browser
+        assertThat(EcmaScriptSafeRegex.isSafe("^\\S+$")).isFalse();
+        assertThat(EcmaScriptSafeRegex.isSafe("\\s*")).isFalse();
+    }
+
+    @Test
+    public void rejectsWordBoundariesWhoseMeaningVariesByJdk() {
+        // Up to Java 18 the boundary was decided by Character.isLetterOrDigit, so it was
+        // Unicode-aware while \w stayed ASCII; JDK 19 made the two consistent. Pattern.matches(
+        // "^\\bäiti\\b$", "äiti") is therefore true on Java 17 and false on Java 21, while
+        // ECMAScript's always-ASCII boundary rejects it in every browser - a false reject on the
+        // Java 17 baseline.
+        assertThat(EcmaScriptSafeRegex.isSafe("^\\bäiti\\b$")).isFalse();
+        assertThat(EcmaScriptSafeRegex.isSafe("^ä\\Biti$")).isFalse();
+        // rejected as a construct, not conditionally on the regex containing non-ASCII: a
+        // pattern is a template for input we have not seen, so an ASCII-only regex says nothing
+        // about the values it will be asked to match
+        assertThat(EcmaScriptSafeRegex.isSafe("\\bword\\b")).isFalse();
+        assertThat(EcmaScriptSafeRegex.isSafe("a\\Bb")).isFalse();
+    }
+}
diff --git a/core/src/test/java/org/apache/struts2/components/FormFieldValidatorsTest.java b/core/src/test/java/org/apache/struts2/components/FormFieldValidatorsTest.java
new file mode 100644
index 000000000..cdc2fbfc2
--- /dev/null
+++ b/core/src/test/java/org/apache/struts2/components/FormFieldValidatorsTest.java
@@ -0,0 +1,95 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.struts2.components;
+
+import org.apache.struts2.TestConfigurationProvider;
+import org.apache.struts2.mock.MockActionProxy;
+import org.apache.struts2.validator.ActionValidatorManager;
+import org.apache.struts2.validator.Validator;
+import org.apache.struts2.views.jsp.AbstractUITagTest;
+import org.apache.struts2.views.jsp.ui.FormTag;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.nullable;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.when;
+
+public class FormFieldValidatorsTest extends AbstractUITagTest {
+
+    public void testFindsTheFieldsValidators() throws Exception {
+        Form form = formForDoubleValidationAction();
+
+        List<Validator> validators = form.getFieldValidators("myUpDownSelectTag");
+
+        assertEquals(1, validators.size());
+        assertEquals("double", validators.get(0).getValidatorType());
+    }
+
+    public void testReturnsEmptyForAnUnvalidatedField() throws Exception {
+        Form form = formForDoubleValidationAction();
+
+        assertTrue(form.getFieldValidators("noSuchField").isEmpty());
+    }
+
+    public void testResolvesTheActionsValidatorsOnlyOnceAcrossFields() throws Exception {
+        Form form = formForDoubleValidationAction();
+
+        ActionValidatorManager manager = mock(ActionValidatorManager.class);
+        when(manager.getValidators(any(Class.class), anyString(), nullable(String.class)))
+            .thenReturn(Collections.emptyList());
+        form.setActionValidatorManager(manager);
+
+        form.getFieldValidators("myUpDownSelectTag");
+        form.getFieldValidators("someOtherField");
+
+        // fully qualified: AbstractUITagTest inherits verify(URL), which would shadow a static import
+        org.mockito.Mockito.verify(manager, times(1))
+            .getValidators(any(Class.class), anyString(), nullable(String.class));
+    }
+
+    private Form formForDoubleValidationAction() throws Exception {
+        FormTag tag = new FormTag();
+        tag.setPageContext(pageContext);
+        tag.setName("myForm");
+        tag.setAction("doubleValidationAction");
+        tag.setNamespace("");
+        tag.doStartTag();
+        return (Form) tag.getComponent();
+    }
+
+    @Override
+    protected void setUp() throws Exception {
+        super.setUp();
+        initDispatcher(new HashMap<String, String>() {{
+            put("configProviders", TestConfigurationProvider.class.getName());
+        }});
+        createMocks();
+        // AnnotationActionValidatorManager.buildValidatorKey() dereferences the current ActionInvocation's
+        // proxy config; at real runtime the Dispatcher always attaches one, but the mock proxy from
+        // createMocks() does not, so it has to be wired up explicitly here.
+        ((MockActionProxy) actionProxy).setConfig(
+            configuration.getRuntimeConfiguration().getActionConfig("", "doubleValidationAction"));
+    }
+}
diff --git a/core/src/test/java/org/apache/struts2/components/HtmlControlTypeTest.java b/core/src/test/java/org/apache/struts2/components/HtmlControlTypeTest.java
new file mode 100644
index 000000000..9fa862cdd
--- /dev/null
+++ b/core/src/test/java/org/apache/struts2/components/HtmlControlTypeTest.java
@@ -0,0 +1,78 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.struts2.components;
+
+import org.junit.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class HtmlControlTypeTest {
+
+    @Test
+    public void resolvesKnownTypes() {
+        assertThat(HtmlControlType.from("text")).isEqualTo(HtmlControlType.TEXT);
+        assertThat(HtmlControlType.from("number")).isEqualTo(HtmlControlType.NUMBER);
+        assertThat(HtmlControlType.from("datetime-local")).isEqualTo(HtmlControlType.DATETIME_LOCAL);
+    }
+
+    @Test
+    public void isLenientAboutCaseAndWhitespace() {
+        assertThat(HtmlControlType.from("  NuMbEr ")).isEqualTo(HtmlControlType.NUMBER);
+    }
+
+    @Test
+    public void neverThrowsOnUnusableInput() {
+        assertThat(HtmlControlType.from(null)).isEqualTo(HtmlControlType.OTHER);
+        assertThat(HtmlControlType.from("")).isEqualTo(HtmlControlType.OTHER);
+        assertThat(HtmlControlType.from("   ")).isEqualTo(HtmlControlType.OTHER);
+        assertThat(HtmlControlType.from("supercolor")).isEqualTo(HtmlControlType.OTHER);
+    }
+
+    @Test
+    public void otherSupportsNothing() {
+        assertThat(HtmlControlType.OTHER.supportsPattern()).isFalse();
+        assertThat(HtmlControlType.OTHER.supportsLength()).isFalse();
+        assertThat(HtmlControlType.OTHER.supportsRange()).isFalse();
+    }
+
+    @Test
+    public void patternIsTextEntryOnly() {
+        assertThat(HtmlControlType.TEXT.supportsPattern()).isTrue();
+        assertThat(HtmlControlType.PASSWORD.supportsPattern()).isTrue();
+        assertThat(HtmlControlType.NUMBER.supportsPattern()).isFalse();
+        assertThat(HtmlControlType.TEXTAREA.supportsPattern()).isFalse();
+        assertThat(HtmlControlType.SELECT.supportsPattern()).isFalse();
+    }
+
+    @Test
+    public void lengthIsTextEntryPlusTextarea() {
+        assertThat(HtmlControlType.TEXT.supportsLength()).isTrue();
+        assertThat(HtmlControlType.TEXTAREA.supportsLength()).isTrue();
+        assertThat(HtmlControlType.NUMBER.supportsLength()).isFalse();
+        assertThat(HtmlControlType.CHECKBOX.supportsLength()).isFalse();
+    }
+
+    @Test
+    public void rangeIsNumericAndTemporalOnly() {
+        assertThat(HtmlControlType.NUMBER.supportsRange()).isTrue();
+        assertThat(HtmlControlType.RANGE.supportsRange()).isTrue();
+        assertThat(HtmlControlType.DATE.supportsRange()).isTrue();
+        assertThat(HtmlControlType.TEXT.supportsRange()).isFalse();
+    }
+}
diff --git a/core/src/test/java/org/apache/struts2/components/StrutsHtmlConstraintProviderTest.java b/core/src/test/java/org/apache/struts2/components/StrutsHtmlConstraintProviderTest.java
new file mode 100644
index 000000000..0cc97eab0
--- /dev/null
+++ b/core/src/test/java/org/apache/struts2/components/StrutsHtmlConstraintProviderTest.java
@@ -0,0 +1,312 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.struts2.components;
+
+import org.apache.struts2.ActionSupport;
+import org.apache.struts2.validator.Validator;
+import org.apache.struts2.validator.validators.CreditCardValidator;
+import org.apache.struts2.validator.validators.DateRangeFieldValidator;
+import org.apache.struts2.validator.validators.DoubleRangeFieldValidator;
+import org.apache.struts2.validator.validators.EmailValidator;
+import org.apache.struts2.validator.validators.IntRangeFieldValidator;
+import org.apache.struts2.validator.validators.RegexFieldValidator;
+import org.apache.struts2.validator.validators.RequiredFieldValidator;
+import org.apache.struts2.validator.validators.RequiredStringValidator;
+import org.apache.struts2.validator.validators.StringLengthFieldValidator;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+import static java.util.Collections.singletonList;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class StrutsHtmlConstraintProviderTest {
+
+    private StrutsHtmlConstraintProvider provider;
+    private Object action;
+
+    @Before
+    public void setUp() {
+        provider = new StrutsHtmlConstraintProvider();
+        action = new ActionSupport();
+    }
+
+    private Map<String, String> constraints(Validator validator, HtmlControlType control) {
+        return provider.constraintsFor(singletonList(validator), control, null);
+    }
+
+    @Test
+    public void requiredStringEmitsRequiredEvenThoughServerIsStricter() {
+        assertThat(constraints(new RequiredStringValidator(), HtmlControlType.TEXT))
+            .containsEntry("required", "required");
+    }
+
+    @Test
+    public void requiredStringEmitsRequiredOnTextarea() {
+        assertThat(constraints(new RequiredStringValidator(), HtmlControlType.TEXTAREA))
+            .containsEntry("required", "required");
+    }
+
+    @Test
+    public void requiredFieldEmitsNothingOnATextControlBecauseEmptyStringWouldPassServerSide() {
+        // an empty text input submits name="", which RequiredFieldValidator accepts (it only
+        // rejects null / empty array / empty collection) — required here would false-reject
+        assertThat(constraints(new RequiredFieldValidator(), HtmlControlType.TEXT)).isEmpty();
+    }
+
+    @Test
+    public void requiredFieldEmitsNothingOnACheckboxBecauseUncheckedSubstitutesFalse() {
+        // CheckboxInterceptor substitutes "false" for an unticked box, so the field is never
+        // null server-side and an unticked required checkbox would still pass validation
+        assertThat(constraints(new RequiredFieldValidator(), HtmlControlType.CHECKBOX)).isEmpty();
+    }
+
+    @Test
+    public void requiredFieldEmitsNothingOnASelectBecauseAnEmptyOptionWouldPassServerSide() {
+        // a select with an empty-valued header option submits "", which passes server-side
+        assertThat(constraints(new RequiredFieldValidator(), HtmlControlType.SELECT)).isEmpty();
+    }
+
+    @Test
+    public void requiredFieldEmitsRequiredOnRadioBecauseNoSelectionOmitsTheParameter() {
+        // an unselected radio group omits the parameter entirely, agreeing with the server
+        assertThat(constraints(new RequiredFieldValidator(), HtmlControlType.RADIO))
+            .containsEntry("required", "required");
+    }
+
+    @Test
+    public void requiredFieldEmitsRequiredOnFileBecauseNoSelectionOmitsTheParameter() {
+        // an empty file input omits the parameter entirely, agreeing with the server
+        assertThat(constraints(new RequiredFieldValidator(), HtmlControlType.FILE))
+            .containsEntry("required", "required");
+    }
+
+    @Test
+    public void stringLengthEmitsLengthsWhenNotTrimming() {
+        StringLengthFieldValidator validator = new StringLengthFieldValidator();
+        validator.setTrim(false);
+        validator.setMinLength(3);
+        validator.setMaxLength(10);
+
+        assertThat(constraints(validator, HtmlControlType.TEXT))
+            .containsEntry("minlength", "3")
+            .containsEntry("maxlength", "10");
+    }
+
+    @Test
+    public void stringLengthEmitsNothingWhenTrimming() {
+        StringLengthFieldValidator validator = new StringLengthFieldValidator();
+        validator.setTrim(true);
+        validator.setMinLength(3);
+        validator.setMaxLength(10);
+
+        // the server measures the trimmed value, so maxlength here would stop the user
+        // typing input the server would have accepted
+        assertThat(constraints(validator, HtmlControlType.TEXT)).isEmpty();
+    }
+
+    @Test
+    public void stringLengthEmitsNothingOnAControlWithoutLength() {
+        StringLengthFieldValidator validator = new StringLengthFieldValidator();
+        validator.setTrim(false);
+        validator.setMaxLength(10);
+
+        assertThat(constraints(validator, HtmlControlType.NUMBER)).isEmpty();
+    }
+
+    @Test
+    public void stringLengthOmitsMinlengthWhenOnlyMaxLengthIsSet() {
+        StringLengthFieldValidator validator = new StringLengthFieldValidator();
+        validator.setTrim(false);
+        validator.setMaxLength(10);
+
+        Map<String, String> result = constraints(validator, HtmlControlType.TEXT);
+
+        // minLength defaults to the -1 sentinel (unset), which must not become "minlength=-1"
+        assertThat(result)
+            .containsEntry("maxlength", "10")
+            .doesNotContainKey("minlength");
+    }
+
+    @Test
+    public void regexEmitsPatternWhenPortableAndCaseSensitive() {
+        RegexFieldValidator validator = new RegexFieldValidator();
+        validator.setRegex("[a-z]+");
+        validator.setCaseSensitive(true);
+        validator.setTrim(false);
+
+        assertThat(constraints(validator, HtmlControlType.TEXT))
+            .containsEntry("pattern", "[a-z]+");
+    }
+
+    @Test
+    public void regexEmitsNothingWhenCaseInsensitive() {
+        RegexFieldValidator validator = new RegexFieldValidator();
+        validator.setRegex("[a-z]+");
+        validator.setCaseSensitive(false);
+        validator.setTrim(false);
+
+        // HTML pattern accepts no flags, so a case-insensitive rule cannot be expressed
+        assertThat(constraints(validator, HtmlControlType.TEXT)).isEmpty();
+    }
+
+    @Test
+    public void regexEmitsNothingWhenNotPortable() {
+        RegexFieldValidator validator = new RegexFieldValidator();
+        validator.setRegex("\\p{Alpha}+");
+        validator.setCaseSensitive(true);
+        validator.setTrim(false);
+
+        assertThat(constraints(validator, HtmlControlType.TEXT)).isEmpty();
+    }
+
+    @Test
+    public void regexEmitsNothingWhenTrimming() {
+        // trim defaults to true: the server matches the trimmed value while pattern matches the
+        // raw one, so "abc " would pass server-side and be blocked by the browser
+        RegexFieldValidator validator = new RegexFieldValidator();
+        validator.setRegex("[a-z]+");
+        validator.setCaseSensitive(true);
+
+        assertThat(constraints(validator, HtmlControlType.TEXT)).isEmpty();
+    }
+
+    @Test
+    public void creditCardValidatorNeverContributesAPatternConstraint() {
+        // CreditCardValidator strips all whitespace before matching, so its regex cannot be
+        // expressed as a browser pattern without also stripping whitespace client-side.
+        // caseSensitive and trim are set explicitly here so this test actually reaches the
+        // EmailValidator/CreditCardValidator exclusion in addPattern, rather than returning
+        // earlier at the case-sensitivity guard (the constructor defaults caseSensitive to false).
+        CreditCardValidator validator = new CreditCardValidator();
+        validator.setCaseSensitive(true);
+        validator.setTrim(false);
+
+        assertThat(constraints(validator, HtmlControlType.TEXT)).isEmpty();
+    }
+
+    @Test
+    public void intRangeEmitsBoundsOnlyOnANumericControl() {
+        IntRangeFieldValidator validator = new IntRangeFieldValidator();
+        validator.setMin(5);
+        validator.setMax(50);
+
+        assertThat(constraints(validator, HtmlControlType.NUMBER))
+            .containsEntry("min", "5")
+            .containsEntry("max", "50");
+        assertThat(constraints(validator, HtmlControlType.TEXT)).isEmpty();
+    }
+
+    @Test
+    public void doubleRangeEmitsInclusiveBoundsOnlyOnANumericControl() {
+        // integral bounds here, deliberately: a fractional min is covered separately by
+        // doubleRangeOmitsMinWhenItIsFractionalBecauseItWouldShiftTheStepBase, since it must NOT
+        // emit min at all (it would shift the HTML step base off zero)
+        DoubleRangeFieldValidator validator = new DoubleRangeFieldValidator();
+        validator.setMinInclusive(6000.0);
+        validator.setMaxInclusive(10000.1);
+
+        assertThat(constraints(validator, HtmlControlType.NUMBER))
+            .containsEntry("min", "6000.0")
+            .containsEntry("max", "10000.1");
+        assertThat(constraints(validator, HtmlControlType.TEXT)).isEmpty();
+    }
+
+    @Test
+    public void doubleRangeOmitsMinWhenItIsFractionalBecauseItWouldShiftTheStepBase() {
+        // min becomes the HTML step base, and the default step is 1: min="6000.1" would make the
+        // browser reject 6002, which DoubleRangeFieldValidator accepts server-side. max does not
+        // participate in the step base, so it is unaffected.
+        DoubleRangeFieldValidator validator = new DoubleRangeFieldValidator();
+        validator.setMinInclusive(6000.1);
+        validator.setMaxInclusive(10000.1);
+
+        Map<String, String> result = constraints(validator, HtmlControlType.NUMBER);
+
+        assertThat(result)
+            .containsEntry("max", "10000.1")
+            .doesNotContainKey("min");
+    }
+
+    @Test
+    public void doubleRangeEmitsMinWhenItIsIntegral() {
+        DoubleRangeFieldValidator validator = new DoubleRangeFieldValidator();
+        validator.setMinInclusive(6000.0);
+        validator.setMaxInclusive(10000.0);
+
+        assertThat(constraints(validator, HtmlControlType.NUMBER))
+            .containsEntry("min", "6000.0")
+            .containsEntry("max", "10000.0");
+    }
+
+    @Test
+    public void dateRangeEmitsNothingBecauseTemporalFormattingIsDeferred() {
+        DateRangeFieldValidator validator = new DateRangeFieldValidator();
+        validator.setMin(new Date(0));
+        validator.setMax(new Date(1_000_000L));
+
+        assertThat(constraints(validator, HtmlControlType.DATE)).isEmpty();
+    }
+
+    @Test
+    public void emailValidatorNeverContributesAConstraint() {
+        // the browser's email grammar differs from EmailValidator's, so honouring it could reject
+        // an address the server accepts. caseSensitive and trim are set explicitly here so this
+        // test actually reaches addPattern's EmailValidator exclusion, rather than returning
+        // earlier at the case-sensitivity guard or the isTrimed() guard (the constructor defaults
+        // caseSensitive to false, and trim defaults to true).
+        EmailValidator textControlValidator = new EmailValidator();
+        textControlValidator.setCaseSensitive(true);
+        textControlValidator.setTrim(false);
+        assertThat(constraints(textControlValidator, HtmlControlType.TEXT)).isEmpty();
+
+        EmailValidator emailControlValidator = new EmailValidator();
+        emailControlValidator.setCaseSensitive(true);
+        emailControlValidator.setTrim(false);
+        assertThat(constraints(emailControlValidator, HtmlControlType.EMAIL)).isEmpty();
+    }
+
+    @Test
+    public void unknownControlGetsNothing() {
+        assertThat(constraints(new RequiredFieldValidator(), HtmlControlType.OTHER)).isEmpty();
+    }
+
+    @Test
+    public void emptyInputIsHandled() {
+        assertThat(provider.constraintsFor(null, HtmlControlType.TEXT, null)).isEmpty();
+        assertThat(provider.constraintsFor(List.of(), HtmlControlType.TEXT, null)).isEmpty();
+    }
+
+    @Test
+    public void messageIsEmittedEvenForAValidatorThatContributesNoConstraint() {
+        Validator validator = mock(Validator.class);
+        when(validator.getValidatorType()).thenReturn("email");
+        when(validator.getMessage(action)).thenReturn("not an email");
+
+        Map<String, String> result =
+            provider.constraintsFor(singletonList(validator), HtmlControlType.TEXT, action);
+
+        assertThat(result).containsEntry("data-msg-email", "not an email");
+    }
+}
diff --git a/core/src/test/java/org/apache/struts2/views/jsp/ui/Html5ConstraintRenderingTest.java b/core/src/test/java/org/apache/struts2/views/jsp/ui/Html5ConstraintRenderingTest.java
new file mode 100644
index 000000000..c5979e36d
--- /dev/null
+++ b/core/src/test/java/org/apache/struts2/views/jsp/ui/Html5ConstraintRenderingTest.java
@@ -0,0 +1,131 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.struts2.views.jsp.ui;
+
+import org.apache.struts2.StrutsConstants;
+import org.apache.struts2.TestConfigurationProvider;
+import org.apache.struts2.mock.MockActionProxy;
+import org.apache.struts2.views.jsp.AbstractUITagTest;
+
+import java.util.HashMap;
+
+public class Html5ConstraintRenderingTest extends AbstractUITagTest {
+
+    public void testRendersConstraintAttributes() throws Exception {
+        String output = render("true");
+
+        assertTrue("expected minlength in: " + output, output.contains("minlength=\"3\""));
+    }
+
+    public void testRendersNothingWhenTheConstantIsOff() throws Exception {
+        String output = render("false");
+
+        assertFalse("expected no minlength in: " + output, output.contains("minlength="));
+    }
+
+    public void testRendersExactMarkupWhenTheConstantIsOff() throws Exception {
+        String output = render("false");
+
+        assertEquals("<form id=\"constraintAction\" name=\"constraintAction\" action=\"/constraintAction.action\" method=\"post\">"
+            + "<input type=\"text\" name=\"username\" value=\"\" id=\"constraintAction_username\"/></form>", output);
+    }
+
+    public void testRequiredLabelDoesNotBecomeARequiredAttribute() throws Exception {
+        String output = render("true", "username", "true");
+
+        assertTrue("expected minlength in: " + output, output.contains("minlength=\"3\""));
+        assertFalse("requiredLabel draws an asterisk; it must never emit a required attribute: " + output,
+            output.contains("required=\"required\""));
+    }
+
+    /**
+     * text.ftl renders {@code attributes.maxlength} (the developer's own tag attribute) before
+     * including common-attributes.ftl, which renders the derived {@code attributes.constraints} map.
+     * Without suppressing the derived duplicate, a stringlength validator on this field would render
+     * {@code maxlength} twice: once from the tag attribute, once from the constraint.
+     */
+    public void testDeveloperSetMaxlengthSuppressesTheDerivedOne() throws Exception {
+        String output = renderWithMaxlength("bio", "20");
+
+        int firstIndex = output.indexOf("maxlength=");
+        assertTrue("expected a maxlength attribute in: " + output, firstIndex >= 0);
+        assertEquals("expected exactly one maxlength attribute in: " + output,
+            firstIndex, output.lastIndexOf("maxlength="));
+        assertTrue("expected the developer's own value to win: " + output,
+            output.contains("maxlength=\"20\""));
+    }
+
+    /**
+     * data-msg-* values pass through TextParseUtil.translateVariables and can carry user-submitted
+     * content into an HTML attribute. Escaping is applied by FreemarkerManager's HTMLOutputFormat
+     * configuration, not by the template, so this pins it against regression.
+     */
+    public void testDataMsgAttributesAreHtmlEscaped() throws Exception {
+        String output = render("true", "comment", null);
+
+        assertTrue("expected the escaped message in: " + output,
+            output.contains("data-msg-requiredstring=\"Contains &quot;quotes&quot; and &lt;brackets&gt;\""));
+        assertFalse("the raw, unescaped message must never appear in: " + output,
+            output.contains("Contains \"quotes\" and <brackets>"));
+    }
+
+    private String render(String constraintsEnabled) throws Exception {
+        return render(constraintsEnabled, "username", null);
+    }
+
+    private String render(String constraintsEnabled, String fieldName, String requiredLabel) throws Exception {
+        return render(constraintsEnabled, fieldName, requiredLabel, null);
+    }
+
+    private String renderWithMaxlength(String fieldName, String maxlength) throws Exception {
+        return render("true", fieldName, null, maxlength);
+    }
+
+    private String render(String constraintsEnabled, String fieldName, String requiredLabel, String maxlength) throws Exception {
+        initDispatcher(new HashMap<String, String>() {{
+            put("configProviders", TestConfigurationProvider.class.getName());
+            put(StrutsConstants.STRUTS_UI_HTML5_CONSTRAINTS, constraintsEnabled);
+        }});
+        createMocks();
+        ((MockActionProxy) actionProxy).setConfig(configuration.getRuntimeConfiguration().getActionConfig("", "constraintAction"));
+
+        FormTag form = new FormTag();
+        form.setPageContext(pageContext);
+        form.setTheme("html5");
+        form.setAction("constraintAction");
+        form.setNamespace("");
+        form.doStartTag();
+
+        TextFieldTag field = new TextFieldTag();
+        field.setPageContext(pageContext);
+        field.setTheme("html5");
+        field.setName(fieldName);
+        if (requiredLabel != null) {
+            field.setRequiredLabel(requiredLabel);
+        }
+        if (maxlength != null) {
+            field.setMaxlength(maxlength);
+        }
+        field.doStartTag();
+        field.doEndTag();
+        form.doEndTag();
+
+        return writer.toString();
+    }
+}
diff --git a/core/src/test/resources/org/apache/struts2/components/ConstraintAction-validation.xml b/core/src/test/resources/org/apache/struts2/components/ConstraintAction-validation.xml
new file mode 100644
index 000000000..30bf5dd43
--- /dev/null
+++ b/core/src/test/resources/org/apache/struts2/components/ConstraintAction-validation.xml
@@ -0,0 +1,43 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+-->
+<!DOCTYPE validators PUBLIC "-//Apache Struts//XWork Validator 1.0//EN" "https://struts.apache.org/dtds/xwork-validator-1.0.dtd">
+<validators>
+    <field name="username">
+        <field-validator type="stringlength">
+            <param name="trim">false</param>
+            <param name="minLength">3</param>
+            <message>username must be at least ${minLength} characters</message>
+        </field-validator>
+    </field>
+    <field name="comment">
+        <field-validator type="requiredstring">
+            <message>Contains "quotes" and &lt;brackets&gt;</message>
+        </field-validator>
+    </field>
+    <field name="bio">
+        <field-validator type="stringlength">
+            <param name="trim">false</param>
+            <param name="maxLength">10</param>
+            <message>bio must be at most ${maxLength} characters</message>
+        </field-validator>
+    </field>
+</validators>
diff --git a/docs/superpowers/plans/2026-08-24-html5-constraint-validation.md b/docs/superpowers/plans/2026-08-24-html5-constraint-validation.md
new file mode 100644
index 000000000..2bde1ae1f
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-24-html5-constraint-validation.md
@@ -0,0 +1,1828 @@
+# HTML5 Constraint Validation (7.4.0) Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Deprecate the generated JavaScript client-side validator, and let the `html5` theme emit native HTML5 constraint attributes derived from the action's validators.
+
+**Architecture:** A container-registered `HtmlConstraintProvider` maps a field's validators plus its resolved `HtmlControlType` to a map of HTML attributes. `UIBean.evaluateParams` calls it at the end of the method and stashes the result as the `constraints` attribute; a new `html5/constraints.ftl`, included from `common-attributes.ftl`, renders it. Everything is gated behind a new constant defaulting to `false`, so no existing rendering changes.
+
+**Tech Stack:** Java 17, Maven, FreeMarker templates, JUnit 4 + JUnit 3 (`XWorkTestCase`), AssertJ, EasyMock.
+
+**Spec:** `docs/superpowers/specs/2026-08-24-html5-constraint-validation-design.md`
+
+**Tickets:** WW-5694 (deprecation, Task 1), WW-5695 (constraints, Tasks 2–9). WW-5696 is the 8.0.0 removal and is **out of scope for this plan**.
+
+## Global Constraints
+
+- Target version string in every `@Deprecated` annotation: `since = "7.4.0", forRemoval = true`.
+- **Every new file — main source AND test source — must carry the Apache licence header**, copied verbatim
+  from a sibling file in the same directory. The code blocks in this plan omit it for brevity; that omission
+  is not permission to skip it. `apache-rat-plugin:check` is bound to the `prepare-package` phase
+  (`pom.xml:549-555`), so `mvn test -DskipAssembly` — the command every task below uses — **never runs the
+  licence check**. A missing header therefore passes every task-level verification in this plan and fails
+  the real build. Add it as you create each file.
+- **Tests are JUnit 4 or JUnit 3. There is no JUnit 5 in this repo.** New pure-unit test classes use `org.junit.Test`. Any test that renders a tag must extend `AbstractUITagTest` (JUnit 3 style: methods named `testXxx()`, no annotation). A Jupiter `@Test` on an `XWorkTestCase` subclass silently never runs.
+- Commit message format: `WW-XXXX <type>(<scope>): <description>`. Ticket prefix is mandatory.
+- Never commit to `main`. Work happens on `feature/WW-5695-html5-constraint-validation`.
+- Never `git add -A` in this repo — the tree holds ~20 long-lived untracked files. Stage explicit paths and verify with `git diff --cached --name-only`.
+- Build command: `mvn test -DskipAssembly -pl core -Dtest=<TestClass>` from the repo root.
+- `DateTest.testJavaSqlDate` is a known flake (WW-5686). A red build on that one alone is the clock, not your change.
+- **The governing rule for every mapping decision: never false-reject.** Emit a constraint only when the browser cannot reject input the server would accept. When unsure, emit nothing.
+- Struts ships **no JavaScript** as part of this work.
+
+---
+
+### Task 1: Deprecate the JavaScript client-side validator (WW-5694)
+
+Independent of every other task and separately mergeable. Annotations and documentation only — no behaviour changes.
+
+**Files:**
+- Modify: `core/src/main/java/org/apache/struts2/components/Form.java`
+- Modify: `core/src/main/java/org/apache/struts2/views/jsp/ui/FormTag.java`
+- Modify: `core/src/main/resources/template/xhtml/form-close-validate.ftl`
+
+**Interfaces:**
+- Consumes: nothing.
+- Produces: nothing. Later tasks do not depend on this one.
+
+- [ ] **Step 1: Annotate the `Form` component**
+
+In `Form.java`, add `@Deprecated(since = "7.4.0", forRemoval = true)` to three members. `setValidate` is around line 512, `getValidators` around line 270, `evaluateClientSideJsEnablement` around line 242 — confirm by search, not by line number.
+
+```java
+    @Deprecated(since = "7.4.0", forRemoval = true)
+    protected void evaluateClientSideJsEnablement(String actionName, String namespace, String actionMethod) {
+
+    @Deprecated(since = "7.4.0", forRemoval = true)
+    public List getValidators(String name) {
+
+    @StrutsTagAttribute(description = "Whether client side/js validation should be performed. Only useful with theme xhtml/ajax", type = "Boolean", defaultValue = "false")
+    @Deprecated(since = "7.4.0", forRemoval = true)
+    public void setValidate(String validate) {
+```
+
+Keep the existing `@StrutsTagAttribute` annotation and its exact text; only add the `@Deprecated` line beneath it.
+
+**Do not touch `Dispatcher.setValidate` or `ValidationInterceptor.setValidate`.** They are unrelated methods that happen to share the name.
+
+- [ ] **Step 2: Annotate the JSP tag layer**
+
+WW-5510 (`c2a5bfe3c`) annotated both the component and the tag. In `FormTag.java` (setter around line 96):
+
+```java
+    @Deprecated(since = "7.4.0", forRemoval = true)
+    public void setValidate(String validate) {
+        this.validate = validate;
+    }
+```
+
+Leave `FormTag.clearTagStateForTagPoolingServers` alone — the field reset stays until removal.
+
+- [ ] **Step 3: Add the Javadoc banner**
+
+Find the `<!-- START SNIPPET: ... -->` block in `Form.java`'s class Javadoc that documents `validate`. Add, as the first line inside the snippet:
+
+```java
+ * <strong>Deprecated since 7.4.0 — use the html5 theme's constraint attributes instead. Removed in 8.0.0.</strong>
+```
+
+This matters because the website pulls these snippets in via the `remote_file_content` Jekyll plugin. Omitting it leaves struts.apache.org advertising the feature as current.
+
+- [ ] **Step 4: Add the template banner**
+
+A `.ftl` carries no annotation, and anyone who overrode this template needs to see the notice there. In `core/src/main/resources/template/xhtml/form-close-validate.ftl`, immediately after the Apache licence header comment:
+
+```
+<#--
+DEPRECATED since Struts 7.4.0, removed in 8.0.0 (WW-5694 / WW-5696).
+
+JavaScript client-side validation is superseded by native HTML5 constraint
+attributes in the html5 theme (WW-5695). This template, form-validate.ftl and
+validation.js are all removed in 8.0.0.
+-->
+```
+
+- [ ] **Step 5: Verify the build still compiles and existing tests pass**
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=FormTagTest`
+Expected: PASS. Deprecation annotations change no behaviour, so all four `validateForm_` golden files must still match byte-for-byte.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add core/src/main/java/org/apache/struts2/components/Form.java \
+        core/src/main/java/org/apache/struts2/views/jsp/ui/FormTag.java \
+        core/src/main/resources/template/xhtml/form-close-validate.ftl
+git diff --cached --name-only
+git commit -m "WW-5694 refactor(validation): deprecate JavaScript client-side validation
+
+Marks the form tag's validate attribute and the machinery behind it for
+removal in 8.0.0. Annotations and documentation only; no behaviour change.
+
+Follows the WW-5510 precedent: annotate both the component and the JSP tag,
+and put the notice inside the START SNIPPET block so the website picks it up.
+
+Co-Authored-By: Claude Opus 5 <no...@anthropic.com>"
+```
+
+---
+
+### Task 2: `HtmlControlType` enum
+
+**Files:**
+- Create: `core/src/main/java/org/apache/struts2/components/HtmlControlType.java`
+- Test: `core/src/test/java/org/apache/struts2/components/HtmlControlTypeTest.java`
+
+**Interfaces:**
+- Consumes: nothing.
+- Produces: `HtmlControlType` with `static HtmlControlType from(String type)`, and instance predicates `boolean supportsPattern()`, `boolean supportsLength()`, `boolean supportsRange()`. Used by Tasks 4, 6, 7.
+
+Package note: `org.apache.struts2.components` is where `UrlRenderer` lives, so a new view-layer extension point belongs there by convention. WW-5689 eventually moves this whole package into a plugin; that is expected and does not change the right answer for 7.4.0.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `core/src/test/java/org/apache/struts2/components/HtmlControlTypeTest.java` (JUnit 4 — this is a plain object with no container):
+
+```java
+package org.apache.struts2.components;
+
+import org.junit.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class HtmlControlTypeTest {
+
+    @Test
+    public void resolvesKnownTypes() {
+        assertThat(HtmlControlType.from("text")).isEqualTo(HtmlControlType.TEXT);
+        assertThat(HtmlControlType.from("number")).isEqualTo(HtmlControlType.NUMBER);
+        assertThat(HtmlControlType.from("datetime-local")).isEqualTo(HtmlControlType.DATETIME_LOCAL);
+    }
+
+    @Test
+    public void isLenientAboutCaseAndWhitespace() {
+        assertThat(HtmlControlType.from("  NuMbEr ")).isEqualTo(HtmlControlType.NUMBER);
+    }
+
+    @Test
+    public void neverThrowsOnUnusableInput() {
+        assertThat(HtmlControlType.from(null)).isEqualTo(HtmlControlType.OTHER);
+        assertThat(HtmlControlType.from("")).isEqualTo(HtmlControlType.OTHER);
+        assertThat(HtmlControlType.from("   ")).isEqualTo(HtmlControlType.OTHER);
+        assertThat(HtmlControlType.from("supercolor")).isEqualTo(HtmlControlType.OTHER);
+    }
+
+    @Test
+    public void otherSupportsNothing() {
+        assertThat(HtmlControlType.OTHER.supportsPattern()).isFalse();
+        assertThat(HtmlControlType.OTHER.supportsLength()).isFalse();
+        assertThat(HtmlControlType.OTHER.supportsRange()).isFalse();
+    }
+
+    @Test
+    public void patternIsTextEntryOnly() {
+        assertThat(HtmlControlType.TEXT.supportsPattern()).isTrue();
+        assertThat(HtmlControlType.PASSWORD.supportsPattern()).isTrue();
+        assertThat(HtmlControlType.NUMBER.supportsPattern()).isFalse();
+        assertThat(HtmlControlType.TEXTAREA.supportsPattern()).isFalse();
+        assertThat(HtmlControlType.SELECT.supportsPattern()).isFalse();
+    }
+
+    @Test
+    public void lengthIsTextEntryPlusTextarea() {
+        assertThat(HtmlControlType.TEXT.supportsLength()).isTrue();
+        assertThat(HtmlControlType.TEXTAREA.supportsLength()).isTrue();
+        assertThat(HtmlControlType.NUMBER.supportsLength()).isFalse();
+        assertThat(HtmlControlType.CHECKBOX.supportsLength()).isFalse();
+    }
+
+    @Test
+    public void rangeIsNumericAndTemporalOnly() {
+        assertThat(HtmlControlType.NUMBER.supportsRange()).isTrue();
+        assertThat(HtmlControlType.RANGE.supportsRange()).isTrue();
+        assertThat(HtmlControlType.DATE.supportsRange()).isTrue();
+        assertThat(HtmlControlType.TEXT.supportsRange()).isFalse();
+    }
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=HtmlControlTypeTest`
+Expected: FAIL — compilation error, `HtmlControlType` does not exist.
+
+- [ ] **Step 3: Write the implementation**
+
+Create `core/src/main/java/org/apache/struts2/components/HtmlControlType.java` with the standard Apache licence header (copy it verbatim from any neighbouring file in the same package), then:
+
+```java
+package org.apache.struts2.components;
+
+import java.util.EnumSet;
+import java.util.Locale;
+import java.util.Set;
+
+/**
+ * The kind of HTML form control a {@link UIBean} renders, used to decide which HTML5 constraint
+ * attributes are legal on it.
+ * <p>
+ * This models the <em>control</em> rather than the {@code type} attribute, because {@code textarea}
+ * and {@code select} have no {@code type} attribute yet still accept {@code required}.
+ *
+ * @since 7.4.0
+ */
+public enum HtmlControlType {
+
+    TEXT, SEARCH, TEL, PASSWORD, EMAIL, URL,
+    NUMBER, RANGE,
+    DATE, MONTH, WEEK, TIME, DATETIME_LOCAL,
+    CHECKBOX, RADIO, FILE, HIDDEN, SELECT,
+    TEXTAREA,
+    OTHER;
+
+    private static final Set<HtmlControlType> TEXT_ENTRY = EnumSet.of(TEXT, SEARCH, TEL, PASSWORD, EMAIL, URL);
+    private static final Set<HtmlControlType> NUMERIC = EnumSet.of(NUMBER, RANGE);
+    private static final Set<HtmlControlType> TEMPORAL = EnumSet.of(DATE, MONTH, WEEK, TIME, DATETIME_LOCAL);
+
+    /**
+     * Resolves a raw {@code type} attribute value. Never throws: the attribute is OGNL-evaluated, so at
+     * runtime it can be any string. Anything unrecognised becomes {@link #OTHER}, which supports no
+     * constraints at all — so an unknown control degrades to emitting nothing.
+     */
+    public static HtmlControlType from(String type) {
+        if (type == null) {
+            return OTHER;
+        }
+        String normalised = type.trim().toUpperCase(Locale.ROOT).replace('-', '_');
+        if (normalised.isEmpty()) {
+            return OTHER;
+        }
+        try {
+            return valueOf(normalised);
+        } catch (IllegalArgumentException e) {
+            return OTHER;
+        }
+    }
+
+    public boolean supportsPattern() {
+        return TEXT_ENTRY.contains(this);
+    }
+
+    public boolean supportsLength() {
+        return TEXT_ENTRY.contains(this) || this == TEXTAREA;
+    }
+
+    public boolean supportsRange() {
+        return NUMERIC.contains(this) || TEMPORAL.contains(this);
+    }
+}
+```
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=HtmlControlTypeTest`
+Expected: PASS, 7 tests.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add core/src/main/java/org/apache/struts2/components/HtmlControlType.java \
+        core/src/test/java/org/apache/struts2/components/HtmlControlTypeTest.java
+git diff --cached --name-only
+git commit -m "WW-5695 feat(components): add HtmlControlType
+
+Models the kind of form control a UIBean renders, so constraint derivation can
+ask which HTML5 attributes are legal rather than string-matching a type
+attribute. Models the control, not the attribute, because textarea and select
+have no type yet still accept required.
+
+from() never throws; unknown input becomes OTHER, which supports nothing.
+
+Co-Authored-By: Claude Opus 5 <no...@anthropic.com>"
+```
+
+---
+
+### Task 3: ECMAScript-safe regex detection
+
+The highest-risk logic in this work. A regex that Java and the browser interpret differently becomes a false rejection the user cannot get past, so detection is an **allowlist**: anything not provably common to both engines is rejected.
+
+**Files:**
+- Create: `core/src/main/java/org/apache/struts2/components/EcmaScriptSafeRegex.java`
+- Test: `core/src/test/java/org/apache/struts2/components/EcmaScriptSafeRegexTest.java`
+
+**Interfaces:**
+- Consumes: nothing.
+- Produces: `EcmaScriptSafeRegex.isSafe(String regex)` returning `boolean`. Used by Task 4.
+
+- [ ] **Step 1: Write the failing test**
+
+```java
+package org.apache.struts2.components;
+
+import org.junit.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class EcmaScriptSafeRegexTest {
+
+    @Test
+    public void acceptsPortableConstructs() {
+        assertThat(EcmaScriptSafeRegex.isSafe("[a-z]+")).isTrue();
+        assertThat(EcmaScriptSafeRegex.isSafe("\\d{3}-\\d{4}")).isTrue();
+        assertThat(EcmaScriptSafeRegex.isSafe("(foo|bar)?baz")).isTrue();
+        assertThat(EcmaScriptSafeRegex.isSafe("^\\w+@\\w+\\.\\w{2,6}$")).isTrue();
+        assertThat(EcmaScriptSafeRegex.isSafe("(?:ab)+")).isTrue();
+        assertThat(EcmaScriptSafeRegex.isSafe("a(?=b)")).isTrue();
+        assertThat(EcmaScriptSafeRegex.isSafe("a(?!b)")).isTrue();
+    }
+
+    @Test
+    public void rejectsJavaOnlyEscapes() {
+        assertThat(EcmaScriptSafeRegex.isSafe("\\p{Alpha}+")).isFalse();
+        assertThat(EcmaScriptSafeRegex.isSafe("\\A\\d+\\z")).isFalse();
+        assertThat(EcmaScriptSafeRegex.isSafe("\\Qliteral\\E")).isFalse();
+        assertThat(EcmaScriptSafeRegex.isSafe("\\h+")).isFalse();
+    }
+
+    @Test
+    public void rejectsWhitespaceClassesWhoseMeaningDiffersBetweenEngines() {
+        // Java's \s is ASCII-only by default; ECMAScript's includes NBSP and friends, so
+        // ^\S+$ accepts a value containing NBSP on the server and rejects it in the browser
+        assertThat(EcmaScriptSafeRegex.isSafe("^\\S+$")).isFalse();
+        assertThat(EcmaScriptSafeRegex.isSafe("\\s*")).isFalse();
+    }
+
+    @Test
+    public void rejectsPossessiveQuantifiers() {
+        assertThat(EcmaScriptSafeRegex.isSafe("\\d++")).isFalse();
+        assertThat(EcmaScriptSafeRegex.isSafe("a*+")).isFalse();
+        assertThat(EcmaScriptSafeRegex.isSafe("a?+")).isFalse();
+        assertThat(EcmaScriptSafeRegex.isSafe("a{2,3}+")).isFalse();
+    }
+
+    @Test
+    public void rejectsNonPortableGroups() {
+        assertThat(EcmaScriptSafeRegex.isSafe("(?<name>a)")).isFalse();
+        assertThat(EcmaScriptSafeRegex.isSafe("(?<=a)b")).isFalse();
+        assertThat(EcmaScriptSafeRegex.isSafe("(?>a)")).isFalse();
+        assertThat(EcmaScriptSafeRegex.isSafe("(?i)abc")).isFalse();
+    }
+
+    @Test
+    public void rejectsJavaCharacterClassFeatures() {
+        assertThat(EcmaScriptSafeRegex.isSafe("[[:alpha:]]")).isFalse();
+        assertThat(EcmaScriptSafeRegex.isSafe("[a-z&&[^aeiou]]")).isFalse();
+    }
+
+    @Test
+    public void rejectsUnusableInput() {
+        assertThat(EcmaScriptSafeRegex.isSafe(null)).isFalse();
+        assertThat(EcmaScriptSafeRegex.isSafe("")).isFalse();
+        assertThat(EcmaScriptSafeRegex.isSafe("abc\\")).isFalse();
+        assertThat(EcmaScriptSafeRegex.isSafe("[abc")).isFalse();
+    }
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=EcmaScriptSafeRegexTest`
+Expected: FAIL — compilation error, `EcmaScriptSafeRegex` does not exist.
+
+- [ ] **Step 3: Write the implementation**
+
+Create `core/src/main/java/org/apache/struts2/components/EcmaScriptSafeRegex.java` with the Apache licence header, then:
+
+```java
+package org.apache.struts2.components;
+
+/**
+ * Decides whether a Java regular expression can be handed to a browser as an HTML5 {@code pattern}
+ * attribute without changing meaning.
+ * <p>
+ * This is an allowlist by design. A denylist of Java-only constructs would violate the
+ * never-false-reject rule the first time it missed one, because a missed construct becomes a pattern
+ * the browser interprets differently and the user cannot get past. Anything not provably common to
+ * both engines is rejected, and the field simply gets no client-side check.
+ *
+ * @since 7.4.0
+ */
+public final class EcmaScriptSafeRegex {
+
+    /**
+     * Escapes with identical meaning in both engines.
+     * <p>
+     * {@code \s} and {@code \S} are deliberately absent. Java's {@code \s} is ASCII-only by default
+     * while ECMAScript's is the wider Unicode set, so {@code ^\S+$} accepts a value containing NBSP
+     * on the server and rejects it in the browser. {@code \d} and {@code \w} are safe — both engines
+     * are ASCII-only for those, and JavaScript never widens them.
+     */
+    private static final String ALLOWED_ESCAPES = "dDwWbBnrtf\\.*+?()[]{}|^$/-";
+
+    private EcmaScriptSafeRegex() {
+    }
+
+    public static boolean isSafe(String regex) {
+        if (regex == null || regex.isEmpty()) {
+            return false;
+        }
+        boolean inCharClass = false;
+        for (int i = 0; i < regex.length(); i++) {
+            char current = regex.charAt(i);
+            switch (current) {
+                case '\\':
+                    if (i + 1 >= regex.length() || ALLOWED_ESCAPES.indexOf(regex.charAt(++i)) < 0) {
+                        return false;
+                    }
+                    break;
+                case '[':
+                    // Java allows nested classes and POSIX names; ECMAScript allows neither
+                    if (inCharClass || regex.startsWith("[:", i)) {
+                        return false;
+                    }
+                    inCharClass = true;
+                    break;
+                case ']':
+                    inCharClass = false;
+                    break;
+                case '&':
+                    // Java character-class intersection
+                    if (inCharClass && i + 1 < regex.length() && regex.charAt(i + 1) == '&') {
+                        return false;
+                    }
+                    break;
+                case '(':
+                    // only non-capturing groups and lookahead are portable; named groups,
+                    // lookbehind, atomic groups and inline flags are not
+                    if (i + 1 < regex.length() && regex.charAt(i + 1) == '?') {
+                        if (i + 2 >= regex.length()) {
+                            return false;
+                        }
+                        char kind = regex.charAt(i + 2);
+                        if (kind != ':' && kind != '=' && kind != '!') {
+                            return false;
+                        }
+                    }
+                    break;
+                case '*':
+                case '+':
+                case '?':
+                case '}':
+                    // possessive quantifier
+                    if (i + 1 < regex.length() && regex.charAt(i + 1) == '+') {
+                        return false;
+                    }
+                    break;
+                default:
+                    break;
+            }
+        }
+        return !inCharClass;
+    }
+}
+```
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=EcmaScriptSafeRegexTest`
+Expected: PASS, 7 tests.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add core/src/main/java/org/apache/struts2/components/EcmaScriptSafeRegex.java \
+        core/src/test/java/org/apache/struts2/components/EcmaScriptSafeRegexTest.java
+git diff --cached --name-only
+git commit -m "WW-5695 feat(components): add ECMAScript-safe regex detection
+
+Decides whether a Java regex can become an HTML5 pattern attribute without
+changing meaning. Allowlist by design: a denylist would violate the
+never-false-reject rule the first time it missed a construct, and a regex the
+browser reads differently is a rejection the user cannot get past.
+
+Co-Authored-By: Claude Opus 5 <no...@anthropic.com>"
+```
+
+---
+
+### Task 4: `HtmlConstraintProvider` and the default implementation
+
+**Files:**
+- Create: `core/src/main/java/org/apache/struts2/components/HtmlConstraintProvider.java`
+- Create: `core/src/main/java/org/apache/struts2/components/StrutsHtmlConstraintProvider.java`
+- Modify: `core/src/main/resources/struts-beans.xml:146`
+- Test: `core/src/test/java/org/apache/struts2/components/StrutsHtmlConstraintProviderTest.java`
+
+**Interfaces:**
+- Consumes: `HtmlControlType` (Task 2), `EcmaScriptSafeRegex.isSafe(String)` (Task 3).
+- Produces: `HtmlConstraintProvider.constraintsFor(List<Validator> validators, HtmlControlType control, Object action)` returning `Map<String, String>`. Used by Task 7.
+
+**Signature note:** the spec's sketch showed two parameters. The third, `action`, is required — `Validator.getMessage(Object)` needs the action instance to resolve i18n text, and the `data-msg-*` attributes come from it.
+
+- [ ] **Step 1: Write the failing test**
+
+The negative cases carry the weight here; they are what protects the never-false-reject rule.
+
+```java
+package org.apache.struts2.components;
+
+import org.apache.struts2.ActionSupport;
+import org.apache.struts2.validator.Validator;
+import org.apache.struts2.validator.validators.DoubleRangeFieldValidator;
+import org.apache.struts2.validator.validators.EmailValidator;
+import org.apache.struts2.validator.validators.IntRangeFieldValidator;
+import org.apache.struts2.validator.validators.RegexFieldValidator;
+import org.apache.struts2.validator.validators.RequiredFieldValidator;
+import org.apache.struts2.validator.validators.RequiredStringValidator;
+import org.apache.struts2.validator.validators.StringLengthFieldValidator;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.util.List;
+import java.util.Map;
+
+import static java.util.Collections.singletonList;
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class StrutsHtmlConstraintProviderTest {
+
+    private StrutsHtmlConstraintProvider provider;
+    private Object action;
+
+    @Before
+    public void setUp() {
+        provider = new StrutsHtmlConstraintProvider();
+        action = new ActionSupport();
+    }
+
+    private Map<String, String> constraints(Validator validator, HtmlControlType control) {
+        return provider.constraintsFor(singletonList(validator), control, null);
+    }
+
+    @Test
+    public void requiredValidatorEmitsRequired() {
+        assertThat(constraints(new RequiredFieldValidator(), HtmlControlType.TEXT))
+            .containsEntry("required", "required");
+    }
+
+    @Test
+    public void requiredStringEmitsRequiredEvenThoughServerIsStricter() {
+        assertThat(constraints(new RequiredStringValidator(), HtmlControlType.TEXT))
+            .containsEntry("required", "required");
+    }
+
+    @Test
+    public void stringLengthEmitsLengthsWhenNotTrimming() {
+        StringLengthFieldValidator validator = new StringLengthFieldValidator();
+        validator.setTrim(false);
+        validator.setMinLength(3);
+        validator.setMaxLength(10);
+
+        assertThat(constraints(validator, HtmlControlType.TEXT))
+            .containsEntry("minlength", "3")
+            .containsEntry("maxlength", "10");
+    }
+
+    @Test
+    public void stringLengthEmitsNothingWhenTrimming() {
+        StringLengthFieldValidator validator = new StringLengthFieldValidator();
+        validator.setTrim(true);
+        validator.setMinLength(3);
+        validator.setMaxLength(10);
+
+        // the server measures the trimmed value, so maxlength here would stop the user
+        // typing input the server would have accepted
+        assertThat(constraints(validator, HtmlControlType.TEXT)).isEmpty();
+    }
+
+    @Test
+    public void stringLengthEmitsNothingOnAControlWithoutLength() {
+        StringLengthFieldValidator validator = new StringLengthFieldValidator();
+        validator.setTrim(false);
+        validator.setMaxLength(10);
+
+        assertThat(constraints(validator, HtmlControlType.NUMBER)).isEmpty();
+    }
+
+    @Test
+    public void regexEmitsPatternWhenPortableAndCaseSensitive() {
+        RegexFieldValidator validator = new RegexFieldValidator();
+        validator.setRegex("[a-z]+");
+        validator.setCaseSensitive(true);
+
+        assertThat(constraints(validator, HtmlControlType.TEXT))
+            .containsEntry("pattern", "[a-z]+");
+    }
+
+    @Test
+    public void regexEmitsNothingWhenCaseInsensitive() {
+        RegexFieldValidator validator = new RegexFieldValidator();
+        validator.setRegex("[a-z]+");
+        validator.setCaseSensitive(false);
+
+        // HTML pattern accepts no flags, so a case-insensitive rule cannot be expressed
+        assertThat(constraints(validator, HtmlControlType.TEXT)).isEmpty();
+    }
+
+    @Test
+    public void regexEmitsNothingWhenNotPortable() {
+        RegexFieldValidator validator = new RegexFieldValidator();
+        validator.setRegex("\\p{Alpha}+");
+        validator.setCaseSensitive(true);
+
+        assertThat(constraints(validator, HtmlControlType.TEXT)).isEmpty();
+    }
+
+    @Test
+    public void intRangeEmitsBoundsOnlyOnANumericControl() {
+        IntRangeFieldValidator validator = new IntRangeFieldValidator();
+        validator.setMin(5);
+        validator.setMax(50);
+
+        assertThat(constraints(validator, HtmlControlType.NUMBER))
+            .containsEntry("min", "5")
+            .containsEntry("max", "50");
+        assertThat(constraints(validator, HtmlControlType.TEXT)).isEmpty();
+    }
+
+    @Test
+    public void doubleRangeEmitsInclusiveBoundsOnlyOnANumericControl() {
+        DoubleRangeFieldValidator validator = new DoubleRangeFieldValidator();
+        validator.setMinInclusive(6000.1);
+        validator.setMaxInclusive(10000.1);
+
+        assertThat(constraints(validator, HtmlControlType.NUMBER))
+            .containsEntry("min", "6000.1")
+            .containsEntry("max", "10000.1");
+        assertThat(constraints(validator, HtmlControlType.TEXT)).isEmpty();
+    }
+
+    @Test
+    public void emailValidatorNeverContributesAConstraint() {
+        // the browser's email grammar differs from EmailValidator's, so honouring it
+        // could reject an address the server accepts
+        assertThat(constraints(new EmailValidator(), HtmlControlType.TEXT)).isEmpty();
+        assertThat(constraints(new EmailValidator(), HtmlControlType.EMAIL)).isEmpty();
+    }
+
+    @Test
+    public void unknownControlGetsNothing() {
+        assertThat(constraints(new RequiredFieldValidator(), HtmlControlType.OTHER)).isEmpty();
+    }
+
+    @Test
+    public void emptyInputIsHandled() {
+        assertThat(provider.constraintsFor(null, HtmlControlType.TEXT, null)).isEmpty();
+        assertThat(provider.constraintsFor(List.of(), HtmlControlType.TEXT, null)).isEmpty();
+    }
+
+    @Test
+    public void messageIsEmittedEvenForAValidatorThatContributesNoConstraint() {
+        EmailValidator validator = new EmailValidator();
+        validator.setDefaultMessage("not an email");
+
+        Map<String, String> result =
+            provider.constraintsFor(singletonList(validator), HtmlControlType.TEXT, action);
+
+        assertThat(result).containsEntry("data-msg-email", "not an email");
+    }
+}
+```
+
+**Note on `required` and `OTHER`:** the test above asserts `OTHER` gets nothing at all, including `required`. That is deliberate — `OTHER` means "we do not know what this control is", and guessing is how false rejections happen.
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=StrutsHtmlConstraintProviderTest`
+Expected: FAIL — compilation error, `StrutsHtmlConstraintProvider` does not exist.
+
+- [ ] **Step 3: Write the interface**
+
+Create `core/src/main/java/org/apache/struts2/components/HtmlConstraintProvider.java` with the licence header, then:
+
+```java
+package org.apache.struts2.components;
+
+import org.apache.struts2.validator.Validator;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Maps a field's validators onto the HTML attributes a theme should render for it.
+ * <p>
+ * The default implementation is deliberately conservative — see {@link StrutsHtmlConstraintProvider}.
+ * Applications wanting a best-effort mapping (an {@code email} validator becoming
+ * {@code type="email"}, say) should register their own implementation instead.
+ *
+ * @since 7.4.0
+ */
+public interface HtmlConstraintProvider {
+
+    /**
+     * @param validators the field's validators; may be null or empty
+     * @param control    the kind of control being rendered
+     * @param action     the action instance, used to resolve i18n validator messages; may be null
+     * @return attribute name to value; never null, possibly empty
+     */
+    Map<String, String> constraintsFor(List<Validator> validators, HtmlControlType control, Object action);
+}
+```
+
+- [ ] **Step 4: Write the default implementation**
+
+Create `core/src/main/java/org/apache/struts2/components/StrutsHtmlConstraintProvider.java` with the licence header, then:
+
+**Post-implementation note (added during final-review fixup, not part of the original TDD pass):** the
+listing below is the shipped implementation, not the first draft. Two corrections were made after this
+task's tests originally went green, both discovered in a whole-branch review:
+
+- `addRequired` split into `addRequiredString` (safe on any text-entry or `TEXTAREA` control) and
+  `addRequiredField` (safe only on `RADIO`/`FILE` — see the mapping table in the spec). The original single
+  `addRequired`, gated only on `control != OTHER`, would have emitted `required` on a text input or a select,
+  which false-rejects an empty string the server accepts. `addRequiredField` was briefly dead code in an
+  intermediate commit because no component overrode `getControlType()` to return `RADIO` or `FILE`; that is
+  fixed in Task 6 below, which now includes those two overrides.
+- `addPattern` gained an explicit `EmailValidator`/`CreditCardValidator` exclusion and a `trim` guard, and
+  `addRange`/`addDoubleRange` gained an integral-`min` guard. Each is explained inline below.
+
+```java
+package org.apache.struts2.components;
+
+import org.apache.struts2.validator.Validator;
+import org.apache.struts2.validator.validators.CreditCardValidator;
+import org.apache.struts2.validator.validators.DoubleRangeFieldValidator;
+import org.apache.struts2.validator.validators.EmailValidator;
+import org.apache.struts2.validator.validators.RangeValidatorSupport;
+import org.apache.struts2.validator.validators.RegexFieldValidator;
+import org.apache.struts2.validator.validators.RequiredFieldValidator;
+import org.apache.struts2.validator.validators.RequiredStringValidator;
+import org.apache.struts2.validator.validators.StringLengthFieldValidator;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Default {@link HtmlConstraintProvider}.
+ * <p>
+ * Governed by one rule: never false-reject. A constraint is emitted only when the browser cannot
+ * reject input the server would accept. In particular this implementation <em>never sets or changes
+ * an input's {@code type}</em> — switching a field to {@code type="number"} would reject
+ * {@code 1234,50}, which the framework's locale-aware conversion accepts in a comma-decimal locale,
+ * and the browsers' {@code email}/{@code url} grammars differ from the framework's validators.
+ * Range constraints are therefore emitted only on a control the developer already made numeric.
+ *
+ * @since 7.4.0
+ */
+public class StrutsHtmlConstraintProvider implements HtmlConstraintProvider {
+
+    @Override
+    public Map<String, String> constraintsFor(List<Validator> validators, HtmlControlType control, Object action) {
+        Map<String, String> attributes = new LinkedHashMap<>();
+        if (validators == null || validators.isEmpty() || control == null) {
+            return attributes;
+        }
+        for (Validator validator : validators) {
+            addConstraints(attributes, validator, control);
+            addMessage(attributes, validator, action);
+        }
+        return attributes;
+    }
+
+    protected void addConstraints(Map<String, String> attributes, Validator validator, HtmlControlType control) {
+        if (validator instanceof RequiredStringValidator) {
+            addRequiredString(attributes, control);
+        } else if (validator instanceof RequiredFieldValidator) {
+            addRequiredField(attributes, control);
+        } else if (validator instanceof StringLengthFieldValidator lengthValidator) {
+            addLength(attributes, lengthValidator, control);
+        } else if (validator instanceof RegexFieldValidator regexValidator) {
+            addPattern(attributes, regexValidator, control);
+        } else if (validator instanceof DoubleRangeFieldValidator doubleValidator) {
+            addDoubleRange(attributes, doubleValidator, control);
+        } else if (validator instanceof RangeValidatorSupport<?> rangeValidator) {
+            addRange(attributes, rangeValidator, control);
+        }
+    }
+
+    /**
+     * {@code requiredstring} fails on null, empty and (by default) blank, so the browser's
+     * {@code required} can only reject what the server would also reject. Safe on any text-entry control.
+     */
+    protected void addRequiredString(Map<String, String> attributes, HtmlControlType control) {
+        if (!control.supportsLength()) {
+            return;
+        }
+        attributes.put("required", "required");
+    }
+
+    /**
+     * {@code required} fails only on null, an empty array or an empty collection. A control that submits
+     * an empty string rather than omitting the parameter therefore passes server-side while the browser
+     * blocks it — an empty text input, a select with an empty-valued header option, and an unticked
+     * checkbox (CheckboxInterceptor substitutes "false") are all in that group. Only RADIO and FILE omit
+     * the parameter entirely when empty, so only they agree with the browser.
+     */
+    protected void addRequiredField(Map<String, String> attributes, HtmlControlType control) {
+        if (control != HtmlControlType.RADIO && control != HtmlControlType.FILE) {
+            return;
+        }
+        attributes.put("required", "required");
+    }
+
+    protected void addLength(Map<String, String> attributes, StringLengthFieldValidator validator, HtmlControlType control) {
+        // with trim=true the server measures the trimmed value, so a maxlength taken from it would
+        // stop the user typing input the server would have accepted
+        if (!control.supportsLength() || validator.isTrim()) {
+            return;
+        }
+        if (validator.getMinLength() > -1) {
+            attributes.put("minlength", String.valueOf(validator.getMinLength()));
+        }
+        if (validator.getMaxLength() > -1) {
+            attributes.put("maxlength", String.valueOf(validator.getMaxLength()));
+        }
+    }
+
+    protected void addPattern(Map<String, String> attributes, RegexFieldValidator validator, HtmlControlType control) {
+        // HTML pattern accepts no flags, so a case-insensitive rule cannot be expressed at all
+        if (!control.supportsPattern() || !validator.isCaseSensitive()) {
+            return;
+        }
+        // trim defaults to true, and the server matches the trimmed value while pattern matches the
+        // raw one: "[a-z]+" would accept "abc " server-side and be blocked by the browser
+        if (validator.isTrimed()) {
+            return;
+        }
+        // Both extend RegexFieldValidator but do not match their regex against the raw value:
+        // CreditCardValidator strips all whitespace first, and both carry grammars the browser
+        // does not share. Neither is expressible as a pattern.
+        if (validator instanceof EmailValidator || validator instanceof CreditCardValidator) {
+            return;
+        }
+        String regex = validator.getRegex();
+        if (EcmaScriptSafeRegex.isSafe(regex)) {
+            attributes.put("pattern", regex);
+        }
+    }
+
+    protected void addRange(Map<String, String> attributes, RangeValidatorSupport<?> validator, HtmlControlType control) {
+        if (!isNumericRange(control)) {
+            // Temporal controls support ranges too, but min/max there need per-control ISO
+            // formatting (date -> yyyy-MM-dd, month -> yyyy-MM, week -> yyyy-'W'ww, time -> HH:mm).
+            // Deliberately deferred; DateRangeFieldValidator therefore emits nothing for now.
+            return;
+        }
+        // min is guarded by isIntegral; see the comment on that method. The shipped Integer/Short/Long
+        // range validators always pass it, but a custom RangeValidatorSupport<Double> would not.
+        Object min = validator.getMin();
+        if (isIntegral(min)) {
+            putIfPresent(attributes, "min", min);
+        }
+        putIfPresent(attributes, "max", validator.getMax());
+    }
+
+    protected void addDoubleRange(Map<String, String> attributes, DoubleRangeFieldValidator validator, HtmlControlType control) {
+        if (!isNumericRange(control)) {
+            return;
+        }
+        // exclusive bounds have no HTML equivalent; omitting them leaves the browser more
+        // permissive than the server, which is the safe direction
+        Double minInclusive = validator.getMinInclusive();
+        if (isIntegral(minInclusive)) {
+            putIfPresent(attributes, "min", minInclusive);
+        }
+        putIfPresent(attributes, "max", validator.getMaxInclusive());
+    }
+
+    private boolean isNumericRange(HtmlControlType control) {
+        return control.supportsRange() && (control == HtmlControlType.NUMBER || control == HtmlControlType.RANGE);
+    }
+
+    /**
+     * A fractional {@code min} moves the HTML step base off zero, and with the default {@code step="1"}
+     * the browser then rejects whole numbers the server accepts. {@code max} does not participate in the
+     * step base, so only {@code min} needs this guard. For {@code input type="number"}/{@code "range"},
+     * the step base is the {@code min} attribute's value when present (otherwise 0); a {@code double}
+     * validator with {@code minInclusive=6000.1} would render {@code min="6000.1"}, making the only valid
+     * values 6000.1, 6001.1, 6002.1 … — the browser then rejects {@code 6002}, which
+     * {@code DoubleRangeFieldValidator} accepts server-side.
+     */
+    private boolean isIntegral(Object value) {
+        if (!(value instanceof java.lang.Number number)) {
+            return false;
+        }
+        double asDouble = number.doubleValue();
+        return !Double.isNaN(asDouble) && !Double.isInfinite(asDouble) && asDouble == Math.floor(asDouble);
+    }
+
+    protected void addMessage(Map<String, String> attributes, Validator validator, Object action) {
+        if (action == null) {
+            return;
+        }
+        String message = validator.getMessage(action);
+        if (message != null && !message.isEmpty()) {
+            attributes.put("data-msg-" + validator.getValidatorType(), message);
+        }
+    }
+
+    private void putIfPresent(Map<String, String> attributes, String name, Object value) {
+        if (value != null) {
+            attributes.put(name, String.valueOf(value));
+        }
+    }
+}
+```
+
+**Scope decision, made explicit:** `DateRangeFieldValidator` extends `RangeValidatorSupport<Date>`, so it reaches `addRange`, where `isNumericRange` rejects it and it emits nothing. Temporal `min`/`max` need per-control ISO formatting (`date` wants `yyyy-MM-dd`, `week` wants `2026-W12`, and so on) and are **deliberately not implemented here**. `HtmlControlType.supportsRange()` already covers the temporal types so the enum needs no change when this lands; file it as a follow-up rather tha [...]
+
+- [ ] **Step 5: Register the bean**
+
+In `core/src/main/resources/struts-beans.xml`, immediately after the `UrlRenderer` bean (around line 146):
+
+```xml
+    <bean type="org.apache.struts2.components.HtmlConstraintProvider" name="struts"
+          class="org.apache.struts2.components.StrutsHtmlConstraintProvider"/>
+```
+
+Register it **once**, under a single type. Registering a bean under two types builds two instances.
+
+- [ ] **Step 6: Run the tests to verify they pass**
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=StrutsHtmlConstraintProviderTest`
+Expected: PASS, 14 tests.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add core/src/main/java/org/apache/struts2/components/HtmlConstraintProvider.java \
+        core/src/main/java/org/apache/struts2/components/StrutsHtmlConstraintProvider.java \
+        core/src/main/resources/struts-beans.xml \
+        core/src/test/java/org/apache/struts2/components/StrutsHtmlConstraintProviderTest.java
+git diff --cached --name-only
+git commit -m "WW-5695 feat(components): map validators onto HTML5 constraint attributes
+
+Adds HtmlConstraintProvider and its conservative default implementation, which
+never sets or changes an input's type: type=number would reject 1234,50 that
+locale-aware conversion accepts, and browser email/url grammars differ from the
+framework's validators. Range constraints therefore land only on a control the
+developer already made numeric.
+
+Registered as a swappable bean so applications wanting a best-effort mapping
+can replace the policy.
+
+Co-Authored-By: Claude Opus 5 <no...@anthropic.com>"
+```
+
+---
+
+### Task 5: `Form.getFieldValidators` with per-render memoisation
+
+**Files:**
+- Modify: `core/src/main/java/org/apache/struts2/components/Form.java`
+- Test: `core/src/test/java/org/apache/struts2/components/FormFieldValidatorsTest.java`
+
+**Interfaces:**
+- Consumes: nothing new.
+- Produces: `Form.getFieldValidators(String name)` returning `List<Validator>`. Used by Task 7.
+
+`Form.getValidators(String)` re-runs the action-mapping lookup and `actionValidatorManager.getValidators(...)` on **every** call, so a twenty-field form would do twenty full resolutions. The new method resolves once per form render.
+
+**Memoisation location:** on private fields of the `Form` component, **not** on the attributes map as the spec sketch said. A `Form` component is constructed per render by `ComponentTagSupport`, so a field is naturally request-scoped, and the attributes map is exposed to templates and should not carry private bookkeeping.
+
+Leave `getValidators(String)` exactly as it is. It is deprecated and dies with the JavaScript validator in 8.0.0; the small duplication dies with it.
+
+- [ ] **Step 1: Write the failing test**
+
+```java
+package org.apache.struts2.components;
+
+import org.apache.struts2.TestConfigurationProvider;
+import org.apache.struts2.validator.Validator;
+import org.apache.struts2.views.jsp.AbstractUITagTest;
+import org.apache.struts2.views.jsp.ui.FormTag;
+
+import java.util.HashMap;
+import java.util.List;
+
+public class FormFieldValidatorsTest extends AbstractUITagTest {
+
+    public void testFindsTheFieldsValidators() throws Exception {
+        Form form = formForDoubleValidationAction();
+
+        List<Validator> validators = form.getFieldValidators("myUpDownSelectTag");
+
+        assertEquals(1, validators.size());
+        assertEquals("double", validators.get(0).getValidatorType());
+    }
+
+    public void testReturnsEmptyForAnUnvalidatedField() throws Exception {
+        Form form = formForDoubleValidationAction();
+
+        assertTrue(form.getFieldValidators("noSuchField").isEmpty());
+    }
+
+    public void testResolvesTheActionsValidatorsOnlyOnceAcrossFields() throws Exception {
+        Form form = formForDoubleValidationAction();
+
+        ActionValidatorManager manager = mock(ActionValidatorManager.class);
+        when(manager.getValidators(any(), any(), any())).thenReturn(Collections.emptyList());
+        form.setActionValidatorManager(manager);
+
+        form.getFieldValidators("myUpDownSelectTag");
+        form.getFieldValidators("someOtherField");
+
+        verify(manager, times(1)).getValidators(any(), any(), any());
+    }
+
+    private Form formForDoubleValidationAction() throws Exception {
+        FormTag tag = new FormTag();
+        tag.setPageContext(pageContext);
+        tag.setName("myForm");
+        tag.setAction("doubleValidationAction");
+        tag.setNamespace("");
+        tag.doStartTag();
+        return (Form) tag.getComponent();
+    }
+
+    @Override
+    protected void setUp() throws Exception {
+        super.setUp();
+        initDispatcher(new HashMap<String, String>() {{
+            put("configProviders", TestConfigurationProvider.class.getName());
+        }});
+        createMocks();
+    }
+}
+```
+
+**Harness trap, part two:** `createMocks()` never calls `setConfig` on the `MockActionProxy` it builds, so
+`AnnotationActionValidatorManager.buildValidatorKey` dereferences a null `ActionConfig` and NPEs. The test's
+`setUp` also needs `((MockActionProxy) actionProxy).setConfig(configuration.getRuntimeConfiguration()
+.getActionConfig("", "doubleValidationAction"))`. This is why `FormTagTest` carries its own
+`prepareMockInvocation()` helper.
+
+**Do not write a memoisation test that only compares result sizes** — resolution is deterministic, so such a
+test passes identically against an implementation with no cache at all. Assert the *number of resolutions*
+with a mocked `ActionValidatorManager`, across two different field names.
+
+**Harness trap:** without `initDispatcher(configProviders = TestConfigurationProvider)` *and* `createMocks()`, the action config is not present and validator resolution silently returns nothing — the test would pass or fail for entirely the wrong reason. `DoubleValidationAction-validation.xml` already exists at `core/src/test/resources/org/apache/struts2/views/jsp/ui/` and declares a `double` validator for `myUpDownSelectTag`.
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=FormFieldValidatorsTest`
+Expected: FAIL — compilation error, `getFieldValidators` does not exist.
+
+- [ ] **Step 3: Write the implementation**
+
+In `Form.java`, add two fields beside the existing ones (near `actionValidatorManager`, around line 120):
+
+```java
+    private List<Validator> cachedActionValidators;
+    private String cachedActionName;
+    private boolean actionValidatorsResolved;
+```
+
+Then add the method next to `getValidators`:
+
+```java
+    /**
+     * Returns the validators declared for a single field, resolving the action's validator list at
+     * most once per form render.
+     *
+     * @since 7.4.0
+     */
+    public List<Validator> getFieldValidators(String name) {
+        resolveActionValidators();
+        if (cachedActionValidators.isEmpty()) {
+            return Collections.emptyList();
+        }
+        Class actionClass = (Class) getAttributes().get("actionClass");
+        List<Validator> validators = new ArrayList<>();
+        findFieldValidators(name, actionClass, cachedActionName, cachedActionValidators, validators, "");
+        return validators;
+    }
+
+    private void resolveActionValidators() {
+        if (actionValidatorsResolved) {
+            return;
+        }
+        actionValidatorsResolved = true;
+        cachedActionValidators = Collections.emptyList();
+
+        Class actionClass = (Class) getAttributes().get("actionClass");
+        if (actionClass == null) {
+            return;
+        }
+        ActionMapping mapping = actionMapper.getMappingFromActionName(findString(action));
+        if (mapping == null) {
+            mapping = actionMapper.getMappingFromActionName((String) getAttributes().get("actionName"));
+        }
+        if (mapping == null) {
+            return;
+        }
+        cachedActionName = mapping.getName();
+        String methodName = isValidateAnnotatedMethodOnly(cachedActionName) ? mapping.getMethod() : null;
+        cachedActionValidators =
+            actionValidatorManager.getValidators(actionClass, cachedActionName, methodName);
+    }
+```
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=FormFieldValidatorsTest`
+Expected: PASS, 3 tests.
+
+- [ ] **Step 5: Verify nothing regressed**
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=FormTagTest`
+Expected: PASS. `getValidators` was not touched, so all four `validateForm_` golden files must still match.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add core/src/main/java/org/apache/struts2/components/Form.java \
+        core/src/test/java/org/apache/struts2/components/FormFieldValidatorsTest.java
+git diff --cached --name-only
+git commit -m "WW-5695 feat(components): add Form.getFieldValidators with per-render caching
+
+getValidators re-runs the action-mapping lookup and the validator-manager
+resolution on every call, so a twenty-field form would do twenty of them. The
+new method resolves once per form render and filters per field.
+
+Memoised on component fields rather than the attributes map: a Form component
+is built per render, and the attributes map is exposed to templates.
+
+Co-Authored-By: Claude Opus 5 <no...@anthropic.com>"
+```
+
+---
+
+### Task 6: `getControlType()` on the component hierarchy
+
+**Files:**
+- Modify: `core/src/main/java/org/apache/struts2/components/UIBean.java`
+- Modify: `core/src/main/java/org/apache/struts2/components/TextField.java`
+- Modify: `core/src/main/java/org/apache/struts2/components/Password.java`
+- Modify: `core/src/main/java/org/apache/struts2/components/TextArea.java`
+- Modify: `core/src/main/java/org/apache/struts2/components/Select.java`
+- Modify: `core/src/main/java/org/apache/struts2/components/Radio.java` (added as a fix — see the correction note below)
+- Modify: `core/src/main/java/org/apache/struts2/components/File.java` (added as a fix — see the correction note below)
+- Test: `core/src/test/java/org/apache/struts2/components/ControlTypeTest.java`
+
+**Interfaces:**
+- Consumes: `HtmlControlType` (Task 2).
+- Produces: `protected HtmlControlType UIBean.getControlType()`. Used by Task 7.
+
+`attributes.type` is set by `TextField.evaluateExtraParams` (`TextField.java:91`) and by nothing else on the input path — `TextArea`, `Select`, `Checkbox`, `File`, `Hidden` and `Radio` have no `type` attribute at all. So the control type needs a component hook.
+
+**Post-implementation correction (added during final-review fixup):** the steps below, as originally
+written, gave `Checkbox`, `Radio`, `File` and `Hidden` **no** override, on the theory that all four should
+fall through to `OTHER`. That was wrong for two of them. `RADIO` and `FILE` are the only controls where an
+unselected/empty submission omits the parameter entirely rather than submitting an empty value, so they are
+the only two where `required` agrees with the server (see the mapping table in the spec) — without their own
+`getControlType()` override they fall through to `OTHER`, and `addRequiredField` in `StrutsHtmlConstraintProvider`
+becomes unreachable dead code. `Radio` and `File` were given overrides as a fix, making it six overrides
+total, not four. `Checkbox` and `Hidden` are unaffected by this correction and still deliberately get no
+override: `CheckboxInterceptor` substitutes `"false"` for an unticked box, so the field is never missing
+server-side and a browser `required` there would false-reject. `ComboBox` extends `TextField` and correctly
+inherits `TEXT`.
+
+- [ ] **Step 1: Write the failing test**
+
+```java
+package org.apache.struts2.components;
+
+import org.apache.struts2.views.jsp.AbstractUITagTest;
+
+public class ControlTypeTest extends AbstractUITagTest {
+
+    public void testTextFieldDefaultsToText() {
+        TextField textField = new TextField(stack, request, response);
+        assertEquals(HtmlControlType.TEXT, textField.getControlType());
+    }
+
+    public void testTextFieldHonoursAnExplicitType() {
+        TextField textField = new TextField(stack, request, response);
+        textField.addParameter("type", "number");
+        assertEquals(HtmlControlType.NUMBER, textField.getControlType());
+    }
+
+    public void testTextFieldFallsBackForAnUnknownType() {
+        TextField textField = new TextField(stack, request, response);
+        textField.addParameter("type", "supercolor");
+        assertEquals(HtmlControlType.OTHER, textField.getControlType());
+    }
+
+    public void testPasswordIsAlwaysPassword() {
+        Password password = new Password(stack, request, response);
+        assertEquals(HtmlControlType.PASSWORD, password.getControlType());
+    }
+
+    public void testTextAreaIsTextarea() {
+        TextArea textArea = new TextArea(stack, request, response);
+        assertEquals(HtmlControlType.TEXTAREA, textArea.getControlType());
+    }
+
+    public void testSelectIsSelect() {
+        Select select = new Select(stack, request, response);
+        assertEquals(HtmlControlType.SELECT, select.getControlType());
+    }
+
+    public void testControlsWithoutAnOverrideAreUnknown() {
+        assertEquals(HtmlControlType.OTHER, new Checkbox(stack, request, response).getControlType());
+        assertEquals(HtmlControlType.OTHER, new Hidden(stack, request, response).getControlType());
+        assertEquals(HtmlControlType.OTHER, new File(stack, request, response).getControlType());
+    }
+}
+```
+
+`getControlType()` is `protected`, and this test lives in the same package, so it is directly callable.
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=ControlTypeTest`
+Expected: FAIL — compilation error, `getControlType` does not exist.
+
+- [ ] **Step 3: Add the base method to `UIBean`**
+
+Place it next to the other `protected` helpers, above `evaluateExtraParams()`:
+
+```java
+    /**
+     * The kind of HTML control this component renders, used to decide which HTML5 constraint
+     * attributes are legal on it. Defaults to {@link HtmlControlType#OTHER}, which supports no
+     * constraints — so a component that does not override this emits none.
+     *
+     * @since 7.4.0
+     */
+    protected HtmlControlType getControlType() {
+        return HtmlControlType.OTHER;
+    }
+```
+
+- [ ] **Step 4: Add the overrides** (originally four; `Radio` and `File` were added as a fix — see the
+  correction note above)
+
+`TextField.java` — reads the attribute set by its own `evaluateExtraParams`, defaulting to `TEXT` to match `text.ftl`'s `attributes.type!"text"`:
+
+```java
+    @Override
+    protected HtmlControlType getControlType() {
+        Object type = getAttributes().get("type");
+        return type == null ? HtmlControlType.TEXT : HtmlControlType.from(String.valueOf(type));
+    }
+```
+
+`Password.java` — it extends `TextField`, but its template hardcodes the type:
+
+```java
+    @Override
+    protected HtmlControlType getControlType() {
+        return HtmlControlType.PASSWORD;
+    }
+```
+
+`TextArea.java`:
+
+```java
+    @Override
+    protected HtmlControlType getControlType() {
+        return HtmlControlType.TEXTAREA;
+    }
+```
+
+`Select.java`:
+
+```java
+    @Override
+    protected HtmlControlType getControlType() {
+        return HtmlControlType.SELECT;
+    }
+```
+
+`Radio.java` and `File.java` — added as a fix; without these, `addRequiredField` in
+`StrutsHtmlConstraintProvider` has no control type it can ever match, making it dead code:
+
+```java
+    @Override
+    protected HtmlControlType getControlType() {
+        return HtmlControlType.RADIO;
+    }
+```
+
+```java
+    @Override
+    protected HtmlControlType getControlType() {
+        return HtmlControlType.FILE;
+    }
+```
+
+- [ ] **Step 5: Run the test to verify it passes**
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=ControlTypeTest`
+Expected: PASS, 7 tests.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add core/src/main/java/org/apache/struts2/components/UIBean.java \
+        core/src/main/java/org/apache/struts2/components/TextField.java \
+        core/src/main/java/org/apache/struts2/components/Password.java \
+        core/src/main/java/org/apache/struts2/components/TextArea.java \
+        core/src/main/java/org/apache/struts2/components/Select.java \
+        core/src/main/java/org/apache/struts2/components/Radio.java \
+        core/src/main/java/org/apache/struts2/components/File.java \
+        core/src/test/java/org/apache/struts2/components/ControlTypeTest.java
+git diff --cached --name-only
+git commit -m "WW-5695 feat(components): resolve the HTML control type per component
+
+attributes.type is set by TextField and nothing else on the input path, so the
+control type cannot come from the attribute map alone. Adds getControlType()
+with six overrides; Checkbox and Hidden fall through to OTHER, which emits
+nothing and is correct for both (CheckboxInterceptor substitutes false for an
+unticked box, so required there would false-reject). Radio and File get their
+own RADIO/FILE overrides, since they are the only controls where required
+agrees with the server.
+
+Co-Authored-By: Claude Opus 5 <no...@anthropic.com>"
+```
+
+---
+
+### Task 7: The constant and the `UIBean` wiring
+
+**Files:**
+- Modify: `core/src/main/java/org/apache/struts2/StrutsConstants.java:204`
+- Modify: `core/src/main/resources/org/apache/struts2/default.properties:167`
+- Modify: `core/src/main/java/org/apache/struts2/components/UIBean.java`
+- Test: `core/src/test/java/org/apache/struts2/components/ConstraintAttributesTest.java`
+
+**Interfaces:**
+- Consumes: `HtmlConstraintProvider` (Task 4), `Form.getFieldValidators` (Task 5), `getControlType()` (Task 6).
+- Produces: the `constraints` attribute on the component's attribute map, a `Map<String, String>`. Consumed by Task 8's template.
+
+**Hook placement — this is the part that is easy to get wrong.** `evaluateParams()` resolves `final Form form = (Form) findAncestor(Form.class)` at `UIBean.java:824` and appends to `tagNames` just below. **The hook must not go there.** `evaluateExtraParams()` is the *last* statement of `evaluateParams()` (`UIBean.java:905`), and that is where `TextField` sets `attributes.type` — so at the `tagNames` block no text field has a resolved type and every one of them would look like `OTHER`. Th [...]
+
+- [ ] **Step 1: Write the failing test**
+
+```java
+package org.apache.struts2.components;
+
+import org.apache.struts2.StrutsConstants;
+import org.apache.struts2.TestConfigurationProvider;
+import org.apache.struts2.views.jsp.AbstractUITagTest;
+import org.apache.struts2.views.jsp.ui.FormTag;
+import org.apache.struts2.views.jsp.ui.TextFieldTag;
+
+import java.util.HashMap;
+import java.util.Map;
+
+public class ConstraintAttributesTest extends AbstractUITagTest {
+
+    public void testNoConstraintsWhenTheConstantIsOff() throws Exception {
+        initDispatcherWith("false");
+
+        assertNull(renderFieldAndReturnConstraints());
+    }
+
+    public void testConstraintsWhenTheConstantIsOn() throws Exception {
+        initDispatcherWith("true");
+
+        Map<String, String> constraints = renderFieldAndReturnConstraints();
+        assertNotNull("expected constraints to be populated", constraints);
+        assertEquals("3", constraints.get("minlength"));
+    }
+
+    @SuppressWarnings("unchecked")
+    private Map<String, String> renderFieldAndReturnConstraints() throws Exception {
+        FormTag form = new FormTag();
+        form.setPageContext(pageContext);
+        form.setAction("constraintAction");
+        form.setNamespace("");
+        form.doStartTag();
+
+        TextFieldTag field = new TextFieldTag();
+        field.setPageContext(pageContext);
+        field.setName("username");
+        field.doStartTag();
+
+        Map<String, Object> attributes =
+            ((UIBean) field.getComponent()).getAttributes();
+
+        field.doEndTag();
+        form.doEndTag();
+
+        return (Map<String, String>) attributes.get("constraints");
+    }
+
+    private void initDispatcherWith(String constraintsEnabled) throws Exception {
+        initDispatcher(new HashMap<String, String>() {{
+            put("configProviders", TestConfigurationProvider.class.getName());
+            put(StrutsConstants.STRUTS_UI_HTML5_CONSTRAINTS, constraintsEnabled);
+        }});
+        createMocks();
+    }
+}
+```
+
+This test needs an action named `constraintAction` with a `stringlength` validator carrying `trim="false"` and `minLength=3` on a `username` field. Create both:
+
+`core/src/test/java/org/apache/struts2/components/ConstraintAction.java`:
+
+```java
+package org.apache.struts2.components;
+
+import org.apache.struts2.ActionSupport;
+import org.apache.struts2.interceptor.parameter.StrutsParameter;
+
+public class ConstraintAction extends ActionSupport {
+
+    private String username;
+
+    public String getUsername() {
+        return username;
+    }
+
+    @StrutsParameter
+    public void setUsername(String username) {
+        this.username = username;
+    }
+}
+```
+
+`core/src/test/resources/org/apache/struts2/components/ConstraintAction-validation.xml` (copy the licence header and DOCTYPE verbatim from `core/src/test/resources/org/apache/struts2/views/jsp/ui/DoubleValidationAction-validation.xml`):
+
+```xml
+<validators>
+    <field name="username">
+        <field-validator type="stringlength">
+            <param name="trim">false</param>
+            <param name="minLength">3</param>
+            <message>username must be at least ${minLength} characters</message>
+        </field-validator>
+    </field>
+</validators>
+```
+
+Then register `constraintAction` in `core/src/test/java/org/apache/struts2/TestConfigurationProvider.java`. Add the config beside the existing `doubleValidationActionConfig` (around line 90) — it must carry the `ValidationInterceptor` mapping, or `evaluateClientSideJsEnablement` finds no interceptor and the action looks unvalidated:
+
+```java
+        ActionConfig constraintActionConfig = new ActionConfig.Builder("", "constraintAction", ConstraintAction.class.getName())
+            .addResultConfig(new ResultConfig.Builder(Action.SUCCESS, ServletDispatcherResult.class.getName())
+                    .addParam("location", "success.jsp")
+                    .build())
+            .addInterceptor(new InterceptorMapping("validation", validationInterceptor))
+            .build();
+```
+
+and register it in the `defaultPackageConfig` builder (around line 121), next to the `doubleValidationAction` line:
+
+```java
+            .addActionConfig("constraintAction", constraintActionConfig)
+```
+
+Add `import org.apache.struts2.components.ConstraintAction;` at the top.
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=ConstraintAttributesTest`
+Expected: FAIL — compilation error, `STRUTS_UI_HTML5_CONSTRAINTS` does not exist.
+
+- [ ] **Step 3: Add the constant**
+
+In `StrutsConstants.java`, after `STRUTS_UI_STATIC_CONTENT_PATH` (around line 204):
+
+```java
+    /**
+     * Whether the html5 theme emits HTML5 constraint attributes derived from the action's validators.
+     * Defaults to false in 7.4.0; the default becomes true in 8.0.0.
+     *
+     * @since 7.4.0
+     */
+    public static final String STRUTS_UI_HTML5_CONSTRAINTS = "struts.ui.html5.constraints";
+```
+
+In `default.properties`, in the "Standard UI theme" block after `struts.ui.templateSuffix=ftl`:
+
+```properties
+### Whether the html5 theme emits HTML5 constraint attributes (required, minlength,
+### maxlength, pattern, min, max) derived from the action's validators.
+### Defaults to false so existing html5-theme forms render unchanged; becomes true in 8.0.0.
+struts.ui.html5.constraints=false
+```
+
+- [ ] **Step 4: Wire `UIBean`**
+
+Add the field and injection setters alongside the existing ones (near `setCspNonceReader`, around line 559):
+
+```java
+    protected HtmlConstraintProvider htmlConstraintProvider;
+    protected boolean html5ConstraintsEnabled;
+
+    @Inject
+    public void setHtmlConstraintProvider(HtmlConstraintProvider htmlConstraintProvider) {
+        this.htmlConstraintProvider = htmlConstraintProvider;
+    }
+
+    @Inject(value = StrutsConstants.STRUTS_UI_HTML5_CONSTRAINTS, required = false)
+    public void setHtml5ConstraintsEnabled(String html5ConstraintsEnabled) {
+        this.html5ConstraintsEnabled = BooleanUtils.toBoolean(html5ConstraintsEnabled);
+    }
+```
+
+Then, at the **end** of `evaluateParams()`, after the existing `evaluateExtraParams();` call at line 905:
+
+```java
+        evaluateExtraParams();
+
+        // must run after evaluateExtraParams(): that is where TextField resolves attributes.type,
+        // and the control type decides which constraints are legal
+        addConstraintAttributes(form);
+    }
+
+    /**
+     * Derives HTML5 constraint attributes for this field from the action's validators.
+     *
+     * @since 7.4.0
+     */
+    protected void addConstraintAttributes(Form form) {
+        if (!html5ConstraintsEnabled || form == null || htmlConstraintProvider == null) {
+            return;
+        }
+        String fieldName = (String) getAttributes().get("name");
+        if (fieldName == null) {
+            return;
+        }
+        Map<String, String> constraints = htmlConstraintProvider.constraintsFor(
+            form.getFieldValidators(fieldName), getControlType(), stack.peek());
+        if (!constraints.isEmpty()) {
+            addParameter("constraints", constraints);
+        }
+    }
+```
+
+Add `import org.apache.commons.lang3.BooleanUtils;` if it is not already present.
+
+Gating on `html5ConstraintsEnabled` first means the cost is exactly zero when the feature is off.
+
+- [ ] **Step 5: Run the test to verify it passes**
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=ConstraintAttributesTest`
+Expected: PASS, 2 tests.
+
+- [ ] **Step 6: Verify nothing regressed**
+
+Run: `mvn test -DskipAssembly -pl core`
+Expected: PASS. The constant defaults to `false`, so every existing golden file must be unchanged. If `DateTest.testJavaSqlDate` fails alone, that is the known WW-5686 flake — rerun it on its own to confirm.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add core/src/main/java/org/apache/struts2/StrutsConstants.java \
+        core/src/main/resources/org/apache/struts2/default.properties \
+        core/src/main/java/org/apache/struts2/components/UIBean.java \
+        core/src/test/java/org/apache/struts2/components/ConstraintAttributesTest.java \
+        core/src/test/java/org/apache/struts2/components/ConstraintAction.java \
+        core/src/test/resources/org/apache/struts2/components/ConstraintAction-validation.xml \
+        core/src/test/java/org/apache/struts2/TestConfigurationProvider.java
+git diff --cached --name-only
+git commit -m "WW-5695 feat(components): derive constraint attributes during tag evaluation
+
+Adds struts.ui.html5.constraints, default false, and wires UIBean to the
+constraint provider behind it.
+
+The hook sits at the end of evaluateParams rather than beside the tagNames
+block, because evaluateExtraParams is where TextField resolves attributes.type
+and it runs last; hooking earlier would make every text field look like OTHER.
+
+Co-Authored-By: Claude Opus 5 <no...@anthropic.com>"
+```
+
+---
+
+### Task 8: Render the attributes from the html5 theme
+
+**Files:**
+- Create: `core/src/main/resources/template/html5/constraints.ftl`
+- Modify: `core/src/main/resources/template/html5/common-attributes.ftl`
+- Test: `core/src/test/java/org/apache/struts2/views/jsp/ui/Html5ConstraintRenderingTest.java`
+
+**Interfaces:**
+- Consumes: the `constraints` attribute produced by Task 7.
+- Produces: rendered HTML. Nothing downstream depends on it.
+
+Including from `common-attributes.ftl` means every html5 input picks the attributes up without per-template edits.
+
+- [ ] **Step 1: Write the failing test**
+
+```java
+package org.apache.struts2.views.jsp.ui;
+
+import org.apache.struts2.StrutsConstants;
+import org.apache.struts2.TestConfigurationProvider;
+import org.apache.struts2.views.jsp.AbstractUITagTest;
+
+import java.util.HashMap;
+
+public class Html5ConstraintRenderingTest extends AbstractUITagTest {
+
+    public void testRendersConstraintAttributes() throws Exception {
+        String output = render("true");
+
+        assertTrue("expected minlength in: " + output, output.contains("minlength=\"3\""));
+    }
+
+    public void testRendersNothingWhenTheConstantIsOff() throws Exception {
+        String output = render("false");
+
+        assertFalse("expected no minlength in: " + output, output.contains("minlength="));
+    }
+
+    public void testRequiredLabelDoesNotBecomeARequiredAttribute() throws Exception {
+        FormTag form = new FormTag();
+        form.setPageContext(pageContext);
+        form.setTheme("html5");
+        form.setAction("constraintAction");
+        form.setNamespace("");
+        form.doStartTag();
+
+        TextFieldTag field = new TextFieldTag();
+        field.setPageContext(pageContext);
+        field.setTheme("html5");
+        field.setName("noValidatorHere");
+        field.setRequiredLabel("true");
+        field.doStartTag();
+        field.doEndTag();
+        form.doEndTag();
+
+        String output = writer.toString();
+        assertFalse("requiredLabel draws an asterisk; it must never emit a required attribute: " + output,
+            output.contains("required=\"required\""));
+    }
+
+    private String render(String constraintsEnabled) throws Exception {
+        initDispatcher(new HashMap<String, String>() {{
+            put("configProviders", TestConfigurationProvider.class.getName());
+            put(StrutsConstants.STRUTS_UI_HTML5_CONSTRAINTS, constraintsEnabled);
+        }});
+        createMocks();
+
+        FormTag form = new FormTag();
+        form.setPageContext(pageContext);
+        form.setTheme("html5");
+        form.setAction("constraintAction");
+        form.setNamespace("");
+        form.doStartTag();
+
+        TextFieldTag field = new TextFieldTag();
+        field.setPageContext(pageContext);
+        field.setTheme("html5");
+        field.setName("username");
+        field.doStartTag();
+        field.doEndTag();
+        form.doEndTag();
+
+        return writer.toString();
+    }
+}
+```
+
+The third test is the one that matters most: `requiredLabel` is the visual asterisk and must never become a `required` attribute. Conflating them is the most likely regression in this whole change.
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=Html5ConstraintRenderingTest`
+Expected: FAIL — `testRendersConstraintAttributes` finds no `minlength`, because nothing renders the map yet.
+
+- [ ] **Step 3: Create the template**
+
+`core/src/main/resources/template/html5/constraints.ftl`, with the same `<#--` licence header used by the neighbouring html5 templates, then:
+
+```
+<#if attributes.constraints??><#list attributes.constraints as attributeName, attributeValue> ${attributeName}="${attributeValue}"<#rt/></#list></#if>
+```
+
+**Do not add a `?html` builtin — it is a parse error here.** `FreemarkerManager` sets
+`ENABLE_IF_DEFAULT_AUTO_ESCAPING_POLICY` with `HTMLOutputFormat` (`FreemarkerManager.java:354-355`), so values
+are already HTML-escaped by configuration and FreeMarker rejects `?html` as a double-escape. Zero templates in
+this repo use it. The escaping is still load-bearing — `data-msg-*` values are OGNL-interpolated and can carry
+user-submitted content — so never disable auto-escaping for this template.
+
+- [ ] **Step 4: Include it**
+
+Append to `core/src/main/resources/template/html5/common-attributes.ftl`, after the existing `accesskey` block:
+
+```
+<#include "/${attributes.templateDir}/${attributes.expandTheme}/constraints.ftl" /><#rt/>
+```
+
+- [ ] **Step 5: Run the test to verify it passes**
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=Html5ConstraintRenderingTest`
+Expected: PASS, 3 tests.
+
+- [ ] **Step 6: Verify the whole suite**
+
+Run: `mvn test -DskipAssembly -pl core`
+Expected: PASS, including every existing html5 golden file — the constant is off by default, so `Formtag-1-html5.txt` and friends must be byte-identical.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add core/src/main/resources/template/html5/constraints.ftl \
+        core/src/main/resources/template/html5/common-attributes.ftl \
+        core/src/test/java/org/apache/struts2/views/jsp/ui/Html5ConstraintRenderingTest.java
+git diff --cached --name-only
+git commit -m "WW-5695 feat(html5): render derived constraint attributes
+
+Included from common-attributes.ftl so every html5 input picks the attributes
+up without per-template edits. Values are HTML-escaped: pattern and the
+data-msg-* text are author-controlled and land inside an attribute.
+
+Covers the regression that matters most - requiredLabel draws an asterisk and
+must never emit a required attribute.
+
+Co-Authored-By: Claude Opus 5 <no...@anthropic.com>"
+```
+
+---
+
+### Task 9: Documentation (`struts-site` repository)
+
+**This task is in a different repository:** `~/Projects/Apache/struts-site`. It needs its own branch, its own commit, and its own PR. Docs changes there take conventional-commit form with no ticket prefix.
+
+**Files:**
+- Modify: `source/core-developers/client-side-validation.md`
+- Delete: `source/core-developers/pure-java-script-client-side-validation.md`
+- Modify: `source/core-developers/client-validation-example.md`
+- Modify: `source/tag-developers/form-tag.md`
+
+- [ ] **Step 1: Rewrite the client-side validation page**
+
+Rewrite `client-side-validation.md` around the html5 theme. It must carry:
+
+- the `struts.ui.html5.constraints` constant, its `false` default in 7.4.0 and the 8.0.0 flip;
+- the full mapping table from the spec, including the `trim="false"` and `caseSensitive="true"` conditions;
+- the never-change-the-type rule, stated as the reason `type="email"`, `type="url"` and `type="number"` are never set by Struts, with the `1234,50` comma-decimal example;
+- the `data-msg-*` attributes, and that Struts ships nothing that consumes them;
+- that `requiredLabel` is unrelated to the `required` attribute;
+- a deprecation notice for the JavaScript validator pointing at WW-5694 and WW-5696.
+
+Its existing "Client Side Validation Types" table links to the pure-JavaScript page; replace that section.
+
+- [ ] **Step 2: Delete the pure-JavaScript page and fix the stale i18n claim**
+
+Delete `pure-java-script-client-side-validation.md`. Before deleting, note that it claims errors are reported "not the internationalized version that the server-side might be aware of" — **that is wrong**, `ValidatorSupport.getMessage` resolves through `DelegatingValidatorContext` and `textProviderFactory`. Do not carry the claim into the rewritten page.
+
+Grep for inbound links and repoint them:
+
+```bash
+cd ~/Projects/Apache/struts-site
+grep -rn "pure-java-script-client-side-validation" source/
+```
+
+- [ ] **Step 3: Mark the example page deprecated**
+
+Add a deprecation banner at the top of `client-validation-example.md` pointing at the html5 theme.
+
+- [ ] **Step 4: Note the deprecated attribute**
+
+In `tag-developers/form-tag.md`, mark `validate` deprecated since 7.4.0, removed in 8.0.0.
+
+- [ ] **Step 5: Leave the html5 theme's "since" version alone**
+
+`tag-developers/html5-theme.md` says "Available since Struts 7.2.0". **That is correct** — `git tag --contains e24d2f2d3` returns `STRUTS_7_2_0`. WW-5444's fix version of 7.2.1 is the wrong record. Do not "fix" the docs to match Jira; the Jira ticket is what needs correcting.
+
+- [ ] **Step 6: Commit**
+
+```bash
+cd ~/Projects/Apache/struts-site
+git checkout -b docs/html5-constraint-validation
+git add source/core-developers/client-side-validation.md \
+        source/core-developers/client-validation-example.md \
+        source/tag-developers/form-tag.md
+git rm source/core-developers/pure-java-script-client-side-validation.md
+git diff --cached --name-only
+git commit -m "docs: document html5 constraint validation, deprecate the JS validator
+
+Rewrites the client-side validation page around the html5 theme's constraint
+attributes and removes the pure-JavaScript page.
+
+Drops that page's claim that client-side messages are not internationalized:
+ValidatorSupport.getMessage resolves through DelegatingValidatorContext and
+textProviderFactory, so they always were.
+
+Co-Authored-By: Claude Opus 5 <no...@anthropic.com>"
+```
+
+---
+
+## Follow-ups (file after this plan lands, do not do them here)
+
+- **Temporal `min`/`max`.** `DateRangeFieldValidator` currently emits nothing. Needs per-control ISO formatting (`date` → `yyyy-MM-dd`, `month` → `yyyy-MM`, `week` → `yyyy-'W'ww`, `time` → `HH:mm`). `HtmlControlType.supportsRange()` already covers the temporal types, so no enum change is needed.
+- **WW-5444's fix version** says 7.2.1; the html5 theme actually shipped in 7.2.0. Correct the ticket.
+- **The regex allowlist will be too strict for some real applications.** Expect tuning after the first release; the swappable provider is the escape hatch in the meantime.
+- **WW-5696** — the 8.0.0 removal. Already filed, out of scope here.
diff --git a/docs/superpowers/specs/2026-08-24-html5-constraint-validation-design.md b/docs/superpowers/specs/2026-08-24-html5-constraint-validation-design.md
new file mode 100644
index 000000000..1de76384e
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-24-html5-constraint-validation-design.md
@@ -0,0 +1,422 @@
+# HTML5 constraint validation, and retiring the JavaScript client-side validator
+
+**Tickets:** not yet filed — three proposed, see "Ticket structure"
+**Supersedes:** [WW-2975](https://issues.apache.org/jira/browse/WW-2975) (to be closed Won't Fix)
+**Target:** 7.4.0 (deprecate + new feature), 8.0.0 (remove)
+**Date:** 2026-08-24
+**Status:** Design approved, pending implementation plan
+
+**Plan scope:** the implementation plan drawn from this spec covers the **7.4.0 work only** — the deprecation
+and the new constraint feature. The 8.0.0 removal is specified here so the deprecation is written against a
+known endpoint, but it is a separate ticket, a separate release and a separate plan.
+
+## Problem
+
+`xhtml/form-close-validate.ftl` generates a `validateForm_<id>()` function by iterating
+`attributes.tagNames`. That list is seeded by `Form.evaluateExtraParams` (`Form.java:207`) and appended to
+by `UIBean.evaluateParams` (`UIBean.java:834`) — but **only** when `findAncestor(Form.class)` finds the form
+on the component stack.
+
+Any input that reaches the form's markup by another route is therefore invisible to client-side validation:
+raw HTML, a custom tag, an included fragment, or a component handed pre-rendered field markup. The generated
+function is then an empty shell:
+
+```js
+function validateForm_doubleValidationAction() {
+    ...
+    var errors = false;
+    var continueValidation = true;
+
+    return !errors;
+}
+```
+
+This is WW-2975, reported 2009-01-30 against 2.1.6 and reproduced unchanged on `main` (7.4.0-SNAPSHOT) on
+2026-08-24. The reproduction renders a form whose action declares a `double` validator for
+`myUpDownSelectTag`: a nested `<s:updownselect>` produces the `if (form.elements['myUpDownSelectTag'])`
+block, while an identical raw `<input name="myUpDownSelectTag">` produces nothing at all.
+
+The ticket's second complaint is also live, at LOW confidence (the repo has **no** JavaScript test
+infrastructure — no `package.json`, nothing). `validation.js` `addErrorXHTML` walks `row.parentNode` up
+until it finds a `TR`. A field with no `TR` ancestor sends that walk off the top of the document, and the
+handler is `catch (err) { alert(err) }` — so the user gets a raw JavaScript error instead of a validation
+message.
+
+### Why this is not being fixed in place
+
+The feature is legacy on every axis. It exists only in `xhtml` (the default theme) and `css_xhtml`
+(`parent = xhtml`); the `html5` theme has `parent = simple` and never had it. It supports eight validators.
+It reports errors by inserting `<tr>` elements, so it only works with the table layout. It has zero test
+coverage beyond four golden files.
+
+The successor is native HTML5 constraint validation, emitted per field. That dissolves the root cause rather
+than patching it: with constraints riding on each `<input>`, there is no central field list for a foreign
+field to be missing from.
+
+## Goals
+
+- Emit HTML5 constraint attributes from the `html5` theme, derived from the action's validators.
+- Never emit a constraint the browser would enforce more strictly than the server does.
+- Deprecate the JavaScript client-side validator in 7.4.0, remove it in 8.0.0.
+- Change nothing about existing renderings on upgrade to 7.4.0.
+
+## Non-goals
+
+- Fixing the `tagNames` scope or `addError` fragility in the deprecated path. It is documented as a known
+  limitation and deleted in 8.0.0.
+- Shipping any JavaScript. Struts emits messages as `data-*` attributes and stops there.
+- Changing an input's `type` attribute. See "The never-change-the-type rule".
+- Constraint support in `xhtml`, `css_xhtml` or `simple`. This is an `html5` theme feature.
+- Touching `struts.ui.theme`'s default. `xhtml` remains the default theme in 7.4.0.
+
+## Approach
+
+Derive constraints in Java from the action's validators, behind a swappable container bean, and render them
+from a single new `html5` template include.
+
+### The never-change-the-type rule
+
+`min`/`max` are inert on `type="text"`; they only apply to `number`, `range` and the temporal types. Emitting
+them therefore means switching the input to `type="number"` — and that **is** a false rejection: a browser
+`type="number"` refuses `1234,50`, which Struts' locale-aware conversion accepts in a comma-decimal locale.
+The same argument rules out `type="email"` and `type="url"`, whose browser regexes differ from
+`EmailValidator` and `URLValidator`.
+
+So: **Struts never sets or changes `type`. It only adds constraints that are safe for whatever type is
+already there.** A developer who writes `type="number"` has accepted that widget's semantics, and `min`/`max`
+become pure additions.
+
+### Mapping table
+
+| Validator | Emits | Condition |
+|---|---|---|
+| `required` | `required` | only on `RADIO` or `FILE` — everywhere else the control can submit a value the server accepts as non-missing (empty string, an unticked checkbox's substituted `"false"`, a select's empty-valued header option), so `required` there would false-reject |
+| `requiredstring` | `required` | on any text-entry or `TEXTAREA` control — server is stricter on whitespace-only input, which is safe |
+| `stringlength` | `minlength` / `maxlength` | only when the control supports length **and** `trim="false"` |
+| `regex` | `pattern` | only when the control supports pattern, `caseSensitive="true"`, `trim="false"`, the regex is ECMAScript-safe, and the validator is not `EmailValidator` or `CreditCardValidator` (see below) |
+| `int`, `short`, `long` | `min` / `max` | only when the control is already numeric; `min` additionally requires an integral bound (always true for these types) |
+| `double` | `min` / `max` | only when the control is already numeric; `min` is omitted when `minInclusive` is fractional — see "The integral-min guard" below |
+| `date` | `min` / `max` | only when the control is already temporal — **deferred, see below** |
+| `email`, `url` | — | never; browser regexes diverge from Struts' |
+| `creditcard` | — | never; `CreditCardValidator` strips whitespace before matching, which a browser `pattern` cannot replicate |
+| `fieldexpression`, `expression`, `conversion`, visitor | — | no safe mapping |
+
+`RegexFieldValidator.trim` defaults to `true`, and the server matches the *trimmed* value while `pattern`
+matches the raw one, so a case-sensitive, portable regex like `[a-z]+` would still need `trim="false"` to be
+safe: `"abc "` would pass server-side and be blocked by the browser otherwise.
+
+### The integral-min guard
+
+For `input type="number"` and `type="range"`, the HTML step base is the `min` attribute's value when
+present (otherwise 0), and the default `step` is `1`. A `double` validator with a fractional
+`minInclusive` (say `6000.1`) would therefore render `min="6000.1"`, making the only valid values
+6000.1, 6001.1, 6002.1 … — the browser would reject `6002`, which `DoubleRangeFieldValidator` accepts
+server-side. `max` does not participate in the step base, so it is unaffected and always emitted when
+present. `min` is emitted only when the bound is integral; the shipped `Integer`/`Short`/`Long` range
+validators always satisfy this, so the guard only ever suppresses `min` for a fractional `double` bound
+(or a custom `RangeValidatorSupport` subclass parameterised with a fractional type).
+
+`stringlength` with `trim="true"` is excluded because the server measures the *trimmed* value: a
+`maxlength` derived from it would stop the user typing input the server would have accepted.
+
+`RegexFieldValidator` uses `matcher.matches()`, so it is fully anchored and matches HTML5 `pattern`
+semantics. The divergence is syntactic, not positional.
+
+**Temporal ranges are deferred out of the first implementation.** `DateRangeFieldValidator` extends
+`RangeValidatorSupport<Date>`, so it reaches the range branch and is rejected there for not being numeric —
+it emits nothing. Honouring it needs per-control ISO formatting (`date` wants `yyyy-MM-dd`, `month`
+`yyyy-MM`, `week` `yyyy-'W'ww`, `time` `HH:mm`), which is guesswork worth doing deliberately rather than
+alongside everything else. `HtmlControlType.supportsRange()` already covers the temporal types, so the enum
+needs no change when it lands. Filed as a follow-up on the implementation plan.
+
+### ECMAScript-safe regex detection
+
+This is the least-solved part of the design and the strongest argument for the provider being swappable.
+
+A denylist of Java-only constructs (`\p{Alpha}`, possessive quantifiers, `\A`/`\z`, lookbehind) violates the
+never-false-reject rule the first time it misses one: a missed construct becomes a `pattern` the browser
+interprets differently. So detection is an **allowlist** — literals, `\d` and `\w` and their negations,
+character classes without POSIX or Unicode property syntax, grouping, alternation, anchors, and bounded
+quantifiers. Anything outside it emits no `pattern`.
+
+**`\s` and `\S` are excluded, and this is the rule's first real test.** Java's `\s` is ASCII-only by default
+(`[ \t\n\x0B\f\r]`); ECMAScript's is always the wider Unicode set — NBSP, ``, `
`, the ` `
+range. For a rule as ordinary as `^\S+$`, a value containing NBSP satisfies Java's `\S` and fails the
+browser's, so the server would accept input the browser silently refuses to submit. `\d` and `\w` are safe:
+both engines are ASCII-only for those by default, and JavaScript never widens them. An earlier draft of this
+spec listed `\s` among the safe escapes — that was wrong, and the governing rule outranks its own example
+list.
+
+This is conservative to the point that some legitimate regexes will silently get no client-side check. That
+is the correct failure direction under the agreed rule, and it is the piece most likely to need tuning after
+real use.
+
+## Components
+
+### `HtmlControlType` (new enum)
+
+The provider's real question is not "what string is in `type`" but "which constraint attributes are legal on
+this control". `textarea` and `select` have no `type` attribute at all yet do accept `required`, so the enum
+models the *control*, not the attribute — hence `HtmlControlType`, not `HtmlInputType`.
+
+```java
+public enum HtmlControlType {
+    TEXT, SEARCH, TEL, PASSWORD, EMAIL, URL,
+    NUMBER, RANGE,
+    DATE, MONTH, WEEK, TIME, DATETIME_LOCAL,
+    CHECKBOX, RADIO, FILE, HIDDEN, SELECT,
+    TEXTAREA,
+    OTHER;
+
+    public static HtmlControlType from(String type);
+    public boolean supportsPattern();  // text-entry only
+    public boolean supportsLength();   // text-entry + TEXTAREA
+    public boolean supportsRange();    // numeric + temporal
+}
+```
+
+`from()` must never throw. `type` is an OGNL-evaluated tag attribute, so at runtime it can be any string — a
+typo, or an input type newer than this enum. Unknown and `null` normalise to `OTHER`, which supports nothing,
+so an unrecognised type degrades to emitting no constraints. The conservative default falls out for free.
+
+The enum appears in the public signature of an extension point. Adding members later stays binary- and
+source-compatible for callers, but can make an exhaustive `switch` in a custom provider non-exhaustive. That
+belongs in the release notes.
+
+### `HtmlConstraintProvider` (new interface) and `StrutsHtmlConstraintProvider`
+
+```java
+public interface HtmlConstraintProvider {
+    Map<String, String> constraintsFor(List<Validator> validators, HtmlControlType control, Object action);
+}
+```
+
+The third parameter is required, not incidental: `Validator.getMessage(Object)` needs the action instance
+to resolve i18n text, and the `data-msg-*` attributes come from it. It may be null, in which case no
+messages are emitted.
+
+Named per the project convention of `Struts*` for default implementations rather than `Default*`. Registered
+**once** in `struts-beans.xml` as `type="...HtmlConstraintProvider" name="struts"`, following the
+`UrlRenderer` model — a bean registered under two types builds two instances, which is not wanted here.
+
+The default implementation encodes the mapping table. Because the agreed policy is deliberately restrictive,
+the swappable bean is how applications that want `type="email"` or best-effort `pattern` get served.
+
+### `StrutsConstants.STRUTS_UI_HTML5_CONSTRAINTS`
+
+`"struts.ui.html5.constraints"`, following the `struts.ui.checkbox.submitUnchecked` naming precedent.
+Defaults to `false` in `default.properties` for 7.4.0 and `true` for 8.0.0.
+
+Off by default matters: the `html5` theme shipped in 7.2.x, so emitting `required` on upgrade would start
+blocking submits on forms that render unchanged today. The 8.0.0 flip lands in a major with a migration entry.
+
+### Resolving the control type
+
+`attributes.type` is set by `TextField.evaluateExtraParams` (`TextField.java:91`) and by nothing else on the
+input path — `TextArea`, `Select`, `Checkbox`, `File`, `Hidden` and `Radio` have no `type` attribute at all.
+So the control type cannot be read from the attribute map alone; it needs a component-level hook:
+
+```java
+protected HtmlControlType getControlType();   // UIBean, returns OTHER
+```
+
+Overridden in six places, which is all that is needed:
+
+| Component | Returns |
+|---|---|
+| `UIBean` (base) | `OTHER` — supports nothing, so unknown controls emit no constraints |
+| `TextField` | `HtmlControlType.from(getAttributes().get("type"))`, defaulting to `TEXT` when absent, matching `text.ftl`'s `attributes.type!"text"` |
+| `Password` | `PASSWORD` — it extends `TextField` but its template hardcodes the type |
+| `TextArea` | `TEXTAREA` |
+| `Select` | `SELECT` |
+| `Radio` | `RADIO` |
+| `File` | `FILE` |
+
+`Radio` and `File` are overridden even though they support no pattern, length or range constraint — `RADIO`
+and `FILE` are the only two controls where an unselected/empty submission omits the parameter entirely
+rather than submitting an empty value, so they are the only two where `required` agrees with the server (see
+the mapping table above). Without the override both fall through to `OTHER`, which made `required` dead code
+for them — this was caught and fixed as part of implementation triage.
+
+`Checkbox` and `Hidden` deliberately get no override and fall through to `OTHER`, which emits nothing. This
+is not an oversight: `CheckboxInterceptor` substitutes `"false"` for an unticked box, so the field is never
+missing server-side and a browser `required` there would false-reject. `ComboBox` extends `TextField` and
+correctly inherits `TEXT`.
+
+### `UIBean` hook
+
+`UIBean.evaluateParams` resolves `final Form form = (Form) findAncestor(Form.class)` at `UIBean.java:824`
+and appends to `tagNames` just below it. **The hook cannot go there.** `evaluateExtraParams()` is the *last*
+statement of `evaluateParams()` (`UIBean.java:905`), and that is where `TextField` sets `attributes.type` —
+so at the `tagNames` block the control type is not yet resolved and every text field would look like `OTHER`.
+
+The hook therefore goes at the very end of `evaluateParams()`, after the `evaluateExtraParams()` call. The
+`form` local is declared at method scope and is still in scope there (the tooltip block below it already
+uses it). When the constant is on and a form was found:
+
+```
+form.getFieldValidators(translatedName)
+  → provider.constraintsFor(validators, getControlType())
+  → addParameter("constraints", map)
+```
+
+Gating the *computation* on the constant keeps the cost at zero when off. Themes that do not render
+`attributes.constraints` simply ignore it.
+
+### `Form.getFieldValidators(String)` (new)
+
+`Form.getValidators(String)` re-runs the action-mapping lookup and
+`actionValidatorManager.getValidators(actionClass, actionName, methodName)` on every call, so a 20-field form
+would do 20 full lookups. `getFieldValidators` resolves the action's validator list **once**, memoises it on
+private fields of the `Form` component, and filters by field name per call.
+
+Memoise on component fields, not on the attributes map. A `Form` component is constructed per render by
+`ComponentTagSupport`, so a field is naturally request-scoped, and the attributes map is exposed to
+templates and should not carry private bookkeeping.
+
+The existing `getValidators(String)` stays untouched for the deprecated `form-close-validate.ftl` and is
+deleted with it in 8.0.0.
+
+### `html5/constraints.ftl` (new), included from `common-attributes.ftl`
+
+```freemarker
+<#if attributes.constraints??><#list attributes.constraints as k, v> ${k}="${v}"<#rt/></#list></#if>
+```
+
+Including it from `common-attributes.ftl` means every `html5` input picks it up without per-template edits.
+
+`constraintsFor` returns the full set of attributes to render, not only constraints — messages ride the same
+map as `data-msg-<validatorType>` entries: `data-msg-required`, `data-msg-stringlength`, `data-msg-regex`.
+The text comes from `validator.getMessage(action)`, which resolves through `DelegatingValidatorContext` and
+`textProviderFactory`, so it is properly i18n'd.
+
+A `data-msg-*` entry is emitted for **every** validator carrying a message, including those that produce no
+constraint. An `email` validator therefore contributes `data-msg-email` and nothing else — which is exactly
+the case where an application most needs the message, since Struts could not express the rule natively.
+Struts ships nothing that consumes these attributes.
+
+### Deliberately unchanged
+
+`requiredLabel` keeps meaning "draw a `*` next to the label". It never produces a `required` attribute — only
+a `required` *validator* does. Conflating the two is the most likely regression in this work.
+
+## Deprecation and removal
+
+`validate="true"` is not one feature but four edits to the rendered form:
+
+- `xhtml/form-validate.ftl` injects the `validation.js` `<script>` tag
+- the same template rewrites `onsubmit` to `return validateForm_<id>();`
+- `xhtml/form.ftl` overwrites `onreset` with `clearErrorMessages(this);clearErrorLabels(this);`
+- `xhtml/form-close.ftl` includes `form-close-validate.ftl`
+
+### 7.4.0 — deprecate, change nothing
+
+Following the WW-5510 tooltip precedent (`c2a5bfe3c`):
+
+- `@Deprecated(since = "7.4.0", forRemoval = true)` on `Form.setValidate`, `Form.getValidators(String)` and
+  `Form.evaluateClientSideJsEnablement`, **and** on the mirrored `FormTag.setValidate`. WW-5510 annotated
+  both the component and the JSP tag layer; both surfaces are public API.
+- `<strong>Deprecated since 7.4.0</strong>` banners inside the relevant `<!-- START SNIPPET: ... -->` blocks
+  in the `Form` Javadoc. Those snippets are pulled into the website by the `remote_file_content` plugin, so
+  omitting this leaves the site advertising the feature as current.
+- A deprecation banner in the `form-close-validate.ftl` header comment, since a `.ftl` carries no annotation
+  and anyone who overrode that template needs to see it there.
+- **No runtime warning.** WW-5510 shipped annotations and Javadoc only. `validate="true"` is opt-in, so every
+  affected user made a deliberate choice and will be reading release notes.
+- `struts.ui.html5.constraints` ships `false`.
+
+Two traps: `Dispatcher` and `ValidationInterceptor` also declare unrelated `setValidate` methods that must
+not be annotated. And WW-5510's annotations say `since = "7.0.1"` while its fix version was 7.0.3 — use the
+real target version here.
+
+### 8.0.0 — remove
+
+Delete `xhtml/form-close-validate.ftl`, `xhtml/form-validate.ftl`, `xhtml/validation.js` and
+`css_xhtml/validation.js`. Strip the `validate` branches from `xhtml/form.ftl` and `xhtml/form-close.ftl`.
+Drop `validate`, `performValidation` and `tagNames` from `Form`, `FormTag` (including its
+`clearTagStateForTagPoolingServers` reset) and the `tagNames` append in `UIBean`. Flip the constant's default
+to `true`.
+
+`html5/form.ftl` also reads `attributes.validate` in its `onsubmit` guard and must be updated in the same
+change.
+
+Retire the `Formtag-2.txt`, `Formtag-11.txt`, `Formtag-22.txt` and `Formtag-24.txt` fixtures and their
+`FormTagTest` methods.
+
+`tagNames` dies here, which is what finally closes WW-2975's root cause rather than patching it.
+
+## Testing
+
+`HtmlControlType` and `StrutsHtmlConstraintProvider` are plain objects — JUnit 4 (`org.junit.Test`). Anything
+that renders a tag must extend `AbstractUITagTest`, which is JUnit 3 style: methods named `testXxx()`, and a
+Jupiter `@Test` there silently never runs.
+
+The negative cases carry the weight, because they are what protects the never-false-reject rule:
+
+- `stringlength trim="true"` → no `minlength`/`maxlength`
+- `regex caseSensitive="false"` → no `pattern`
+- a regex using Java-only syntax → no `pattern`
+- `int`/`double` on `TEXT` → no `min`/`max`; on `NUMBER` → both
+- `email`/`url` validators → never set or change `type`
+- `creditcard`, `fieldexpression`, visitor → empty map
+- `HtmlControlType.from(null)`, `from("NuMbEr")`, `from("supercolor")` → `OTHER`/`NUMBER`/`OTHER`, never throws
+
+Tag-rendering tests use the existing `verify(resource)` golden-file pattern with new `.txt` fixtures,
+covering constraints on and off via the constant, and `requiredLabel="true"` **not** producing a `required`
+attribute.
+
+**Harness trap, encountered during triage:** a form-validation tag test needs
+`initDispatcher(configProviders = TestConfigurationProvider)` *and* `createMocks()` in `setUp`, plus the
+`prepareMockInvocation()` EasyMock helper from `FormTagTest`. Without them `evaluateClientSideJsEnablement`
+finds no `ValidationInterceptor`, `performValidation` stays `false`, and no validation function is emitted at
+all — while `onsubmit` still calls the missing function. A test written without that setup passes or fails
+for entirely the wrong reason.
+
+The WW-2975 reproduction test is **not** committed. It asserts behaviour scheduled for deletion, so it stays
+attached to the triage comment as evidence rather than becoming a permanent red or an inverted test pinning a
+known-bad behaviour.
+
+## Ticket structure
+
+WW-2975 is a 2009 Improvement carrying six fix-version bumps and is the wrong vehicle. Close it **Won't Fix**,
+superseded, with a triage comment recording the reproduction and the empty `validateForm_` output.
+
+The repo convention is a matched deprecate/remove pair — WW-5682/WW-5683, WW-5673, WW-5654 all follow it.
+
+| Summary | Type | Fix version |
+|---|---|---|
+| Deprecate JavaScript client-side validation in the xhtml and css_xhtml themes | Improvement | 7.4.0 |
+| Derive HTML5 constraint attributes from validators in the html5 theme | New Feature | 7.4.0 |
+| Remove deprecated JavaScript client-side validation | Improvement | 8.0.0 |
+
+Cross-link [WW-4395](https://issues.apache.org/jira/browse/WW-4395) ("Make email validator regex comply with
+RFC 6531", Open, 7.4.0) from the feature ticket: that regex's divergence from browsers is precisely why
+`type="email"` is never emitted.
+
+## Documentation
+
+In `struts-site`:
+
+- Delete `core-developers/pure-java-script-client-side-validation.md`.
+- Rewrite `core-developers/client-side-validation.md` around the `html5` theme: the mapping table, the
+  `trim` and `caseSensitive` conditions, the never-change-the-type rule, and the `data-msg-*` attributes.
+- Mark `core-developers/client-validation-example.md` as deprecated.
+- Fix the stale claim in the pure-JavaScript page that messages are "not the internationalized version" —
+  `ValidatorSupport.getMessage` resolves through `DelegatingValidatorContext` and `textProviderFactory`.
+- Leave the "Available since Struts 7.2.0" claim in `tag-developers/html5-theme.md` alone — it is correct.
+  `git tag --contains e24d2f2d3` returns `STRUTS_7_2_0`, so the theme shipped in 7.2.0 and **WW-5444's fix
+  version of 7.2.1 is the wrong record**. Correct the Jira ticket, not the docs.
+- Update `tag-developers/form-tag.md` for the deprecated `validate` attribute.
+
+The Migration Guide entry lives on the cwiki as part of the Version Notes process, not in `struts-site`.
+
+## Risks
+
+- **The allowlist is too strict.** Real applications will have regexes that get no `pattern`. Mitigated by
+  the swappable provider; expect tuning after the first release.
+- **`requiredLabel` conflation.** Explicitly tested against.
+- **8.0.0 removal is user-visible.** Forms relying on `validate="true"` lose client-side checking. Server-side
+  validation is unaffected, so this is a UX regression rather than a correctness or security one, but it
+  needs a prominent migration entry.
+- **Constraint derivation runs per field.** Mitigated by memoising the validator list on the form; worth a
+  sanity check on a wide form before release.