From fb40908343f36399291ceaaa6d4d63faec7d18a6 Mon Sep 17 00:00:00 2001 From: Javi R <4920956+rameerez@users.noreply.github.com> Date: Wed, 19 Aug 2026 04:37:53 +0100 Subject: [PATCH 1/3] The composed sentence speaks its language; the box meets the text (0.1.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Acepto Términos y Condiciones y He recibido Política de Privacidad." is broken Spanish twice over — the missing articles and the capital H mid-sentence — and it shipped as the one-line signup every Spanish host renders. Three fixes, all first-class: - Per-key fragment templates: the composer looks up clickwrap.sentence.fragments.. before the per-kind fallback, and es.yml ships defaults for the standard keys with the article each document needs ("acepto los %{documents}", "he recibido la %{documents}"). Applications add their own keys in their locale files; no DSL change, and a key nobody translated still composes with the neutral generic. - Templates are written lowercase for the middle of the sentence, and the composer capitalizes exactly one letter — the first — once, at build time, so the signed manifest text and the rendered HTML derive from the same fragment and can never disagree about the capital. - The checkbox's lh-math centred it on the first line and it still read as floating high: text mass sits between cap-height and baseline, below the line box's midpoint. The offset gains an optical 0.125em, calibrated against rendered screenshots at 400%. Spanish golden: "Acepto los Términos de Servicio y he recibido la Política de Privacidad." — pinned, with the per-key override path tested and the old broken wording's test corrected. 789 runs, 0 failures; rubocop clean. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013A6ZmfmFuQ2z3GyvQCuECA --- CHANGELOG.md | 24 +++++++++++++ app/assets/stylesheets/clickwrap.css | 16 +++++---- config/locales/es.yml | 24 +++++++++---- lib/clickwrap/presenter.rb | 15 +++++++- lib/clickwrap/version.rb | 2 +- test/combined_statement_test.rb | 51 +++++++++++++++++++++++++++- 6 files changed, 117 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47c79a5..83f368e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.1] - 2026-08-19 + +### Fixed — the composed sentence in a language that declines its articles + +- **Per-key sentence fragments.** Spanish cannot say "acepto %{documents}" for + every document — the article agrees with the noun ("los Términos", "la + Política"). The composer now looks up + `clickwrap.sentence.fragments..` before falling back to the + per-kind template, and the gem ships Spanish defaults for its standard keys. + An application adds its own in its locale files; no DSL change. +- **One opening capital.** Fragment templates are written lowercase for the + middle of a sentence; the composer capitalizes exactly one letter — the + first — once, at build time, so the signed manifest and the rendered HTML + can never disagree. The Spanish signup now reads + "Acepto los Términos y Condiciones y he recibido la Política de Privacidad." + instead of "Acepto Términos y Condiciones y He recibido Política de + Privacidad." +- **Checkbox optical alignment.** The box was mathematically centred on the + first line and still read as floating high: Latin text carries its mass + between cap-height and baseline, below the line box's midpoint. The offset + gains an optical eighth of an em, calibrated against rendered screenshots. + +## [0.1.0] - 2026-08-19 + ### Changed — the signup clickwrap is one line - **`form.clickwrap` renders ONE checkbox carrying ONE sentence** whenever every diff --git a/app/assets/stylesheets/clickwrap.css b/app/assets/stylesheets/clickwrap.css index f71bf6d..b4fbc98 100644 --- a/app/assets/stylesheets/clickwrap.css +++ b/app/assets/stylesheets/clickwrap.css @@ -88,14 +88,18 @@ .clickwrap-statement__checkbox, .clickwrap-statement__radio { /* Sized in em so the box matches the sentence it belongs to instead of - towering over small text, and optically centered against the FIRST line - of the label: (line-height minus box) halved. Flex `baseline` cannot do - this — a checkbox aligns its bottom edge to the baseline and ends up - floating high. The rem fallback covers engines without the lh unit. */ + towering over small text, and aligned against the FIRST line of the + label: (line-height minus box) halved, plus an optical eighth of an em. + The mathematical centre is not the visual one — Latin text carries its + mass between cap-height and baseline, below the line box's midpoint, so + a box centred by arithmetic reads as floating high (calibrated against + rendered screenshots at 400%). Flex `baseline` is still worse: a + checkbox aligns its bottom edge to the baseline and floats higher. The + em fallback covers engines without the lh unit. */ width: 1em; height: 1em; - margin: 0.2em 0 0; - margin-top: calc((1lh - 1em) / 2); + margin: 0.325em 0 0; + margin-top: calc((1lh - 1em) / 2 + 0.125em); flex: 0 0 auto; accent-color: var(--clickwrap-focus); } diff --git a/config/locales/es.yml b/config/locales/es.yml index 410f0a7..0e8b338 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -22,13 +22,25 @@ es: acknowledgment: privacy_notice: "Doy por recibida la Política de Privacidad." - # La frase de una sola línea. En español el artículo concuerda con el - # documento ("los Términos", "la Política"), y solo la aplicación sabe qué - # documento nombra, así que el artículo vive en la etiqueta del enlace y no - # en estas plantillas — por eso `documents_joiner` es " y " y no " y el ". + # La frase de una sola línea. Las plantillas se escriben en minúscula y + # PARA EL MEDIO de la frase ("… y he recibido …"): la mayúscula inicial la + # pone el compositor, una sola vez, al primer fragmento. + # + # En español el artículo concuerda con el documento ("los Términos", "la + # Política"), así que cada clave estándar lleva su propia plantilla bajo + # `fragments`, con el artículo correcto. Una aplicación con documentos + # propios añade aquí los suyos (p. ej. + # `fragments.agreement.contrato_marco: "acepto el %{documents}"`); sin + # plantilla propia se usa la genérica, que es neutra y correcta aunque + # menos idiomática. sentence: - agreement: "Acepto %{documents}" - acknowledgment: "He recibido %{documents}" + fragments: + agreement: + terms: "acepto los %{documents}" + acknowledgment: + privacy_notice: "he recibido la %{documents}" + agreement: "acepto %{documents}" + acknowledgment: "he recibido %{documents}" documents_joiner: " y " joiner: " y " terminator: "." diff --git a/lib/clickwrap/presenter.rb b/lib/clickwrap/presenter.rb index 3359d45..ca159cb 100644 --- a/lib/clickwrap/presenter.rb +++ b/lib/clickwrap/presenter.rb @@ -383,6 +383,12 @@ def compose(resolved) def build_combined(composable, fragments, connectives) control = composable.first + # Fragment templates are written for the middle of a sentence ("he + # recibido la …"), because every fragment but the first sits there. The + # sentence's opening capital is applied here, once, so the signed + # manifest text and the rendered HTML can never disagree about it. + fragments = [fragments.first.with(prefix: fragments.first.prefix.sub(/\A\p{Ll}/, &:upcase))] + + fragments[1..] Combined.new( sentence: fragments.map(&:to_text).join(connectives[:joiner]) + connectives[:terminator], @@ -433,7 +439,14 @@ def sentence_connectives end def sentence_fragment(statement, connectives) - template = sentence_text(statement.kind) + # A language that needs to agree with its nouns — an article, a gendered + # participle — cannot say "acepto %{documents}" for every document. A + # per-key template carries those words for THIS document + # (clickwrap.sentence.fragments.agreement.terms: "acepto los + # %{documents}"); the per-kind template stays the fallback for keys + # nobody translated specifically. + template = sentence_text("fragments.#{statement.kind}.#{statement.key}") || + sentence_text(statement.kind) return nil if template.nil? prefix, suffix = template.split(DOCUMENTS_PLACEHOLDER, 2) diff --git a/lib/clickwrap/version.rb b/lib/clickwrap/version.rb index 5256f38..972b8bc 100644 --- a/lib/clickwrap/version.rb +++ b/lib/clickwrap/version.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Clickwrap - VERSION = "0.1.0" + VERSION = "0.1.1" # The canonical schema version for receipts, event digests, and presentation # manifests. This is deliberately independent of VERSION: gem releases may diff --git a/test/combined_statement_test.rb b/test/combined_statement_test.rb index 6e93d78..03eee8e 100644 --- a/test/combined_statement_test.rb +++ b/test/combined_statement_test.rb @@ -17,6 +17,52 @@ class CombinedStatementTest < ActiveSupport::TestCase setup { @user = create_user } + # --- A language that declines its articles ------------------------------------ + + test "Spanish composes each document behind its own article, with one opening capital" do + Clickwrap.document(:terms, version: "2026-08-15", locale: :es, content: "Términos.", + link: "/es/terminos") + Clickwrap.document(:privacy_notice, version: "2026-08-15", locale: :es, content: "Privacidad.", + link: "/es/privacidad") + Clickwrap.publish! + Clickwrap.policy :es_signup do + agree_to :terms, link_label: "Términos de Servicio" + acknowledge :privacy_notice, link_label: "Política de Privacidad" + retain_with :ordinary_agreement_evidence + end + + combined = Clickwrap.present(:es_signup, actor: @user, locale: :es).combined + + # "Acepto Términos y He recibido Política" is broken Spanish twice over — + # the missing articles and the capital H mid-sentence. The per-key + # fragment templates carry the article each document needs, the templates + # are written lowercase for the middle of the sentence, and the composer + # capitalizes exactly one letter: the first. + assert_equal "Acepto los Términos de Servicio y he recibido la Política de Privacidad.", + combined.sentence + assert_equal ["Acepto los ", "he recibido la "], combined.fragments.map(&:prefix) + end + + test "an application's own per-key fragment beats the generic template" do + Clickwrap.document(:terms, version: "2026-08-15", locale: :es, content: "Términos.", + link: "/es/terminos") + Clickwrap.publish! + Clickwrap.policy :es_signup_override do + agree_to :terms, link_label: "Condiciones Generales" + retain_with :ordinary_agreement_evidence + end + + ::I18n.backend.store_translations( + :es, { clickwrap: { sentence: { fragments: { agreement: { terms: "acepto las %{documents}" } } } } } + ) + + combined = Clickwrap.present(:es_signup_override, actor: @user, locale: :es).combined + assert_equal "Acepto las Condiciones Generales.", combined.sentence + ensure + restored = { clickwrap: { sentence: { fragments: { agreement: { terms: "acepto los %{documents}" } } } } } + ::I18n.backend.store_translations(:es, restored) + end + # --- What composes ----------------------------------------------------------- test "two ordinary statements compose into one sentence with the documents inside it" do @@ -305,8 +351,11 @@ class CombinedStatementTest < ActiveSupport::TestCase # default-worded :signup policy in Spanish. assert_nil Clickwrap.present(:spanish_signup, actor: @user, locale: :es).combined + # The English labels are this dummy policy's, not the locale's — what + # the assertion pins is the Spanish template work: per-key articles and + # the single opening capital. combined = Clickwrap.present(:signup, actor: @user, locale: :es).combined - assert_equal "Acepto Terms of Service y He recibido Privacy Policy.", combined.sentence + assert_equal "Acepto los Terms of Service y he recibido la Privacy Policy.", combined.sentence end test "a locale with no connective words itemizes rather than composing half a sentence" do From 17da77959ba0d837b22e4af36cc62686328e000e Mon Sep 17 00:00:00 2001 From: Javi R <4920956+rameerez@users.noreply.github.com> Date: Wed, 19 Aug 2026 04:41:35 +0100 Subject: [PATCH 2/3] The composed acknowledgment speaks in the itemized voice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "…y doy por recibida la Política de Privacidad." — the same words the itemized default uses: the same act must read the same way composed or detailed, and it is better register than "he recibido". The owner floated "he leído"; es.yml's own header rule stands — "'He leído...' is deliberately absent" — the evidence records an affirmative act on an offered notice, never that anyone read it. An application that wants a read-declaration owns that wording via its per-key fragment override. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013A6ZmfmFuQ2z3GyvQCuECA --- config/locales/es.yml | 6 +++++- test/combined_statement_test.rb | 6 +++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/config/locales/es.yml b/config/locales/es.yml index 0e8b338..a57370f 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -38,7 +38,11 @@ es: agreement: terms: "acepto los %{documents}" acknowledgment: - privacy_notice: "he recibido la %{documents}" + # La misma voz que la declaración detallada ("Doy por recibida la + # Política de Privacidad."): el mismo acto debe leerse igual + # compuesto que detallado. Nunca "he leído": la evidencia registra + # un acto afirmativo sobre un aviso ofrecido, no que alguien leyera. + privacy_notice: "doy por recibida la %{documents}" agreement: "acepto %{documents}" acknowledgment: "he recibido %{documents}" documents_joiner: " y " diff --git a/test/combined_statement_test.rb b/test/combined_statement_test.rb index 03eee8e..dba4590 100644 --- a/test/combined_statement_test.rb +++ b/test/combined_statement_test.rb @@ -38,9 +38,9 @@ class CombinedStatementTest < ActiveSupport::TestCase # fragment templates carry the article each document needs, the templates # are written lowercase for the middle of the sentence, and the composer # capitalizes exactly one letter: the first. - assert_equal "Acepto los Términos de Servicio y he recibido la Política de Privacidad.", + assert_equal "Acepto los Términos de Servicio y doy por recibida la Política de Privacidad.", combined.sentence - assert_equal ["Acepto los ", "he recibido la "], combined.fragments.map(&:prefix) + assert_equal ["Acepto los ", "doy por recibida la "], combined.fragments.map(&:prefix) end test "an application's own per-key fragment beats the generic template" do @@ -355,7 +355,7 @@ class CombinedStatementTest < ActiveSupport::TestCase # the assertion pins is the Spanish template work: per-key articles and # the single opening capital. combined = Clickwrap.present(:signup, actor: @user, locale: :es).combined - assert_equal "Acepto los Terms of Service y he recibido la Privacy Policy.", combined.sentence + assert_equal "Acepto los Terms of Service y doy por recibida la Privacy Policy.", combined.sentence end test "a locale with no connective words itemizes rather than composing half a sentence" do From d8d90b08f457d614607e712d7c7fbd1c55b04f99 Mon Sep 17 00:00:00 2001 From: Javi R <4920956+rameerez@users.noreply.github.com> Date: Wed, 19 Aug 2026 04:49:58 +0100 Subject: [PATCH 3/3] Host locale overrides actually win now MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine appended its locale files to app.config.i18n.load_path on top of Rails::Engine's automatic :add_locales. Railties paths are unshifted ahead of that list, so the appended copy landed AFTER the host application's own locale files — and every host override of a gem key silently lost to the gem's default, the exact opposite of what the initializer's comment promised. Discovered live: a host's "He leído la Política de Privacidad." kept rendering as the gem's "Doy por recibida...". The manual append is deleted; Rails's own ordering (gem first, host last) is the whole contract. The dummy app now ships a real override of a real gem key, and two tests pin both the outcome and the load-path ordering that produces it. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013A6ZmfmFuQ2z3GyvQCuECA --- CHANGELOG.md | 20 ++++++++++++++++--- lib/clickwrap/engine.rb | 15 ++++++++------ test/dummy/config/locales/es.yml | 12 +++++++++++ test/host_locale_override_test.rb | 33 +++++++++++++++++++++++++++++++ 4 files changed, 71 insertions(+), 9 deletions(-) create mode 100644 test/dummy/config/locales/es.yml create mode 100644 test/host_locale_override_test.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 83f368e..85d6b1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,9 +20,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 middle of a sentence; the composer capitalizes exactly one letter — the first — once, at build time, so the signed manifest and the rendered HTML can never disagree. The Spanish signup now reads - "Acepto los Términos y Condiciones y he recibido la Política de Privacidad." - instead of "Acepto Términos y Condiciones y He recibido Política de - Privacidad." + "Acepto los Términos y Condiciones y doy por recibida la Política de + Privacidad." instead of "Acepto Términos y Condiciones y He recibido + Política de Privacidad." +- **One voice, composed or itemized.** The Spanish composed acknowledgment now + says "doy por recibida" — the same words as the itemized default statement — + instead of the flatter "he recibido". The same act reads the same way in + either rendering. (Still never "he leído": the evidence records an + affirmative act on an offered notice, not that anyone read it. A host that + wants the first-person read-declaration owns that wording via its locale + files — see the next fix.) +- **Host locale overrides actually win now.** The engine appended its locale + files to `app.config.i18n.load_path` on top of Rails's automatic engine + locale loading. Railties paths are unshifted ahead of that list, so the + appended copy landed AFTER the host's own locale files — and every host + override of a gem key silently lost to the gem's default. The manual append + is gone; Rails::Engine's own `:add_locales` ordering (gem first, host last) + is the contract, and a test now pins it. - **Checkbox optical alignment.** The box was mathematically centred on the first line and still read as floating high: Latin text carries its mass between cap-height and baseline, below the line box's midpoint. The offset diff --git a/lib/clickwrap/engine.rb b/lib/clickwrap/engine.rb index 6eac152..e7c39f1 100644 --- a/lib/clickwrap/engine.rb +++ b/lib/clickwrap/engine.rb @@ -124,12 +124,15 @@ class Engine < ::Rails::Engine end end - # Ship the gem's locale files. Host locale files with the same keys override - # these automatically (I18n's load order puts the app last), which is how a - # host rewords a statement without forking a view. - initializer "clickwrap.locales" do |app| - app.config.i18n.load_path += Dir[root.join("config", "locales", "**", "*.{rb,yml}").to_s] - end + # The gem's locale files ship through Rails::Engine's own :add_locales + # (every engine's `config/locales` is picked up automatically) — and NOT + # through a manual `app.config.i18n.load_path +=` on top of it. The manual + # append is not merely redundant: railties paths are UNSHIFTED before + # everything in `load_path`, so an appended copy of these files lands + # AFTER the host's own locales and quietly overrides them. Load order is + # the whole contract here — gem first, host last — because a host rewords + # a statement by shipping the same key in its own locale file, and the + # host's counsel must always get the last word. initializer "clickwrap.assets" do |app| app.config.assets.paths << root.join("app/assets/stylesheets") if app.config.respond_to?(:assets) diff --git a/test/dummy/config/locales/es.yml b/test/dummy/config/locales/es.yml new file mode 100644 index 0000000..da783f1 --- /dev/null +++ b/test/dummy/config/locales/es.yml @@ -0,0 +1,12 @@ +# The dummy HOST's own wording for one gem key. The gem's default is "Doy por +# recibida la Política de Privacidad." and the gem itself never says "he +# leído" — but a host application MAY choose the conventional first-person +# read-declaration, and it does so exactly like this: ship the same key in its +# own locale file and let load order do the rest. This file exists so a test +# can prove the host's wording actually wins (the engine once re-appended its +# own locales after the host's, and every host override silently lost). +es: + clickwrap: + statements: + acknowledgment: + privacy_notice: "He leído la Política de Privacidad." diff --git a/test/host_locale_override_test.rb b/test/host_locale_override_test.rb new file mode 100644 index 0000000..48d9963 --- /dev/null +++ b/test/host_locale_override_test.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require "test_helper" + +# A host rewords a gem statement by shipping the same key in its own locale +# file — that is the documented override path, and it works only if I18n loads +# the gem's files FIRST and the host's LAST. Rails::Engine's :add_locales +# initializer guarantees exactly that (engine paths are unshifted ahead of the +# application's). The gem once also appended its locales manually to +# `app.config.i18n.load_path`, which put a second copy AFTER the host's files: +# every host override silently lost to the gem's defaults, discovered when a +# real host's "He leído la Política de Privacidad." kept rendering as the +# gem's "Doy por recibida...". These tests pin both the outcome and the +# ordering that produces it. +class HostLocaleOverrideTest < ActiveSupport::TestCase + test "the host's wording for a gem key wins over the gem's default" do + assert_equal "He leído la Política de Privacidad.", + I18n.t("clickwrap.statements.acknowledgment.privacy_notice", locale: :es) + end + + test "every gem locale file loads before every host locale file" do + engine_locales = Clickwrap::Engine.root.join("config", "locales").to_s + host_locales = Rails.root.join("config", "locales").to_s + + last_engine = I18n.load_path.rindex { |path| path.to_s.start_with?(engine_locales) } + first_host = I18n.load_path.index { |path| path.to_s.start_with?(host_locales) } + + refute_nil last_engine, "the gem's locale files never made it into I18n.load_path" + refute_nil first_host, "the dummy host's locale files never made it into I18n.load_path" + assert last_engine < first_host, + "gem locales must load before host locales, or host overrides lose" + end +end