Skip to content
Open
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
111 changes: 100 additions & 11 deletions frontend/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
--layout-max-width: 1400px;
--layout-wide-max-width: 1520px;
--chat-column-width: clamp(360px, 28vw, 420px);
--font-primary: var(--font-outfit), -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
--font-mono: var(--font-mono), "Menlo", "Monaco", "Courier New", monospace;
}

/* ── Accessibility: sr-only ──────────────────────────────────── */
Expand Down Expand Up @@ -41,15 +43,7 @@
}

body {
font-family:
var(--font-outfit),
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
Helvetica,
Arial,
sans-serif;
font-family: var(--font-primary);
background-color: var(--bg-dark);
background-image:
radial-gradient(
Expand All @@ -64,6 +58,49 @@ body {
display: flex;
flex-direction: column;
overflow-x: hidden;
position: relative;
}

body::before {
content: "";
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: radial-gradient(
circle at 20% 60%,
rgba(139, 92, 246, 0.08),
transparent 35%
), radial-gradient(
circle at 80% 20%,
rgba(6, 182, 212, 0.08),
transparent 35%
);
animation: gradient-drift 20s ease-in-out infinite;
pointer-events: none;
z-index: -1;
}

@media (prefers-reduced-motion: reduce) {
body::before {
animation: none;
}
}

@keyframes gradient-drift {
0% {
opacity: 0.6;
transform: translateY(0px);
}
50% {
opacity: 0.8;
transform: translateY(-20px);
}
100% {
opacity: 0.6;
transform: translateY(0px);
}
}

/* Dashboard Header */
Expand Down Expand Up @@ -912,6 +949,7 @@ h2 {
font-size: 2.25rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
font-family: var(--font-mono);
display: flex;
align-items: baseline;
gap: 0.5rem;
Expand Down Expand Up @@ -1628,32 +1666,51 @@ h2 {
.status-indicator {
display: inline-flex;
align-items: center;
gap: 6px;
gap: 8px;
padding: 6px 12px;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 600;
margin-top: 8px;
}

.status-indicator-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}

.status-indicator.falling-behind {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
border: 1px solid rgba(239, 68, 68, 0.3);
}

.status-indicator.falling-behind .status-indicator-dot {
background: #ef4444;
}

.status-indicator.on-track {
background: rgba(34, 197, 94, 0.1);
color: #22c55e;
border: 1px solid rgba(34, 197, 94, 0.3);
}

.status-indicator.on-track .status-indicator-dot {
background: #22c55e;
}

.status-indicator.ahead {
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
border: 1px solid rgba(59, 130, 246, 0.3);
}

.status-indicator.ahead .status-indicator-dot {
background: #3b82f6;
}

/* ── Skeleton loaders ────────────────────────────────────────── */
.skeleton-bone {
background: rgba(255, 255, 255, 0.03);
Expand Down Expand Up @@ -2013,12 +2070,25 @@ h2 {
.goal-title {
font-size: 1.25rem;
margin-bottom: 4px;
font-weight: 600;
}

.goal-subtitle {
font-size: 0.9rem;
}

.goal-metric {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
font-weight: 600;
color: var(--text-main);
}

.goal-header-content {
min-width: 0;
flex: 1;
}

.goal-status-text {
font-size: 0.85rem;
font-weight: 600;
Expand All @@ -2028,11 +2098,30 @@ h2 {
.progress-stats {
display: flex;
justify-content: space-between;
gap: 1.5rem;
font-size: 0.85rem;
color: var(--text-muted);
font-weight: 500;
}

.progress-stat-label {
display: block;
color: var(--text-muted);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.25rem;
font-weight: 600;
}

.progress-stat-value {
display: block;
color: var(--text-main);
font-size: 1.1rem;
font-weight: 700;
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
}

.allocation-title {
display: flex;
align-items: center;
Expand Down
11 changes: 9 additions & 2 deletions frontend/src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Metadata } from 'next';
import { Outfit } from 'next/font/google';
import { Outfit, JetBrains_Mono } from 'next/font/google';
import './globals.css';
import { WalletProvider } from '../components/features/wallet/WalletContext';
import { ErrorBoundary } from '../components/feedback/ErrorBoundary';
Expand All @@ -12,6 +12,13 @@ const outfit = Outfit({
variable: '--font-outfit',
});

const jetbrainsMono = JetBrains_Mono({
subsets: ['latin'],
weight: ['400', '500', '600', '700'],
display: 'swap',
variable: '--font-mono',
});

export const metadata: Metadata = {
title: 'Smasage | AI Portfolio Manager',
description: 'AI Portfolio Manager natively on Stellar',
Expand All @@ -23,7 +30,7 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
<html lang="en" className={outfit.variable}>
<html lang="en" className={`${outfit.variable} ${jetbrainsMono.variable}`}>
<body className={outfit.className}>
<WalletProvider>
<ErrorBoundary fallbackMessage="The app ran into a problem. Please try again.">
Expand Down
37 changes: 29 additions & 8 deletions frontend/src/components/features/portfolio/GoalTracker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,19 @@ function getStatusClass(status: ProjectionResult["status"]): string {
}
}

function getStatusLabel(status: ProjectionResult["status"]): string {
switch (status) {
case "Ahead":
return "Ahead of schedule";
case "On Track":
return "On track";
case "Falling Behind":
return "Falling behind";
default:
return "On track";
}
}

function formatCurrency(value: number): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
Expand Down Expand Up @@ -53,20 +66,22 @@ export function GoalTracker({
}: GoalTrackerProps) {
const statusColor = getStatusColor(status);
const clampedProgress = Math.max(0, Math.min(100, progressPercentage));
const statusLabel = getStatusLabel(status);

return (
<div className="goal-section skeleton-fade-in">
<div className="goal-header">
<div>
<div className="goal-header-content">
<h3 className="goal-title">{goalName}</h3>
<p className="text-muted goal-subtitle">
Target: {formatCurrency(targetAmount)} by {formatTargetDate(targetDate)}
Target: <span className="goal-metric">{formatCurrency(targetAmount)}</span> by <time>{formatTargetDate(targetDate)}</time>
</p>
<div className={`status-indicator ${getStatusClass(status)}`}>
Status: {status}
<div className={`status-indicator ${getStatusClass(status)}`} role="status" aria-label={`Status: ${statusLabel}`}>
<span className="status-indicator-dot"></span>
{statusLabel}
</div>
</div>
<Target size={32} color={statusColor} opacity={0.8} />
<Target size={32} color={statusColor} opacity={0.8} aria-hidden="true" />
</div>

<div className="progress-bar-container">
Expand All @@ -77,13 +92,19 @@ export function GoalTracker({
aria-valuenow={Math.round(clampedProgress)}
aria-valuemin={0}
aria-valuemax={100}
aria-label="Savings goal progress"
aria-label={`Savings goal progress: ${Math.round(clampedProgress)}% complete`}
></div>
</div>

<div className="progress-stats">
<span>{Math.round(clampedProgress)}% Completed</span>
<span>{formatCurrency(remainingAmount)} Remaining</span>
<div>
<span className="progress-stat-label">Completed</span>
<span className="progress-stat-value">{Math.round(clampedProgress)}%</span>
</div>
<div>
<span className="progress-stat-label">Remaining</span>
<span className="progress-stat-value">{formatCurrency(remainingAmount)}</span>
</div>
</div>
</div>
);
Expand Down
37 changes: 22 additions & 15 deletions frontend/src/components/features/portfolio/PortfolioStats.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,41 +9,48 @@ export interface PortfolioStatsProps {
valueChange: number;
}

const formatMetric = (value: number, style: 'currency' | 'percent'): string => {
if (style === 'currency') {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
}
return value.toFixed(1) + '%';
};

export const PortfolioStats: React.FC<PortfolioStatsProps> = ({
totalValue,
apy,
valueChange,
}) => {
const formattedValue = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(totalValue);

const formattedValue = formatMetric(totalValue, 'currency');
const changeLabel = `${valueChange >= 0 ? '+' : ''}${valueChange.toFixed(1)}%`;
const apyLabel = formatMetric(apy, 'percent');

return (
<div className="stats-grid">
<div className="stat-card">
<div className="stat-label">
<Wallet size={16} color="var(--accent-primary)" />
Total Value
<Wallet size={16} color="var(--accent-primary)" aria-hidden="true" />
<span>Total Value</span>
</div>
<div className="stat-value">
{formattedValue}
<span className="stat-sub">{changeLabel}</span>
<span>{formattedValue}</span>
<span className="stat-sub" aria-label={`Change: ${changeLabel}`}>{changeLabel}</span>
</div>
</div>

<div className="stat-card secondary">
<div className="stat-label">
<TrendingUp size={16} color="var(--accent-secondary)" />
Est. Monthly APY
<TrendingUp size={16} color="var(--accent-secondary)" aria-hidden="true" />
<span>Est. Monthly APY</span>
</div>
<div className="stat-value">
{apy.toFixed(1)}%
<span className="stat-sub">Active</span>
<span>{apyLabel}</span>
<span className="stat-sub" aria-label="Status: Active">Active</span>
</div>
</div>
</div>
Expand Down