From 29a3388cec56b7112c47b0ec2e165c07495fcd18 Mon Sep 17 00:00:00 2001 From: Jingle Huang Date: Mon, 14 Sep 2026 20:28:33 -0700 Subject: [PATCH 01/11] auto-rolling logos --- libs/c2/blocks/logo-ticker/logo-ticker.css | 90 ++++++++++++++-------- libs/c2/blocks/logo-ticker/logo-ticker.js | 90 ++++++++++++++++++++-- 2 files changed, 143 insertions(+), 37 deletions(-) diff --git a/libs/c2/blocks/logo-ticker/logo-ticker.css b/libs/c2/blocks/logo-ticker/logo-ticker.css index 77b2ac7a51d..0704b11d862 100644 --- a/libs/c2/blocks/logo-ticker/logo-ticker.css +++ b/libs/c2/blocks/logo-ticker/logo-ticker.css @@ -2,15 +2,13 @@ --logo-ticker-gap: var(--s2a-spacing-80); --logo-ticker-logo-height: 36px; --logo-ticker-dir: 1; - --logo-ticker-drift-distance: 240px; + --logo-ticker-duration: 30s; position: relative; width: 100%; - overflow-x: auto; + overflow: hidden; contain: layout paint; padding-block: var(--s2a-spacing-24); - /* stylelint-disable-next-line property-no-unknown */ - view-timeline: --logo-ticker-view block; .logo-ticker-track { display: flex; @@ -29,50 +27,82 @@ } span.icon, - svg, + .logo-ticker-track svg, picture img { height: var(--logo-ticker-logo-height); width: auto; flex: 0 0 auto; } + .logo-ticker-track:not(.is-static) { + animation: logo-ticker-scroll var(--logo-ticker-duration) linear infinite; + animation-play-state: var(--logo-ticker-play-state, running); + + .logo-ticker-set:not(:first-child) { + display: flex; + } + } + .logo-ticker-track.is-static { width: 100%; box-sizing: border-box; justify-content: center; padding-inline: var(--logo-ticker-gap); } + + .logo-ticker-play-pause { + position: absolute; + top: 50%; + translate: 0 -50%; + inset-inline-end: var(--s2a-spacing-24); + width: 2rem; + height: 2rem; + border-radius: 50%; + border: 1px solid currentcolor; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + opacity: 0.8; + padding: 0; + + svg { + width: 1.125rem; + height: 1.125rem; + } + + &.is-playing .logo-ticker-play-icon, + &:not(.is-playing) .logo-ticker-pause-icon { + display: none; + } + + &:focus-visible { + outline: 2px solid linktext; + outline-offset: 2px; + } + } + + .logo-ticker-track.is-static ~ .logo-ticker-play-pause { + display: none; + } } :dir(rtl) .logo-ticker { --logo-ticker-dir: -1; } -@supports (animation-timeline: view()) { - @media (prefers-reduced-motion: no-preference) { - .logo-ticker { - overflow: clip; - - .logo-ticker-set:not(:first-child) { - display: flex; - } - - .logo-ticker-track.is-static .logo-ticker-set:not(:first-child) { - display: none; - } - - .logo-ticker-track:not(.is-static) { - animation: logo-ticker-drift linear 1; - /* stylelint-disable-next-line property-no-unknown */ - animation-timeline: --logo-ticker-view; - /* stylelint-disable-next-line property-no-unknown */ - animation-range: cover 0% cover 100%; - } - } +@media (prefers-reduced-motion: reduce) { + .logo-ticker { + --logo-ticker-play-state: paused; + } +} - @keyframes logo-ticker-drift { - from { translate: calc(var(--logo-ticker-drift-distance) * var(--logo-ticker-dir)); } - to { translate: calc(calc(-1 * var(--logo-ticker-drift-distance)) * var(--logo-ticker-dir)); } - } +@keyframes logo-ticker-scroll { + from { + translate: calc((var(--logo-ticker-scroll-offset, 0px)) * -1 * var(--logo-ticker-dir)); + } + + to { + translate: calc((var(--logo-ticker-set-width, 0px) + var(--logo-ticker-scroll-offset, 0px)) * -1 * var(--logo-ticker-dir)); } } diff --git a/libs/c2/blocks/logo-ticker/logo-ticker.js b/libs/c2/blocks/logo-ticker/logo-ticker.js index 89e4ff2b882..860efa55a64 100644 --- a/libs/c2/blocks/logo-ticker/logo-ticker.js +++ b/libs/c2/blocks/logo-ticker/logo-ticker.js @@ -2,7 +2,20 @@ import { createTag } from '../../../utils/utils.js'; const SET_COUNT = 2; -function buildTrack(el, logos) { +const PLAY_SVG = ''; +const PAUSE_SVG = ''; + +function parseAuthoring(el) { + const rows = [...el.children]; + const logos = [...rows[0]?.querySelectorAll('span.icon') ?? []]; + const trackLabel = rows[1]?.textContent.trim() ?? ''; + const btnParts = (rows[2]?.textContent ?? '').split('||').map((s) => s.trim()); + const playLabel = btnParts[0] || 'Play'; + const pauseLabel = btnParts[1] || 'Pause'; + return { logos, trackLabel, playLabel, pauseLabel }; +} + +function buildTrack(logos, trackLabel) { const logoSets = Array.from({ length: SET_COUNT }, (_, i) => { const logoSet = createTag('div', { class: 'logo-ticker-set' }); if (i > 0) logoSet.setAttribute('aria-hidden', 'true'); @@ -11,12 +24,70 @@ function buildTrack(el, logos) { }); const track = createTag('div', { class: 'logo-ticker-track', role: 'img' }, logoSets); - if (el.children.length >= 2) { - track.setAttribute('aria-label', el.children[1].textContent); - } + if (trackLabel) track.setAttribute('aria-label', trackLabel); return track; } +function buildPlayPauseButton(pauseLabel) { + return createTag('button', { + class: 'logo-ticker-play-pause is-playing', + 'aria-label': pauseLabel, + 'aria-pressed': 'true', + }, `${PLAY_SVG}${PAUSE_SVG}`); +} + +function initPlayPause(button, el, playLabel, pauseLabel) { + const reducedMotionMQ = window.matchMedia('(prefers-reduced-motion: reduce)'); + let isPlaying = !reducedMotionMQ.matches; + + const update = (playing) => { + isPlaying = playing; + el.style.setProperty('--logo-ticker-play-state', playing ? 'running' : 'paused'); + button.setAttribute('aria-label', playing ? pauseLabel : playLabel); + button.setAttribute('aria-pressed', String(playing)); + button.classList.toggle('is-playing', playing); + }; + + button.addEventListener('click', () => update(!isPlaying)); + reducedMotionMQ.addEventListener('change', ({ matches }) => update(!matches)); + + update(isPlaying); +} + +function addScrollBoost(track, el, getIsPlaying) { + let targetOffset = 0; + let currentOffset = 0; + let lastScrollY = window.scrollY; + let isVisible = false; + let rafId = null; + + new IntersectionObserver(([entry]) => { isVisible = entry.isIntersecting; }).observe(el); + + const step = () => { + const y = window.lenis?.animatedScroll ?? window.scrollY; + const delta = y - lastScrollY; + lastScrollY = y; + + if (getIsPlaying() && !track.classList.contains('is-static') && delta !== 0) { + targetOffset += delta * 0.6; + } + + currentOffset += (targetOffset - currentOffset) * 0.08; + el.style.setProperty('--logo-ticker-scroll-offset', `${currentOffset}px`); + + if (Math.abs(targetOffset - currentOffset) > 0.1) { + rafId = requestAnimationFrame(step); + } else { + rafId = null; + } + }; + + window.addEventListener('scroll', () => { + if (!isVisible || rafId) return; + rafId = requestAnimationFrame(step); + }, { passive: true }); +} + function syncTrackMetrics(track) { const firstSet = track.firstElementChild; if (!firstSet) return; @@ -24,17 +95,22 @@ function syncTrackMetrics(track) { const setWidth = firstSet.offsetWidth; const containerWidth = track.parentElement?.clientWidth || 0; track.classList.toggle('is-static', setWidth + 2 * gap <= containerWidth); + track.style.setProperty('--logo-ticker-set-width', `${setWidth + gap}px`); } export default function init(el) { - const logos = [...el.querySelectorAll('span.icon')]; + const { logos, trackLabel, playLabel, pauseLabel } = parseAuthoring(el); if (!logos.length) return; - const track = buildTrack(el, logos); - el.replaceChildren(track); + const track = buildTrack(logos, trackLabel); + const button = buildPlayPauseButton(pauseLabel); + el.replaceChildren(track, button); syncTrackMetrics(track); const ro = new ResizeObserver(() => syncTrackMetrics(track)); ro.observe(track.firstElementChild); ro.observe(el); + + initPlayPause(button, el, playLabel, pauseLabel); + addScrollBoost(track, el, () => button.classList.contains('is-playing')); } From 4995a0b021c60506ce2170ea6dad9405b9c404ab Mon Sep 17 00:00:00 2001 From: Jingle Huang Date: Mon, 14 Sep 2026 21:01:54 -0700 Subject: [PATCH 02/11] button gap --- libs/c2/blocks/logo-ticker/logo-ticker.css | 39 ++++++++++++++++++---- libs/c2/blocks/logo-ticker/logo-ticker.js | 14 ++++---- 2 files changed, 40 insertions(+), 13 deletions(-) diff --git a/libs/c2/blocks/logo-ticker/logo-ticker.css b/libs/c2/blocks/logo-ticker/logo-ticker.css index 0704b11d862..48f5212d999 100644 --- a/libs/c2/blocks/logo-ticker/logo-ticker.css +++ b/libs/c2/blocks/logo-ticker/logo-ticker.css @@ -3,12 +3,28 @@ --logo-ticker-logo-height: 36px; --logo-ticker-dir: 1; --logo-ticker-duration: 30s; + --logo-ticker-inset: var(--s2a-spacing-24); + --logo-ticker-btn-size: 2rem; + --logo-ticker-mask-width: calc(var(--logo-ticker-inset) * 2 + var(--logo-ticker-btn-size)); position: relative; width: 100%; - overflow: hidden; contain: layout paint; - padding-block: var(--s2a-spacing-24); + padding-block: var(--logo-ticker-inset); + + .logo-ticker-wrap { + width: 100%; + overflow: hidden; + } + + .logo-ticker-wrap:not(:has(.is-static)) { + mask-image: linear-gradient( + to right, + black 0, + black calc(100% - var(--logo-ticker-mask-width) - var(--logo-ticker-inset)), + transparent calc(100% - var(--logo-ticker-mask-width)) + ); + } .logo-ticker-track { display: flex; @@ -54,9 +70,9 @@ position: absolute; top: 50%; translate: 0 -50%; - inset-inline-end: var(--s2a-spacing-24); - width: 2rem; - height: 2rem; + inset-inline-end: var(--logo-ticker-inset); + width: var(--logo-ticker-btn-size); + height: var(--logo-ticker-btn-size); border-radius: 50%; border: 1px solid currentcolor; cursor: pointer; @@ -82,13 +98,22 @@ } } - .logo-ticker-track.is-static ~ .logo-ticker-play-pause { + .logo-ticker-wrap:has(.is-static) ~ .logo-ticker-play-pause { display: none; } } :dir(rtl) .logo-ticker { --logo-ticker-dir: -1; + + .logo-ticker-wrap:not(:has(.is-static)) { + mask-image: linear-gradient( + to left, + black 0, + black calc(100% - var(--logo-ticker-mask-width) - var(--logo-ticker-inset)), + transparent calc(100% - var(--logo-ticker-mask-width)) + ); + } } @media (prefers-reduced-motion: reduce) { @@ -99,7 +124,7 @@ @keyframes logo-ticker-scroll { from { - translate: calc((var(--logo-ticker-scroll-offset, 0px)) * -1 * var(--logo-ticker-dir)); + translate: calc(var(--logo-ticker-scroll-offset, 0px) * -1 * var(--logo-ticker-dir)); } to { diff --git a/libs/c2/blocks/logo-ticker/logo-ticker.js b/libs/c2/blocks/logo-ticker/logo-ticker.js index 860efa55a64..008483c6a05 100644 --- a/libs/c2/blocks/logo-ticker/logo-ticker.js +++ b/libs/c2/blocks/logo-ticker/logo-ticker.js @@ -88,13 +88,13 @@ function addScrollBoost(track, el, getIsPlaying) { }, { passive: true }); } -function syncTrackMetrics(track) { +function syncTrackMetrics(track, el) { const firstSet = track.firstElementChild; if (!firstSet) return; const gap = parseFloat(getComputedStyle(track).columnGap) || 0; const setWidth = firstSet.offsetWidth; - const containerWidth = track.parentElement?.clientWidth || 0; - track.classList.toggle('is-static', setWidth + 2 * gap <= containerWidth); + const isStatic = setWidth + 2 * gap <= el.clientWidth; + track.classList.toggle('is-static', isStatic); track.style.setProperty('--logo-ticker-set-width', `${setWidth + gap}px`); } @@ -103,11 +103,13 @@ export default function init(el) { if (!logos.length) return; const track = buildTrack(logos, trackLabel); + const wrap = createTag('div', { class: 'logo-ticker-wrap' }); + wrap.append(track); const button = buildPlayPauseButton(pauseLabel); - el.replaceChildren(track, button); + el.replaceChildren(wrap, button); - syncTrackMetrics(track); - const ro = new ResizeObserver(() => syncTrackMetrics(track)); + syncTrackMetrics(track, el); + const ro = new ResizeObserver(() => syncTrackMetrics(track, el)); ro.observe(track.firstElementChild); ro.observe(el); From aa6d357dc135e7c90ead5a17799a6c227047c93c Mon Sep 17 00:00:00 2001 From: Jingle Huang Date: Mon, 14 Sep 2026 21:08:54 -0700 Subject: [PATCH 03/11] svg centering --- libs/c2/blocks/logo-ticker/logo-ticker.css | 1 + 1 file changed, 1 insertion(+) diff --git a/libs/c2/blocks/logo-ticker/logo-ticker.css b/libs/c2/blocks/logo-ticker/logo-ticker.css index 48f5212d999..cacfc20fd17 100644 --- a/libs/c2/blocks/logo-ticker/logo-ticker.css +++ b/libs/c2/blocks/logo-ticker/logo-ticker.css @@ -83,6 +83,7 @@ padding: 0; svg { + display: block; width: 1.125rem; height: 1.125rem; } From 9358e4e641330212eb8c94bd189ebb3be74bc43d Mon Sep 17 00:00:00 2001 From: Jingle Huang Date: Tue, 15 Sep 2026 11:03:42 -0700 Subject: [PATCH 04/11] authoring+visual tweaks --- libs/c2/blocks/logo-ticker/logo-ticker.css | 18 ++++++++++++++---- libs/c2/blocks/logo-ticker/logo-ticker.js | 11 ++++------- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/libs/c2/blocks/logo-ticker/logo-ticker.css b/libs/c2/blocks/logo-ticker/logo-ticker.css index cacfc20fd17..bbce6542a6f 100644 --- a/libs/c2/blocks/logo-ticker/logo-ticker.css +++ b/libs/c2/blocks/logo-ticker/logo-ticker.css @@ -73,16 +73,26 @@ inset-inline-end: var(--logo-ticker-inset); width: var(--logo-ticker-btn-size); height: var(--logo-ticker-btn-size); - border-radius: 50%; - border: 1px solid currentcolor; + border-radius: var(--s2a-border-radius-xs); + border: var(--s2a-border-width-sm) solid var(--s2a-color-transparent-white-24); + background: var(--s2a-color-transparent-black-64); + color: var(--s2a-color-gray-25); cursor: pointer; display: flex; align-items: center; justify-content: center; - opacity: 0.8; padding: 0; + transition: background 0.2s ease, transform 0.2s ease; - svg { + &:hover { + background: var(--s2a-color-transparent-black-80); + transform: scale(1.05); + } + + &:active { transform: scale(0.98); } + + .logo-ticker-play-icon, + .logo-ticker-pause-icon { display: block; width: 1.125rem; height: 1.125rem; diff --git a/libs/c2/blocks/logo-ticker/logo-ticker.js b/libs/c2/blocks/logo-ticker/logo-ticker.js index 008483c6a05..bb15beab716 100644 --- a/libs/c2/blocks/logo-ticker/logo-ticker.js +++ b/libs/c2/blocks/logo-ticker/logo-ticker.js @@ -2,16 +2,13 @@ import { createTag } from '../../../utils/utils.js'; const SET_COUNT = 2; -const PLAY_SVG = ''; -const PAUSE_SVG = ''; +const PLAY_SVG = ''; +const PAUSE_SVG = ''; function parseAuthoring(el) { const rows = [...el.children]; const logos = [...rows[0]?.querySelectorAll('span.icon') ?? []]; - const trackLabel = rows[1]?.textContent.trim() ?? ''; - const btnParts = (rows[2]?.textContent ?? '').split('||').map((s) => s.trim()); - const playLabel = btnParts[0] || 'Play'; - const pauseLabel = btnParts[1] || 'Pause'; + const [trackLabel = '', playLabel = 'Play logos', pauseLabel = 'Pause logos'] = (rows[1]?.textContent ?? '').split('||').map((s) => s.trim()); return { logos, trackLabel, playLabel, pauseLabel }; } @@ -33,7 +30,7 @@ function buildPlayPauseButton(pauseLabel) { class: 'logo-ticker-play-pause is-playing', 'aria-label': pauseLabel, 'aria-pressed': 'true', - }, `${PLAY_SVG}${PAUSE_SVG}`); + }, `${PLAY_SVG}${PAUSE_SVG}`); } function initPlayPause(button, el, playLabel, pauseLabel) { From 451156fed5b74f39d9cedd0305f8954835368ac8 Mon Sep 17 00:00:00 2001 From: Jingle Huang Date: Tue, 15 Sep 2026 13:02:28 -0700 Subject: [PATCH 05/11] buttons dark mode --- libs/c2/blocks/logo-ticker/logo-ticker.css | 29 +++- libs/mep/ace1209/logo-ticker/logo-ticker.css | 140 +++++++++++++++---- libs/mep/ace1209/logo-ticker/logo-ticker.js | 99 +++++++++++-- 3 files changed, 219 insertions(+), 49 deletions(-) diff --git a/libs/c2/blocks/logo-ticker/logo-ticker.css b/libs/c2/blocks/logo-ticker/logo-ticker.css index bbce6542a6f..c24bddeeb1c 100644 --- a/libs/c2/blocks/logo-ticker/logo-ticker.css +++ b/libs/c2/blocks/logo-ticker/logo-ticker.css @@ -42,6 +42,11 @@ } } + &.dark svg *, + .dark & svg * { + fill: var(--s2a-color-gray-25); + } + span.icon, .logo-ticker-track svg, picture img { @@ -73,19 +78,19 @@ inset-inline-end: var(--logo-ticker-inset); width: var(--logo-ticker-btn-size); height: var(--logo-ticker-btn-size); - border-radius: var(--s2a-border-radius-xs); - border: var(--s2a-border-width-sm) solid var(--s2a-color-transparent-white-24); - background: var(--s2a-color-transparent-black-64); - color: var(--s2a-color-gray-25); + border-radius: var(--s2a-border-radius-2xs); + border: var(--s2a-border-width-sm) solid var(--s2a-color-transparent-black-08); + background: var(--s2a-color-transparent-black-04); cursor: pointer; display: flex; align-items: center; justify-content: center; padding: 0; - transition: background 0.2s ease, transform 0.2s ease; + transition: background 0.2s ease, border-color 0.2s ease, transform 0.2s ease; &:hover { - background: var(--s2a-color-transparent-black-80); + border-color: var(--s2a-color-transparent-black-16); + background: var(--s2a-color-transparent-black-08); transform: scale(1.05); } @@ -109,6 +114,18 @@ } } + &.dark .logo-ticker-play-pause, + .dark & .logo-ticker-play-pause { + border-radius: var(--s2a-border-radius-6); + border-color: var(--s2a-color-transparent-white-24); + background: var(--s2a-color-transparent-black-64); + color: var(--s2a-color-gray-25); + + &:hover { + background: var(--s2a-color-transparent-black-80); + } + } + .logo-ticker-wrap:has(.is-static) ~ .logo-ticker-play-pause { display: none; } diff --git a/libs/mep/ace1209/logo-ticker/logo-ticker.css b/libs/mep/ace1209/logo-ticker/logo-ticker.css index 992e4d27687..75b0d053eab 100644 --- a/libs/mep/ace1209/logo-ticker/logo-ticker.css +++ b/libs/mep/ace1209/logo-ticker/logo-ticker.css @@ -2,15 +2,29 @@ --logo-ticker-gap: var(--s2a-spacing-80); --logo-ticker-logo-height: 36px; --logo-ticker-dir: 1; - --logo-ticker-drift-distance: 240px; + --logo-ticker-duration: 30s; + --logo-ticker-inset: var(--s2a-spacing-24); + --logo-ticker-btn-size: 2rem; + --logo-ticker-mask-width: calc(var(--logo-ticker-inset) * 2 + var(--logo-ticker-btn-size)); position: relative; width: 100%; - overflow-x: auto; contain: layout paint; - padding-block: var(--s2a-spacing-24); - /* stylelint-disable-next-line property-no-unknown */ - view-timeline: --logo-ticker-view block; + padding-block: var(--logo-ticker-inset); + + .logo-ticker-wrap { + width: 100%; + overflow: hidden; + } + + .logo-ticker-wrap:not(:has(.is-static)) { + mask-image: linear-gradient( + to right, + black 0, + black calc(100% - var(--logo-ticker-mask-width) - var(--logo-ticker-inset)), + transparent calc(100% - var(--logo-ticker-mask-width)) + ); + } .logo-ticker-track { display: flex; @@ -29,7 +43,7 @@ } span.icon, - svg, + .logo-ticker-track svg, picture img { height: var(--logo-ticker-logo-height); width: auto; @@ -41,43 +55,107 @@ fill: var(--s2a-color-gray-25); } + .logo-ticker-track:not(.is-static) { + animation: logo-ticker-scroll var(--logo-ticker-duration) linear infinite; + animation-play-state: var(--logo-ticker-play-state, running); + + .logo-ticker-set:not(:first-child) { + display: flex; + } + } + .logo-ticker-track.is-static { width: 100%; box-sizing: border-box; justify-content: center; padding-inline: var(--logo-ticker-gap); } + + .logo-ticker-play-pause { + position: absolute; + top: 50%; + translate: 0 -50%; + inset-inline-end: var(--logo-ticker-inset); + width: var(--logo-ticker-btn-size); + height: var(--logo-ticker-btn-size); + border-radius: var(--s2a-border-radius-2xs); + border: var(--s2a-border-width-sm) solid var(--s2a-color-transparent-black-08); + background: var(--s2a-color-transparent-black-04); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + transition: background 0.2s ease, border-color 0.2s ease, transform 0.2s ease; + + &:hover { + border-color: var(--s2a-color-transparent-black-16); + background: var(--s2a-color-transparent-black-08); + transform: scale(1.05); + } + + &:active { transform: scale(0.98); } + + .logo-ticker-play-icon, + .logo-ticker-pause-icon { + display: block; + width: 1.125rem; + height: 1.125rem; + } + + &.is-playing .logo-ticker-play-icon, + &:not(.is-playing) .logo-ticker-pause-icon { + display: none; + } + + &:focus-visible { + outline: 2px solid linktext; + outline-offset: 2px; + } + } + + &.dark .logo-ticker-play-pause, + .dark & .logo-ticker-play-pause { + border-radius: var(--s2a-border-radius-6); + border-color: var(--s2a-color-transparent-white-24); + background: var(--s2a-color-transparent-black-64); + color: var(--s2a-color-gray-25); + + &:hover { + background: var(--s2a-color-transparent-black-80); + } + } + + .logo-ticker-wrap:has(.is-static) ~ .logo-ticker-play-pause { + display: none; + } } :dir(rtl) .logo-ticker { --logo-ticker-dir: -1; + + .logo-ticker-wrap:not(:has(.is-static)) { + mask-image: linear-gradient( + to left, + black 0, + black calc(100% - var(--logo-ticker-mask-width) - var(--logo-ticker-inset)), + transparent calc(100% - var(--logo-ticker-mask-width)) + ); + } } -@supports (animation-timeline: view()) { - @media (prefers-reduced-motion: no-preference) { - .logo-ticker { - overflow: clip; - - .logo-ticker-set:not(:first-child) { - display: flex; - } - - .logo-ticker-track.is-static .logo-ticker-set:not(:first-child) { - display: none; - } - - .logo-ticker-track:not(.is-static) { - animation: logo-ticker-drift linear 1; - /* stylelint-disable-next-line property-no-unknown */ - animation-timeline: --logo-ticker-view; - /* stylelint-disable-next-line property-no-unknown */ - animation-range: cover 0% cover 100%; - } - } +@media (prefers-reduced-motion: reduce) { + .logo-ticker { + --logo-ticker-play-state: paused; + } +} - @keyframes logo-ticker-drift { - from { translate: calc(var(--logo-ticker-drift-distance) * var(--logo-ticker-dir)); } - to { translate: calc(calc(-1 * var(--logo-ticker-drift-distance)) * var(--logo-ticker-dir)); } - } +@keyframes logo-ticker-scroll { + from { + translate: calc(var(--logo-ticker-scroll-offset, 0px) * -1 * var(--logo-ticker-dir)); + } + + to { + translate: calc((var(--logo-ticker-set-width, 0px) + var(--logo-ticker-scroll-offset, 0px)) * -1 * var(--logo-ticker-dir)); } } diff --git a/libs/mep/ace1209/logo-ticker/logo-ticker.js b/libs/mep/ace1209/logo-ticker/logo-ticker.js index 89e4ff2b882..bb15beab716 100644 --- a/libs/mep/ace1209/logo-ticker/logo-ticker.js +++ b/libs/mep/ace1209/logo-ticker/logo-ticker.js @@ -2,7 +2,17 @@ import { createTag } from '../../../utils/utils.js'; const SET_COUNT = 2; -function buildTrack(el, logos) { +const PLAY_SVG = ''; +const PAUSE_SVG = ''; + +function parseAuthoring(el) { + const rows = [...el.children]; + const logos = [...rows[0]?.querySelectorAll('span.icon') ?? []]; + const [trackLabel = '', playLabel = 'Play logos', pauseLabel = 'Pause logos'] = (rows[1]?.textContent ?? '').split('||').map((s) => s.trim()); + return { logos, trackLabel, playLabel, pauseLabel }; +} + +function buildTrack(logos, trackLabel) { const logoSets = Array.from({ length: SET_COUNT }, (_, i) => { const logoSet = createTag('div', { class: 'logo-ticker-set' }); if (i > 0) logoSet.setAttribute('aria-hidden', 'true'); @@ -11,30 +21,95 @@ function buildTrack(el, logos) { }); const track = createTag('div', { class: 'logo-ticker-track', role: 'img' }, logoSets); - if (el.children.length >= 2) { - track.setAttribute('aria-label', el.children[1].textContent); - } + if (trackLabel) track.setAttribute('aria-label', trackLabel); return track; } -function syncTrackMetrics(track) { +function buildPlayPauseButton(pauseLabel) { + return createTag('button', { + class: 'logo-ticker-play-pause is-playing', + 'aria-label': pauseLabel, + 'aria-pressed': 'true', + }, `${PLAY_SVG}${PAUSE_SVG}`); +} + +function initPlayPause(button, el, playLabel, pauseLabel) { + const reducedMotionMQ = window.matchMedia('(prefers-reduced-motion: reduce)'); + let isPlaying = !reducedMotionMQ.matches; + + const update = (playing) => { + isPlaying = playing; + el.style.setProperty('--logo-ticker-play-state', playing ? 'running' : 'paused'); + button.setAttribute('aria-label', playing ? pauseLabel : playLabel); + button.setAttribute('aria-pressed', String(playing)); + button.classList.toggle('is-playing', playing); + }; + + button.addEventListener('click', () => update(!isPlaying)); + reducedMotionMQ.addEventListener('change', ({ matches }) => update(!matches)); + + update(isPlaying); +} + +function addScrollBoost(track, el, getIsPlaying) { + let targetOffset = 0; + let currentOffset = 0; + let lastScrollY = window.scrollY; + let isVisible = false; + let rafId = null; + + new IntersectionObserver(([entry]) => { isVisible = entry.isIntersecting; }).observe(el); + + const step = () => { + const y = window.lenis?.animatedScroll ?? window.scrollY; + const delta = y - lastScrollY; + lastScrollY = y; + + if (getIsPlaying() && !track.classList.contains('is-static') && delta !== 0) { + targetOffset += delta * 0.6; + } + + currentOffset += (targetOffset - currentOffset) * 0.08; + el.style.setProperty('--logo-ticker-scroll-offset', `${currentOffset}px`); + + if (Math.abs(targetOffset - currentOffset) > 0.1) { + rafId = requestAnimationFrame(step); + } else { + rafId = null; + } + }; + + window.addEventListener('scroll', () => { + if (!isVisible || rafId) return; + rafId = requestAnimationFrame(step); + }, { passive: true }); +} + +function syncTrackMetrics(track, el) { const firstSet = track.firstElementChild; if (!firstSet) return; const gap = parseFloat(getComputedStyle(track).columnGap) || 0; const setWidth = firstSet.offsetWidth; - const containerWidth = track.parentElement?.clientWidth || 0; - track.classList.toggle('is-static', setWidth + 2 * gap <= containerWidth); + const isStatic = setWidth + 2 * gap <= el.clientWidth; + track.classList.toggle('is-static', isStatic); + track.style.setProperty('--logo-ticker-set-width', `${setWidth + gap}px`); } export default function init(el) { - const logos = [...el.querySelectorAll('span.icon')]; + const { logos, trackLabel, playLabel, pauseLabel } = parseAuthoring(el); if (!logos.length) return; - const track = buildTrack(el, logos); - el.replaceChildren(track); + const track = buildTrack(logos, trackLabel); + const wrap = createTag('div', { class: 'logo-ticker-wrap' }); + wrap.append(track); + const button = buildPlayPauseButton(pauseLabel); + el.replaceChildren(wrap, button); - syncTrackMetrics(track); - const ro = new ResizeObserver(() => syncTrackMetrics(track)); + syncTrackMetrics(track, el); + const ro = new ResizeObserver(() => syncTrackMetrics(track, el)); ro.observe(track.firstElementChild); ro.observe(el); + + initPlayPause(button, el, playLabel, pauseLabel); + addScrollBoost(track, el, () => button.classList.contains('is-playing')); } From 3f83f711c148c8fbbaa5a67e626b613b0569c37e Mon Sep 17 00:00:00 2001 From: Jingle Huang Date: Tue, 15 Sep 2026 13:27:50 -0700 Subject: [PATCH 06/11] handle gap potentially caused by deadscroll --- libs/c2/blocks/logo-ticker/logo-ticker.js | 2 +- libs/mep/ace1209/logo-ticker/logo-ticker.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/c2/blocks/logo-ticker/logo-ticker.js b/libs/c2/blocks/logo-ticker/logo-ticker.js index bb15beab716..1460a372934 100644 --- a/libs/c2/blocks/logo-ticker/logo-ticker.js +++ b/libs/c2/blocks/logo-ticker/logo-ticker.js @@ -1,6 +1,6 @@ import { createTag } from '../../../utils/utils.js'; -const SET_COUNT = 2; +const SET_COUNT = 3; const PLAY_SVG = ''; const PAUSE_SVG = ''; diff --git a/libs/mep/ace1209/logo-ticker/logo-ticker.js b/libs/mep/ace1209/logo-ticker/logo-ticker.js index bb15beab716..1460a372934 100644 --- a/libs/mep/ace1209/logo-ticker/logo-ticker.js +++ b/libs/mep/ace1209/logo-ticker/logo-ticker.js @@ -1,6 +1,6 @@ import { createTag } from '../../../utils/utils.js'; -const SET_COUNT = 2; +const SET_COUNT = 3; const PLAY_SVG = ''; const PAUSE_SVG = ''; From efdd6024f75231b8bbbd86a6f9643aa36f7c4c2c Mon Sep 17 00:00:00 2001 From: Jingle Huang Date: Tue, 15 Sep 2026 14:38:30 -0700 Subject: [PATCH 07/11] fix ut --- test/blocks/logo-ticker/logo-ticker.test.js | 69 ++++++++++++++++++--- 1 file changed, 59 insertions(+), 10 deletions(-) diff --git a/test/blocks/logo-ticker/logo-ticker.test.js b/test/blocks/logo-ticker/logo-ticker.test.js index 0b225eb332d..ce7bc83cb7b 100644 --- a/test/blocks/logo-ticker/logo-ticker.test.js +++ b/test/blocks/logo-ticker/logo-ticker.test.js @@ -4,34 +4,44 @@ import { expect } from '@esm-bundle/chai'; import init from '../../../libs/c2/blocks/logo-ticker/logo-ticker.js'; describe('Logo Ticker', () => { - it('builds a single track (role=img) that replaces the block content', async () => { + it('builds a wrap + play/pause button as the two direct block children', async () => { document.body.innerHTML = await readFile({ path: './mocks/default.html' }); const block = document.querySelector('.logo-ticker'); init(block); - expect(block.children.length).to.equal(1); - const track = block.querySelector(':scope > .logo-ticker-track'); + expect(block.children.length).to.equal(2); + expect(block.querySelector(':scope > .logo-ticker-wrap')).to.exist; + expect(block.querySelector(':scope > .logo-ticker-play-pause')).to.exist; + }); + + it('places the track (role=img) inside the wrap', async () => { + document.body.innerHTML = await readFile({ path: './mocks/default.html' }); + const block = document.querySelector('.logo-ticker'); + init(block); + + const track = block.querySelector('.logo-ticker-wrap > .logo-ticker-track'); expect(track).to.exist; expect(track.getAttribute('role')).to.equal('img'); }); - it('duplicates the logos into two sets, hiding the second from assistive tech', async () => { + it('duplicates logos into three sets, hiding sets 2 and 3 from assistive tech', async () => { document.body.innerHTML = await readFile({ path: './mocks/default.html' }); const block = document.querySelector('.logo-ticker'); init(block); const sets = block.querySelectorAll('.logo-ticker-set'); - expect(sets.length).to.equal(2); + expect(sets.length).to.equal(3); expect(sets[0].hasAttribute('aria-hidden')).to.be.false; expect(sets[1].getAttribute('aria-hidden')).to.equal('true'); + expect(sets[2].getAttribute('aria-hidden')).to.equal('true'); - // each set has a clone of all three logos → six icons total expect(sets[0].querySelectorAll('span.icon').length).to.equal(3); expect(sets[1].querySelectorAll('span.icon').length).to.equal(3); - expect(block.querySelectorAll('span.icon').length).to.equal(6); + expect(sets[2].querySelectorAll('span.icon').length).to.equal(3); + expect(block.querySelectorAll('span.icon').length).to.equal(9); }); - it('labels the track from the second block row', async () => { + it('labels the track from the first segment of the second row', async () => { document.body.innerHTML = await readFile({ path: './mocks/default.html' }); const block = document.querySelector('.logo-ticker'); init(block); @@ -40,7 +50,46 @@ describe('Logo Ticker', () => { .to.equal('Trusted by leading brands'); }); - it('omits the aria-label when there is no second row', async () => { + it('reads play and pause labels from the || segments of the second row', async () => { + document.body.innerHTML = ` +
+

+
Logo row || Start rolling || Stop rolling
+
`; + const block = document.querySelector('.logo-ticker'); + init(block); + + const button = block.querySelector('.logo-ticker-play-pause'); + expect(button.getAttribute('aria-label')).to.equal('Stop rolling'); + }); + + it('play/pause button starts in playing state with correct aria attributes', async () => { + document.body.innerHTML = await readFile({ path: './mocks/default.html' }); + const block = document.querySelector('.logo-ticker'); + init(block); + + const button = block.querySelector('.logo-ticker-play-pause'); + expect(button).to.exist; + expect(button.classList.contains('is-playing')).to.be.true; + expect(button.getAttribute('aria-pressed')).to.equal('true'); + }); + + it('toggles playing state and aria-pressed when the button is clicked', async () => { + document.body.innerHTML = await readFile({ path: './mocks/default.html' }); + const block = document.querySelector('.logo-ticker'); + init(block); + + const button = block.querySelector('.logo-ticker-play-pause'); + button.click(); + expect(button.classList.contains('is-playing')).to.be.false; + expect(button.getAttribute('aria-pressed')).to.equal('false'); + + button.click(); + expect(button.classList.contains('is-playing')).to.be.true; + expect(button.getAttribute('aria-pressed')).to.equal('true'); + }); + + it('omits the track aria-label when there is no second row', async () => { document.body.innerHTML = await readFile({ path: './mocks/no-aria.html' }); const block = document.querySelector('.logo-ticker'); init(block); @@ -48,7 +97,7 @@ describe('Logo Ticker', () => { const track = block.querySelector('.logo-ticker-track'); expect(track).to.exist; expect(track.hasAttribute('aria-label')).to.be.false; - expect(block.querySelectorAll('.logo-ticker-set span.icon').length).to.equal(4); + expect(block.querySelectorAll('.logo-ticker-set span.icon').length).to.equal(6); }); it('does nothing when there are no logos', async () => { From 4a8aaa91c872c65bcbad62545e41f1c6ddf7ac71 Mon Sep 17 00:00:00 2001 From: Jingle Huang Date: Tue, 15 Sep 2026 14:44:57 -0700 Subject: [PATCH 08/11] do clamping --- libs/c2/blocks/logo-ticker/logo-ticker.js | 5 +++-- libs/mep/ace1209/logo-ticker/logo-ticker.js | 5 +++-- test/blocks/logo-ticker/logo-ticker.test.js | 10 ++++------ 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/libs/c2/blocks/logo-ticker/logo-ticker.js b/libs/c2/blocks/logo-ticker/logo-ticker.js index 1460a372934..aab690935f0 100644 --- a/libs/c2/blocks/logo-ticker/logo-ticker.js +++ b/libs/c2/blocks/logo-ticker/logo-ticker.js @@ -1,6 +1,6 @@ import { createTag } from '../../../utils/utils.js'; -const SET_COUNT = 3; +const SET_COUNT = 2; const PLAY_SVG = ''; const PAUSE_SVG = ''; @@ -66,7 +66,8 @@ function addScrollBoost(track, el, getIsPlaying) { lastScrollY = y; if (getIsPlaying() && !track.classList.contains('is-static') && delta !== 0) { - targetOffset += delta * 0.6; + const setWidth = parseFloat(track.style.getPropertyValue('--logo-ticker-set-width')) || Infinity; + targetOffset = Math.min(targetOffset + delta * 0.6, setWidth); } currentOffset += (targetOffset - currentOffset) * 0.08; diff --git a/libs/mep/ace1209/logo-ticker/logo-ticker.js b/libs/mep/ace1209/logo-ticker/logo-ticker.js index 1460a372934..aab690935f0 100644 --- a/libs/mep/ace1209/logo-ticker/logo-ticker.js +++ b/libs/mep/ace1209/logo-ticker/logo-ticker.js @@ -1,6 +1,6 @@ import { createTag } from '../../../utils/utils.js'; -const SET_COUNT = 3; +const SET_COUNT = 2; const PLAY_SVG = ''; const PAUSE_SVG = ''; @@ -66,7 +66,8 @@ function addScrollBoost(track, el, getIsPlaying) { lastScrollY = y; if (getIsPlaying() && !track.classList.contains('is-static') && delta !== 0) { - targetOffset += delta * 0.6; + const setWidth = parseFloat(track.style.getPropertyValue('--logo-ticker-set-width')) || Infinity; + targetOffset = Math.min(targetOffset + delta * 0.6, setWidth); } currentOffset += (targetOffset - currentOffset) * 0.08; diff --git a/test/blocks/logo-ticker/logo-ticker.test.js b/test/blocks/logo-ticker/logo-ticker.test.js index ce7bc83cb7b..5f2be9bc083 100644 --- a/test/blocks/logo-ticker/logo-ticker.test.js +++ b/test/blocks/logo-ticker/logo-ticker.test.js @@ -24,21 +24,19 @@ describe('Logo Ticker', () => { expect(track.getAttribute('role')).to.equal('img'); }); - it('duplicates logos into three sets, hiding sets 2 and 3 from assistive tech', async () => { + it('duplicates logos into two sets, hiding set 2 from assistive tech', async () => { document.body.innerHTML = await readFile({ path: './mocks/default.html' }); const block = document.querySelector('.logo-ticker'); init(block); const sets = block.querySelectorAll('.logo-ticker-set'); - expect(sets.length).to.equal(3); + expect(sets.length).to.equal(2); expect(sets[0].hasAttribute('aria-hidden')).to.be.false; expect(sets[1].getAttribute('aria-hidden')).to.equal('true'); - expect(sets[2].getAttribute('aria-hidden')).to.equal('true'); expect(sets[0].querySelectorAll('span.icon').length).to.equal(3); expect(sets[1].querySelectorAll('span.icon').length).to.equal(3); - expect(sets[2].querySelectorAll('span.icon').length).to.equal(3); - expect(block.querySelectorAll('span.icon').length).to.equal(9); + expect(block.querySelectorAll('span.icon').length).to.equal(6); }); it('labels the track from the first segment of the second row', async () => { @@ -97,7 +95,7 @@ describe('Logo Ticker', () => { const track = block.querySelector('.logo-ticker-track'); expect(track).to.exist; expect(track.hasAttribute('aria-label')).to.be.false; - expect(block.querySelectorAll('.logo-ticker-set span.icon').length).to.equal(6); + expect(block.querySelectorAll('.logo-ticker-set span.icon').length).to.equal(4); }); it('does nothing when there are no logos', async () => { From c73da69f3c8ea49a7ca9b2494758e72cc93e0655 Mon Sep 17 00:00:00 2001 From: Jingle Huang Date: Tue, 15 Sep 2026 14:57:25 -0700 Subject: [PATCH 09/11] clamp+3sets --- libs/c2/blocks/logo-ticker/logo-ticker.js | 2 +- libs/mep/ace1209/logo-ticker/logo-ticker.js | 2 +- test/blocks/logo-ticker/logo-ticker.test.js | 10 ++++++---- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/libs/c2/blocks/logo-ticker/logo-ticker.js b/libs/c2/blocks/logo-ticker/logo-ticker.js index aab690935f0..bf1024e6e23 100644 --- a/libs/c2/blocks/logo-ticker/logo-ticker.js +++ b/libs/c2/blocks/logo-ticker/logo-ticker.js @@ -1,6 +1,6 @@ import { createTag } from '../../../utils/utils.js'; -const SET_COUNT = 2; +const SET_COUNT = 3; const PLAY_SVG = ''; const PAUSE_SVG = ''; diff --git a/libs/mep/ace1209/logo-ticker/logo-ticker.js b/libs/mep/ace1209/logo-ticker/logo-ticker.js index aab690935f0..bf1024e6e23 100644 --- a/libs/mep/ace1209/logo-ticker/logo-ticker.js +++ b/libs/mep/ace1209/logo-ticker/logo-ticker.js @@ -1,6 +1,6 @@ import { createTag } from '../../../utils/utils.js'; -const SET_COUNT = 2; +const SET_COUNT = 3; const PLAY_SVG = ''; const PAUSE_SVG = ''; diff --git a/test/blocks/logo-ticker/logo-ticker.test.js b/test/blocks/logo-ticker/logo-ticker.test.js index 5f2be9bc083..ce7bc83cb7b 100644 --- a/test/blocks/logo-ticker/logo-ticker.test.js +++ b/test/blocks/logo-ticker/logo-ticker.test.js @@ -24,19 +24,21 @@ describe('Logo Ticker', () => { expect(track.getAttribute('role')).to.equal('img'); }); - it('duplicates logos into two sets, hiding set 2 from assistive tech', async () => { + it('duplicates logos into three sets, hiding sets 2 and 3 from assistive tech', async () => { document.body.innerHTML = await readFile({ path: './mocks/default.html' }); const block = document.querySelector('.logo-ticker'); init(block); const sets = block.querySelectorAll('.logo-ticker-set'); - expect(sets.length).to.equal(2); + expect(sets.length).to.equal(3); expect(sets[0].hasAttribute('aria-hidden')).to.be.false; expect(sets[1].getAttribute('aria-hidden')).to.equal('true'); + expect(sets[2].getAttribute('aria-hidden')).to.equal('true'); expect(sets[0].querySelectorAll('span.icon').length).to.equal(3); expect(sets[1].querySelectorAll('span.icon').length).to.equal(3); - expect(block.querySelectorAll('span.icon').length).to.equal(6); + expect(sets[2].querySelectorAll('span.icon').length).to.equal(3); + expect(block.querySelectorAll('span.icon').length).to.equal(9); }); it('labels the track from the first segment of the second row', async () => { @@ -95,7 +97,7 @@ describe('Logo Ticker', () => { const track = block.querySelector('.logo-ticker-track'); expect(track).to.exist; expect(track.hasAttribute('aria-label')).to.be.false; - expect(block.querySelectorAll('.logo-ticker-set span.icon').length).to.equal(4); + expect(block.querySelectorAll('.logo-ticker-set span.icon').length).to.equal(6); }); it('does nothing when there are no logos', async () => { From 7fcbf129425ee558db8f19e910f48b3692f78f84 Mon Sep 17 00:00:00 2001 From: Jingle Huang Date: Tue, 15 Sep 2026 16:11:06 -0700 Subject: [PATCH 10/11] clamp both sides --- libs/c2/blocks/logo-ticker/logo-ticker.js | 2 +- libs/mep/ace1209/logo-ticker/logo-ticker.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/c2/blocks/logo-ticker/logo-ticker.js b/libs/c2/blocks/logo-ticker/logo-ticker.js index bf1024e6e23..93334677834 100644 --- a/libs/c2/blocks/logo-ticker/logo-ticker.js +++ b/libs/c2/blocks/logo-ticker/logo-ticker.js @@ -67,7 +67,7 @@ function addScrollBoost(track, el, getIsPlaying) { if (getIsPlaying() && !track.classList.contains('is-static') && delta !== 0) { const setWidth = parseFloat(track.style.getPropertyValue('--logo-ticker-set-width')) || Infinity; - targetOffset = Math.min(targetOffset + delta * 0.6, setWidth); + targetOffset = Math.max(-setWidth, Math.min(targetOffset + delta * 0.6, setWidth)); } currentOffset += (targetOffset - currentOffset) * 0.08; diff --git a/libs/mep/ace1209/logo-ticker/logo-ticker.js b/libs/mep/ace1209/logo-ticker/logo-ticker.js index bf1024e6e23..93334677834 100644 --- a/libs/mep/ace1209/logo-ticker/logo-ticker.js +++ b/libs/mep/ace1209/logo-ticker/logo-ticker.js @@ -67,7 +67,7 @@ function addScrollBoost(track, el, getIsPlaying) { if (getIsPlaying() && !track.classList.contains('is-static') && delta !== 0) { const setWidth = parseFloat(track.style.getPropertyValue('--logo-ticker-set-width')) || Infinity; - targetOffset = Math.min(targetOffset + delta * 0.6, setWidth); + targetOffset = Math.max(-setWidth, Math.min(targetOffset + delta * 0.6, setWidth)); } currentOffset += (targetOffset - currentOffset) * 0.08; From d2df97bc7bc6f54686bc0675ac3b649b75fedf88 Mon Sep 17 00:00:00 2001 From: Jingle Huang Date: Wed, 16 Sep 2026 07:49:48 -0700 Subject: [PATCH 11/11] =?UTF-8?q?tokenize=20=C6=92ocus=20outline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- libs/c2/blocks/logo-ticker/logo-ticker.css | 2 +- libs/mep/ace1209/logo-ticker/logo-ticker.css | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/c2/blocks/logo-ticker/logo-ticker.css b/libs/c2/blocks/logo-ticker/logo-ticker.css index c24bddeeb1c..2a432c7cf71 100644 --- a/libs/c2/blocks/logo-ticker/logo-ticker.css +++ b/libs/c2/blocks/logo-ticker/logo-ticker.css @@ -109,7 +109,7 @@ } &:focus-visible { - outline: 2px solid linktext; + outline: 2px solid var(--s2a-color-focus-ring-default); outline-offset: 2px; } } diff --git a/libs/mep/ace1209/logo-ticker/logo-ticker.css b/libs/mep/ace1209/logo-ticker/logo-ticker.css index 75b0d053eab..83288d8b269 100644 --- a/libs/mep/ace1209/logo-ticker/logo-ticker.css +++ b/libs/mep/ace1209/logo-ticker/logo-ticker.css @@ -109,7 +109,7 @@ } &:focus-visible { - outline: 2px solid linktext; + outline: 2px solid var(--s2a-color-focus-ring-default); outline-offset: 2px; } }