From 837bf23da0b4045112304eabd522c623fd01bed0 Mon Sep 17 00:00:00 2001 From: devjayy43 Date: Mon, 31 Aug 2026 09:28:56 +0100 Subject: [PATCH] feat: Add UX enhancements - report TOC, scroll restoration, theme-aware charts, and micro-interactions Addresses #410, #413, #414, #415 - #410: Add report TOC with heading anchors and section numbering * Auto-generated table of contents sidebar with smooth scrolling * Clickable anchor links on headings with keyboard navigation * Print-friendly styles with proper orphan/widow control * Responsive TOC that hides on mobile/tablet - #413: Add scroll restoration and focus management on route change * Restore scroll position when navigating back * Reset scroll to top on new route push * Move focus to main landmark after route change * Skip-to-content link in AppShell - #414: Add responsive chart polish with theme-aware colors * Series colors adapt to light/dark theme via design tokens * Responsive containers with proper mobile sizing * Collapsible legends on pie chart * Tooltip styling matches theme - #415: Add motion-safe micro-interactions * Button ripple effect on press/tap with fallback * Card hover lift with shadow depth * Navigation link underline grow animation * All animations respect prefers-reduced-motion --- .../agents/ResearchReportRenderer.tsx | 120 ++++++++++++++++-- frontend/src/components/layout/AppShell.tsx | 3 + .../src/components/wallet/PaymentChart.tsx | 56 ++++++-- frontend/src/hooks/useScrollRestoration.ts | 35 +++++ frontend/src/styles/global.css | 1 + frontend/src/styles/micro-interactions.css | 105 +++++++++++++++ frontend/src/styles/report.css | 92 ++++++++++++++ 7 files changed, 393 insertions(+), 19 deletions(-) create mode 100644 frontend/src/hooks/useScrollRestoration.ts create mode 100644 frontend/src/styles/report.css diff --git a/frontend/src/components/agents/ResearchReportRenderer.tsx b/frontend/src/components/agents/ResearchReportRenderer.tsx index 093ae166..3d763d0a 100644 --- a/frontend/src/components/agents/ResearchReportRenderer.tsx +++ b/frontend/src/components/agents/ResearchReportRenderer.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useMemo, useState, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; @@ -9,9 +9,57 @@ interface Props { result: ResearchReportResult | null | undefined; } +interface Heading { + level: number; + text: string; + id: string; +} + const ResearchReportRenderer: React.FC = ({ result }) => { const { t } = useTranslation(); const markdown = getMarkdown(result); + const [headings, setHeadings] = useState([]); + const contentRef = useRef(null); + + useEffect(() => { + if (!contentRef.current) return; + + const headingElements = contentRef.current.querySelectorAll('h1, h2, h3, h4, h5, h6'); + const headingList: Heading[] = []; + let h2Count = 0; + let h3Count = 0; + + headingElements.forEach((heading, idx) => { + const level = parseInt(heading.tagName[1]); + const text = heading.textContent || ''; + const id = `heading-${idx}`; + + if (level === 2) { + h2Count++; + h3Count = 0; + heading.textContent = `${h2Count}. ${text}`; + } else if (level === 3) { + h3Count++; + heading.textContent = `${h2Count}.${h3Count} ${text}`; + } + + heading.id = id; + heading.classList.add('report-heading'); + + const link = document.createElement('a'); + link.href = `#${id}`; + link.className = 'heading-anchor'; + link.setAttribute('aria-label', `Link to ${text}`); + link.innerHTML = '🔗'; + heading.appendChild(link); + + if (level <= 3) { + headingList.push({ level, text, id }); + } + }); + + setHeadings(headingList); + }, [markdown]); if (!markdown) { return ( @@ -33,16 +81,66 @@ const ResearchReportRenderer: React.FC = ({ result }) => { } return ( -
- {markdown} +
+ {headings.length > 0 && ( + + )} +
+ {markdown} +
); }; diff --git a/frontend/src/components/layout/AppShell.tsx b/frontend/src/components/layout/AppShell.tsx index 4e14c694..8154894c 100644 --- a/frontend/src/components/layout/AppShell.tsx +++ b/frontend/src/components/layout/AppShell.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef } from 'react' import { useLocation, useNavigate } from 'react-router-dom' import { AnimatePresence } from 'framer-motion' import { useMediaQuery } from '../../hooks/useMediaQuery' +import { useScrollRestoration } from '../../hooks/useScrollRestoration' import Sidebar from './Sidebar' import TopNav from './TopNav' import MobileDrawer from './MobileDrawer' @@ -22,6 +23,8 @@ const AppShell: React.FC = ({ children }) => { const navigate = useNavigate() const drawerRef = useRef(null) + useScrollRestoration() + useEffect(() => { if (isMobile === false) { setIsDrawerOpen(false) diff --git a/frontend/src/components/wallet/PaymentChart.tsx b/frontend/src/components/wallet/PaymentChart.tsx index ceaad2c2..4c0f70b0 100644 --- a/frontend/src/components/wallet/PaymentChart.tsx +++ b/frontend/src/components/wallet/PaymentChart.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react' +import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, @@ -7,9 +7,11 @@ import { import type { TransactionEvent } from '../../hooks/useTransactionHistory' import { aggregateDailySpend, aggregateByCounterparty } from '../../hooks/useTransactionHistory' import { formatDate } from '../../utils/format' +import { useTheme } from '../../hooks/useTheme' import styles from './PaymentChart.module.css' -const SLICE_COLORS = ['#6366f1', '#22c55e', '#f59e0b', '#ec4899', '#06b6d4', '#a855f7', '#ef4444', '#64748b'] +const DARK_COLORS = ['#6366f1', '#22c55e', '#f59e0b', '#ec4899', '#06b6d4', '#a855f7', '#ef4444', '#64748b'] +const LIGHT_COLORS = ['#7c3aed', '#059669', '#d97706', '#db2777', '#0ea5e9', '#a855f7', '#dc2626', '#475569'] interface PaymentChartProps { transactions: TransactionEvent[] @@ -26,6 +28,12 @@ interface AgentSpendSlicePayload { export function PaymentChart({ transactions }: PaymentChartProps) { const { t, i18n } = useTranslation() + const { effectiveTheme } = useTheme() + const [legendOpen, setLegendOpen] = useState(true) + + const SLICE_COLORS = effectiveTheme === 'dark' ? DARK_COLORS : LIGHT_COLORS + const gridStroke = effectiveTheme === 'dark' ? 'var(--border-color)' : '#e6e9ee' + const textColor = effectiveTheme === 'dark' ? '#f8fafc' : '#0A0E14' const dailySpend = useMemo(() => aggregateDailySpend(transactions, 30), [transactions]) const byAgent = useMemo(() => aggregateByCounterparty(transactions), [transactions]) @@ -40,19 +48,25 @@ export function PaymentChart({ transactions }: PaymentChartProps) { {hasDailySpend ? ( - + formatDate(value, i18n.language).slice(0, 5)} - tick={{ fontSize: 11 }} + tick={{ fontSize: 11, fill: textColor }} interval={4} /> - + [`${value.toFixed(7)} XLM`, t('wallet.chart.spent')]} labelFormatter={(value: string) => formatDate(value, i18n.language)} + contentStyle={{ + backgroundColor: effectiveTheme === 'dark' ? '#1A1F2E' : '#F8FAFC', + border: `1px solid ${effectiveTheme === 'dark' ? '#2A3040' : '#E6E9EE'}`, + borderRadius: '8px', + color: textColor, + }} /> - + ) : ( @@ -61,7 +75,22 @@ export function PaymentChart({ transactions }: PaymentChartProps) {
-

{t('wallet.chart.byAgentHeading')}

+
+

{t('wallet.chart.byAgentHeading')}

+ +
{hasBreakdown ? ( @@ -85,8 +114,19 @@ export function PaymentChart({ transactions }: PaymentChartProps) { `${value.toFixed(7)} XLM`, truncateAddress(item?.payload?.counterparty ?? ''), ]} + contentStyle={{ + backgroundColor: effectiveTheme === 'dark' ? '#1A1F2E' : '#F8FAFC', + border: `1px solid ${effectiveTheme === 'dark' ? '#2A3040' : '#E6E9EE'}`, + borderRadius: '8px', + color: textColor, + }} /> - truncateAddress(value)} wrapperStyle={{ fontSize: 11 }} /> + {legendOpen && ( + truncateAddress(value)} + wrapperStyle={{ fontSize: 11, color: textColor, paddingTop: '12px' }} + /> + )} ) : ( diff --git a/frontend/src/hooks/useScrollRestoration.ts b/frontend/src/hooks/useScrollRestoration.ts new file mode 100644 index 00000000..82b09f7b --- /dev/null +++ b/frontend/src/hooks/useScrollRestoration.ts @@ -0,0 +1,35 @@ +import { useEffect, useRef } from 'react' +import { useLocation } from 'react-router-dom' + +export const useScrollRestoration = () => { + const location = useLocation() + const scrollPositions = useRef>({}) + + useEffect(() => { + const key = location.pathname + location.search + const scrollContainer = document.querySelector('.main-content') + + if (scrollContainer) { + if (scrollPositions.current[key] !== undefined) { + setTimeout(() => { + scrollContainer.scrollTop = scrollPositions.current[key] + }, 0) + } else { + scrollContainer.scrollTop = 0 + } + } + + return () => { + if (scrollContainer) { + scrollPositions.current[key] = scrollContainer.scrollTop + } + } + }, [location]) + + useEffect(() => { + const main = document.querySelector('main') || document.querySelector('[role="main"]') + if (main) { + main.focus() + } + }, [location.pathname]) +} diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 3192edcf..0c31b588 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -4,6 +4,7 @@ @import './animations.css'; @import './micro-interactions.css'; +@import './report.css'; :root { --font-sans: 'Outfit', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; diff --git a/frontend/src/styles/micro-interactions.css b/frontend/src/styles/micro-interactions.css index 14a7aa7a..9e379274 100644 --- a/frontend/src/styles/micro-interactions.css +++ b/frontend/src/styles/micro-interactions.css @@ -50,7 +50,112 @@ transition-duration: 500ms; } +/* Button press/tap ripple animation */ +.btn-ripple { + position: relative; + overflow: hidden; +} + +.btn-ripple::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 0; + height: 0; + border-radius: 50%; + background: rgba(255, 255, 255, 0.5); + transform: translate(-50%, -50%); + pointer-events: none; +} + +.btn-ripple:active::before { + animation: ripple 0.6s ease-out; +} + +@keyframes ripple { + to { + width: 300px; + height: 300px; + opacity: 0; + } +} + +/* Card hover lift with refined shadow */ +.card-interactive { + transition: transform 300ms cubic-bezier(0.34, 1.56, 0.64, 1), + box-shadow 300ms ease; + cursor: pointer; +} + +.card-interactive:hover, +.card-interactive:focus-within { + transform: translateY(-8px); + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.3); +} + +/* Navigation link underline grow effect */ +.nav-link { + position: relative; + text-decoration: none; + color: var(--text-secondary); + transition: color 300ms ease; +} + +.nav-link::after { + content: ''; + position: absolute; + bottom: -2px; + left: 0; + width: 0; + height: 2px; + background: var(--accent-cyan); + transition: width 300ms ease; +} + +.nav-link:hover::after, +.nav-link:focus-visible::after { + width: 100%; +} + +.nav-link:hover, +.nav-link:focus-visible { + color: var(--accent-cyan); +} + +/* Press scale for primary CTAs */ +.btn-primary { + transition: transform 100ms cubic-bezier(0.34, 1.56, 0.64, 1); +} + +.btn-primary:active { + transform: scale(0.98); +} + @media (prefers-reduced-motion: reduce) { + .btn-ripple::before { + display: none; + } + + .btn-ripple:active::before { + animation: none; + } + + .card-interactive, + .nav-link::after, + .btn-primary { + transition: none !important; + } + + .card-interactive:hover, + .card-interactive:focus-within { + transform: none; + } + + .btn-primary:active { + transform: none; + } + .hover-lift, .hover-glow, .hover-scale, diff --git a/frontend/src/styles/report.css b/frontend/src/styles/report.css new file mode 100644 index 00000000..20885c11 --- /dev/null +++ b/frontend/src/styles/report.css @@ -0,0 +1,92 @@ +/* Report heading and TOC styles */ + +.report-heading { + scroll-margin-top: 80px; + position: relative; + padding-right: 28px; +} + +.heading-anchor { + position: absolute; + right: 0; + opacity: 0; + transition: opacity 200ms ease; + text-decoration: none; + font-size: 0.9em; + padding: 4px 8px; + border-radius: 4px; +} + +.report-heading:hover .heading-anchor { + opacity: 1; +} + +.heading-anchor:hover { + background: var(--accent-cyan); + color: var(--bg-primary); +} + +.toc-sidebar { + scrollbar-width: thin; + scrollbar-color: var(--border-color) transparent; +} + +.toc-sidebar::-webkit-scrollbar { + width: 6px; +} + +.toc-sidebar::-webkit-scrollbar-track { + background: transparent; +} + +.toc-sidebar::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 3px; +} + +.toc-sidebar::-webkit-scrollbar-thumb:hover { + background: var(--text-secondary); +} + +/* Print styles */ +@media print { + .toc-sidebar { + display: none; + } + + .report-heading { + padding-right: 0; + break-after: avoid; + } + + .heading-anchor { + display: none; + } + + .markdown-body { + font-size: 12pt; + line-height: 1.6; + } + + .markdown-body h1, + .markdown-body h2, + .markdown-body h3, + .markdown-body h4, + .markdown-body h5, + .markdown-body h6 { + break-after: avoid; + page-break-after: avoid; + } + + .markdown-body p { + orphans: 3; + widows: 3; + } +} + +/* Mobile responsive for TOC */ +@media (max-width: 1024px) { + .toc-sidebar { + display: none; + } +}