Skip to content
Open
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
15 changes: 6 additions & 9 deletions express/code/blocks/ax-columns/ax-columns.css
Original file line number Diff line number Diff line change
Expand Up @@ -971,7 +971,7 @@ main .ax-columns.highlight.dark .columns-video:last-of-type {
gap: var(--spacing-300);
}

.ax-columns.fullsize .button-container.two-ctas .button {
.ax-columns.fullsize .button-container.two-ctas .con-button {
margin: 0;
}

Expand Down Expand Up @@ -1328,7 +1328,7 @@ main .ax-columns.highlight.dark .columns-video:last-of-type {
display: none;
}

.ax-columns.marquee .button-container.free-plan-container a.button {
.ax-columns.marquee .button-container.free-plan-container a.con-button {
width: 137px;
height: 48px;
font-size: var(--body-font-size-l);
Expand Down Expand Up @@ -1709,7 +1709,7 @@ main .ax-columns.highlight.dark .columns-video:last-of-type {
text-align: center;
}

.ax-columns.slim-button a.button {
.ax-columns.slim-button a.con-button {
padding: 5px 19.5px;
}

Expand Down Expand Up @@ -1941,16 +1941,13 @@ main .ax-columns .express-logo.icon.marquee-eyebrow-logo-large {

/* Temporary styles for the Font Generator SEO page's ax-columns instance.
Not a long-term variant — remove once a permanent layout solution lands. */
.section .ax-columns.temporary-font-generator-styles p.button-container a.button,
.section .ax-columns.temporary-font-generator-styles a.con-button {
background-color: var(--Background-Primary-Default);
border-color: var(--Background-Primary-Default);
border-radius: 999px;
color: var(--color-white);
}

.section .ax-columns.temporary-font-generator-styles p.button-container a.button:hover,
.section .ax-columns.temporary-font-generator-styles p.button-container a.button:active,
.section .ax-columns.temporary-font-generator-styles a.con-button:hover,
.section .ax-columns.temporary-font-generator-styles a.con-button:active {
background-color: var(--Background-Primary-Hover);
Expand Down Expand Up @@ -1989,15 +1986,15 @@ main .ax-columns .express-logo.icon.marquee-eyebrow-logo-large {
}
}

main .ax-columns a.button.fill {
main .ax-columns a.con-button.fill {
background-color: var(--Background-Primary-Default);
border-color: var(--Background-Primary-Default);
border-radius: 999px;
color: var(--color-white);
}

main .ax-columns a.button.fill:any-link:hover,
main .ax-columns a.button.fill:any-link:active {
main .ax-columns a.con-button.fill:any-link:hover,
main .ax-columns a.con-button.fill:any-link:active {
background-color: var(--color-info-accent-hover);
border-color: var(--color-info-accent-hover);
}
44 changes: 30 additions & 14 deletions express/code/blocks/ax-columns/ax-columns.js
Original file line number Diff line number Diff line change
Expand Up @@ -223,25 +223,29 @@ function injectLogo(block) {

const decoratePrimaryCTARow = (rowNum, cellNum, cell) => {
if (rowNum + cellNum !== 0) return;
const italicAnchor = cell.querySelector('p > em > a');
if (!italicAnchor) return;
const boldAnchor = italicAnchor.parentElement?.previousElementSibling?.querySelector('a');
const block = cell.closest('.ax-columns');

if (boldAnchor && block?.className.includes('fullsize')) {
boldAnchor.classList.add('button', 'accent', 'xlarge', 'primaryCTA');
BlockMediator.set('primaryCtaUrl', boldAnchor.href);
italicAnchor.classList.add('button', 'primary', 'reverse', 'xlarge');
boldAnchor.parentElement?.replaceWith(boldAnchor);
italicAnchor.parentElement?.replaceWith(italicAnchor);
boldAnchor.closest('p')?.classList.add('button-container', 'two-ctas');
// Post milo-decoration the primary CTA (authored bold/strong) is `a.con-button.blue`
// and the secondary (authored italic/em) is `a.con-button.outline`; milo has
// already unwrapped the <em>/<strong> and moved each anchor into its <p>.
const primaryAnchor = cell.querySelector('a.con-button.blue');
const secondaryAnchor = cell.querySelector('a.con-button.outline');
// Legacy fallback: plain anchors that milo didn't decorate still land as `.button`.
const italicAnchor = secondaryAnchor || cell.querySelector('p > em > a');
if (!italicAnchor && !primaryAnchor) return;

if (block?.className.includes('fullsize') && primaryAnchor) {
primaryAnchor.classList.add('xlarge', 'primaryCTA');
BlockMediator.set('primaryCtaUrl', primaryAnchor.href);
secondaryAnchor?.classList.add('reverse', 'xlarge');
primaryAnchor.closest('p')?.classList.add('button-container', 'two-ctas');
return;
}

const links = italicAnchor.parentElement?.querySelectorAll('a');
if (links.length < 2) return;
italicAnchor.parentElement?.classList.add('phone-number-cta-row');
links[0].classList.add('button', 'xlarge', 'trial-cta');
const links = italicAnchor?.closest('p')?.querySelectorAll('a');
if (!links || links.length < 2) return;
italicAnchor.closest('p')?.classList.add('phone-number-cta-row');
links[0].classList.add('con-button', 'xlarge', 'trial-cta');
links[1].classList.add('phone');
italicAnchor.closest('p')?.prepend(links[0]);
};
Expand Down Expand Up @@ -359,6 +363,18 @@ export default async function decorate(block) {
decorateSocialIcons(block);
await decorateButtonsDeprecated(block, 'button-xxl');

// ax-columns is no longer in decorateButtonsDeprecated's exclusion list, so
// its CTAs are now decorated by milo's decorateButtons: strong->`.con-button.blue`,
// em->`.con-button.outline`, size `button-xxl`, and the parent <p>/<div> gets
// `.action-area`. This block's CSS is written against `.button-container` (39
// rules) and its JS/CSS expect `.xlarge`, so bridge milo's output back onto the
// existing contract instead of rewriting every rule. Idempotent and scoped to
// this block's already-decorated con-buttons.
block.querySelectorAll('a.con-button').forEach((btn) => {
btn.classList.add('xlarge');
btn.closest('p, div')?.classList.add('button-container');
});

const rows = Array.from(block.children);

// Handle background images for marquee and hero-animation-overlay variants
Expand Down
16 changes: 5 additions & 11 deletions express/code/blocks/blog-columns/blog-columns.css
Original file line number Diff line number Diff line change
Expand Up @@ -133,24 +133,18 @@ main .section .blog-columns p {
line-height: var(--heading-line-height);
}

.blog-columns a.con-button.blue:any-link {
.blog-columns a.con-button:any-link {
margin: var(--spacing-400) 0 0;
font-size: var(--ax-body-m-size);
line-height: 22px;
border: 0px;
min-width: 108px;
box-sizing: border-box;
}

.blog-columns a.con-button.blue.fill:any-link {
background-color: var(--color-default-font);
border-color: var(--color-default-font);
color: var(--color-white);
}

.blog-columns a.con-button.blue.fill:hover {
background-color: var(--color-gray-700);
border-color: var(--color-gray-700);
/* Only the solid-fill variants (blue, fill) drop their border — outline needs
its border-width intact for the .s2 outline stroke to render. */
.blog-columns a.con-button:is(.blue, .fill):any-link {
border: 0px;
}

@media (min-width: 800px) {
Expand Down
5 changes: 4 additions & 1 deletion express/code/blocks/blog-columns/blog-columns.js
Original file line number Diff line number Diff line change
Expand Up @@ -430,7 +430,10 @@ export default async function decorate(block) {
if (ctaNode) {
ctaNode.querySelectorAll('a').forEach((link) => {
link.classList.add('button-xl');
if (!link.classList.contains('con-button')) link.classList.add('con-button');
// A plain link (no bold/italic wrapping) never passes through milo's
// decorateButtons, which only matches `em a, strong a, p > a strong` —
// so it reaches here without a type class. Default it to blue.
if (!link.classList.contains('con-button')) link.classList.add('con-button', 'blue');
const customClasses = link.href && [...link.href.matchAll(/#_button-([a-zA-Z-]+)/g)];
if (customClasses) {
customClasses.forEach((match) => {
Expand Down
11 changes: 0 additions & 11 deletions express/code/blocks/comparison-table-v2/comparison-table-v2.css
Original file line number Diff line number Diff line change
Expand Up @@ -1324,17 +1324,6 @@ main .comparison-table-v2 .sticky-header .plan-cell-wrapper:not(:has(p))> :is(h2
text-decoration-skip-ink: auto;
}


.comparison-table-v2 a.con-button.fill:any-link {
background: var(--text-color);
border-color: var(--text-color);
color: var(--color-white);
}

.comparison-table-v2 a.con-button.fill:hover {
background-color: var(--color-gray-700);
}

@keyframes scaleSlideIn {
from {
transform: translateY(-100%) scaleY(0.8);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -182,102 +182,6 @@
color: var(--color-black);
}

/* Focus ring — shared by every CTA in this block (blue primary + both outline
secondary variants), pulled from the Figma SoT "Focus Ring Background"
layer used across S2 buttons: a 2px --color-focus-ring-strong (#4b75ff)
border offset 2px from the button, with a translucent-white halo
(11%-opacity) filling the gap in between. box-shadow: none/override on
each :focus rule below cancels milo's own shared con-button box-shadow
(styles.css) — those rules still match here, and without this their ring
would win the cascade for that property since ours replaces box-shadow's
*value* rather than the whole rule. */
.transparent-img-marquee a.con-button.blue:any-link:focus {
outline: 2px solid var(--color-focus-ring-strong);
outline-offset: 2px;
box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.11);
}

/* Secondary CTA, light variant — per the Figma SoT "Button (Secondary,
Static black)" component, Style=Outline (the transparent variant, not the
filled "Style=Fill" one): a real 2px border, transparent at rest, using
the same opacity ladder as the fill: default 22% (#00000038), hover 15%
(#00000026), down 12% (#0000001f) border, paired with its own (slightly
different) background fill per state — default transparent, hover 9%
(#00000017), down 12% (#0000001f). Text stays a constant 93%-opacity
black (#000000ed) in every state. Focus reuses the down fill+border (12%)
and adds the shared ring, offset 2px — same offset as the primary button,
regardless of this button's own 2px border (milo's base con-button rule
gives every button, including primary, a 2px border-width already —
primary's just happens to be background-colored, so there's no reason
for the two rings to sit at different distances). */
.transparent-img-marquee.light a.con-button.outline:any-link {
background-color: transparent;
border-color: #00000038;
color: #000000ed;
}

.transparent-img-marquee.light a.con-button.outline:any-link:hover {
background-color: #00000017;
border-color: #00000026;
color: #000000ed;
}

.transparent-img-marquee.light a.con-button.outline:any-link:active {
background-color: #0000001f;
border-color: #0000001f;
color: #000000ed;
}

.transparent-img-marquee.light a.con-button.outline:any-link:focus {
background-color: #0000001f;
border-color: #0000001f;
color: #000000ed;
outline: 2px solid var(--color-focus-ring-strong);
outline-offset: 2px;
box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.11);
}

/* Secondary CTA, dark variant — per QA (Julia): "Default state has an outer
stroke of #FFFFFF at 22% opacity. Hover state maintains same outer stroke
of #FFFFFF at 22% opacity and has a fill of #FFFFFF at 17% opacity. Focus
state maintains the same stroke and fill color as above with no fill
between the outside of the button and the focus ring." So the stroke
stays a constant 22% (#ffffff38) across default/hover/focus — only the
fill steps from transparent to 17% (#ffffff2b) on hover, which focus then
holds too. The focus ring's gap is intentionally empty (no halo) per "no
fill between the outside of the button and the focus ring" — just the
ring itself, offset 2px (same offset as the primary button and the light
variant above — no reason for the two secondary variants or the primary
button to sit at different distances). Text stays solid white in every
state. (Down/active — #ffffff1c fill, #ffffff24 stroke — isn't covered by
this feedback; left at its Figma-sampled value.) */
.transparent-img-marquee.dark a.con-button.outline:any-link {
background-color: transparent;
border-color: #ffffff38;
color: var(--color-white);
}

.transparent-img-marquee.dark a.con-button.outline:any-link:hover {
background-color: #ffffff2b;
border-color: #ffffff38;
color: var(--color-white);
}

.transparent-img-marquee.dark a.con-button.outline:any-link:active {
background-color: #ffffff1c;
border-color: #ffffff24;
color: var(--color-white);
}

.transparent-img-marquee.dark a.con-button.outline:any-link:focus {
background-color: #ffffff2b;
border-color: #ffffff38;
color: var(--color-white);
outline: 2px solid var(--color-focus-ring-strong);
outline-offset: 2px;
box-shadow: none;
}

/* ── Tablet (M) ≥ 600px: 2-column that wraps to 1-column ≤ ~853px ── */
@media (min-width: 600px) {
.transparent-img-marquee {
Expand Down
21 changes: 21 additions & 0 deletions express/code/scripts/scripts.js
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,21 @@ function preloadLCPImage(img) {
}());

let fragmentLcpPreloaded = false;

// Rollout gate for the Spectrum-2 (s2) button system. Blocks listed here get the

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can you confirm with design that S2 is the right name for our design system? We're not fully on S2 so they might have a name more specific to express.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Its based on the S2 buttons in the design, I think this is a good name for it.

Screenshot 2026-08-18 at 10 47 46 AM

// `s2` class applied centrally — no per-page authoring needed — so they render
// the proper S2 CTA styling (see the `.s2` rules in styles.css). Add a block
// name once its buttons are verified; when every block is verified, retire this
// gate and make s2 the default. Authors can still opt a single block in by
// adding the `s2` variant in the block's name row.
const S2_BUTTON_BLOCKS = ['ax-columns', 'transparent-img-marquee', 'comparison-table-v2', 'blog-columns'];

function applyS2ButtonClasses(area) {
S2_BUTTON_BLOCKS.forEach((name) => {
area.querySelectorAll(`.${name}`).forEach((block) => block.classList.add('s2'));
});
}

// eslint-disable-next-line import/prefer-default-export
export function decorateAreaWithLCP(area = document, options = {}) {
const { fragmentLink } = options;
Expand All @@ -381,6 +396,11 @@ export function decorateAreaWithLCP(area = document, options = {}) {
}
}
decorateArea(area, options);
// Milo's core loadArea() never calls config.decorateArea for the main
// document — only its fragment block does, for fragment content (see
// libs/blocks/fragment/fragment.js). Blocks authored directly on the page
// (the common case) are classed explicitly after loadArea() in loadPage().
applyS2ButtonClasses(area);
Comment on lines +399 to +403

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Yes, because the button decoration function is called in the block. It'd be good to check with Ernest that we want to deviate from milo in this way. It sounds like once we retire the gate, buttons would get decorated in the body too.

}
CONFIG.decorateArea = decorateAreaWithLCP;

Expand Down Expand Up @@ -575,6 +595,7 @@ async function loadPage() {
});

await loadArea();
applyS2ButtonClasses(document);

const { fixIcons } = await import('./utils.js');
document.querySelectorAll('.section>.text').forEach((block) => fixIcons(block));
Expand Down
20 changes: 19 additions & 1 deletion express/code/scripts/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,7 @@ export async function decorateButtonsDeprecated(el, size) {
const { decorateButtons } = await import(`${getLibs()}/utils/decorate.js`);
// eslint-disable-next-line max-len
// DO NOT add any more exceptions here. Authors must learn to author buttons the new milo way, even with old blocks
if (!el.closest('.ax-columns') && !el.closest('.banner') && !el.closest('.fullscreen-marquee') && !el.closest('.link-list')) decorateButtons(el, size);
if (!el.closest('.banner') && !el.closest('.fullscreen-marquee') && !el.closest('.link-list')) decorateButtons(el, size);
// DO NOT add any more exceptions above. We should be removing the exceptions and not adding more.
el.querySelectorAll(':scope a:not(.con-button, .social-link)').forEach(($a) => {
// Mirrors decorateButtons' own #_button-<name> handling (milo's utils/decorate.js)
Expand Down Expand Up @@ -364,6 +364,24 @@ export async function decorateButtonsDeprecated(el, size) {
$a.classList.add('button', 'accent', 'light');
$twoup.classList.add('button-container');
}
// Custom button variants authored the milo way via `#_button-<class>`
// hashes on the href (e.g. `#_button-fill`, `#_button-outline`). The
// blocks that reach this fallback (ax-columns, banner,
// fullscreen-marquee, link-list) bypass milo's decorateButtons, which
// would normally strip these hashes and add the classes — so replicate
// that here: add each class, strip the hash, and drop the default
// `accent` when an explicit fill/outline variant is requested.
// Additive and hash-gated: no effect on content without these hashes.
if ($a.classList.contains('button')) {
const customClasses = [...originalHref.matchAll(/#_button-([a-zA-Z-]+)/g)];
customClasses.forEach(([token, cls]) => {
$a.classList.add(cls);
$a.setAttribute('href', $a.href.replace(token, ''));
});
if ($a.classList.contains('fill') || $a.classList.contains('outline')) {
$a.classList.remove('accent');
}
}
Comment on lines +367 to +384

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Ideally we would upgrade to the new button decoration rather than continue to add code to the deprecated function. Do you have an idea of how much work that would be?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Why ax-columns stays on decorateButtonsDeprecated (whitelist), not milo's native decorateButtons

We prototyped switching ax-columns to call milo's real decorateButtons directly, matching the pattern already used by blog-columns, comparison-table-v2, transparent-img-marquee, etc. It doesn't hold up for this block:

  • Milo's decorateButtons only converts links matching em a, strong a, or p > a strong (verified against the live decorate.js source) — i.e., a link needs bold/italic formatting to be recognized as a CTA at all.
  • ax-columns content relies on the older Express convention where any link alone in a paragraph becomes a button, formatted or not. That behavior only exists in the deprecated fallback (decorateButtonsDeprecated's own single-child-of-p check) — milo has no equivalent.
  • Switching would silently drop button styling from every plain-link CTA authored in existing ax-columns content, including the #_button-fill/#_button-outline hash convention on unformatted links, since milo never even reaches them to strip the hash. This wouldn't be a CSS specificity fix — the elements never get a class in the first place.
  • Working around it requires re-implementing a plain-link catch-up in ax-columns.js (matching what blog-columns.js already does), which only shrinks — doesn't eliminate — the custom classification code the migration was meant to remove.

Given ax-columns is one of our highest-traffic, highest-regression-risk blocks, the whitelist/deprecated-fallback approach remains the safer choice until/unless we're ready to also handle the plain-link gap deliberately, rather than fold it into a "quick" migration.

https://main--da-express-milo--adobecom.aem.page/drafts/echen/plain-link-decoration-demo

}
if (linkText.startsWith('{{icon-') && linkText.endsWith('}}')) {
const $iconName = /{{icon-([\w-]+)}}/g.exec(linkText)[1];
Expand Down
Loading
Loading