Repository: tennitech/juke
Pull request #176: feat: Add oscillating scrollable text for long titles/artists
Author: login: tennitech
Labels: name: enhancement
description: New feature or request
name: front-end
description: Front-end related tasks
Created: 2025-04-12T00:18:22Z | Merged: 2025-04-18T16:29:44Z

### What's this about?
This PR fixes issues where long song titles or artist names could get cut off or break the layout in the player. It introduces scrolling text for these cases.

### What's changed?
- Added a new ScrollableText component to handle text overflow.
- Long text now scrolls back and forth smoothly instead of just being cut off.
- Scrolling only happens when the text is actually too long for the available space.
- The text pauses for 3 seconds at the beginning and end of its scroll path.
- A subtle fade effect is applied to the edges during scrolling, appearing and disappearing smoothly with the movement.
- Updated the placeholder text for title/artist to "Sign-In Required" when not logged in (previously "Unknown").
- Integrated the new component into the Home page for song titles and artist names.

### Result:
Long song titles and artist names are now displayed nicely without breaking the UI. The scrolling makes the full text readable while the fade effect keeps it looking clean.

### Testing:
- [ ] Play songs with long titles/artists and verify they scroll correctly with pauses and fades.
- [ ] Play songs with short titles/artists and verify they don't scroll.
- [ ] Check that scrolling restarts properly when changing songs.
- [ ] Resize the window to ensure responsiveness.
- [ ] Log out and verify the "Sign-In Required" text appears.

Closes issue https://github.com/tennitech/juke/issues/125.


Diff:
diff --git a/jukeos/src/components/Home.js b/jukeos/src/components/Home.js
index 2b5d1f4..97518de 100644
--- a/jukeos/src/components/Home.js
+++ b/jukeos/src/components/Home.js
@@ -4,6 +4,7 @@ import { PlayerContext } from './Player';
 import defaultAlbumArt from '../assets/default-art-placeholder.svg';
 import '../App.css';
 import AnimatedBlob from './AnimatedBlob';
+import ScrollableText from './ScrollableText';
 import cloudsSvg from '../assets/clouds.svg';
 import playIcon from '../assets/play-icon.svg';
 import pauseIcon from '../assets/pause-icon.svg';
@@ -236,32 +237,35 @@ const Home = () => {
             paddingLeft: 'clamp(20px, 4vw, 60px)',
             flex: '1 1 auto'
           }}>
-            <h1 style={{
-              fontFamily: 'Loubag, sans-serif',
-              fontSize: 'clamp(2.2rem, 4vw, 4rem)',
-              margin: '0',
-              textAlign: 'left',
-              color: '#ECE0C4',
-              textShadow: `
-                2px 2px 0 rgba(255,0,0,0.2),
-                -2px -2px 0 rgba(0,0,255,0.2),
-                1px -1px 0 rgba(255,0,255,0.2)
-              `,
-              animation: 'textGlitch 3s infinite'
-            }}>
-              {track?.name || "Unknown"}
-            </h1>
-
-            <h2 style={{
-              fontFamily: 'Notable, sans-serif',
-              fontSize: 'clamp(1.2rem, 2vw, 1.6rem)',
-              margin: '0',
-              opacity: 0.9,
-              letterSpacing: '1px',
-              color: 'white'
-            }}>
-              {track?.artists?.map(artist => artist.name)?.join(", ") || "Unknown"}
-            </h2>
+            <ScrollableText
+              text={track?.name || "Welcome to Juke"}
+              speed="title"
+              style={{
+                fontFamily: 'Loubag, sans-serif',
+                fontSize: 'clamp(2.2rem, 4vw, 4rem)',
+                margin: '0',
+                textAlign: 'left',
+                color: '#ECE0C4',
+                textShadow: `
+                  2px 2px 0 rgba(255,0,0,0.2),
+                  -2px -2px 0 rgba(0,0,255,0.2),
+                  1px -1px 0 rgba(255,0,255,0.2)
+                `,
+              }}
+            />
+
+            <ScrollableText
+              text={track?.artists?.map(artist => artist.name)?.join(", ") || "Your Music, Reimagined"}
+              speed="artist"
+              style={{
+                fontFamily: 'Notable, sans-serif',
+                fontSize: 'clamp(1.2rem, 2vw, 1.6rem)',
+                margin: '0',
+                opacity: 0.9,
+                letterSpacing: '1px',
+                color: 'white'
+              }}
+            />
 
             <div style={{
               display: 'flex',
diff --git a/jukeos/src/components/ScrollableText.css b/jukeos/src/components/ScrollableText.css
new file mode 100644
index 0000000..7ec2137
--- /dev/null
+++ b/jukeos/src/components/ScrollableText.css
@@ -0,0 +1,153 @@
+/* The container clips overflowing content and applies a fade on edges */
+.scrollable-text-container {
+  position: relative;
+  width: 100%;
+  overflow: hidden;
+  white-space: nowrap;
+}
+
+/* Apply very subtle fade on left edge when preparing */
+.scrollable-text-container.is-preparing {
+  -webkit-mask-image: linear-gradient(to right, rgba(0,0,0,0.95) 0%, black 10%, black 95%, transparent 100%);
+  mask-image: linear-gradient(to right, rgba(0,0,0,0.95) 0%, black 10%, black 95%, transparent 100%);
+}
+
+/* Apply stronger fade on both edges when scrolling */
+.scrollable-text-container.is-scrolling {
+  -webkit-mask-image: linear-gradient(to right, transparent 0%, black 5%, black 95%, transparent 100%);
+  mask-image: linear-gradient(to right, transparent 0%, black 5%, black 95%, transparent 100%);
+}
+
+/* Keep left fade active when text is at scrolled position */
+.scrollable-text-container.is-scrolled {
+  -webkit-mask-image: linear-gradient(to right, transparent 0%, black 5%, black 95%, transparent 100%);
+  mask-image: linear-gradient(to right, transparent 0%, black 5%, black 95%, transparent 100%);
+}
+
+/* Wrapper for the scrolling content - constrains the viewable area */
+.scrolling-wrapper {
+  position: relative;
+  width: 100%;
+  overflow: hidden;
+}
+
+/* Main scrolling container */
+.scrolling-content {
+  position: relative;
+  display: inline-block;
+  width: 100%;
+}
+
+/* Scrollable text element - controlled by JavaScript for animation */
+.scrollable-text {
+  display: inline-block;
+  position: relative;
+  white-space: nowrap;
+  will-change: transform;
+}
+
+/* Fade masks - only show on the side where text is moving away from */
+.scrollable-text-container::before,
+.scrollable-text-container::after {
+  content: '';
+  position: absolute;
+  top: 0;
+  bottom: 0;
+  width: 40px;
+  pointer-events: none;
+  z-index: 1;
+  transition: opacity 0.3s ease;
+}
+
+/* Left fade - visible when scrolling right or at start */
+.scrollable-text-container::before {
+  left: 0;
+  background: linear-gradient(to right, var(--background-color) 0%, transparent 100%);
+  opacity: 0;
+}
+
+/* Right fade - visible when scrolling left or at end */
+.scrollable-text-container::after {
+  right: 0;
+  background: linear-gradient(to left, var(--background-color) 0%, transparent 100%);
+  opacity: 0;
+}
+
+/* Default state - no fade */
+.scrollable-text-container {
+  -webkit-mask-image: none;
+  mask-image: none;
+}
+
+/* When scrolling - both fades active */
+.scrollable-text-container.is-scrolling {
+  -webkit-mask-image: linear-gradient(to right, transparent 0%, black 5%, black 95%, transparent 100%);
+  mask-image: linear-gradient(to right, transparent 0%, black 5%, black 95%, transparent 100%);
+}
+
+/* When at start - only right fade */
+.scrollable-text-container[data-phase="pause-start"] {
+  -webkit-mask-image: linear-gradient(to right, black 0%, black 95%, transparent 100%);
+  mask-image: linear-gradient(to right, black 0%, black 95%, transparent 100%);
+}
+
+/* When at end - only left fade */
+.scrollable-text-container[data-phase="pause-end"] {
+  -webkit-mask-image: linear-gradient(to right, transparent 0%, black 5%, black 100%);
+  mask-image: linear-gradient(to right, transparent 0%, black 5%, black 100%);
+}
+
+/* Show fade during initial pause */
+.scrollable-text-container[data-phase="initial-pause"] {
+  -webkit-mask-image: linear-gradient(to right, black 0%, black 95%, transparent 100%);
+  mask-image: linear-gradient(to right, black 0%, black 95%, transparent 100%);
+}
+
+/* Show left fade when scrolling right */
+.scrollable-text-container.is-scrolling[data-direction="1"]::before {
+  opacity: 1;
+}
+
+/* Show right fade when scrolling left */
+.scrollable-text-container.is-scrolling[data-direction="-1"]::after {
+  opacity: 1;
+}
+
+/* Keep left fade when at start position */
+.scrollable-text-container[data-phase="pause-start"]::before {
+  opacity: 1;
+}
+
+/* Keep right fade when at end position */
+.scrollable-text-container[data-phase="pause-end"]::after {
+  opacity: 1;
+}
+
+/* Hover state styles */
+.scrollable-text-container.is-hovered {
+  -webkit-mask-image: linear-gradient(to right, transparent 0%, black 5%, black 95%, transparent 100%);
+  mask-image: linear-gradient(to right, transparent 0%, black 5%, black 95%, transparent 100%);
+}
+
+/* When hovering at start position */
+.scrollable-text-container.is-hovered[data-phase="pause-start"] {
+  -webkit-mask-image: linear-gradient(to right, black 0%, black 95%, transparent 100%);
+  mask-image: linear-gradient(to right, black 0%, black 95%, transparent 100%);
+}
+
+/* When hovering at end position */
+.scrollable-text-container.is-hovered[data-phase="pause-end"] {
+  -webkit-mask-image: linear-gradient(to right, transparent 0%, black 5%, black 100%);
+  mask-image: linear-gradient(to right, transparent 0%, black 5%, black 100%);
+}
+
+.scrollable-text-container.is-hovered .scrollable-text {
+  transition: transform 0.3s ease-out;
+}
+
+/* Touch device styles */
+@media (hover: none) {
+  .scrollable-text-container {
+    cursor: pointer;
+  }
+} 
\ No newline at end of file
diff --git a/jukeos/src/components/ScrollableText.js b/jukeos/src/components/ScrollableText.js
new file mode 100644
index 0000000..b98c051
--- /dev/null
+++ b/jukeos/src/components/ScrollableText.js
@@ -0,0 +1,231 @@
+import React, { useEffect, useRef, useState } from 'react';
+import './ScrollableText.css';
+
+const ScrollableText = ({ text, style, className = '', speed = 'title' }) => {
+  const containerRef = useRef(null);
+  const textRef = useRef(null);
+  const animationRef = useRef(null);
+  const [shouldScroll, setShouldScroll] = useState(false);
+  const [position, setPosition] = useState(0);
+  const [direction, setDirection] = useState(1);
+  const [phase, setPhase] = useState('initial-pause');
+  const [maxScroll, setMaxScroll] = useState(0);
+  const [isHovered, setIsHovered] = useState(false);
+  const lastTimestampRef = useRef(null);
+  const phaseStartTimeRef = useRef(null);
+  
+  // Constants for animation speed
+  const PIXELS_PER_SECOND = speed === 'title' ? 80 : 40;
+  const PAUSE_DURATION = 3000; // 3 seconds
+  const FADE_DURATION = 800;
+  
+  // Check if text is too long for container
+  const checkOverflow = () => {
+    if (!containerRef.current || !textRef.current) return;
+    
+    const containerWidth = containerRef.current.clientWidth;
+    const textWidth = textRef.current.scrollWidth;
+    const needsScroll = textWidth > containerWidth + 20;
+    
+    setShouldScroll(needsScroll);
+    
+    if (needsScroll) {
+      const overflow = textWidth - containerWidth;
+      const visiblePortion = Math.max(containerWidth * 0.4, 150);
+      const scrollDistance = Math.min(overflow, textWidth - visiblePortion);
+      setMaxScroll(scrollDistance);
+      setPosition(0);
+    }
+  };
+
+  // Handle hover events
+  const handleMouseEnter = () => {
+    if (shouldScroll) {
+      setIsHovered(true);
+      if (animationRef.current) {
+        cancelAnimationFrame(animationRef.current);
+      }
+    }
+  };
+
+  const handleMouseLeave = () => {
+    if (shouldScroll) {
+      setIsHovered(false);
+      lastTimestampRef.current = null;
+      phaseStartTimeRef.current = null;
+      startAnimation();
+    }
+  };
+
+  const startAnimation = () => {
+    if (!shouldScroll || isHovered) return;
+
+    const animate = (timestamp) => {
+      if (!lastTimestampRef.current) {
+        lastTimestampRef.current = timestamp;
+        phaseStartTimeRef.current = timestamp;
+        animationRef.current = requestAnimationFrame(animate);
+        return;
+      }
+
+      const elapsed = timestamp - lastTimestampRef.current;
+      lastTimestampRef.current = timestamp;
+
+      switch (phase) {
+        case 'initial-pause':
+          if (timestamp - phaseStartTimeRef.current >= PAUSE_DURATION) {
+            setPhase('scrolling');
+            phaseStartTimeRef.current = timestamp;
+          }
+          break;
+
+        case 'scrolling':
+          let newPos = position + (direction * PIXELS_PER_SECOND * elapsed / 1000);
+
+          if (direction > 0 && newPos >= maxScroll) {
+            newPos = maxScroll;
+            setPhase('pause-end');
+            phaseStartTimeRef.current = timestamp;
+          } else if (direction < 0 && newPos <= 0) {
+            newPos = 0;
+            setPhase('pause-start');
+            phaseStartTimeRef.current = timestamp;
+          }
+
+          setPosition(newPos);
+          break;
+
+        case 'pause-end':
+          if (timestamp - phaseStartTimeRef.current >= PAUSE_DURATION) {
+            setDirection(-1);
+            setPhase('scrolling');
+            phaseStartTimeRef.current = timestamp;
+          }
+          break;
+
+        case 'pause-start':
+          if (timestamp - phaseStartTimeRef.current >= PAUSE_DURATION) {
+            setDirection(1);
+            setPhase('scrolling');
+            phaseStartTimeRef.current = timestamp;
+          }
+          break;
+      }
+
+      animationRef.current = requestAnimationFrame(animate);
+    };
+
+    animationRef.current = requestAnimationFrame(animate);
+  };
+  
+  // Handle animation lifecycle
+  useEffect(() => {
+    setPosition(0);
+    setDirection(1);
+    setPhase('initial-pause');
+    lastTimestampRef.current = null;
+    phaseStartTimeRef.current = null;
+    
+    checkOverflow();
+    
+    const initialTimer = setTimeout(() => {
+      checkOverflow();
+    }, 100);
+    
+    const fontTimer = setTimeout(() => {
+      checkOverflow();
+    }, 1000);
+    
+    return () => {
+      clearTimeout(initialTimer);
+      clearTimeout(fontTimer);
+      if (animationRef.current) {
+        cancelAnimationFrame(animationRef.current);
+      }
+    };
+  }, [text]);
+  
+  // Handle window resize
+  useEffect(() => {
+    const handleResize = () => {
+      checkOverflow();
+    };
+    
+    window.addEventListener('resize', handleResize);
+    return () => window.removeEventListener('resize', handleResize);
+  }, []);
+  
+  // Main animation loop
+  useEffect(() => {
+    if (!shouldScroll || isHovered) {
+      if (animationRef.current) {
+        cancelAnimationFrame(animationRef.current);
+      }
+      return;
+    }
+    
+    startAnimation();
+    
+    return () => {
+      if (animationRef.current) {
+        cancelAnimationFrame(animationRef.current);
+      }
+    };
+  }, [shouldScroll, position, direction, phase, maxScroll, isHovered]);
+  
+  // Determine CSS classes based on current animation phase
+  const isScrolling = phase === 'scrolling' && !isHovered;
+  const isPaused = phase === 'pause-end' || phase === 'pause-start' || phase === 'initial-pause' || isHovered;
+  
+  // Build class names based on current state
+  const containerClass = `scrollable-text-container ${className} 
+    ${isScrolling ? 'is-scrolling' : ''} 
+    ${isPaused ? 'is-paused' : ''}
+    ${isHovered ? 'is-hovered' : ''}`.replace(/\s+/g, ' ').trim();
+  
+  // Apply inline transform for precise positioning
+  const textStyle = shouldScroll ? {
+    transform: `translateX(${-position}px)`,
+    transition: isHovered ? 'transform 0.3s ease-out' : 'none'
+  } : {};
+  
+  return React.createElement(
+    'div',
+    {
+      className: containerClass,
+      ref: containerRef,
+      style: style,
+      'data-direction': direction,
+      'data-phase': phase,
+      onMouseEnter: handleMouseEnter,
+      onMouseLeave: handleMouseLeave,
+      onTouchStart: handleMouseEnter,
+      onTouchEnd: handleMouseLeave
+    },
+    shouldScroll
+      ? React.createElement(
+          'div',
+          { className: 'scrolling-wrapper' },
+          React.createElement(
+            'div',
+            { className: 'scrolling-content' },
+            React.createElement(
+              'div',
+              {
+                className: 'scrollable-text',
+                ref: textRef,
+                style: textStyle
+              },
+              text
+            )
+          )
+        )
+      : React.createElement(
+          'div',
+          { className: 'scrolling-content', ref: textRef },
+          text
+        )
+  );
+};
+
+export default ScrollableText; 
\ No newline at end of file


Reviews:
user: login: EvWhymark
body: This can definitely be better written but its a good first pass.  I think that the text movement is too fast and when the text is scrolling to the left it goes too far left (see screenshot). It shouldn't move so far left. Make these changes with my specific code comments and I think this is ready to merge.

![image](https://github.com/user-attachments/assets/f8f859f6-731d-4db0-bce3-14b67fb34c5c)

state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-04-15T19:01:35Z
user: login: tennitech
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-17T23:53:32Z
user: login: tennitech
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-17T23:53:59Z
user: login: tennitech
body: RTG
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-18T00:00:51Z
user: login: EvWhymark
body: A lot of changes done here that weren't part of my review. Look over my comments.
state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-04-18T00:16:55Z
user: login: tennitech
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-18T00:18:06Z
user: login: tennitech
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-18T00:19:43Z
user: login: tennitech
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-18T01:08:39Z

Review comments:
diff_hunk: @@ -236,32 +237,35 @@ const Home = () => {
             paddingLeft: 'clamp(20px, 4vw, 60px)',
             flex: '1 1 auto'
           }}>
-            <h1 style={{
-              fontFamily: 'Loubag, sans-serif',
-              fontSize: 'clamp(2.2rem, 4vw, 4rem)',
-              margin: '0',
-              textAlign: 'left',
-              color: '#ECE0C4',
-              textShadow: `
-                2px 2px 0 rgba(255,0,0,0.2),
-                -2px -2px 0 rgba(0,0,255,0.2),
-                1px -1px 0 rgba(255,0,255,0.2)
-              `,
-              animation: 'textGlitch 3s infinite'
-            }}>
-              {track?.name || "Unknown"}
-            </h1>
-
-            <h2 style={{
-              fontFamily: 'Notable, sans-serif',
-              fontSize: 'clamp(1.2rem, 2vw, 1.6rem)',
-              margin: '0',
-              opacity: 0.9,
-              letterSpacing: '1px',
-              color: 'white'
-            }}>
-              {track?.artists?.map(artist => artist.name)?.join(", ") || "Unknown"}
-            </h2>
+            <ScrollableText
+              text={track?.name || "Sign-In Required"}
path: jukeos/src/components/Home.js
user: login: EvWhymark
body: Staying with Unknown makes more sense. You can be signed in and still not be playing something. Saying that sign in is required when you are already are signed in is confusing. This should apply to both the title and artist
```suggestion
              text={track?.name || "Unknown"}
```
created_at: 2025-04-15T18:37:07Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,232 @@
+import React, { useEffect, useRef, useState } from 'react';
path: jukeos/src/components/ScrollableText.jsx
user: login: EvWhymark
body: Why is this file .jsx? We have been using .js for the entire repo. We should be consistent either make all the files .jsx or make this one .js
created_at: 2025-04-15T18:41:26Z
author_association: COLLABORATOR
diff_hunk: @@ -236,32 +237,35 @@ const Home = () => {
             paddingLeft: 'clamp(20px, 4vw, 60px)',
             flex: '1 1 auto'
           }}>
-            <h1 style={{
-              fontFamily: 'Loubag, sans-serif',
-              fontSize: 'clamp(2.2rem, 4vw, 4rem)',
-              margin: '0',
-              textAlign: 'left',
-              color: '#ECE0C4',
-              textShadow: `
-                2px 2px 0 rgba(255,0,0,0.2),
-                -2px -2px 0 rgba(0,0,255,0.2),
-                1px -1px 0 rgba(255,0,255,0.2)
-              `,
-              animation: 'textGlitch 3s infinite'
-            }}>
-              {track?.name || "Unknown"}
-            </h1>
-
-            <h2 style={{
-              fontFamily: 'Notable, sans-serif',
-              fontSize: 'clamp(1.2rem, 2vw, 1.6rem)',
-              margin: '0',
-              opacity: 0.9,
-              letterSpacing: '1px',
-              color: 'white'
-            }}>
-              {track?.artists?.map(artist => artist.name)?.join(", ") || "Unknown"}
-            </h2>
+            <ScrollableText
+              text={track?.name || "Sign-In Required"}
path: jukeos/src/components/Home.js
user: login: tennitech
body: Resolved in https://github.com/tennitech/juke/pull/176/commits/df90abfde5ffdf11e239b1376bcc2dd6bfa9faf8.

> -Changed default track and artist placeholders to "Welcome to Juke" and "Your Music, Reimagined" respectively
created_at: 2025-04-17T23:53:32Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,232 @@
+import React, { useEffect, useRef, useState } from 'react';
path: jukeos/src/components/ScrollableText.jsx
user: login: tennitech
body: Resolved in https://github.com/tennitech/juke/pull/176/commits/df90abfde5ffdf11e239b1376bcc2dd6bfa9faf8.

`-Removed ScrollableText.jsx and created ScrollableText.js to maintain file consistency between our components`
created_at: 2025-04-17T23:53:59Z
author_association: OWNER
diff_hunk: @@ -236,32 +237,35 @@ const Home = () => {
             paddingLeft: 'clamp(20px, 4vw, 60px)',
             flex: '1 1 auto'
           }}>
-            <h1 style={{
-              fontFamily: 'Loubag, sans-serif',
-              fontSize: 'clamp(2.2rem, 4vw, 4rem)',
-              margin: '0',
-              textAlign: 'left',
-              color: '#ECE0C4',
-              textShadow: `
-                2px 2px 0 rgba(255,0,0,0.2),
-                -2px -2px 0 rgba(0,0,255,0.2),
-                1px -1px 0 rgba(255,0,255,0.2)
-              `,
-              animation: 'textGlitch 3s infinite'
-            }}>
-              {track?.name || "Unknown"}
-            </h1>
-
-            <h2 style={{
-              fontFamily: 'Notable, sans-serif',
-              fontSize: 'clamp(1.2rem, 2vw, 1.6rem)',
-              margin: '0',
-              opacity: 0.9,
-              letterSpacing: '1px',
-              color: 'white'
-            }}>
-              {track?.artists?.map(artist => artist.name)?.join(", ") || "Unknown"}
-            </h2>
+            <ScrollableText
+              text={track?.name || "Welcome to JukeWelcome to JukeWelcome to Juke"}
path: jukeos/src/components/Home.js
user: login: EvWhymark
body: Something went wrong here should just be Welcome to Juke
```suggestion
              text={track?.name || "Welcome to Juke"}
```
created_at: 2025-04-18T00:04:18Z
author_association: COLLABORATOR
diff_hunk: @@ -236,32 +237,35 @@ const Home = () => {
             paddingLeft: 'clamp(20px, 4vw, 60px)',
             flex: '1 1 auto'
           }}>
-            <h1 style={{
-              fontFamily: 'Loubag, sans-serif',
-              fontSize: 'clamp(2.2rem, 4vw, 4rem)',
-              margin: '0',
-              textAlign: 'left',
-              color: '#ECE0C4',
-              textShadow: `
-                2px 2px 0 rgba(255,0,0,0.2),
-                -2px -2px 0 rgba(0,0,255,0.2),
-                1px -1px 0 rgba(255,0,255,0.2)
-              `,
-              animation: 'textGlitch 3s infinite'
-            }}>
-              {track?.name || "Unknown"}
-            </h1>
-
-            <h2 style={{
-              fontFamily: 'Notable, sans-serif',
-              fontSize: 'clamp(1.2rem, 2vw, 1.6rem)',
-              margin: '0',
-              opacity: 0.9,
-              letterSpacing: '1px',
-              color: 'white'
-            }}>
-              {track?.artists?.map(artist => artist.name)?.join(", ") || "Unknown"}
-            </h2>
+            <ScrollableText
+              text={track?.name || "Welcome to JukeWelcome to JukeWelcome to Juke"}
+              speed="title"
+              style={{
+                fontFamily: 'Loubag, sans-serif',
+                fontSize: 'clamp(2.2rem, 4vw, 4rem)',
+                margin: '0',
+                textAlign: 'left',
+                color: '#ECE0C4',
+                textShadow: `
+                  2px 2px 0 rgba(255,0,0,0.2),
+                  -2px -2px 0 rgba(0,0,255,0.2),
+                  1px -1px 0 rgba(255,0,255,0.2)
+                `,
+              }}
+            />
+
+            <ScrollableText
+              text={track?.artists?.map(artist => artist.name)?.join(", ") || "Your Music, ReimaginedWelcome to Juke"}
path: jukeos/src/components/Home.js
user: login: EvWhymark
body: Similar thing here somehow the Welcome to Juke got moved to here?
```suggestion
              text={track?.artists?.map(artist => artist.name)?.join(", ") || "Your Music, Reimagined"}
```
created_at: 2025-04-18T00:05:08Z
author_association: COLLABORATOR
diff_hunk: @@ -236,32 +237,35 @@ const Home = () => {
             paddingLeft: 'clamp(20px, 4vw, 60px)',
             flex: '1 1 auto'
           }}>
-            <h1 style={{
-              fontFamily: 'Loubag, sans-serif',
-              fontSize: 'clamp(2.2rem, 4vw, 4rem)',
-              margin: '0',
-              textAlign: 'left',
-              color: '#ECE0C4',
-              textShadow: `
-                2px 2px 0 rgba(255,0,0,0.2),
-                -2px -2px 0 rgba(0,0,255,0.2),
-                1px -1px 0 rgba(255,0,255,0.2)
-              `,
-              animation: 'textGlitch 3s infinite'
-            }}>
-              {track?.name || "Unknown"}
-            </h1>
-
-            <h2 style={{
-              fontFamily: 'Notable, sans-serif',
-              fontSize: 'clamp(1.2rem, 2vw, 1.6rem)',
-              margin: '0',
-              opacity: 0.9,
-              letterSpacing: '1px',
-              color: 'white'
-            }}>
-              {track?.artists?.map(artist => artist.name)?.join(", ") || "Unknown"}
-            </h2>
+            <ScrollableText
+              text={track?.name || "Welcome to JukeWelcome to JukeWelcome to Juke"}
path: jukeos/src/components/Home.js
user: login: EvWhymark
body: Now noticing that these are probably just from testing. Should still be removed shouldnt be in main branch.
created_at: 2025-04-18T00:06:50Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,200 @@
+import React, { useEffect, useRef, useState } from 'react';
+import './ScrollableText.css';
+
+const ScrollableText = ({ text, style, className = '', speed = 'title' }) => {
+  const containerRef = useRef(null);
+  const textRef = useRef(null);
+  const animationRef = useRef(null);
+  const [shouldScroll, setShouldScroll] = useState(false);
+  const [position, setPosition] = useState(0);
+  const [direction, setDirection] = useState(1);
+  const [phase, setPhase] = useState('initial-pause');
+  const [maxScroll, setMaxScroll] = useState(0);
+  
+  // Constants for animation speed
+  const PIXELS_PER_SECOND = speed === 'title' ? 80 : 40;
+  const PAUSE_DURATION = 3000; // 3 seconds
+  const FADE_DURATION = 800;
+  
+  // Check if text is too long for container
+  const checkOverflow = () => {
+    if (!containerRef.current || !textRef.current) return;
+    
+    const containerWidth = containerRef.current.clientWidth;
+    const textWidth = textRef.current.scrollWidth;
+    const needsScroll = textWidth > containerWidth + 20;
+    
+    setShouldScroll(needsScroll);
+    
+    if (needsScroll) {
+      const overflow = textWidth - containerWidth;
+      const visiblePortion = Math.max(containerWidth * 0.4, 150);
+      const scrollDistance = Math.min(overflow, textWidth - visiblePortion);
+      setMaxScroll(scrollDistance);
+      setPosition(0);
+    }
+  };
+  
+  // Handle animation lifecycle
+  useEffect(() => {
+    setPosition(0);
+    setDirection(1);
+    setPhase('initial-pause');
+    
+    checkOverflow();
+    
+    const initialTimer = setTimeout(() => {
+      checkOverflow();
+    }, 100);
+    
+    const fontTimer = setTimeout(() => {
+      checkOverflow();
+    }, 1000);
+    
+    return () => {
+      clearTimeout(initialTimer);
+      clearTimeout(fontTimer);
+      if (animationRef.current) {
+        cancelAnimationFrame(animationRef.current);
+      }
+    };
+  }, [text]);
+  
+  // Handle window resize
+  useEffect(() => {
+    const handleResize = () => {
+      checkOverflow();
+    };
+    
+    window.addEventListener('resize', handleResize);
+    return () => window.removeEventListener('resize', handleResize);
+  }, []);
+  
+  // Main animation loop
+  useEffect(() => {
+    if (!shouldScroll) {
+      if (animationRef.current) {
+        cancelAnimationFrame(animationRef.current);
+      }
+      setPhase('initial-pause');
+      return;
+    }
+    
+    let lastTimestamp = null;
+    let phaseStartTime = null;
+    
+    const animate = (timestamp) => {
+      if (!lastTimestamp) {
+        lastTimestamp = timestamp;
+        phaseStartTime = timestamp;
+        animationRef.current = requestAnimationFrame(animate);
+        return;
+      }
+      
+      const elapsed = timestamp - lastTimestamp;
+      lastTimestamp = timestamp;
+      
+      switch (phase) {
+        case 'initial-pause':
+          if (timestamp - phaseStartTime >= PAUSE_DURATION) {
+            setPhase('scrolling');
+            phaseStartTime = timestamp;
+          }
+          break;
+          
+        case 'scrolling':
+          let newPos = position + (direction * PIXELS_PER_SECOND * elapsed / 1000);
+          
+          if (direction > 0 && newPos >= maxScroll) {
+            newPos = maxScroll;
+            setPhase('pause-end');
+            phaseStartTime = timestamp;
+          } else if (direction < 0 && newPos <= 0) {
+            newPos = 0;
+            setPhase('pause-start');
+            phaseStartTime = timestamp;
+          }
+          
+          setPosition(newPos);
+          break;
+          
+        case 'pause-end':
+          if (timestamp - phaseStartTime >= PAUSE_DURATION) {
+            setDirection(-1);
+            setPhase('scrolling');
+            phaseStartTime = timestamp;
+          }
+          break;
+          
+        case 'pause-start':
+          if (timestamp - phaseStartTime >= PAUSE_DURATION) {
+            setDirection(1);
+            setPhase('scrolling');
+            phaseStartTime = timestamp;
+          }
+          break;
+      }
+      
+      animationRef.current = requestAnimationFrame(animate);
+    };
+    
+    animationRef.current = requestAnimationFrame(animate);
+    
+    return () => {
+      if (animationRef.current) {
+        cancelAnimationFrame(animationRef.current);
+      }
+    };
+  }, [shouldScroll, position, direction, phase, maxScroll]);
+  
+  // Determine CSS classes based on current animation phase
+  const isScrolling = phase === 'scrolling';
+  const isPaused = phase === 'pause-end' || phase === 'pause-start' || phase === 'initial-pause';
+  
+  // Build class names based on current state
+  const containerClass = `scrollable-text-container ${className} 
+    ${isScrolling ? 'is-scrolling' : ''} 
+    ${isPaused ? 'is-paused' : ''}`.replace(/\s+/g, ' ').trim();
+  
+  // Apply inline transform for precise positioning
+  const textStyle = shouldScroll ? {
+    transform: `translateX(${-position}px)`,
+    transition: 'none'
+  } : {};
+  
+  return React.createElement(
+    'div',
+    {
+      className: containerClass,
+      ref: containerRef,
+      style: style,
+      'data-direction': direction,
+      'data-phase': phase
+    },
+    shouldScroll
+      ? React.createElement(
+          'div',
+          { className: 'scrolling-wrapper' },
+          React.createElement(
+            'div',
+            { className: 'scrolling-content' },
+            React.createElement(
+              'div',
+              {
+                className: 'scrollable-text',
+                ref: textRef,
+                style: textStyle
+              },
+              text
+            )
+          )
+        )
+      : React.createElement(
+          'div',
+          { className: 'scrolling-content', ref: textRef },
+          text
+        )
+  );
+};
path: jukeos/src/components/ScrollableText.js
user: login: EvWhymark
body: Don't know what this is or why this is like this. Checked the previous diff and this wasn't here before. It use be like this:
```suggestion
  return (
    <div 
      className={containerClass}
      ref={containerRef} 
      style={style}
    >
      {shouldScroll ? (
        // Apply back-and-forth animation when text is too long
        <div className="scrolling-wrapper">
          <div className="scrolling-content">
            <div 
              className={oscillatingClass} 
              key={`oscillating-${text}-${scrollEnd}`}
              ref={oscillatingRef}
              style={cssVars}
            >
              {text}
            </div>
          </div>
        </div>
      ) : (
        // Just display the text normally when it fits
        <div className="scrolling-content" ref={textRef}>
          {text}
        </div>
      )}
    </div>
  );
};
```

I have no idea if the old version still works but this is definitely not the right way to write this return function. 
created_at: 2025-04-18T00:10:59Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,118 @@
+/* The container clips overflowing content and applies a fade on edges */
path: jukeos/src/components/ScrollableText.css
user: login: EvWhymark
body: You got rid of the on hover functionality. I dont know if that was intended but it is now gone. 
created_at: 2025-04-18T00:11:37Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,200 @@
+import React, { useEffect, useRef, useState } from 'react';
+import './ScrollableText.css';
+
+const ScrollableText = ({ text, style, className = '', speed = 'title' }) => {
+  const containerRef = useRef(null);
+  const textRef = useRef(null);
+  const animationRef = useRef(null);
+  const [shouldScroll, setShouldScroll] = useState(false);
+  const [position, setPosition] = useState(0);
+  const [direction, setDirection] = useState(1);
+  const [phase, setPhase] = useState('initial-pause');
+  const [maxScroll, setMaxScroll] = useState(0);
+  
+  // Constants for animation speed
+  const PIXELS_PER_SECOND = speed === 'title' ? 80 : 40;
+  const PAUSE_DURATION = 3000; // 3 seconds
+  const FADE_DURATION = 800;
+  
+  // Check if text is too long for container
+  const checkOverflow = () => {
+    if (!containerRef.current || !textRef.current) return;
+    
+    const containerWidth = containerRef.current.clientWidth;
+    const textWidth = textRef.current.scrollWidth;
+    const needsScroll = textWidth > containerWidth + 20;
+    
+    setShouldScroll(needsScroll);
+    
+    if (needsScroll) {
+      const overflow = textWidth - containerWidth;
+      const visiblePortion = Math.max(containerWidth * 0.4, 150);
+      const scrollDistance = Math.min(overflow, textWidth - visiblePortion);
+      setMaxScroll(scrollDistance);
+      setPosition(0);
+    }
+  };
+  
+  // Handle animation lifecycle
+  useEffect(() => {
+    setPosition(0);
+    setDirection(1);
+    setPhase('initial-pause');
+    
+    checkOverflow();
+    
+    const initialTimer = setTimeout(() => {
+      checkOverflow();
+    }, 100);
+    
+    const fontTimer = setTimeout(() => {
+      checkOverflow();
+    }, 1000);
+    
+    return () => {
+      clearTimeout(initialTimer);
+      clearTimeout(fontTimer);
+      if (animationRef.current) {
+        cancelAnimationFrame(animationRef.current);
+      }
+    };
+  }, [text]);
+  
+  // Handle window resize
+  useEffect(() => {
+    const handleResize = () => {
+      checkOverflow();
+    };
+    
+    window.addEventListener('resize', handleResize);
+    return () => window.removeEventListener('resize', handleResize);
+  }, []);
+  
+  // Main animation loop
+  useEffect(() => {
+    if (!shouldScroll) {
+      if (animationRef.current) {
+        cancelAnimationFrame(animationRef.current);
+      }
+      setPhase('initial-pause');
+      return;
+    }
+    
+    let lastTimestamp = null;
+    let phaseStartTime = null;
+    
+    const animate = (timestamp) => {
+      if (!lastTimestamp) {
+        lastTimestamp = timestamp;
+        phaseStartTime = timestamp;
+        animationRef.current = requestAnimationFrame(animate);
+        return;
+      }
+      
+      const elapsed = timestamp - lastTimestamp;
+      lastTimestamp = timestamp;
+      
+      switch (phase) {
+        case 'initial-pause':
+          if (timestamp - phaseStartTime >= PAUSE_DURATION) {
+            setPhase('scrolling');
+            phaseStartTime = timestamp;
+          }
+          break;
+          
+        case 'scrolling':
+          let newPos = position + (direction * PIXELS_PER_SECOND * elapsed / 1000);
+          
+          if (direction > 0 && newPos >= maxScroll) {
+            newPos = maxScroll;
+            setPhase('pause-end');
+            phaseStartTime = timestamp;
+          } else if (direction < 0 && newPos <= 0) {
+            newPos = 0;
+            setPhase('pause-start');
+            phaseStartTime = timestamp;
+          }
+          
+          setPosition(newPos);
+          break;
+          
+        case 'pause-end':
+          if (timestamp - phaseStartTime >= PAUSE_DURATION) {
+            setDirection(-1);
+            setPhase('scrolling');
+            phaseStartTime = timestamp;
+          }
+          break;
+          
+        case 'pause-start':
+          if (timestamp - phaseStartTime >= PAUSE_DURATION) {
+            setDirection(1);
+            setPhase('scrolling');
+            phaseStartTime = timestamp;
+          }
+          break;
+      }
+      
+      animationRef.current = requestAnimationFrame(animate);
+    };
+    
+    animationRef.current = requestAnimationFrame(animate);
+    
+    return () => {
+      if (animationRef.current) {
+        cancelAnimationFrame(animationRef.current);
+      }
+    };
+  }, [shouldScroll, position, direction, phase, maxScroll]);
+  
+  // Determine CSS classes based on current animation phase
+  const isScrolling = phase === 'scrolling';
+  const isPaused = phase === 'pause-end' || phase === 'pause-start' || phase === 'initial-pause';
+  
+  // Build class names based on current state
+  const containerClass = `scrollable-text-container ${className} 
+    ${isScrolling ? 'is-scrolling' : ''} 
+    ${isPaused ? 'is-paused' : ''}`.replace(/\s+/g, ' ').trim();
+  
+  // Apply inline transform for precise positioning
+  const textStyle = shouldScroll ? {
+    transform: `translateX(${-position}px)`,
+    transition: 'none'
+  } : {};
+  
+  return React.createElement(
+    'div',
+    {
+      className: containerClass,
+      ref: containerRef,
+      style: style,
+      'data-direction': direction,
+      'data-phase': phase
+    },
+    shouldScroll
+      ? React.createElement(
+          'div',
+          { className: 'scrolling-wrapper' },
+          React.createElement(
+            'div',
+            { className: 'scrolling-content' },
+            React.createElement(
+              'div',
+              {
+                className: 'scrollable-text',
+                ref: textRef,
+                style: textStyle
+              },
+              text
+            )
+          )
+        )
+      : React.createElement(
+          'div',
+          { className: 'scrolling-content', ref: textRef },
+          text
+        )
+  );
+};
path: jukeos/src/components/ScrollableText.js
user: login: EvWhymark
body: This is a non critical issue and I understand we are running out of time. But still is a pretty big code style change. Should probably fix this. 
created_at: 2025-04-18T00:12:39Z
author_association: COLLABORATOR
diff_hunk: @@ -236,32 +237,35 @@ const Home = () => {
             paddingLeft: 'clamp(20px, 4vw, 60px)',
             flex: '1 1 auto'
           }}>
-            <h1 style={{
-              fontFamily: 'Loubag, sans-serif',
-              fontSize: 'clamp(2.2rem, 4vw, 4rem)',
-              margin: '0',
-              textAlign: 'left',
-              color: '#ECE0C4',
-              textShadow: `
-                2px 2px 0 rgba(255,0,0,0.2),
-                -2px -2px 0 rgba(0,0,255,0.2),
-                1px -1px 0 rgba(255,0,255,0.2)
-              `,
-              animation: 'textGlitch 3s infinite'
-            }}>
-              {track?.name || "Unknown"}
-            </h1>
-
-            <h2 style={{
-              fontFamily: 'Notable, sans-serif',
-              fontSize: 'clamp(1.2rem, 2vw, 1.6rem)',
-              margin: '0',
-              opacity: 0.9,
-              letterSpacing: '1px',
-              color: 'white'
-            }}>
-              {track?.artists?.map(artist => artist.name)?.join(", ") || "Unknown"}
-            </h2>
+            <ScrollableText
+              text={track?.name || "Welcome to JukeWelcome to JukeWelcome to Juke"}
+              speed="title"
+              style={{
+                fontFamily: 'Loubag, sans-serif',
+                fontSize: 'clamp(2.2rem, 4vw, 4rem)',
+                margin: '0',
+                textAlign: 'left',
+                color: '#ECE0C4',
+                textShadow: `
+                  2px 2px 0 rgba(255,0,0,0.2),
+                  -2px -2px 0 rgba(0,0,255,0.2),
+                  1px -1px 0 rgba(255,0,255,0.2)
+                `,
+              }}
+            />
+
+            <ScrollableText
+              text={track?.artists?.map(artist => artist.name)?.join(", ") || "Your Music, ReimaginedWelcome to Juke"}
path: jukeos/src/components/Home.js
user: login: tennitech
body: Was testing longer names, forgot to delete. Will fix.
created_at: 2025-04-18T00:18:06Z
author_association: OWNER
diff_hunk: @@ -236,32 +237,35 @@ const Home = () => {
             paddingLeft: 'clamp(20px, 4vw, 60px)',
             flex: '1 1 auto'
           }}>
-            <h1 style={{
-              fontFamily: 'Loubag, sans-serif',
-              fontSize: 'clamp(2.2rem, 4vw, 4rem)',
-              margin: '0',
-              textAlign: 'left',
-              color: '#ECE0C4',
-              textShadow: `
-                2px 2px 0 rgba(255,0,0,0.2),
-                -2px -2px 0 rgba(0,0,255,0.2),
-                1px -1px 0 rgba(255,0,255,0.2)
-              `,
-              animation: 'textGlitch 3s infinite'
-            }}>
-              {track?.name || "Unknown"}
-            </h1>
-
-            <h2 style={{
-              fontFamily: 'Notable, sans-serif',
-              fontSize: 'clamp(1.2rem, 2vw, 1.6rem)',
-              margin: '0',
-              opacity: 0.9,
-              letterSpacing: '1px',
-              color: 'white'
-            }}>
-              {track?.artists?.map(artist => artist.name)?.join(", ") || "Unknown"}
-            </h2>
+            <ScrollableText
+              text={track?.name || "Welcome to JukeWelcome to JukeWelcome to Juke"}
path: jukeos/src/components/Home.js
user: login: tennitech
body: Yep - I'll clean these up.
created_at: 2025-04-18T00:19:43Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,200 @@
+import React, { useEffect, useRef, useState } from 'react';
+import './ScrollableText.css';
+
+const ScrollableText = ({ text, style, className = '', speed = 'title' }) => {
+  const containerRef = useRef(null);
+  const textRef = useRef(null);
+  const animationRef = useRef(null);
+  const [shouldScroll, setShouldScroll] = useState(false);
+  const [position, setPosition] = useState(0);
+  const [direction, setDirection] = useState(1);
+  const [phase, setPhase] = useState('initial-pause');
+  const [maxScroll, setMaxScroll] = useState(0);
+  
+  // Constants for animation speed
+  const PIXELS_PER_SECOND = speed === 'title' ? 80 : 40;
+  const PAUSE_DURATION = 3000; // 3 seconds
+  const FADE_DURATION = 800;
+  
+  // Check if text is too long for container
+  const checkOverflow = () => {
+    if (!containerRef.current || !textRef.current) return;
+    
+    const containerWidth = containerRef.current.clientWidth;
+    const textWidth = textRef.current.scrollWidth;
+    const needsScroll = textWidth > containerWidth + 20;
+    
+    setShouldScroll(needsScroll);
+    
+    if (needsScroll) {
+      const overflow = textWidth - containerWidth;
+      const visiblePortion = Math.max(containerWidth * 0.4, 150);
+      const scrollDistance = Math.min(overflow, textWidth - visiblePortion);
+      setMaxScroll(scrollDistance);
+      setPosition(0);
+    }
+  };
+  
+  // Handle animation lifecycle
+  useEffect(() => {
+    setPosition(0);
+    setDirection(1);
+    setPhase('initial-pause');
+    
+    checkOverflow();
+    
+    const initialTimer = setTimeout(() => {
+      checkOverflow();
+    }, 100);
+    
+    const fontTimer = setTimeout(() => {
+      checkOverflow();
+    }, 1000);
+    
+    return () => {
+      clearTimeout(initialTimer);
+      clearTimeout(fontTimer);
+      if (animationRef.current) {
+        cancelAnimationFrame(animationRef.current);
+      }
+    };
+  }, [text]);
+  
+  // Handle window resize
+  useEffect(() => {
+    const handleResize = () => {
+      checkOverflow();
+    };
+    
+    window.addEventListener('resize', handleResize);
+    return () => window.removeEventListener('resize', handleResize);
+  }, []);
+  
+  // Main animation loop
+  useEffect(() => {
+    if (!shouldScroll) {
+      if (animationRef.current) {
+        cancelAnimationFrame(animationRef.current);
+      }
+      setPhase('initial-pause');
+      return;
+    }
+    
+    let lastTimestamp = null;
+    let phaseStartTime = null;
+    
+    const animate = (timestamp) => {
+      if (!lastTimestamp) {
+        lastTimestamp = timestamp;
+        phaseStartTime = timestamp;
+        animationRef.current = requestAnimationFrame(animate);
+        return;
+      }
+      
+      const elapsed = timestamp - lastTimestamp;
+      lastTimestamp = timestamp;
+      
+      switch (phase) {
+        case 'initial-pause':
+          if (timestamp - phaseStartTime >= PAUSE_DURATION) {
+            setPhase('scrolling');
+            phaseStartTime = timestamp;
+          }
+          break;
+          
+        case 'scrolling':
+          let newPos = position + (direction * PIXELS_PER_SECOND * elapsed / 1000);
+          
+          if (direction > 0 && newPos >= maxScroll) {
+            newPos = maxScroll;
+            setPhase('pause-end');
+            phaseStartTime = timestamp;
+          } else if (direction < 0 && newPos <= 0) {
+            newPos = 0;
+            setPhase('pause-start');
+            phaseStartTime = timestamp;
+          }
+          
+          setPosition(newPos);
+          break;
+          
+        case 'pause-end':
+          if (timestamp - phaseStartTime >= PAUSE_DURATION) {
+            setDirection(-1);
+            setPhase('scrolling');
+            phaseStartTime = timestamp;
+          }
+          break;
+          
+        case 'pause-start':
+          if (timestamp - phaseStartTime >= PAUSE_DURATION) {
+            setDirection(1);
+            setPhase('scrolling');
+            phaseStartTime = timestamp;
+          }
+          break;
+      }
+      
+      animationRef.current = requestAnimationFrame(animate);
+    };
+    
+    animationRef.current = requestAnimationFrame(animate);
+    
+    return () => {
+      if (animationRef.current) {
+        cancelAnimationFrame(animationRef.current);
+      }
+    };
+  }, [shouldScroll, position, direction, phase, maxScroll]);
+  
+  // Determine CSS classes based on current animation phase
+  const isScrolling = phase === 'scrolling';
+  const isPaused = phase === 'pause-end' || phase === 'pause-start' || phase === 'initial-pause';
+  
+  // Build class names based on current state
+  const containerClass = `scrollable-text-container ${className} 
+    ${isScrolling ? 'is-scrolling' : ''} 
+    ${isPaused ? 'is-paused' : ''}`.replace(/\s+/g, ' ').trim();
+  
+  // Apply inline transform for precise positioning
+  const textStyle = shouldScroll ? {
+    transform: `translateX(${-position}px)`,
+    transition: 'none'
+  } : {};
+  
+  return React.createElement(
+    'div',
+    {
+      className: containerClass,
+      ref: containerRef,
+      style: style,
+      'data-direction': direction,
+      'data-phase': phase
+    },
+    shouldScroll
+      ? React.createElement(
+          'div',
+          { className: 'scrolling-wrapper' },
+          React.createElement(
+            'div',
+            { className: 'scrolling-content' },
+            React.createElement(
+              'div',
+              {
+                className: 'scrollable-text',
+                ref: textRef,
+                style: textStyle
+              },
+              text
+            )
+          )
+        )
+      : React.createElement(
+          'div',
+          { className: 'scrolling-content', ref: textRef },
+          text
+        )
+  );
+};
path: jukeos/src/components/ScrollableText.js
user: login: tennitech
body: Yep, good call it's easier to read and still works the same, plus the .js extension is fine with it.
created_at: 2025-04-18T01:08:39Z
author_association: OWNER

PR comments:
user: login: tennitech
created_at: 2025-04-18T00:00:03Z
body: See [this](https://github.com/tennitech/juke/pull/176/commits/df90abfde5ffdf11e239b1376bcc2dd6bfa9faf8) commit. Good review, all comments have been addressed.

I've refined the component pretty thoroughly. See the summary of the changes below, or in the commit desc. Take a look:
`-Replaced the CSS scroll animation with a JavaScript-controlled one that maintains absolute constant speed regardless of character length
-Scroll speed has been refined for both the song (slow) and artist (slower) titles
-AFAIK, the instance where the text occasionally scrolls to far should be resolved - it stops at the lower bound, the ending of the string
-Left/right fades appropriately appear and disappear (both sides during movement, when stopping at the end of the string, for each side, the respective fade disappears then reappears once movement starts again)
`
author_association: OWNER
user: login: tennitech
created_at: 2025-04-18T01:22:34Z
body: Resolved all comments with https://github.com/tennitech/juke/pull/176/commits/744d3e0a33b19285b2a748e49d4e55e0f3946c57. RTG
author_association: OWNER