@@ -237,6 +237,186 @@ test.describe("Podcast Player E2E", () => {
237237 } ) ;
238238 } ) ;
239239
240+ test . describe ( "Footer title scrolling" , ( ) => {
241+ /** Synthesize a podcast-play event that activates the footer with the
242+ * given title. Used to drive the marquee code without depending on a
243+ * specific example post having a long title. */
244+ const synthesizePlay = async ( page , title ) => {
245+ await page . evaluate ( ( t ) => {
246+ const footer = document . querySelector ( "podcast-footer" ) ;
247+ if ( footer ) footer . setAttribute ( "active" , "" ) ;
248+ document . dispatchEvent ( new CustomEvent ( "podcast-play" , {
249+ detail : {
250+ src : "https://example.com/test.mp3" ,
251+ title : t ,
252+ poster : "" ,
253+ } ,
254+ } ) ) ;
255+ } , title ) ;
256+ // Give the footer's requestAnimationFrame a chance to run.
257+ await page . waitForTimeout ( 150 ) ;
258+ } ;
259+
260+ test ( "footer shadow DOM contains a .title-text span inside .title" , async ( { page } ) => {
261+ await page . goto ( "posts/test-episode/" ) ;
262+ const footer = page . locator ( "podcast-footer" ) ;
263+ await expect ( footer ) . toBeAttached ( ) ;
264+
265+ const hasInner = await footer . evaluate ( ( el ) => {
266+ const title = el . shadowRoot ?. querySelector ( ".title" ) ;
267+ const inner = el . shadowRoot ?. querySelector ( ".title-text" ) ;
268+ return ! ! title && ! ! inner && title . contains ( inner ) ;
269+ } ) ;
270+ expect ( hasInner ) . toBe ( true ) ;
271+ } ) ;
272+
273+ test ( "short title does not set data-overflow on the footer title" , async ( { page } ) => {
274+ await page . goto ( "posts/test-episode/" ) ;
275+ const footer = page . locator ( "podcast-footer" ) ;
276+ await expect ( footer ) . toBeAttached ( ) ;
277+
278+ // Use a known short title — the footer's available width is narrow
279+ // enough that even the example post's "Episode 42: Hello World"
280+ // overflows. Synthesizing the event with a deterministic short
281+ // string makes the assertion independent of the post content.
282+ await synthesizePlay ( page , "Hi" ) ;
283+
284+ const result = await footer . evaluate ( ( el ) => {
285+ const title = el . shadowRoot ?. querySelector ( ".title" ) ;
286+ return {
287+ hasOverflow : title ?. hasAttribute ( "data-overflow" ) ?? false ,
288+ distance : title ?. style . getPropertyValue ( "--marquee-distance" ) ?? "" ,
289+ } ;
290+ } ) ;
291+ expect ( result . hasOverflow ) . toBe ( false ) ;
292+ expect ( result . distance ) . toBe ( "" ) ;
293+ } ) ;
294+
295+ test ( "long title sets data-overflow, CSS vars, and runs the marquee animation" , async ( { page } ) => {
296+ await page . goto ( "posts/test-episode/" ) ;
297+ const footer = page . locator ( "podcast-footer" ) ;
298+ await expect ( footer ) . toBeAttached ( ) ;
299+
300+ // Synthesize a long title to force overflow.
301+ const longTitle = "A" . repeat ( 500 ) + " — this is a very long episode title to force overflow" ;
302+ await synthesizePlay ( page , longTitle ) ;
303+
304+ const result = await footer . evaluate ( ( el ) => {
305+ const title = el . shadowRoot ?. querySelector ( ".title" ) ;
306+ const titleText = el . shadowRoot ?. querySelector ( ".title-text" ) ;
307+ if ( ! title || ! titleText ) return null ;
308+ const cs = getComputedStyle ( titleText ) ;
309+ return {
310+ hasOverflow : title . hasAttribute ( "data-overflow" ) ,
311+ distance : title . style . getPropertyValue ( "--marquee-distance" ) ,
312+ duration : title . style . getPropertyValue ( "--marquee-duration" ) ,
313+ animationName : cs . animationName ,
314+ animationPlayState : cs . animationPlayState ,
315+ } ;
316+ } ) ;
317+
318+ expect ( result ) . not . toBeNull ( ) ;
319+ expect ( result . hasOverflow ) . toBe ( true ) ;
320+ expect ( result . distance ) . not . toBe ( "" ) ;
321+ expect ( result . duration ) . not . toBe ( "" ) ;
322+ expect ( result . animationName ) . toBe ( "marquee" ) ;
323+ expect ( result . animationPlayState ) . toBe ( "running" ) ;
324+ } ) ;
325+
326+ test ( "prefers-reduced-motion: reduce disables the marquee animation" , async ( { page } ) => {
327+ // Opt into reduced motion before the page loads so the CSS media
328+ // query resolves to "reduce" for the duration of the test.
329+ await page . emulateMedia ( { reducedMotion : "reduce" } ) ;
330+
331+ await page . goto ( "posts/test-episode/" ) ;
332+ const footer = page . locator ( "podcast-footer" ) ;
333+ await expect ( footer ) . toBeAttached ( ) ;
334+
335+ const longTitle = "A" . repeat ( 500 ) + " — long title under reduced motion" ;
336+ await synthesizePlay ( page , longTitle ) ;
337+
338+ const result = await footer . evaluate ( ( el ) => {
339+ const title = el . shadowRoot ?. querySelector ( ".title" ) ;
340+ const titleText = el . shadowRoot ?. querySelector ( ".title-text" ) ;
341+ if ( ! title || ! titleText ) return null ;
342+ const cs = getComputedStyle ( titleText ) ;
343+ return {
344+ hasOverflow : title . hasAttribute ( "data-overflow" ) ,
345+ animationName : cs . animationName ,
346+ } ;
347+ } ) ;
348+
349+ expect ( result ) . not . toBeNull ( ) ;
350+ // Detection still runs — the attribute is set.
351+ expect ( result . hasOverflow ) . toBe ( true ) ;
352+ // But the animation is suppressed.
353+ expect ( result . animationName ) . toBe ( "none" ) ;
354+ } ) ;
355+
356+ test ( "marquee state survives a page navigation (Turbolinks permanent)" , async ( { page } ) => {
357+ await page . goto ( "posts/test-episode/" ) ;
358+ const footer = page . locator ( "podcast-footer" ) ;
359+ await expect ( footer ) . toBeAttached ( ) ;
360+
361+ const longTitle = "A" . repeat ( 500 ) + " — long title that must survive navigation" ;
362+ await synthesizePlay ( page , longTitle ) ;
363+
364+ // Sanity: the marquee is active before navigation.
365+ const before = await footer . evaluate ( ( el ) => {
366+ const title = el . shadowRoot ?. querySelector ( ".title" ) ;
367+ return {
368+ hasOverflow : title ?. hasAttribute ( "data-overflow" ) ?? false ,
369+ distance : title ?. style . getPropertyValue ( "--marquee-distance" ) ?? "" ,
370+ } ;
371+ } ) ;
372+ expect ( before . hasOverflow ) . toBe ( true ) ;
373+ expect ( before . distance ) . not . toBe ( "" ) ;
374+
375+ // Navigate via a header link (Turbolinks).
376+ await page . locator ( 'header nav a' , { hasText : 'Programs' } ) . click ( ) ;
377+ await page . waitForTimeout ( 1000 ) ;
378+
379+ // Footer is preserved (data-turbolinks-permanent) and still active.
380+ await expect ( footer ) . toHaveAttribute ( "active" , "" ) ;
381+ const after = await footer . evaluate ( ( el ) => {
382+ const title = el . shadowRoot ?. querySelector ( ".title" ) ;
383+ return {
384+ hasOverflow : title ?. hasAttribute ( "data-overflow" ) ?? false ,
385+ distance : title ?. style . getPropertyValue ( "--marquee-distance" ) ?? "" ,
386+ } ;
387+ } ) ;
388+ expect ( after . hasOverflow ) . toBe ( true ) ;
389+ expect ( after . distance ) . not . toBe ( "" ) ;
390+ } ) ;
391+
392+ test ( "hovering the title pauses the marquee animation" , async ( { page } ) => {
393+ await page . goto ( "posts/test-episode/" ) ;
394+ const footer = page . locator ( "podcast-footer" ) ;
395+ await expect ( footer ) . toBeAttached ( ) ;
396+
397+ const longTitle = "A" . repeat ( 500 ) + " — long title for hover pause test" ;
398+ await synthesizePlay ( page , longTitle ) ;
399+
400+ // Sanity: the animation is running before hover.
401+ const before = await footer . evaluate ( ( el ) => {
402+ const titleText = el . shadowRoot ?. querySelector ( ".title-text" ) ;
403+ return getComputedStyle ( titleText ) . animationPlayState ;
404+ } ) ;
405+ expect ( before ) . toBe ( "running" ) ;
406+
407+ // Hover the title element (the part="title" exposes the parent .title
408+ // so the :hover rule from the shadow stylesheet applies).
409+ await footer . locator ( "[part='title']" ) . hover ( ) ;
410+
411+ // The :hover rule sets animation-play-state: paused.
412+ const after = await footer . evaluate ( ( el ) => {
413+ const titleText = el . shadowRoot ?. querySelector ( ".title-text" ) ;
414+ return getComputedStyle ( titleText ) . animationPlayState ;
415+ } ) ;
416+ expect ( after ) . toBe ( "paused" ) ;
417+ } ) ;
418+ } ) ;
419+
240420 test . describe ( "Mobile Responsive" , ( ) => {
241421 test ( "time-duration stays inside player bounds on narrow viewports" , async ( { page } ) => {
242422 // Test with a narrow mobile viewport (iPhone SE width)
0 commit comments