Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 49 additions & 1 deletion frontend/src/components/agents/ResearchReportRenderer.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -14,9 +14,57 @@ interface Props {
searchQuery?: string;
}

interface Heading {
level: number;
text: string;
id: string;
}

const ResearchReportRenderer: React.FC<Props> = ({ result }) => {
const { t } = useTranslation();
const markdown = getMarkdown(result);
const [headings, setHeadings] = useState<Heading[]>([]);
const contentRef = useRef<HTMLDivElement>(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 (
Expand Down
38 changes: 33 additions & 5 deletions frontend/src/components/wallet/PaymentChart.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -7,6 +7,7 @@ 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'
import { AccessibleChart } from '../common/AccessibleChart'

Expand All @@ -27,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])
Expand All @@ -41,17 +48,23 @@ export function PaymentChart({ transactions }: PaymentChartProps) {
{hasDailySpend ? (
<ResponsiveContainer width="100%" height={220}>
<BarChart data={dailySpend} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--border-color)" />
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={gridStroke} />
<XAxis
dataKey="date"
tickFormatter={(value: string) => formatDate(value, i18n.language).slice(0, 5)}
tick={{ fontSize: 11 }}
tick={{ fontSize: 11, fill: textColor }}
interval={4}
/>
<YAxis tick={{ fontSize: 11 }} width={40} />
<YAxis tick={{ fontSize: 11, fill: textColor }} width={40} />
<Tooltip
formatter={(value: number) => [`${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,
}}
/>
<Bar dataKey="total" fill="var(--accent-secondary)" radius={[4, 4, 0, 0]} />
</BarChart>
Expand All @@ -62,7 +75,22 @@ export function PaymentChart({ transactions }: PaymentChartProps) {
</div>

<div className={styles.chartCard}>
<h3 className={styles.heading}>{t('wallet.chart.byAgentHeading')}</h3>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
<h3 className={styles.heading}>{t('wallet.chart.byAgentHeading')}</h3>
<button
onClick={() => setLegendOpen(!legendOpen)}
style={{
background: 'none',
border: 'none',
color: 'var(--text-secondary)',
cursor: 'pointer',
fontSize: '0.9rem',
padding: '4px 8px',
}}
>
{legendOpen ? '▼' : '▶'} Legend
</button>
</div>
{hasBreakdown ? (
<AccessibleChart
label={t('wallet.chart.byAgentHeading')}
Expand Down
35 changes: 35 additions & 0 deletions frontend/src/hooks/useScrollRestoration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { useEffect, useRef } from 'react'
import { useLocation } from 'react-router-dom'

export const useScrollRestoration = () => {
const location = useLocation()
const scrollPositions = useRef<Record<string, number>>({})

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])
}
1 change: 1 addition & 0 deletions frontend/src/styles/global.css
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
@import './tokens.css';
@import './animations.css';
@import './micro-interactions.css';
@import './report.css';

@tailwind base;
@tailwind components;
Expand Down
105 changes: 105 additions & 0 deletions frontend/src/styles/micro-interactions.css
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
92 changes: 92 additions & 0 deletions frontend/src/styles/report.css
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading