diff --git a/web/src/components/Card.jsx b/web/src/components/Card.jsx index f1682ccd1..8946a05f3 100644 --- a/web/src/components/Card.jsx +++ b/web/src/components/Card.jsx @@ -1,4 +1,4 @@ -export default function Card({ +export function Section({ xs, sm, md, @@ -9,6 +9,7 @@ export default function Card({ className = '', role, fullHeight = false, + surface = false, }) { const getGridClasses = () => { const breakpoints = [ @@ -27,19 +28,25 @@ export default function Card({ const gridClasses = getGridClasses(); + const bgClass = surface ? 'bg-base-200/40' : 'bg-base-100'; + return (
{title && ( -
-

{title}

+
+

{title}

)} -
+
{children}
); } + +export const Card = Section; +export default Section; + diff --git a/web/src/components/Chart.jsx b/web/src/components/Chart.jsx index 98f4632f2..a704e26ee 100644 --- a/web/src/components/Chart.jsx +++ b/web/src/components/Chart.jsx @@ -103,6 +103,15 @@ export function ChartComponent({ data, className, chartClassName }) { window.visualViewport.addEventListener('resize', handleResize); } + // ResizeObserver: handles accordion open/close animations and any container resize + let resizeObserver; + if (ref.current && typeof ResizeObserver !== 'undefined') { + resizeObserver = new ResizeObserver(() => { + chart.resize(); + }); + resizeObserver.observe(ref.current); + } + // Initial call to ensure correct sizing handleResize(); @@ -113,6 +122,9 @@ export function ChartComponent({ data, className, chartClassName }) { if (window.visualViewport) { window.visualViewport.removeEventListener('resize', handleResize); } + if (resizeObserver) { + resizeObserver.disconnect(); + } }; }, [chart]); diff --git a/web/src/components/GmLogoIcon.jsx b/web/src/components/GmLogoIcon.jsx new file mode 100644 index 000000000..264f8c63e --- /dev/null +++ b/web/src/components/GmLogoIcon.jsx @@ -0,0 +1,44 @@ +import { useId } from 'preact/hooks'; + +export function GmLogoIcon({ width, height, className = '', style = {} }) { + const clipPathId = useId(); + + return ( + + ); +} diff --git a/web/src/components/Navigation.jsx b/web/src/components/Navigation.jsx index ba0f7a591..0dd301844 100644 --- a/web/src/components/Navigation.jsx +++ b/web/src/components/Navigation.jsx @@ -10,11 +10,12 @@ import { faRotate } from '@fortawesome/free-solid-svg-icons/faRotate'; import { faMagnifyingGlassChart } from '@fortawesome/free-solid-svg-icons/faMagnifyingGlassChart'; import { faChartSimple } from '@fortawesome/free-solid-svg-icons/faChartSimple'; import { faCircleChevronLeft } from '@fortawesome/free-solid-svg-icons/faCircleChevronLeft'; -import { faCircleChevronRight } from '@fortawesome/free-solid-svg-icons/faCircleChevronRight'; -import { GmLogoIcon } from '../pages/ShotAnalyzer/components/SourceMarker.jsx'; + +import { GmLogoIcon } from './GmLogoIcon.jsx'; import { faGithub } from '@fortawesome/free-brands-svg-icons/faGithub'; import { faDiscord } from '@fortawesome/free-brands-svg-icons/faDiscord'; import { useCallback, useEffect, useMemo, useRef } from 'preact/hooks'; +import { Tooltip } from './Tooltip.jsx'; // List of random icons to display - add your icons here (SVG strings, text, or emojis) const RANDOM_ICONS = [ @@ -72,36 +73,51 @@ const NAVIGATION_SECTIONS = [ }, ]; -function MenuItem({ collapsed = false, icon, isNew = false, label, link }) { +function MenuItem({ collapsed = false, icon, isNew = false, label, link, onHover }) { const { path } = useLocation(); - const isActive = path === link; - const isExpanded = collapsed === false; - const baseClassName = collapsed - ? 'btn btn-square btn-md h-12 min-h-0 w-12 min-w-0 rounded-xl border-none bg-transparent px-0 text-base-content hover:bg-base-content/10 hover:text-base-content' - : 'btn btn-md justify-start h-12 gap-3 w-full text-base-content hover:text-base-content hover:bg-base-content/10 bg-transparent border-none px-2'; - const activeClassName = collapsed - ? 'btn btn-square btn-md h-12 min-h-0 w-12 min-w-0 rounded-xl border-none bg-primary px-0 text-primary-content hover:bg-primary hover:text-primary-content' - : 'btn btn-md justify-start h-12 gap-3 w-full bg-primary text-primary-content hover:bg-primary hover:text-primary-content px-2'; - const className = isActive ? activeClassName : baseClassName; + const isActive = link === '/' ? path === '/' : path.startsWith(link); + + const baseClassName = 'btn btn-md h-12 w-full text-base-content hover:text-base-content hover:bg-base-content/10 bg-transparent border-none nav-btn-transition'; + const activeClassName = 'btn btn-md h-12 w-full bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary border-none nav-btn-transition'; + + const className = `${isActive ? activeClassName : baseClassName} flex items-center overflow-hidden ${ + collapsed ? 'nav-btn-collapsed' : '' + }`; + + const handleHover = () => { + if (onHover) onHover(); + }; return ( - - - {isExpanded ? ( -
+ +
+ +
+ {isNew ? ( - NEW + NEW ) : null} - {label} -
- ) : null} - + {label} + + + ); } @@ -128,106 +144,41 @@ export function Navigation({ collapsed = false, onToggleCollapsed }) { return ( <> - {!collapsed && ( -
- )} + ); } + diff --git a/web/src/components/Tooltip.jsx b/web/src/components/Tooltip.jsx index 655ef509d..55f454364 100644 --- a/web/src/components/Tooltip.jsx +++ b/web/src/components/Tooltip.jsx @@ -11,7 +11,7 @@ import { computePosition, flip, shift, offset, autoUpdate } from '@floating-ui/d * @param {preact.ComponentChildren} props.children - Trigger element * @param {'top'|'bottom'|'left'|'right'} [props.placement='top'] - Preferred placement */ -export function Tooltip({ content, children, placement = 'top', disabled = false }) { +export function Tooltip({ content, children, placement = 'top', disabled = false, className = '' }) { const [isVisible, setIsVisible] = useState(false); const [position, setPosition] = useState({ x: 0, y: 0 }); const [actualPlacement, setActualPlacement] = useState(placement); @@ -81,7 +81,7 @@ export function Tooltip({ content, children, placement = 'top', disabled = false onMouseLeave={hide} onFocus={show} onBlur={hide} - className='inline-flex' + className={`inline-flex ${className}`} > {children} diff --git a/web/src/components/VisualizerUploadModal.jsx b/web/src/components/VisualizerUploadModal.jsx index 95e536484..9e5a5a1b6 100644 --- a/web/src/components/VisualizerUploadModal.jsx +++ b/web/src/components/VisualizerUploadModal.jsx @@ -76,7 +76,7 @@ export default function VisualizerUploadModal({ return (
-
+

Upload to Visualizer.coffee

diff --git a/web/src/index.jsx b/web/src/index.jsx index caaf883e9..13bad7dad 100644 --- a/web/src/index.jsx +++ b/web/src/index.jsx @@ -56,20 +56,21 @@ function RouteFallback() { ); } -export function App() { - const [navCollapsed, setNavCollapsed] = useState(readInitialDesktopNavCollapsed); +export default function App() { + const [navCollapsed, setNavCollapsed] = useState( + JSON.parse(localStorage.getItem('gm_nav_collapsed') || 'false'), + ); useEffect(() => { - const storage = globalThis.window?.localStorage; - if (!storage) return; - - try { - storage.setItem(DESKTOP_NAV_COLLAPSED_STORAGE_KEY, String(navCollapsed)); - } catch { - // Ignore storage write failures so the navigation still works in restricted browsers. - } + localStorage.setItem('gm_nav_collapsed', JSON.stringify(navCollapsed)); }, [navCollapsed]); + useEffect(() => { + const handleOpenNav = () => setNavCollapsed(false); + globalThis.addEventListener('open-mobile-nav', handleOpenNav); + return () => globalThis.removeEventListener('open-mobile-nav', handleOpenNav); + }, []); + return ( @@ -79,7 +80,7 @@ export function App() { onToggleCollapsed={() => setNavCollapsed(collapsed => !collapsed)} />
-
+
diff --git a/web/src/pages/Home/ModeTab.jsx b/web/src/pages/Home/ModeTab.jsx index e9dd7bea5..05a9801f1 100644 --- a/web/src/pages/Home/ModeTab.jsx +++ b/web/src/pages/Home/ModeTab.jsx @@ -10,7 +10,7 @@ export const ModeTab = ({ mode, active, onClick, rotation = 0 }) => ( onClick={onClick} className={`flex h-8 min-w-0 flex-1 items-center justify-center rounded-full transition-colors duration-150 ${ active - ? 'bg-primary text-primary-content shadow-sm' + ? 'bg-primary text-primary-content' : 'text-base-content/50 hover:text-base-content' }`} > diff --git a/web/src/style.css b/web/src/style.css index d6e88a1e3..66e027788 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -11,8 +11,8 @@ --color-base-200: oklch(97.466% 0.011 259.822); --color-base-300: oklch(93.268% 0.016 262.751); --color-base-content: oklch(41.886% 0.053 255.824); - --color-primary: oklch(56.86% 0.255 257.57); - --color-primary-content: oklch(91.372% 0.051 257.57); + --color-primary: oklch(45% 0.255 257.57); + --color-primary-content: oklch(97.5% 0.015 257.57); --color-secondary: oklch(42.551% 0.161 282.339); --color-secondary-content: oklch(88.51% 0.032 282.339); --color-accent: oklch(59.939% 0.191 335.171); @@ -119,8 +119,8 @@ --color-base-200: oklch(93.299% 0.01 261.788); --color-base-300: oklch(89.925% 0.016 262.749); --color-base-content: oklch(32.437% 0.022 264.182); - --color-primary: oklch(59.435% 0.077 254.027); - --color-primary-content: oklch(11.887% 0.015 254.027); + --color-primary: oklch(44.5% 0.077 254.027); + --color-primary-content: oklch(98% 0.012 254.027); --color-secondary: oklch(69.651% 0.059 248.687); --color-secondary-content: oklch(13.93% 0.011 248.687); --color-accent: oklch(77.464% 0.062 217.469); @@ -420,7 +420,7 @@ html[data-theme='coffee'] { } @theme { - --font-logo: 'Montserrat', sans-serif; + --font-logo: 'Montserrat', system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; } @source inline("{,sm:,md:,lg:,xl:}col-span-{1..12}"); @@ -445,11 +445,25 @@ html[data-theme='coffee'] { @layer utilities { .profile-list-drag-selected-item { - @apply ring-primary/50 bg-primary/10 shadow-md ring-2; + @apply ring-primary/50 bg-primary/10 ring-2; } .profile-card-container.drop-highlight { @apply bg-primary/10; } + .scrollbar-none { + scrollbar-width: none; + &::-webkit-scrollbar { + display: none; + } + } + + /* Prevent double-tap to zoom on interactive elements which can cause visual jumping when clicked rapidly */ + button, + a, + input[type='button'], + input[type='submit'] { + touch-action: manipulation; + } } @custom-variant hmd { @@ -457,3 +471,402 @@ html[data-theme='coffee'] { @slot; } } + +/* Mobile Search Morph Transitions */ +@media (max-width: 639px) { + .morph-track-exit { + transition: + transform 200ms cubic-bezier(0.645, 0.045, 0.355, 1), + opacity 200ms cubic-bezier(0.645, 0.045, 0.355, 1); + will-change: transform, opacity; + } + + .morph-track-enter { + transition: + transform 250ms cubic-bezier(0.23, 1, 0.32, 1), + opacity 250ms cubic-bezier(0.23, 1, 0.32, 1); + will-change: transform, opacity; + } +} + +@media (prefers-reduced-motion: reduce) { + .morph-track-exit, + .morph-track-enter { + transition: none !important; + } +} + +/* ── Profile card accordion animation ── */ + +/* Chevron rotates 90° when expanded */ +.profile-card-chevron { + transition: transform 280ms cubic-bezier(0.165, 0.84, 0.44, 1); +} +.profile-card-chevron.expanded { + transform: rotate(90deg); +} + +/* Accordion container: grid trick for height 0 → auto */ +.profile-card-accordion { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows 280ms cubic-bezier(0.165, 0.84, 0.44, 1); +} +.profile-card-accordion[data-expanded='true'] { + grid-template-rows: 1fr; +} + +/* Content fades + slides up as it reveals */ +.profile-card-content { + opacity: 0; + transform: translateY(-6px); + transition: + opacity 220ms cubic-bezier(0.165, 0.84, 0.44, 1) 60ms, + transform 280ms cubic-bezier(0.165, 0.84, 0.44, 1); + will-change: opacity, transform; +} +.profile-card-accordion[data-expanded='true'] .profile-card-content { + opacity: 1; + transform: translateY(0); +} + +/* Respect reduced motion */ +@media (prefers-reduced-motion: reduce) { + .profile-card-chevron, + .profile-card-accordion, + .profile-card-content { + transition: none !important; + } +} + +/* Elevate card stacking context when its actions dropdown is open */ +.profile-card-container:has(.action-dropdown-open), +.card:has(.action-dropdown-open) { + position: relative !important; + z-index: 50 !important; + overflow: visible !important; +} + +/* Elevate header stacking context when its actions dropdown is open to prevent overlapping by sticky mobile search */ +.profile-list-header:has(.action-dropdown-open) { + z-index: 50 !important; +} + +/* ── Action dropdown transitions (fully decoupled from DaisyUI) ── */ +.action-dropdown { + display: inline-block; + position: relative; +} + +/* Menu panel: always in the DOM, hidden via opacity+visibility+transform. + No display:none — that kills CSS transitions. */ +.action-dropdown .action-dropdown-menu { + position: absolute; + top: 100%; + right: 0; + z-index: 999; + + /* Closed state */ + opacity: 0; + visibility: hidden; + transform: scale(0.95) translateY(-4px); + pointer-events: none; + + /* Exit: 120ms (~20% faster than entrance) */ + transition: + transform 120ms cubic-bezier(0.23, 1, 0.32, 1), + opacity 120ms cubic-bezier(0.23, 1, 0.32, 1), + visibility 120ms step-end; + transform-origin: top right; + will-change: transform, opacity; +} + +/* Open state */ +.action-dropdown.action-dropdown-open .action-dropdown-menu { + opacity: 1; + visibility: visible; + transform: scale(1) translateY(0); + pointer-events: auto; + + /* Enter: 150ms */ + transition: + transform 150ms cubic-bezier(0.23, 1, 0.32, 1), + opacity 150ms cubic-bezier(0.23, 1, 0.32, 1), + visibility 150ms step-start; +} + +@media (prefers-reduced-motion: reduce) { + .action-dropdown .action-dropdown-menu { + transition: none; + } +} + +/* Premium hardware-accelerated animated focus ring for inputs */ +.input { + outline: 2px solid transparent; + outline-offset: 0px; + transition: + outline-color 150ms ease, + outline-offset 150ms ease, + border-color 150ms ease, + box-shadow 150ms ease; +} + +.input:focus-within { + outline: 2px solid var(--color-primary); + outline-offset: 2px; +} + +/* Prevent double focus outlines and unify caret color on nested inputs */ +.input input { + caret-color: var(--color-primary); +} + +.input input:focus { + outline: none !important; + box-shadow: none !important; +} + +@media (prefers-reduced-motion: reduce) { + .input { + transition: none !important; + } +} + +/* Premium hardware-accelerated slide-down transition for mobile search */ +@media (max-width: 639px) { + /* Outer sticky element: has NO transforms to avoid breaking WebKit sticky positioning */ + .search-slide-sticky { + position: sticky; + top: 0; + z-index: 30; + height: 64px; + margin-bottom: -64px; + pointer-events: none; + } + + .search-slide-sticky-active { + pointer-events: auto; + } + + /* Inner animated container: handles GPU transforms and opacity transitions */ + .search-slide-container { + opacity: 0; + transform: translateY(-16px) scale(0.98); + /* Exit */ + transition: + transform 150ms ease, + opacity 150ms ease; + will-change: transform, opacity; + } + + .search-slide-sticky-active .search-slide-container { + opacity: 1; + transform: translateY(0) scale(1); + /* Enter */ + transition: + transform 250ms cubic-bezier(0.175, 0.885, 0.32, 1.1), + opacity 250ms cubic-bezier(0.175, 0.885, 0.32, 1.1); + } + + .profiles-list-content { + /* Exit */ + transition: transform 150ms ease; + will-change: transform; + } + + .profiles-list-content.search-active { + transform: translateY(64px); + /* Enter */ + transition: transform 250ms cubic-bezier(0.175, 0.885, 0.32, 1.1); + } +} + +@media (prefers-reduced-motion: reduce) { + .search-slide-container, + .profiles-list-content { + transition: none !important; + } +} + +/* Fix DaisyUI v5 alert text contrast/legibility by ensuring content colors override base-content */ +.alert-warning { + color: var(--color-warning-content); +} +.alert-error { + color: var(--color-error-content); +} +.alert-success { + color: var(--color-success-content); +} +.alert-info { + color: var(--color-info-content); +} + +/* Sidebar morph transitions */ +.sidebar-transition { + padding-top: 1.25rem; /* 20px */ + padding-bottom: 1.25rem; /* 20px */ + padding-left: 1rem; /* 16px */ + padding-right: 1rem; /* 16px */ + transition: width 280ms cubic-bezier(0.34, 1.15, 0.64, 1), + translate 280ms cubic-bezier(0.34, 1.15, 0.64, 1), + transform 280ms cubic-bezier(0.34, 1.15, 0.64, 1); + will-change: width, translate, transform; +} + +.sidebar-backdrop-transition { + transition: opacity 280ms cubic-bezier(0.34, 1.15, 0.64, 1); + will-change: opacity; +} + +@media (prefers-reduced-motion: reduce) { + .sidebar-transition, + .sidebar-backdrop-transition { + transition: none !important; + } +} + + +.sidebar-label { + display: inline-block; + vertical-align: middle; + white-space: nowrap; + will-change: opacity, transform; +} + +.sidebar-label-collapsed { + opacity: 0; + transform: translateX(-8px); + pointer-events: none; + transition: opacity 120ms cubic-bezier(0.215, 0.61, 0.355, 1), + transform 180ms cubic-bezier(0.215, 0.61, 0.355, 1); +} + +.sidebar-label-expanded { + opacity: 1; + transform: translateX(0); + transition: opacity 180ms ease-out 100ms, + transform 220ms cubic-bezier(0.34, 1.15, 0.64, 1) 80ms; +} + +.sidebar-logo-img { + width: 150px; + max-width: none !important; + flex-shrink: 0; + will-change: opacity, transform; +} + +.sidebar-logo-collapsed { + opacity: 0; + transform: translateX(-8px); + pointer-events: none; + transition: opacity 120ms cubic-bezier(0.215, 0.61, 0.355, 1), + transform 180ms cubic-bezier(0.215, 0.61, 0.355, 1); +} + +.sidebar-logo-expanded { + opacity: 1; + transform: translateX(0); + transition: opacity 180ms ease-out 100ms, + transform 220ms cubic-bezier(0.34, 1.15, 0.64, 1) 80ms; +} + +.nav-btn-transition { + width: 100%; + margin-left: 0; + margin-right: 0; + padding-left: 0.75rem !important; /* 12px — overrides DaisyUI btn-md padding */ + padding-right: 0.75rem !important; /* 12px — overrides DaisyUI btn-md padding */ + justify-content: flex-start !important; /* overrides DaisyUI btn justify-center */ + transition: background-color 150ms ease, + color 150ms ease; + will-change: background-color, color; +} + +.nav-btn-collapsed { + /* Buttons are 100% width; the sidebar container controls sizing */ +} + +/* Sidebar social icons and copyright footer transition rules */ +.sidebar-footer { + will-change: opacity, transform; + width: 250px; + flex-shrink: 0; + margin-top: 1rem; +} + +.sidebar-footer-collapsed { + opacity: 0; + transform: translateX(-8px); + pointer-events: none; + transition: opacity 120ms cubic-bezier(0.215, 0.61, 0.355, 1), + transform 180ms cubic-bezier(0.215, 0.61, 0.355, 1); +} + +.sidebar-footer-expanded { + opacity: 1; + transform: translateX(0); + transition: opacity 180ms ease-out 100ms, + transform 220ms cubic-bezier(0.34, 1.15, 0.64, 1) 80ms; +} + +.chevron-rotate-transition { + transition: transform 280ms cubic-bezier(0.34, 1.15, 0.64, 1), + rotate 280ms cubic-bezier(0.34, 1.15, 0.64, 1); + will-change: transform, rotate; +} + +/* Progressive loading transitions */ +.progressive-transition-container { + display: grid; + grid-template-columns: 100%; + width: 100%; +} + +.progressive-transition-container > * { + grid-column: 1 / 2; + grid-row: 1 / 2; +} + +.progressive-transition-fade-out { + animation: progressive-fade-out 250ms cubic-bezier(0.215, 0.61, 0.355, 1) forwards; + will-change: opacity, transform; + pointer-events: none; +} + +@keyframes progressive-fade-out { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + +.progressive-transition-fade-in { + animation: progressive-fade-in 250ms cubic-bezier(0.215, 0.61, 0.355, 1) forwards; + will-change: opacity; +} + +@keyframes progressive-fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@media (prefers-reduced-motion: reduce) { + .progressive-transition-fade-out { + animation: none; + opacity: 0; + transform: none; + } + .progressive-transition-fade-in { + animation: none; + opacity: 1; + transform: none; + } +}