Ticker page: one unified drill-down panel, no card salad
Deploy / lint (push) Successful in 7s
Deploy / test (push) Successful in 1m7s
Deploy / deploy (push) Successful in 36s

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:
2026-07-10 08:17:05 +02:00
co-authored by Claude Fable 5
parent f9e12fc78c
commit fc0945367a
2 changed files with 144 additions and 123 deletions
+117 -107
View File
@@ -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 &amp; 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>
+11
View File
@@ -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 {