Validation message from server 
For each field, encode an ActiveModel::Error-generated validation
message that corresponds to a ValidityState  key into the field’s
[data-validation-messages] attribute. When handling invalid events,
retrieve the corresponding error message from that Object and render it
when available.
When a key in a field’s ValidityState  is absent from the
data-validation-messages, fallback to the Browser-provided default.
To force this situation, declare an arbitrary length: { minimum: 7 }
validation on the Authentication#password attribute.
Mapping ActiveModel::Errors to Validation messages 
The application/validation_messages JSON partial is an incomplete
mapping. There are some attribute and validation-specific subtleties
whose solutions are not yet aren’t immediately obvious. For example,
length validations encode a %{count} interpolation
placeholder
There is a possibility to implement a lightweight I18n gem counterpart
on the client side to replace interpolations with the correct values at
validation-time, but at this point in time, that is an exercise best
left up to the reader.
         
          
              
  
    Collapse app/javascript/initializers/validations.ts 
  Expand app/javascript/initializers/validations.ts 
  
      app/javascript/initializers/validations.ts
    
   
diff --git a/app/javascript/initializers/validations.ts b/app/javascript/initializers/validations.ts
index 0008f1f..48ff64e 100644
 --- a/app/javascript/initializers/validations.ts
 +++ b/app/javascript/initializers/validations.ts
 @@ -33,7 +33,7 @@  function reportValidity(input: FieldElement): void {
   if (input.form?.hasAttribute("novalidate")) return
 
   const id = input.getAttribute("data-validation-message")
-  const validationMessage = input.validationMessage
 +  const validationMessage = getValidationMessage(input)
    const element = document.getElementById(id) || createValidationMessageFragment(input.form)
 
   if (element) {
@@ -62,6 +62,22 @@  function createValidationMessageFragment(form) {
   }
 }
 
+function getValidationMessage(input) {
+  const validationMessages = Object.entries(readValidationMessages(input))
+
+  const [ _, validationMessage ] = validationMessages.find(([ key ]) => input.validity[key]) || [ null, null ]
+
+  return validationMessage || input.validationMessage
+}
+
+function readValidationMessages(input) {
+  try {
+    return JSON.parse(input.getAttribute("data-validation-messages"))
+  } catch(_) {
+    return {}
+  }
+}
+
  function isFieldElement(element: any): element is FieldElement {
   return [
     HTMLButtonElement,
 
              
  
    Collapse app/models/authentication.rb 
  Expand app/models/authentication.rb 
  
      app/models/authentication.rb
    
   
diff --git a/app/models/authentication.rb b/app/models/authentication.rb
index 6fb6e80..a876249 100644
 --- a/app/models/authentication.rb
 +++ b/app/models/authentication.rb
 @@ -8,7 +8,7 @@  class Authentication
   attribute :session
 
   validates :username, presence: true
-  validates :password, presence: true
 +  validates :password, presence: true, length: { minimum: 7 }
    validate { errors.add(:base, :invalid) unless user.present? }
 
   def self.find(session)
 
              
  
    Collapse app/views/application/_validation_messages.json.jbuilder 
  Expand app/views/application/_validation_messages.json.jbuilder 
  
      app/views/application/_validation_messages.json.jbuilder
    
   
diff --git a/app/views/application/_validation_messages.json.jbuilder b/app/views/application/_validation_messages.json.jbuilder
 new file mode 100644
 index 0000000..c605573
 --- /dev/null
 +++ b/app/views/application/_validation_messages.json.jbuilder
 @@ -0,0 +1,2 @@ 
+json.badInput object.errors.generate_message(method_name, :invalid)
+json.valueMissing object.errors.generate_message(method_name, :blank)
  
              
  
    Collapse lib/rails_ext/action_view.rb 
  Expand lib/rails_ext/action_view.rb 
  
      lib/rails_ext/action_view.rb
    
   
diff --git a/lib/rails_ext/action_view.rb b/lib/rails_ext/action_view.rb
index aa3a192..6bd0227 100644
 --- a/lib/rails_ext/action_view.rb
 +++ b/lib/rails_ext/action_view.rb
 @@ -23,6 +23,7 @@  module ValidationMessageExtension
     index = @options.fetch("index", @auto_index)
 
     @options["data-validation-message"] ||= tag_id(index) + "_validation_message"
+    @options["data-validation-messages"] ||= @template_object.render(formats: :json, partial: "validation_messages", locals: instance_values.symbolize_keys)
  
     super
   end
@@ -53,8 +54,8 @@  ActiveSupport.on_load :action_view do
         end
 
         [RadioButton, CheckBox].each do |kls|
-          prepend CheckableAriaTagsExtension
-          prepend ValidationMessageExtension
 +          kls.prepend CheckableAriaTagsExtension
+          kls.prepend ValidationMessageExtension
          end
       end
     end
 
              
  
    Collapse test/controllers/authentications_controller_test.rb 
  Expand test/controllers/authentications_controller_test.rb 
  
      test/controllers/authentications_controller_test.rb
    
   
diff --git a/test/controllers/authentications_controller_test.rb b/test/controllers/authentications_controller_test.rb
index 04e9c2d..773c833 100644
 --- a/test/controllers/authentications_controller_test.rb
 +++ b/test/controllers/authentications_controller_test.rb
 @@ -40,7 +40,7 @@  class AuthenticationsControllerTest < ActionDispatch::IntegrationTest
     assert_nil session[:user_id]
     assert_select "input#authentication_username ~ #authentication_username_validation_message", text: "can't be blank"
     assert_select %(input[type="text"][aria-invalid="true"][aria-describedby~="authentication_username_validation_message"])
-    assert_select "input#authentication_password ~ #authentication_password_validation_message", text: "can't be blank"
 +    assert_select "input#authentication_password ~ #authentication_password_validation_message", text: "can't be blank and is too short (minimum is 7 characters)"
      assert_select %(input[type="password"][aria-invalid="true"][aria-describedby~="authentication_password_validation_message"])
   end
 
 
              
  
    Collapse test/system/authentications_test.rb 
  Expand test/system/authentications_test.rb 
  
      test/system/authentications_test.rb
    
   
diff --git a/test/system/authentications_test.rb b/test/system/authentications_test.rb
index 7cd5d98..a6ff627 100644
 --- a/test/system/authentications_test.rb
 +++ b/test/system/authentications_test.rb
 @@ -5,8 +5,8 @@  class AuthenticationsTest < ApplicationSystemTestCase
     visit new_authentication_path
     click_on submit(:authentication)
 
-    assert_field label(:authentication, :username), validation_error: "Please fill out this field."
-    assert_field label(:authentication, :password), validation_error: "Please fill out this field."
 +    assert_field label(:authentication, :username), validation_error: "can't be blank"
+    assert_field label(:authentication, :password), validation_error: "can't be blank"
    end
 
   test "invalid inputs re-validate on blur renders errors" do
@@ -16,7 +16,15 @@  class AuthenticationsTest < ApplicationSystemTestCase
     fill_in label(:authentication, :password), with: ""
 
     assert_field label(:authentication, :username), valid: true
-    assert_field label(:authentication, :password), validation_error: "Please fill out this field."
-    assert_no_field label(:authentication, :username), validation_error: "Please fill out this field."
 +    assert_field label(:authentication, :password), validation_error: "can't be blank"
+    assert_no_field label(:authentication, :username), validation_error: "can't be blank"
+  end
+
+  test "invalid authentication falls back to in-Browser validation messages" do
+    visit new_authentication_path
+    fill_in label(:authentication, :password), with: "junk"
+    click_on submit(:authentication)
+
+    assert_field label(:authentication, :password), validation_error: "Please lengthen this text to 7 characters or more (you are currently using 4 characters)."
    end
 end