diff --git a/CHANGELOG.md b/CHANGELOG.md index 47c79a5..85d6b1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,44 @@ 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 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 + 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..a57370f 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -22,13 +22,29 @@ 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: + # 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 " joiner: " y " terminator: "." 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/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..dba4590 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 doy por recibida la Política de Privacidad.", + combined.sentence + 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 + 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 doy por recibida la Privacy Policy.", combined.sentence end test "a locale with no connective words itemizes rather than composing half a sentence" do 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