diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css
index 607aa85..9927b00 100644
--- a/frontend/src/app/globals.css
+++ b/frontend/src/app/globals.css
@@ -652,8 +652,15 @@ body {
}
.btn-loader {
- width: 1rem;
- height: 1rem;
+ width: 1.125rem;
+ height: 1.125rem;
+ animation: spin 0.8s linear infinite;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .btn-loader {
+ animation-duration: 2s;
+ }
}
.connect-wallet-btn {
@@ -1007,10 +1014,10 @@ body {
}
.skeleton-bone--chart-donut {
- width: min(100%, 240px);
- height: min(100vw, 240px);
- max-width: 240px;
- max-height: 240px;
+ width: min(100%, 260px);
+ height: min(100vw, 260px);
+ max-width: 260px;
+ max-height: 260px;
}
.legend-item {
@@ -1920,9 +1927,20 @@ h2 {
}
/* ── Skeleton loaders ────────────────────────────────────────── */
+/*
+ * Design rules:
+ * - Bones sit on top of the same card backgrounds as real content
+ * (stat-card: rgba(255,255,255,0.03), goal-section: rgba(255,255,255,0.02))
+ * so the bone fill must be brighter than the card to be visible.
+ * - Shimmer peak matches the MetricTile inline skeleton for consistency.
+ * - border-radius per bone type: text lines → 4px, pill/progress → 999px,
+ * circular → 50%, swatch/icon → 4–8px, card-level → matches real card.
+ * - Dimensions are locked to the real component measurements to prevent
+ * layout shift on swap.
+ */
.skeleton-bone {
- background: rgba(255, 255, 255, 0.03);
- border-radius: 12px;
+ background: rgba(255, 255, 255, 0.07);
+ border-radius: 4px;
position: relative;
overflow: hidden;
}
@@ -1931,13 +1949,18 @@ h2 {
margin-bottom: 2.5rem;
}
+/* Stat card skeleton — matches .stat-card padding so bones don't sit flush */
.skeleton-stat-card {
- gap: 0;
+ gap: 0.5rem;
+ padding: 1.5rem;
}
.skeleton-goal-copy {
flex: 1;
min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.4rem;
}
.skeleton-progress-row {
@@ -1958,36 +1981,56 @@ h2 {
.skeleton-legend-text {
flex: 1;
min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.3rem;
}
+/* ── Bone dimensions — locked to real component measurements ── */
+
+/* stat-label: matches .metric-tile__label font-size ~0.75rem → ~12px rendered */
.skeleton-bone--stat-label {
- width: 60%;
- height: 14px;
- margin-bottom: 12px;
+ width: 55%;
+ height: 12px;
+ border-radius: 4px;
}
+/* stat-value: matches .metric-tile__value font-size 1.4rem → ~22px rendered */
.skeleton-bone--stat-value {
- width: 80%;
- height: 36px;
+ width: 75%;
+ height: 22px;
+ border-radius: 4px;
+}
+
+/* stat-trend: matches .metric-tile__trend font-size 0.72rem → ~11px */
+.skeleton-bone--stat-trend {
+ width: 35%;
+ height: 11px;
+ border-radius: 4px;
}
+/* goal-title: matches .goal-title font-size 1.25rem → ~20px */
.skeleton-bone--goal-title {
width: 55%;
- height: 18px;
- margin-bottom: 8px;
+ height: 20px;
+ border-radius: 4px;
}
+/* goal-line: matches .text-muted .goal-subtitle font-size 0.9rem → ~14px */
.skeleton-bone--goal-line {
width: 70%;
- height: 13px;
- margin-bottom: 6px;
+ height: 14px;
+ border-radius: 4px;
}
+/* goal-line-short: status indicator ~14px tall */
.skeleton-bone--goal-line-short {
width: 40%;
- height: 13px;
+ height: 14px;
+ border-radius: 4px;
}
+/* goal-icon: matches Target size={32} */
.skeleton-bone--goal-icon {
width: 32px;
height: 32px;
@@ -1995,6 +2038,7 @@ h2 {
flex-shrink: 0;
}
+/* progress bar: matches .progress-bar-container height 12px, border-radius 999px */
.skeleton-bone--progress {
width: 100%;
height: 12px;
@@ -2002,20 +2046,25 @@ h2 {
margin: 1rem 0;
}
+/* progress stats: matches .progress-stats font-size 0.85rem → ~13px */
.skeleton-bone--progress-stat {
width: 30%;
height: 13px;
+ border-radius: 4px;
}
+/* chart donut: matches PortfolioChart default width/height={320} minus 18px padding
+ outerRadius = 320/2 - 18 = 142 → full SVG viewBox is 320×320 */
.skeleton-bone--chart-donut {
- width: min(100%, 280px);
- height: min(100%, 280px);
- max-width: 280px;
- max-height: 280px;
+ width: min(100%, 320px);
+ height: min(100%, 320px);
+ max-width: 320px;
+ max-height: 320px;
border-radius: 50%;
margin: 0 auto;
}
+/* legend swatch: matches .legend-color 16×16 */
.skeleton-bone--legend-swatch {
width: 16px;
height: 16px;
@@ -2023,24 +2072,28 @@ h2 {
flex-shrink: 0;
}
+/* legend name: matches .legend-name font-size 0.9rem → ~14px */
.skeleton-bone--legend-name {
width: 60%;
- height: 13px;
- margin-bottom: 6px;
+ height: 14px;
+ border-radius: 4px;
}
+/* legend percentage: matches .legend-percentage font-size 0.8rem → ~12px */
.skeleton-bone--legend-pct {
width: 30%;
- height: 11px;
+ height: 12px;
+ border-radius: 4px;
}
+/* ── Shimmer sweep ── */
.skeleton-shimmer {
position: absolute;
inset: 0;
background: linear-gradient(
90deg,
transparent 0%,
- rgba(255, 255, 255, 0.08) 50%,
+ rgba(255, 255, 255, 0.1) 50%,
transparent 100%
);
animation: skeleton-sweep 1.6s infinite linear;
@@ -2520,7 +2573,6 @@ h2 {
white-space: nowrap;
user-select: none;
}
-.status-pill__icon { font-size: 0.6rem; line-height: 1; }
.status-pill--success { background: rgba(0,200,100,0.12); color: var(--success, #00c864); border: 1px solid rgba(0,200,100,0.25); }
.status-pill--warning { background: rgba(255,180,0,0.12); color: #ffb400; border: 1px solid rgba(255,180,0,0.25); }
.status-pill--error { background: rgba(255,60,60,0.12); color: #ff4444; border: 1px solid rgba(255,60,60,0.25); }
@@ -2559,16 +2611,16 @@ h2 {
.metric-tile__trend--flat { color: var(--text-muted, #888); }
.metric-tile__trend-label { font-family: 'JetBrains Mono', monospace; }
-/* Loading skeleton for MetricTile */
+/* Loading skeleton for MetricTile — uses same bone brightness as SkeletonLoader */
.metric-tile--loading .metric-tile__label-skeleton,
.metric-tile--loading .metric-tile__value-skeleton {
border-radius: 4px;
- background: linear-gradient(90deg, rgba(255,255,255,0.05) 25%, rgba(255,255,255,0.1) 50%, rgba(255,255,255,0.05) 75%);
+ background: linear-gradient(90deg, rgba(255,255,255,0.07) 25%, rgba(255,255,255,0.12) 50%, rgba(255,255,255,0.07) 75%);
background-size: 200% 100%;
animation: shimmer 1.6s ease-in-out infinite;
}
-.metric-tile--loading .metric-tile__label-skeleton { height: 0.75rem; width: 60%; margin-bottom: 0.25rem; }
-.metric-tile--loading .metric-tile__value-skeleton { height: 1.6rem; width: 80%; }
+.metric-tile--loading .metric-tile__label-skeleton { height: 0.75rem; width: 55%; margin-bottom: 0.25rem; }
+.metric-tile--loading .metric-tile__value-skeleton { height: 1.4rem; width: 75%; }
/* ── EmptyState (#105) ──────────────────────────────────────────── */
.empty-state {
diff --git a/frontend/src/components/features/chat/ChatInterface.tsx b/frontend/src/components/features/chat/ChatInterface.tsx
index 93fe48d..39082d2 100644
--- a/frontend/src/components/features/chat/ChatInterface.tsx
+++ b/frontend/src/components/features/chat/ChatInterface.tsx
@@ -100,13 +100,13 @@ export function ChatInterface({
>
{isConnected ? (
) : (
-
+
)}{" "}
{isConnected ? connectionCopy.online : connectionCopy.offline}
@@ -121,7 +121,7 @@ export function ChatInterface({
animate={{ opacity: 1, y: 0 }}
transition={prefersReduced ? { duration: 0.12 } : { duration: 0.2 }}
>
-
+
Notification service is offline. You can still draft and send local chat messages.
)}
@@ -161,7 +161,7 @@ export function ChatInterface({
animate={{ opacity: 1, x: 0 }}
transition={prefersReduced ? { duration: 0.12 } : { delay: 0.2 }}
>
- Proactive Nudge
+ Proactive Nudge
)}
@@ -230,7 +230,7 @@ export function ChatInterface({
whileTap={prefersReduced ? undefined : { scale: 0.95 }}
transition={prefersReduced ? { duration: 0.01 } : { duration: 0.15 }}
>
-
+
diff --git a/frontend/src/components/features/portfolio/GoalTracker.tsx b/frontend/src/components/features/portfolio/GoalTracker.tsx
index 9673cee..083e565 100644
--- a/frontend/src/components/features/portfolio/GoalTracker.tsx
+++ b/frontend/src/components/features/portfolio/GoalTracker.tsx
@@ -55,7 +55,7 @@ export function GoalTracker({
const clampedProgress = Math.max(0, Math.min(100, progressPercentage));
return (
-
+
{goalName}
diff --git a/frontend/src/components/features/wallet/ConnectivityWidget.tsx b/frontend/src/components/features/wallet/ConnectivityWidget.tsx
index 5af183b..07c4470 100644
--- a/frontend/src/components/features/wallet/ConnectivityWidget.tsx
+++ b/frontend/src/components/features/wallet/ConnectivityWidget.tsx
@@ -43,14 +43,14 @@ function getTone(status: WebSocketConnectionStatus | WalletConnectionStatus) {
function StatusIcon({ tone }: { tone: ReturnType
}) {
if (tone === "online") {
- return ;
+ return ;
}
if (tone === "pending") {
- return ;
+ return ;
}
- return ;
+ return ;
}
export function ConnectivityWidget({
@@ -109,7 +109,7 @@ export function ConnectivityWidget({
{summary}
diff --git a/frontend/src/components/features/wallet/Drawer.tsx b/frontend/src/components/features/wallet/Drawer.tsx
index 20ca023..28d3972 100644
--- a/frontend/src/components/features/wallet/Drawer.tsx
+++ b/frontend/src/components/features/wallet/Drawer.tsx
@@ -83,6 +83,7 @@ export const Drawer: React.FC = ({ isOpen, onClose, title, children
className="drawer-close-btn"
onClick={onClose}
aria-label="Close drawer"
+ title="Close drawer"
>
diff --git a/frontend/src/components/features/wallet/WalletModal.tsx b/frontend/src/components/features/wallet/WalletModal.tsx
index 0884bd0..c869652 100644
--- a/frontend/src/components/features/wallet/WalletModal.tsx
+++ b/frontend/src/components/features/wallet/WalletModal.tsx
@@ -92,6 +92,7 @@ export const WalletModal: React.FC = ({ isOpen, onClose }) =>
className="modal-close-icon"
onClick={onClose}
aria-label="Close dialog"
+ title="Close dialog"
>
diff --git a/frontend/src/components/feedback/SkeletonLoader.tsx b/frontend/src/components/feedback/SkeletonLoader.tsx
index 5b65654..2188ce2 100644
--- a/frontend/src/components/feedback/SkeletonLoader.tsx
+++ b/frontend/src/components/feedback/SkeletonLoader.tsx
@@ -2,43 +2,79 @@
import React from 'react';
+// ── Bone ─────────────────────────────────────────────────────────────────────
+// A single skeleton bone. className is appended to the base `skeleton-bone`
+// class so per-bone dimension/shape overrides work without specificity fights.
+
interface BoneProps {
className?: string;
}
function Bone({ className }: BoneProps) {
return (
-
-
+
);
}
+// ── PortfolioStatsSkeleton ────────────────────────────────────────────────────
+// Mirrors the two-column .stats-grid → two .stat-card wrappers, each holding
+// a MetricTile-shaped set of bones (label + value + optional trend).
+// Dimensions are locked to MetricTile's rendered sizes to prevent layout shift.
+
export function PortfolioStatsSkeleton() {
return (
-
- {[0, 1].map((i) => (
-
-
-
-
- ))}
+
+ {/* Card 1 — Total Value (has trend row) */}
+
+
+
+
+
+
+ {/* Card 2 — APY (no trend, has status pill placeholder) */}
+
+
+
+
);
}
+// ── GoalTrackerSkeleton ───────────────────────────────────────────────────────
+// Mirrors .goal-section → .goal-header (copy + icon) → progress bar → stats row.
+// .skeleton-goal-copy uses flex-column + gap so bones space themselves without
+// margin hacks that would differ from the real component.
+
export function GoalTrackerSkeleton() {
return (
-
+
+ {/* goal-title: h3.goal-title */}
+ {/* goal-subtitle: p.text-muted */}
+ {/* status-indicator badge */}
+ {/* Target icon — size={32} */}
+
+ {/* .progress-bar-container height=12px, border-radius=999px */}
+
+ {/* .progress-stats row — two short text spans */}
@@ -47,13 +83,33 @@ export function GoalTrackerSkeleton() {
);
}
+// ── PortfolioChartSkeleton ────────────────────────────────────────────────────
+// Mirrors .portfolio-chart-container → donut SVG placeholder + legend list.
+// Donut is 320×320 (matching PortfolioChart default props) to prevent the
+// 40px height jump that occurred with the old 280px value.
+// Legend items use .legend-item so they inherit the same min-height: 54px and
+// padding as the real buttons — no height shift on swap.
+
export function PortfolioChartSkeleton() {
return (
-
-
+
+ {/* Donut placeholder — 320×320 matches PortfolioChart default */}
+
+
+
+
+ {/* Legend — 3 rows matching the default 3-asset allocation */}
- {[0, 1, 2].map((i) => (
-
+ {([0, 1, 2] as const).map((i) => (
+
diff --git a/frontend/src/components/layout/DashboardHeader.tsx b/frontend/src/components/layout/DashboardHeader.tsx
index 7967759..2a8d6e8 100644
--- a/frontend/src/components/layout/DashboardHeader.tsx
+++ b/frontend/src/components/layout/DashboardHeader.tsx
@@ -40,6 +40,7 @@ export const DashboardHeader: React.FC = ({
className="btn btn-secondary btn-icon"
onClick={onOpenSettings}
aria-label="Open technical settings"
+ title="Open technical settings"
style={{ padding: '0.72rem', marginLeft: '0.75rem' }}
>
diff --git a/frontend/src/components/primitives/Button.tsx b/frontend/src/components/primitives/Button.tsx
index 99794d4..b3e9703 100644
--- a/frontend/src/components/primitives/Button.tsx
+++ b/frontend/src/components/primitives/Button.tsx
@@ -1,23 +1,18 @@
-import type { JSX } from "react";
+import { JSX } from "react";
+import { Loader2 } from "lucide-react";
import { motion, useReducedMotion, type HTMLMotionProps } from "framer-motion";
-import { buttonSpring } from "../../lib/motion";
-export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
-export type ButtonSize = "sm" | "md" | "lg";
+export type ButtonVariant = "primary" | "secondary";
export interface ButtonProps extends HTMLMotionProps<"button"> {
variant?: ButtonVariant;
- size?: ButtonSize;
isLoading?: boolean;
loadingLabel?: string;
- icon?: boolean;
}
export function Button({
variant = "primary",
- size = "md",
isLoading = false,
- icon = false,
disabled,
className,
children,
@@ -25,29 +20,24 @@ export function Button({
...rest
}: ButtonProps): JSX.Element {
const prefersReduced = useReducedMotion();
+ const classes = ["btn", `btn-${variant}`, className].filter(Boolean).join(" ");
- const sizeClass = size !== "md" ? `btn-${size}` : "";
- const iconClass = icon ? "btn-icon" : "";
- const classes = ["btn", `btn-${variant}`, sizeClass, iconClass, className]
- .filter(Boolean)
- .join(" ");
-
- // Omit motion-conflicting props to avoid type mismatches with motion.button
- const motionProps: Record = {
- ...rest,
- } as unknown as Record;
+ // Omit motion-conflicting props from rest to avoid type mismatches with motion.button
+ const motionProps: Record = { ...rest } as unknown as Record;
delete motionProps.onAnimationStart;
delete motionProps.onDrag;
delete motionProps.onDragEnd;
delete motionProps.onDragStart;
delete motionProps.style;
- const isInteractive = !prefersReduced && !disabled && !isLoading;
-
- // Tactile spring: hover lifts slightly, tap compresses with spring rebound.
- const hoverProp = isInteractive ? { scale: 1.03 } : undefined;
- const tapProp = isInteractive ? { scale: 0.95 } : undefined;
- const transitionProp = prefersReduced ? { duration: 0.01 } : buttonSpring;
+ // Decorative scale interactions are disabled for reduced-motion users.
+ // The button still shows :active via CSS (transform: scale(0.95)) which is
+ // a CSS-only affordance that browsers can suppress via their own UA sheet.
+ const hoverProp = !prefersReduced && !disabled && !isLoading ? { scale: 1.02 } : undefined;
+ const tapProp = !prefersReduced && !disabled && !isLoading ? { scale: 0.98 } : undefined;
+ const transitionProp = prefersReduced
+ ? { duration: 0.01 }
+ : { duration: 0.15, ease: "easeInOut" };
return (
-
-
-
+ size={18}
+ aria-hidden="true"
+ />
) : null}
diff --git a/frontend/src/components/primitives/MetricTile.tsx b/frontend/src/components/primitives/MetricTile.tsx
index d3e3244..d6acd88 100644
--- a/frontend/src/components/primitives/MetricTile.tsx
+++ b/frontend/src/components/primitives/MetricTile.tsx
@@ -15,9 +15,9 @@ export interface MetricTileProps {
}
const trendIcons: Record = {
- up: ,
- down: ,
- flat: ,
+ up: ,
+ down: ,
+ flat: ,
};
const trendClasses: Record = {
diff --git a/frontend/src/components/primitives/StatusPill.tsx b/frontend/src/components/primitives/StatusPill.tsx
index 69cdb04..1a3c9d1 100644
--- a/frontend/src/components/primitives/StatusPill.tsx
+++ b/frontend/src/components/primitives/StatusPill.tsx
@@ -1,6 +1,7 @@
"use client";
import React from "react";
+import { CheckCircle2, AlertTriangle, XCircle, Circle } from "lucide-react";
export type StatusVariant = "success" | "warning" | "error" | "neutral";
@@ -17,23 +18,21 @@ const variantClasses: Record = {
neutral: "status-pill status-pill--neutral",
};
-const variantIcons: Record = {
- success: "●",
- warning: "▲",
- error: "✕",
- neutral: "○",
-};
-
export function StatusPill({ variant, label, className }: StatusPillProps) {
+ const Icon = {
+ success: CheckCircle2,
+ warning: AlertTriangle,
+ error: XCircle,
+ neutral: Circle,
+ }[variant];
+
return (
-
- {variantIcons[variant]}
-
+
{label}
);