Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<kind>.<key>` 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
Expand Down
16 changes: 10 additions & 6 deletions app/assets/stylesheets/clickwrap.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
28 changes: 22 additions & 6 deletions config/locales/es.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: "."
Expand Down
15 changes: 9 additions & 6 deletions lib/clickwrap/engine.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
15 changes: 14 additions & 1 deletion lib/clickwrap/presenter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion lib/clickwrap/version.rb
Original file line number Diff line number Diff line change
@@ -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
Expand Down
51 changes: 50 additions & 1 deletion test/combined_statement_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions test/dummy/config/locales/es.yml
Original file line number Diff line number Diff line change
@@ -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."
33 changes: 33 additions & 0 deletions test/host_locale_override_test.rb
Original file line number Diff line number Diff line change
@@ -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
Loading