Ticker page: one unified drill-down panel, no card salad
The previous pass kept a stack of independent glass cards (header, recommendation, chart section, tab pills, per-tab sections), which read as clutter. Now the page is a single casting, like the mockup: One glass panel containing: - header (identity, price, chips | actions + score fingerprint) - inline hairline tab row (Analysis / Indicators / S/R Levels / Sentiment / Fundamentals) with the chart-overlay pills riding on the right of the row during Analysis - tab body: Analysis = the candlestick chart; other tabs render their panel inside via .hz-frameless, which strips the nested card chrome (no card-in-card) - panel foot: data freshness row (frameless now) with per-source refresh Below the panel, only on Analysis: the recommendation module and a two-column Standing | Dimensions grid. Nothing removed; the standalone Tabs bar, the separate chart card, and the duplicated section stack are gone. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -20,7 +20,6 @@ import { RecommendationPanel } from '../components/ticker/RecommendationPanel';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { Callout } from '../components/ui/Callout';
|
||||
import { Section } from '../components/ui/Section';
|
||||
import { Tabs } from '../components/ui/Tabs';
|
||||
import { formatPrice } from '../lib/format';
|
||||
import type { TradeSetup } from '../lib/types';
|
||||
import type { FieldPoint } from '../components/ticker/StandingMatrix';
|
||||
@@ -93,7 +92,7 @@ function DataFreshnessBar({
|
||||
busy: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="glass-sm p-3 flex flex-wrap gap-x-5 gap-y-2">
|
||||
<div className="flex flex-wrap gap-x-5 gap-y-2">
|
||||
{items.map((item) => (
|
||||
<div key={item.label} className="flex items-center gap-1.5">
|
||||
<span className={`inline-block h-2 w-2 rounded-full shrink-0 ${
|
||||
@@ -274,7 +273,8 @@ export default function TickerDetailPage() {
|
||||
return (
|
||||
<div className="space-y-6 animate-slide-up">
|
||||
{/* Drill-down header — identity + chips left, score fingerprint right */}
|
||||
<section className="glass p-6 sm:p-7">
|
||||
<section className="glass overflow-hidden">
|
||||
<div className="p-6 pb-5 sm:p-7 sm:pb-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-x-8 gap-y-5">
|
||||
<div className="min-w-0">
|
||||
<p className="num text-[10.5px] uppercase tracking-[0.28em] text-gray-500">Ticker</p>
|
||||
@@ -360,30 +360,28 @@ export default function TickerDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<RecommendationPanel
|
||||
symbol={symbol}
|
||||
longSetup={longSetup}
|
||||
shortSetup={shortSetup}
|
||||
currentPrice={priceInfo?.price}
|
||||
nextEarningsDate={fundamentals.data?.next_earnings_date}
|
||||
/>
|
||||
|
||||
{/* Chart — always visible */}
|
||||
<Section title="Price & Volume">
|
||||
{ohlcv.isLoading && <SkeletonCard className="h-[440px]" />}
|
||||
{ohlcv.isError && (
|
||||
<SectionError
|
||||
message={ohlcv.error instanceof Error ? ohlcv.error.message : 'Failed to load OHLCV data'}
|
||||
onRetry={() => ohlcv.refetch()}
|
||||
/>
|
||||
)}
|
||||
{ohlcv.data && (
|
||||
<div className="glass p-5 space-y-3">
|
||||
{(longSetup || shortSetup) && (
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<span className="text-gray-500">Overlay setup:</span>
|
||||
{/* Tab row — inline, hairline, overlay pills ride along on Analysis */}
|
||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-1 border-b border-white/[0.06] px-6 sm:px-7" role="tablist" aria-label="Ticker sections">
|
||||
{detailTabs.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
role="tab"
|
||||
aria-selected={activeTab === t}
|
||||
onClick={() => setActiveTab(t)}
|
||||
className={`-mb-px border-b pb-2.5 text-[13px] font-medium transition-colors ${
|
||||
activeTab === t
|
||||
? 'border-blue-500 text-gray-100'
|
||||
: 'border-transparent text-gray-400 hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
{activeTab === 'Analysis' && (longSetup || shortSetup) && (
|
||||
<span className="ml-auto flex items-center gap-1.5 pb-2 text-[11px]">
|
||||
<span className="text-gray-500">overlay</span>
|
||||
{([
|
||||
{ key: 'auto', label: 'Auto', show: true },
|
||||
{ key: 'long', label: 'Long', show: !!longSetup },
|
||||
@@ -393,22 +391,32 @@ export default function TickerDetailPage() {
|
||||
<button
|
||||
key={o.key}
|
||||
onClick={() => setOverlayChoice(o.key)}
|
||||
className={`rounded-md px-2.5 py-1 transition-colors ${
|
||||
className={`rounded px-2 py-0.5 transition-colors ${
|
||||
overlayChoice === o.key
|
||||
? 'bg-blue-400/15 text-blue-200 border border-blue-400/30'
|
||||
: 'text-gray-400 border border-white/[0.08] hover:text-gray-200 hover:bg-white/[0.04]'
|
||||
? 'bg-blue-400/15 text-blue-200'
|
||||
: 'text-gray-500 hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
{overlaySetup && (
|
||||
<span className="num ml-1 text-gray-500">
|
||||
showing {overlaySetup.direction.toUpperCase()} · entry {formatPrice(overlaySetup.entry_price)} → target {formatPrice(overlaySetup.target)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tab body */}
|
||||
<div className="p-6 pt-5 sm:p-7 sm:pt-5">
|
||||
{activeTab === 'Analysis' && (
|
||||
<div className="animate-fade-in">
|
||||
{ohlcv.isLoading && <SkeletonCard className="h-[440px]" />}
|
||||
{ohlcv.isError && (
|
||||
<SectionError
|
||||
message={ohlcv.error instanceof Error ? ohlcv.error.message : 'Failed to load OHLCV data'}
|
||||
onRetry={() => ohlcv.refetch()}
|
||||
/>
|
||||
)}
|
||||
{ohlcv.data && (
|
||||
<>
|
||||
<CandlestickChart
|
||||
data={ohlcv.data}
|
||||
srLevels={srLevels.data?.levels}
|
||||
@@ -416,12 +424,77 @@ export default function TickerDetailPage() {
|
||||
tradeSetup={overlaySetup}
|
||||
currentPrice={priceInfo?.price}
|
||||
/>
|
||||
<p className="text-[11px] text-gray-500">
|
||||
<p className="mt-2 text-[11px] text-gray-500">
|
||||
Only the nearest support & resistance are drawn. Full list in the S/R Levels tab.
|
||||
{srLevels.isError && ' S/R levels unavailable.'}
|
||||
</p>
|
||||
{/* Data freshness — per-source age + refresh, at the panel foot like the mockup */}
|
||||
<div className="border-t border-white/[0.06] pt-3">
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'Indicators' && (
|
||||
<div className="hz-frameless animate-fade-in">
|
||||
<IndicatorSelector symbol={symbol} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'S/R Levels' && (
|
||||
<div className="animate-fade-in">
|
||||
{sortedLevels.length === 0 ? (
|
||||
<Callout variant="empty">No S/R levels detected for this ticker yet.</Callout>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-white/[0.06] text-left text-xs uppercase tracking-wider text-gray-500">
|
||||
<th className="px-4 py-3">Type</th>
|
||||
<th className="px-4 py-3">Price Level</th>
|
||||
<th className="px-4 py-3">Strength</th>
|
||||
<th className="px-4 py-3">Method</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedLevels.map((level) => (
|
||||
<tr key={level.id} className="border-b border-white/[0.04] transition-colors duration-150 hover:bg-white/[0.03]">
|
||||
<td className="px-4 py-3">
|
||||
<span className={level.type === 'support' ? 'text-emerald-300' : 'text-gray-300'}>{level.type}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-200 font-mono">{formatPrice(level.price_level)}</td>
|
||||
<td className="px-4 py-3 text-gray-200">{level.strength}</td>
|
||||
<td className="px-4 py-3 text-gray-400">{level.detection_method}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'Sentiment' && (
|
||||
<div className="hz-frameless animate-fade-in">
|
||||
{sentiment.isLoading && <SkeletonCard />}
|
||||
{sentiment.isError && (
|
||||
<SectionError message={sentiment.error instanceof Error ? sentiment.error.message : 'Failed to load sentiment'} onRetry={() => sentiment.refetch()} />
|
||||
)}
|
||||
{sentiment.data && <SentimentPanel data={sentiment.data} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'Fundamentals' && (
|
||||
<div className="hz-frameless animate-fade-in">
|
||||
{fundamentals.isLoading && <SkeletonCard />}
|
||||
{fundamentals.isError && (
|
||||
<SectionError message={fundamentals.error instanceof Error ? fundamentals.error.message : 'Failed to load fundamentals'} onRetry={() => fundamentals.refetch()} />
|
||||
)}
|
||||
{fundamentals.data && <FundamentalsPanel data={fundamentals.data} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Panel foot — per-source data age + refresh */}
|
||||
<div className="border-t border-white/[0.06] px-6 py-3 sm:px-7">
|
||||
<DataFreshnessBar
|
||||
items={dataStatus}
|
||||
onRefresh={handleRefresh}
|
||||
@@ -429,15 +502,18 @@ export default function TickerDetailPage() {
|
||||
busy={ingestion.isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Detail tabs */}
|
||||
<Tabs tabs={detailTabs} active={activeTab} onChange={setActiveTab} />
|
||||
</section>
|
||||
|
||||
{activeTab === 'Analysis' && (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
<RecommendationPanel
|
||||
symbol={symbol}
|
||||
longSetup={longSetup}
|
||||
shortSetup={shortSetup}
|
||||
currentPrice={priceInfo?.price}
|
||||
nextEarningsDate={fundamentals.data?.next_earnings_date}
|
||||
/>
|
||||
<div className="grid items-start gap-6 lg:grid-cols-2">
|
||||
<Section title="Standing" hint="how this ticker ranks vs. the field">
|
||||
{scores.isLoading && <SkeletonCard className="h-80" />}
|
||||
{scores.isError && (
|
||||
@@ -488,72 +564,6 @@ export default function TickerDetailPage() {
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'Sentiment' && (
|
||||
<div className="animate-fade-in">
|
||||
<Section title="Sentiment">
|
||||
{sentiment.isLoading && <SkeletonCard />}
|
||||
{sentiment.isError && (
|
||||
<SectionError message={sentiment.error instanceof Error ? sentiment.error.message : 'Failed to load sentiment'} onRetry={() => sentiment.refetch()} />
|
||||
)}
|
||||
{sentiment.data && <SentimentPanel data={sentiment.data} />}
|
||||
</Section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'Fundamentals' && (
|
||||
<div className="animate-fade-in">
|
||||
<Section title="Fundamentals">
|
||||
{fundamentals.isLoading && <SkeletonCard />}
|
||||
{fundamentals.isError && (
|
||||
<SectionError message={fundamentals.error instanceof Error ? fundamentals.error.message : 'Failed to load fundamentals'} onRetry={() => fundamentals.refetch()} />
|
||||
)}
|
||||
{fundamentals.data && <FundamentalsPanel data={fundamentals.data} />}
|
||||
</Section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'Indicators' && (
|
||||
<div className="animate-fade-in">
|
||||
<Section title="Technical Indicators">
|
||||
<IndicatorSelector symbol={symbol} />
|
||||
</Section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'S/R Levels' && (
|
||||
<div className="animate-fade-in">
|
||||
<Section title="Support & Resistance Levels" hint="sorted by strength">
|
||||
{sortedLevels.length === 0 ? (
|
||||
<Callout variant="empty">No S/R levels detected for this ticker yet.</Callout>
|
||||
) : (
|
||||
<div className="glass overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-white/[0.06] text-left text-xs uppercase tracking-wider text-gray-500">
|
||||
<th className="px-4 py-3">Type</th>
|
||||
<th className="px-4 py-3">Price Level</th>
|
||||
<th className="px-4 py-3">Strength</th>
|
||||
<th className="px-4 py-3">Method</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedLevels.map((level) => (
|
||||
<tr key={level.id} className="border-b border-white/[0.04] transition-colors duration-150 hover:bg-white/[0.03]">
|
||||
<td className="px-4 py-3">
|
||||
<span className={level.type === 'support' ? 'text-emerald-400' : 'text-red-400'}>{level.type}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-200 font-mono">{formatPrice(level.price_level)}</td>
|
||||
<td className="px-4 py-3 text-gray-200">{level.strength}</td>
|
||||
<td className="px-4 py-3 text-gray-400">{level.detection_method}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -267,6 +267,17 @@
|
||||
.hz-lvl-up { fill: var(--up-text); }
|
||||
.hz-lvl-down { fill: var(--down-text); }
|
||||
|
||||
/* Strip a nested panel's own card chrome when it renders inside the
|
||||
unified drill-down panel (ticker page) — one casting, no card-in-card. */
|
||||
.hz-frameless > .glass {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Radar fingerprint */
|
||||
.hz-radar-wrap { position: relative; display: inline-block; }
|
||||
.hz-radar-axis {
|
||||
|
||||
Reference in New Issue
Block a user