diff --git a/web/src/components/Skeleton.tsx b/web/src/components/Skeleton.tsx
new file mode 100644
index 0000000..1e38a04
--- /dev/null
+++ b/web/src/components/Skeleton.tsx
@@ -0,0 +1,33 @@
+import type { CSSProperties } from 'react';
+
+interface SkeletonBlockProps {
+ width?: number | string;
+ height?: number | string;
+ radius?: number;
+ style?: CSSProperties;
+}
+
+export function SkeletonBlock({
+ width = '100%',
+ height = 12,
+ radius = 999,
+ style,
+}: SkeletonBlockProps) {
+ return (
+
+ );
+}
+
+export function SkeletonText({ width = '70%' }: { width?: number | string }) {
+ return ;
+}
diff --git a/web/src/components/layout/RightPanel.tsx b/web/src/components/layout/RightPanel.tsx
index cb838d8..bfbb187 100644
--- a/web/src/components/layout/RightPanel.tsx
+++ b/web/src/components/layout/RightPanel.tsx
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
import { ArrowRight } from 'lucide-react';
import { useAppContext } from '../../context/AppContext';
import { fetchNews, type NewsArticle as MarketNewsArticle } from '../../lib/marketApi';
+import { SkeletonBlock, SkeletonText } from '../Skeleton';
// ─── Portfolio Summary ────────────────────────────────────────────────────────
@@ -147,6 +148,31 @@ function NewsCard({ article }: { article: NewsArticle }) {
);
}
+function NewsFeedSkeleton() {
+ return (
+
+ {[0, 1, 2].map((i) => (
+
+ ))}
+
+ );
+}
+
function NewsFeed() {
const { activeSymbol } = useAppContext();
const [news, setNews] = useState([]);
@@ -180,9 +206,7 @@ function NewsFeed() {
- {loading && (
- Loading news…
- )}
+ {loading && }
{!loading && error && (
{error}
@@ -194,7 +218,7 @@ function NewsFeed() {
)}
- {news.map((a, i) => )}
+ {!loading && news.map((a, i) => )}
);
}
diff --git a/web/src/views/HomeView.tsx b/web/src/views/HomeView.tsx
index 4f063b5..a750d7a 100644
--- a/web/src/views/HomeView.tsx
+++ b/web/src/views/HomeView.tsx
@@ -10,6 +10,7 @@ import {
fetchChartBars, fetchResearchProfile, fetchResearchMetrics, fetchResearchEarnings,
type OHLCVBar,
} from '../lib/marketApi';
+import { SkeletonBlock, SkeletonText } from '../components/Skeleton';
// ─── Time period config ───────────────────────────────────────────────────────
const PERIODS = ['1D', '5D', '1M', '3M', '6M', 'YTD', '1Y', '5Y', 'MAX'] as const;
@@ -589,6 +590,7 @@ function ResearchCards({
const nextEarnings = earnings.find(e => e.date && new Date(e.date) >= new Date());
const fmtDate = (d?: string) => d ? new Date(d).toLocaleDateString('en-US', { month:'short', day:'numeric', year:'numeric' }) : '—';
+ const ValueSkeleton = ({ width = 58 }: { width?: number }) => ;
const financialRows: [string, string][] = [
['Market Cap', fmtBig(profile?.mktCap)],
@@ -610,7 +612,12 @@ function ResearchCards({
📋 Company
{profileLoading ? (
- Loading…
+
+
+
+
+
+
) : profile ? (
<>
@@ -646,7 +653,9 @@ function ResearchCards({
padding: '5px 0', borderBottom: '1px solid #F9FAFB',
}}>
{label}
- {loading || profileLoading ? '…' : val}
+
+ {loading || profileLoading ? 10 ? 64 : 46} /> : val}
+
))}
@@ -667,10 +676,12 @@ function ResearchCards({
padding: '5px 0', borderBottom: '1px solid #F9FAFB',
}}>
{label}
- {val}
+
+ {val === '…' ? : val}
+
))}
- {earnings.length > 0 && (
+ {!loading && earnings.length > 0 && (
Past Earnings
{earnings.slice(0,3).map((e, i) => (
diff --git a/web/src/views/ScreenerView.tsx b/web/src/views/ScreenerView.tsx
index ff70aaf..a176f5b 100644
--- a/web/src/views/ScreenerView.tsx
+++ b/web/src/views/ScreenerView.tsx
@@ -1,10 +1,11 @@
import { useState, useEffect, useCallback } from 'react';
-import { SlidersHorizontal, Search, Loader2, RefreshCw } from 'lucide-react';
+import { SlidersHorizontal, Search, RefreshCw } from 'lucide-react';
import { useAppContext } from '../context/AppContext';
import { useNavigate } from 'react-router-dom';
import { getPlatformAccessToken } from '../lib/authSession';
import { tradingRuntime } from '../lib/runtime';
import { createRequestId } from '../../../shared/request-id.js';
+import { SkeletonBlock } from '../components/Skeleton';
// ─── Types ────────────────────────────────────────────────────────────────────
interface ScreenerRow {
@@ -126,6 +127,32 @@ export function ScreenerView() {
);
+ const ScreenerSkeletonRows = () => (
+
+ {Array.from({ length: 6 }).map((_, i) => (
+
+
+
+
+
+
+
+
+
+ ))}
+
+ );
+
const handleRowClick = (symbol: string) => {
setActiveSymbol(symbol);
navigate('/');
@@ -271,15 +298,7 @@ export function ScreenerView() {
{/* Loading */}
- {loading && (
-
-
- Fetching screener results…
-
- )}
+ {loading && }
{/* Rows */}
{!loading && filtered.map((row, i) => (