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
4 changes: 4 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ Metrics/BlockLength:

# Test files: autofix only, relaxed metrics.
Metrics/ClassLength:
# Declarative data literals (token tables) are length, not complexity.
CountAsOne:
- array
- hash
Exclude:
- 'test/**/*'

Expand Down
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,30 @@ All notable changes to this project are documented here.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.1.0] - 2026-06-15

### Added

- `radius` token group (`button`/`box`) and `Tokens#radius`/`#radius!` accessors.
- `Tokens#button_style(variant)` resolver and the `Tokens::ButtonStyle` value
object: button variants (incl. an **outline** secondary) are now token-driven —
set `<variant>_text`/`<variant>_border` colors instead of forking the component.
- Box-scoped `info_box_background`/`info_box_border`/`info_box_text` tokens
(falling back to the page palette) plus a box `border-radius`.
- `Tokens#load(group: {...})` bulk-loader and a single `Tokens#to_h` snapshot.

### Changed

- `<button>` and `<cta>` share a `bulletproof_button_table` scaffold; `<cta>`
drops its hardcoded `4px` radius in favor of the `radius` token.
- The corner radius is now applied by default on `.button`/`.cta` (the opt-in
`.radius` class is gone); `.button.secondary`/`.cta.secondary` mirror the
secondary color/border tokens.

### Removed

- `Tokens#colors`/`#fonts`/`#spacings` readers — use `Tokens#to_h` instead.

## [1.0.2] - 2026-06-15

### Fixed
Expand Down
37 changes: 22 additions & 15 deletions app/assets/stylesheets/activemail/_components.scss
Original file line number Diff line number Diff line change
@@ -1,48 +1,55 @@
// Background on both inner cell and link so the whole bulletproof button
// (table.button > td > table > td > a) is filled and clickable.
.button table td {
// Background on both cell and link so the whole bulletproof button is filled and
// clickable. Radius by default — progressive enhancement, clients that don't support it ignore it.
.button table td,
.button a {
background: $am-button-primary-bg;
border-radius: $am-button-radius;
}

.button a {
color: $am-button-color;
background: $am-button-primary-bg;
font-family: $am-font-family;
font-weight: bold;
}

.button.secondary table td,
.button.secondary a {
background: $am-button-secondary-bg;
color: $am-button-secondary-color;
}

// Progressive enhancement only — some clients strip border-radius.
.button.radius table td,
.button.radius a {
border-radius: $am-button-radius;
// Border on the cell only — a repeated <a> border would double the line.
.button.secondary table td {
border: 1px solid $am-button-secondary-border;
}

// CTA mirrors the bulletproof button; colors are also inlined by the component.
.cta table td {
.cta table td,
.cta a {
background: $am-button-primary-bg;
border-radius: $am-button-radius;
}

.cta a {
color: $am-button-color;
background: $am-button-primary-bg;
font-family: $am-font-family;
font-weight: bold;
}

.cta.secondary table td,
.cta.secondary a {
background: $am-button-secondary-bg;
color: $am-button-secondary-color;
}

.cta.secondary table td {
border: 1px solid $am-button-secondary-border;
}

.info-box td {
background-color: $am-background;
border-left: 5px solid $am-border;
color: $am-text;
background-color: $am-info-box-bg;
border-left: 5px solid $am-info-box-border;
border-radius: $am-box-radius;
color: $am-info-box-text;
padding: $am-spacing-md;
}

Expand All @@ -52,7 +59,7 @@
padding: $am-spacing-md;
}

// Spacer hook — height is set inline per instance; this is a styling anchor.
// Height is set inline per instance; this rule is just a styling anchor.
.spacer {
width: 100%;
}
5 changes: 4 additions & 1 deletion app/assets/stylesheets/activemail/_dark.scss
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,14 @@ $am-dark-link: $am-color-primary !default;
color: $am-color-button-text !important;
}

// Keep the secondary variant's color — the primary rule above would erase it.
// Restore the secondary fill AND text — the primary rule above forces button-text,
// which would be unreadable on an outline (light) secondary fill.
.button.secondary table td,
.button.secondary a,
.cta.secondary table td,
.cta.secondary a {
background: $am-color-secondary !important;
color: $am-button-secondary-color !important;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dark mode ignores secondary_text

High Severity

Dark-mode rules force secondary .cta/.button link text to $am-button-secondary-color, which is aliased to button_text, not the secondary_text color token. Token-driven outline secondaries inline a dark secondary_text color, but !important stylesheet rules override it—often yielding light-on-light text when secondary is a light fill.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f3600df. Configure here.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dark mode overrides info box tokens

Medium Severity

Dark-mode panel rules apply !important background, border, and text colors to .info-box td, overriding the inline styles built from info_box_background, info_box_border, and info_box_text. Custom box-scoped tokens work in light rendering but are replaced by generic dark panel colors in dark mode.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f3600df. Configure here.

}

// Panels keep their inline background unless overridden here, which would
Expand Down Expand Up @@ -111,6 +113,7 @@ $am-dark-link: $am-color-primary !default;
[data-ogsc] .cta.secondary table td,
[data-ogsc] .cta.secondary a {
background: $am-color-secondary !important;
color: $am-button-secondary-color !important;
}

[data-ogsc] .callout-inner,
Expand Down
20 changes: 14 additions & 6 deletions app/assets/stylesheets/activemail/_settings.scss
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
// Semantic aliases mapped from the $am-* token vars. All !default so a host
// app can pre-declare any value before importing the framework.
// All !default so a host app can pre-declare any value before importing the framework.
@import "activemail/activemail_tokens";

// Typography
Expand All @@ -16,10 +15,19 @@ $am-border: $am-color-border !default;
$am-button-primary-bg: $am-color-primary !default;
$am-button-secondary-bg: $am-color-secondary !default;
$am-button-color: $am-color-button-text !default;
$am-button-radius: 4px !default;
// Secondary falls back to filled-button defaults; override *-secondary-* for an outline variant.
$am-button-secondary-color: $am-color-button-text !default;
$am-button-secondary-border: transparent !default;
$am-button-radius: $am-radius-button !default;

// Grid — the gem emits no gutters; we provide them via column padding.
// Width follows config.container_width via the token bridge, so the SCSS grid
// stays aligned with the transpiled markup (ghost tables, column max-width).
// Info box
$am-info-box-bg: $am-background !default;
$am-info-box-border: $am-border !default;
$am-info-box-text: $am-text !default;
$am-box-radius: $am-radius-box !default;

// Grid — the gem emits no gutters; we provide them via column padding. Width
// follows config.container_width via the token bridge so the SCSS grid stays
// aligned with the transpiled markup (ghost tables, column max-width).
$am-container-width: $am-grid-container-width !default;
$am-gutter: $am-spacing-sm !default;
15 changes: 15 additions & 0 deletions lib/activemail/components/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ class Base
# Layout tables: presentation role (a11y) and zeroed legacy spacing.
TABLE_RESET = 'role="presentation" border="0" cellpadding="0" cellspacing="0"'

BUTTON_PADDING = 'padding:12px 24px;'

sig { params(core: ::ActiveMail::Core).void }
def initialize(core)
@core = core
Expand Down Expand Up @@ -105,6 +107,19 @@ def target_attribute(node)
node.attributes['target'] ? %( target="#{escape_attr(node.attributes['target'])}") : ''
end

# Outlook-safe nested-table structure kept in one place for <button> and <cta>.
sig do
params(outer_classes: String, inner: String, cell_style: String, outer_extra: String).returns(String)
end
def bulletproof_button_table(outer_classes:, inner:, cell_style: '', outer_extra: '')
cell = cell_style.empty? ? '<td>' : %(<td style="#{cell_style}">)
[
%(<table class="#{outer_classes}" #{TABLE_RESET}><tbody><tr><td>),
%(<table #{TABLE_RESET}><tbody><tr>#{cell}#{inner}</td></tr></tbody></table>),
%(</td>#{outer_extra}</tr></tbody></table>)
].join
end

sig { returns(Integer) }
def column_count
core.column_count
Expand Down
15 changes: 8 additions & 7 deletions lib/activemail/components/button.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,14 @@ def transform(node, inner)
inner = anchor(node, inner, expand) if node.attr('href')
inner = "<center>#{inner}</center>" if expand

classes = combine_classes(node, 'button')
expander = expand ? '<td class="expander"></td>' : ''
[
%(<table class="#{classes}" #{TABLE_RESET}><tbody><tr><td>),
%(<table #{TABLE_RESET}><tbody><tr><td>#{inner}</td></tr></tbody></table>),
%(</td>#{expander}</tr></tbody></table>)
].join
# CSS-driven by design: colors come from .button rules, not inline tokens —
# a distinct robustness model from <cta>, which inlines its palette.
bulletproof_button_table(
outer_classes: combine_classes(node, 'button'),
inner: inner,
outer_extra: expander
)
end

private
Expand All @@ -30,7 +31,7 @@ def anchor(node, inner, expand)
target = target_attribute(node)
extra = expand ? ' align="center" class="float-center"' : ''
# Padding on the <a> makes the whole button a clickable target.
link_style = 'display:inline-block;text-decoration:none;padding:12px 24px;'
link_style = "display:inline-block;text-decoration:none;#{BUTTON_PADDING}"
attrs = %(#{pass_through_attributes(node)}href="#{escape_attr(node.attr('href'))}"#{target}#{extra})
%(<a #{attrs}#{style_attribute(node, link_style)}>#{inner}</a>)
end
Expand Down
39 changes: 26 additions & 13 deletions lib/activemail/components/cta.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,30 +6,43 @@
module ActiveMail
module Components
# Colors read from tokens at transform time (runtime config), not load-time constants.
# Styles are inlined so the button survives clients that strip <style> (Gmail mobile…).
class Cta < Base
extend T::Sig

sig { override.params(node: Nokogiri::XML::Node, inner: String).returns(String) }
sig { override.overridable.params(node: Nokogiri::XML::Node, inner: String).returns(String) }
def transform(node, inner)
# A CTA without a link is an authoring bug — surface it at render time.
raise ArgumentError, '<cta> requires an href attribute' if node.attr('href').to_s.strip.empty?

background = ActiveMail.tokens.color!(class?(node, 'secondary') ? :secondary : :primary)
classes = combine_classes(node, 'cta')
anchor = %(<a href="#{escape_attr(node.attr('href'))}"#{target_attribute(node)} style="#{link_style(background)}">#{inner}</a>)
[
%(<table class="#{classes}" #{TABLE_RESET}><tbody><tr><td>),
%(<table #{TABLE_RESET}><tbody><tr><td style="background:#{background};border-radius:4px;">),
"#{anchor}</td></tr></tbody></table></td></tr></tbody></table>"
].join
style = ActiveMail.tokens.button_style(class?(node, 'secondary') ? :secondary : :primary)
anchor = %(<a href="#{escape_attr(node.attr('href'))}"#{target_attribute(node)} ) +
%(style="#{link_style(style)}">#{inner}</a>)
bulletproof_button_table(
outer_classes: combine_classes(node, 'cta'),
inner: anchor,
cell_style: cell_style(style)
)
end

private

sig { params(background: String).returns(String) }
def link_style(background)
'display:inline-block;text-decoration:none;padding:12px 24px;' \
"background:#{background};color:#{ActiveMail.tokens.color!(:button_text)};font-weight:bold;border-radius:4px;"
sig { params(style: ActiveMail::Tokens::ButtonStyle).returns(String) }
def cell_style(style)
"background:#{style.background};border-radius:#{style.radius};#{border_css(style)}"
end

# No border here: it lives on the cell only — a repeated <a> border doubles the line.
sig { params(style: ActiveMail::Tokens::ButtonStyle).returns(String) }
def link_style(style)
"display:inline-block;text-decoration:none;#{BUTTON_PADDING}" \
"background:#{style.background};color:#{style.color};font-weight:bold;" \
"border-radius:#{style.radius};"
end

sig { params(style: ActiveMail::Tokens::ButtonStyle).returns(String) }
def border_css(style)
style.border ? "border:1px solid #{style.border};" : ''
end
end
end
Expand Down
11 changes: 8 additions & 3 deletions lib/activemail/components/info_box.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ module Components
class InfoBox < Base
extend T::Sig

sig { override.params(node: Nokogiri::XML::Node, inner: String).returns(String) }
sig { override.overridable.params(node: Nokogiri::XML::Node, inner: String).returns(String) }
def transform(node, inner)
classes = combine_classes(node, 'info-box')
[
Expand All @@ -21,11 +21,16 @@ def transform(node, inner)

private

# Box-scoped tokens (fall back to page-level ones) so a host can give the box
# a distinct surface without colliding with :background/:border/:text.
sig { returns(String) }
def cell_style
tokens = ActiveMail.tokens
"background-color:#{tokens.color!(:background)};border-left:5px solid #{tokens.color!(:border)};" \
"color:#{tokens.color!(:text)};padding:#{tokens.spacing!(:md)};"
background = tokens.color(:info_box_background) || tokens.color!(:background)
border = tokens.color(:info_box_border) || tokens.color!(:border)
text = tokens.color(:info_box_text) || tokens.color!(:text)
"background-color:#{background};border-left:5px solid #{border};border-radius:#{tokens.radius!(:box)};" \
"color:#{text};padding:#{tokens.spacing!(:md)};"
end
end
end
Expand Down
Loading
Loading