Files
signal-platform/frontend/src/components/ticker/RecommendationPanel.tsx
T
dennisthiessenandClaude Fable 5 16121042cd
Deploy / lint (push) Successful in 7s
Deploy / test (push) Successful in 1m4s
Deploy / deploy (push) Successful in 35s
Target selection syncs to chart; stable rail scale; sizing top-right
- The ladder target choice is lifted to the ticker page (per
  direction), so selecting a target updates the candlestick overlay's
  target line and zone too, with the R:R for the tooltip taken from
  the ladder row.
- Price rail: the scale now always spans the entire target ladder, so
  stop / entry / now hold their positions and only the target marker
  moves when a different target is selected. The furthest target sits
  at the right edge.
- Setup card layout: position sizing (shares / value / max loss,
  details in the tooltip) and the Mark-as-taken button move to the top
  right of the card header row, next to the stats they belong with -
  the dangling bottom row is gone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 12:52:18 +02:00

598 lines
26 KiB
TypeScript

import { useState } from 'react';
import type { TradeSetup } from '../../lib/types';
import { formatPrice, formatPercent } from '../../lib/format';
import { useCreatePaperTrade } from '../../hooks/usePaperTrades';
import { recommendationActionDirection, recommendationActionLabel } from '../../lib/recommendation';
import { useRiskSettings, type RiskSettings } from '../../hooks/useRiskSettings';
import { positionSize } from '../../lib/position';
import { useMarketRegime } from '../../hooks/useMarketRegime';
import { isCounterTrend } from '../../lib/regime';
import { primaryTargetProbability } from '../../lib/qualification';
import { PriceRail } from '../charts/horizon';
import type { MarketRegime } from '../../lib/types';
interface RecommendationPanelProps {
symbol: string;
longSetup?: TradeSetup;
shortSetup?: TradeSetup;
currentPrice?: number;
nextEarningsDate?: string | null;
/** Render without the section/glass wrapper (inside the unified ticker panel). */
frameless?: boolean;
/** Lifted target selection per direction, so the candlestick overlay follows. */
selectedTargets?: { long: number | null; short: number | null };
onSelectTarget?: (direction: 'long' | 'short', price: number) => void;
}
/** Whole days from today until an ISO date (negative if past). */
function daysUntil(iso: string): number | null {
const t = new Date(iso).getTime();
if (Number.isNaN(t)) return null;
return Math.ceil((t - Date.now()) / 86_400_000);
}
/** Earnings within the ~30-day target horizon can gap price through stop/target. */
const EARNINGS_HORIZON_DAYS = 30;
/**
* How far current price has drifted from the setup's entry. A setup whose
* entry is far from the live price (price already ran toward target, or fell
* through the stop) is stale — entering now changes the risk/reward.
*/
function entryDrift(setup: TradeSetup, currentPrice?: number) {
if (currentPrice == null || !setup.entry_price) return null;
const pct = ((currentPrice - setup.entry_price) / setup.entry_price) * 100;
const towardTarget = setup.direction === 'long' ? currentPrice >= setup.entry_price : currentPrice <= setup.entry_price;
// Judge staleness by how much of the entry→target distance is already gone,
// not the raw % move — an 8%-wide setup is "used up" far faster than a 40% one.
const span = Math.abs(setup.target - setup.entry_price);
const moved = Math.abs(currentPrice - setup.entry_price);
const progressPct = span > 0 ? (moved / span) * 100 : 0;
const beyondStop = setup.direction === 'long' ? currentPrice <= setup.stop_loss : currentPrice >= setup.stop_loss;
let status: 'fresh' | 'stale' | 'invalidated' = 'fresh';
if (beyondStop) status = 'invalidated';
else if (towardTarget && progressPct > 33) status = 'stale';
else if (!towardTarget && progressPct > 33) status = 'stale';
return { pct, progressPct, towardTarget, status };
}
/**
* A stored setup is the latest for its direction. When price has run to/past the
* target (played out) or through the stop (invalidated), there is no fresh setup
* — the card and the ticker-level header should say so rather than present a
* stale actionable recommendation. Returns null when there's no live price.
*/
function notActionableState(setup: TradeSetup, currentPrice?: number) {
if (currentPrice == null) return null;
const drift = entryDrift(setup, currentPrice);
const playedOut = setup.direction === 'long' ? currentPrice >= setup.target : currentPrice <= setup.target;
const invalidated = drift?.status === 'invalidated';
if (!playedOut && !invalidated) return null;
return { playedOut, invalidated };
}
function riskClass(risk: TradeSetup['risk_level']) {
if (risk === 'Low') return 'text-emerald-300';
if (risk === 'Medium') return 'text-amber-400';
if (risk === 'High') return 'text-red-300';
return 'text-gray-400';
}
function isRecommended(setup: TradeSetup | undefined, action: TradeSetup['recommended_action'] | undefined) {
if (!setup || !action) return false;
if (setup.direction === 'long') return action.startsWith('LONG');
return action.startsWith('SHORT');
}
function DirTag({ direction }: { direction: string }) {
const isLong = direction === 'long';
return (
<span className={`num inline-block rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider ${
isLong ? 'bg-emerald-500/15 text-emerald-300' : 'bg-red-500/15 text-red-300'
}`}>
{direction}
</span>
);
}
function Chip({ children }: { children: React.ReactNode }) {
return (
<span className="rounded-full border border-white/[0.09] px-2.5 py-0.5 text-[11px] text-gray-400">
{children}
</span>
);
}
type Target = NonNullable<TradeSetup['targets']>[number];
function TargetTable({ setup, selectedPrice, onSelect }: {
setup: TradeSetup;
selectedPrice: number;
onSelect: (target: Target) => void;
}) {
if (!setup.targets || setup.targets.length === 0) {
return <p className="text-xs text-gray-500">No target probabilities available.</p>;
}
return (
<div className="overflow-x-auto">
<table className="w-full text-xs" role="radiogroup" aria-label="Choose the target for the rail and paper trade">
<thead>
<tr className="text-left text-gray-500 border-b border-white/[0.06]">
<th className="py-2 pr-3">Classification</th>
<th className="py-2 pr-3">Price</th>
<th className="py-2 pr-3">Distance</th>
<th className="py-2 pr-3">R:R</th>
<th className="py-2">Probability</th>
</tr>
</thead>
<tbody>
{setup.targets.map((target) => {
const isSel = target.price === selectedPrice;
return (
<tr
key={`${setup.id}-${target.sr_level_id}-${target.price}`}
role="radio"
aria-checked={isSel}
tabIndex={0}
onClick={() => onSelect(target)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelect(target);
}
}}
className={`cursor-pointer border-b border-white/[0.04] transition-colors ${
isSel ? 'bg-blue-400/10' : 'hover:bg-white/[0.03]'
}`}
>
<td className="py-2 pr-3 text-gray-300">
<span
className={`mr-2 inline-block h-1.5 w-1.5 rounded-full align-middle ${
isSel ? 'bg-blue-400' : 'border border-gray-600'
}`}
aria-hidden="true"
/>
{target.is_primary && <span className="mr-1 text-blue-300"></span>}
{target.classification}
</td>
<td className="py-2 pr-3 font-mono text-gray-200">{formatPrice(target.price)}</td>
<td className="py-2 pr-3 font-mono text-gray-200">{formatPercent((target.distance_from_entry / setup.entry_price) * 100)}</td>
<td className="py-2 pr-3 font-mono text-gray-200">{target.rr_ratio.toFixed(2)}</td>
<td className="py-2 font-mono text-gray-200">{target.probability.toFixed(1)}%</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
function SetupCard({ setup, action, currentPrice, risk, regime, selectedPrice, onSelectPrice }: {
setup?: TradeSetup;
action?: TradeSetup['recommended_action'];
currentPrice?: number;
risk: RiskSettings;
regime?: MarketRegime;
/** Controlled target selection (lifted so the candlestick chart can follow). */
selectedPrice?: number | null;
onSelectPrice?: (price: number) => void;
}) {
if (!setup) {
return (
<div className="rounded-xl border border-white/[0.07] p-4 text-xs text-gray-500">
Setup unavailable for this direction.
</div>
);
}
const recommended = isRecommended(setup, action);
const drift = entryDrift(setup, currentPrice);
const sizing = positionSize(risk.accountSize, risk.riskPct, setup.entry_price, setup.stop_loss);
const counterTrend = regime ? isCounterTrend(setup.direction, regime.label) : false;
const prob = primaryTargetProbability(setup);
// When price has run to/past the target (played out) or through the stop
// (invalidated), there is no fresh setup — show a plain "no current setup"
// state instead of an actionable card with no reward left.
const inactive = notActionableState(setup, currentPrice);
const invalidated = inactive?.invalidated ?? false;
const notActionable = inactive != null;
const createTrade = useCreatePaperTrade();
const [taking, setTaking] = useState(false);
const [takeShares, setTakeShares] = useState<number>(sizing?.shares ?? 0);
const [takeEntry, setTakeEntry] = useState<number>(currentPrice ?? setup.entry_price);
const [takeTarget, setTakeTarget] = useState<number>(setup.target);
// Target choice from the ladder drives the rail, the chips, and the take
// flow — the scanner's primary is just the default. Controlled by the page
// when provided (so the candlestick overlay follows), else local.
const [internalSel, setInternalSel] = useState<number | null>(null);
const selPrice = selectedPrice !== undefined ? selectedPrice : internalSel;
const selectTargetPrice = (p: number) => {
if (onSelectPrice) onSelectPrice(p);
else setInternalSel(p);
};
const selected = selPrice != null ? (setup.targets ?? []).find((t) => t.price === selPrice) ?? null : null;
const activePrice = selected?.price ?? setup.target;
const activeRR = selected?.rr_ratio ?? setup.rr_ratio;
const activeProb = selected?.probability ?? prob;
const confirmTake = () => {
createTrade.mutate(
{
symbol: setup.symbol,
direction: setup.direction as 'long' | 'short',
entry_price: takeEntry,
shares: takeShares,
stop_loss: setup.stop_loss,
target: takeTarget,
},
{ onSuccess: () => setTaking(false) },
);
};
if (notActionable) {
const dir = setup.direction.toUpperCase();
return (
<div data-direction={setup.direction} className="rounded-xl border border-white/[0.07] p-4">
<div className="flex flex-wrap items-center gap-2">
<DirTag direction={setup.direction} />
<span className="num text-[10px] uppercase tracking-[0.16em] text-gray-500">no current setup</span>
<span className="num ml-auto text-xs text-gray-500">
now {currentPrice != null ? formatPrice(currentPrice) : '—'} · last entry {formatPrice(setup.entry_price)}
{drift ? ` (${drift.pct >= 0 ? '+' : ''}${drift.pct.toFixed(1)}%)` : ''} · last target {formatPrice(setup.target)}
</span>
</div>
<p className="mt-2 text-[11.5px] leading-relaxed text-gray-400">
{invalidated
? `The last ${dir} setup is invalidated — price (${formatPrice(currentPrice!)}) has passed the stop (${formatPrice(setup.stop_loss)}). No fresh ${dir} setup right now; the scanner surfaces a new one when it forms.`
: `The last ${dir} setup has played out — price (${formatPrice(currentPrice!)}) is at or past the target (${formatPrice(setup.target)}). No fresh ${dir} setup right now; the scanner surfaces a new one when it forms.`}
</p>
</div>
);
}
return (
<div
data-direction={setup.direction}
className={`rounded-xl border p-4 ${recommended ? 'border-blue-400/25' : 'border-white/[0.07] opacity-80'}`}
>
{/* Identity + key stats left, sizing + take top right */}
<div className="flex flex-wrap items-center gap-2">
<DirTag direction={setup.direction} />
{recommended && (
<span className="num rounded-full border border-[#ff6a45]/40 px-2 py-0.5 text-[9px] font-semibold uppercase tracking-[0.18em] text-[#ff6a45]">
preferred
</span>
)}
<Chip>confidence {setup.confidence_score?.toFixed(0) ?? '—'}%</Chip>
<Chip>R:R {activeRR.toFixed(1)}:1</Chip>
{activeProb != null && <Chip>target prob {Math.round(activeProb)}%</Chip>}
{selected && !selected.is_primary && <Chip>custom target</Chip>}
<span className="ml-auto flex flex-wrap items-center gap-3">
{sizing ? (
<span
className="num text-xs text-gray-400"
title={`Position size at ${risk.riskPct}% of ${formatPrice(risk.accountSize)}: ${sizing.shares} shares · ${formatPrice(sizing.positionValue)} position value · ${formatPrice(sizing.dollarRisk)} max loss at the stop`}
>
{sizing.shares} sh · {formatPrice(sizing.positionValue)} · risk {formatPrice(sizing.dollarRisk)}
{sizing.exceedsAccount && <span className="ml-1.5 text-amber-400" title="Position exceeds account — needs margin"></span>}
</span>
) : (
<span className="text-[11px] text-gray-600">set account size to size this</span>
)}
{!taking && (
<button
onClick={() => {
setTakeShares(sizing?.shares ?? 0);
setTakeEntry(currentPrice ?? setup.entry_price);
setTakeTarget(activePrice);
setTaking(true);
}}
className="rounded-lg border border-blue-500/35 bg-blue-500/15 px-3.5 py-1.5 text-xs font-semibold text-blue-300 transition-colors hover:bg-blue-500/25"
>
Mark as taken
</button>
)}
</span>
</div>
{/* Warnings — only when they apply */}
{(counterTrend || (!recommended && recommendationActionDirection(action ?? null) !== 'neutral') || drift?.status !== 'fresh') && (
<div className="mt-2.5 space-y-1">
{!recommended && recommendationActionDirection(action ?? null) !== 'neutral' && (
<p className="text-[11px] text-amber-400">Alternative setup the ticker bias currently favors the opposite direction.</p>
)}
{counterTrend && regime && (
<p className="text-[11px] text-amber-400">
Counter-trend: {setup.direction.toUpperCase()} against a {regime.label} market
({regime.benchmark ?? 'SPY'}). Lower odds size down or wait for confirmation.
</p>
)}
{drift && drift.status === 'invalidated' && (
<p className="text-[11px] text-red-300">
Price ({formatPrice(currentPrice!)}) is past the stop this setup is invalidated.
</p>
)}
{drift && drift.status === 'stale' && (
<p className="text-[11px] text-amber-400">
{drift.towardTarget
? `⚠ ${drift.progressPct.toFixed(0)}% of the entry→target move is already gone (${drift.pct >= 0 ? '+' : ''}${drift.pct.toFixed(1)}% from entry) — little reward left.`
: `⚠ Price has moved ${Math.abs(drift.pct).toFixed(1)}% against the setup (toward the stop) — entry may be stale.`}
</p>
)}
</div>
)}
{activeProb != null && activeProb < 15 && (
<p className="mt-2.5 text-[11px] text-amber-400">
This target has only a {Math.round(activeProb)}% probability pick a nearer one from the target list below.
</p>
)}
{/* The setup, spatially — stop/entry/now hold still, the *selected*
target moves along a scale that always spans the whole ladder */}
<PriceRail
direction={setup.direction}
entry={setup.entry_price}
stop={setup.stop_loss}
target={activePrice}
current={currentPrice ?? null}
scaleTo={(setup.targets ?? []).map((t) => t.price)}
/>
{taking && (
<div className="mt-3 rounded-lg border border-white/[0.08] p-3 space-y-2.5">
<div className="grid grid-cols-2 gap-2">
<label className="block space-y-1">
<span className="num text-[10px] uppercase tracking-wider text-gray-500">Shares</span>
<input
type="number"
min={0}
value={takeShares}
onChange={(e) => setTakeShares(Number(e.target.value))}
className="w-full input-glass px-2 py-1 text-sm num"
/>
</label>
<label className="block space-y-1">
<span className="num text-[10px] uppercase tracking-wider text-gray-500">Entry</span>
<input
type="number"
min={0}
step="0.01"
value={takeEntry}
onChange={(e) => setTakeEntry(Number(e.target.value))}
className="w-full input-glass px-2 py-1 text-sm num"
/>
</label>
</div>
{setup.targets && setup.targets.length > 1 ? (
<label className="block space-y-1">
<span className="num text-[10px] uppercase tracking-wider text-gray-500">Target</span>
<select
value={takeTarget}
onChange={(e) => setTakeTarget(Number(e.target.value))}
className="input-glass w-full px-2 py-1.5 text-sm num"
>
{setup.targets.map((t) => (
<option key={`${t.sr_level_id}-${t.price}`} value={t.price} className="bg-[#14161f]">
{formatPrice(t.price)} · {t.probability.toFixed(0)}% · {t.classification}{t.is_primary ? ' · primary' : ''}
</option>
))}
</select>
</label>
) : null}
<p className="num text-[10px] text-gray-500">
Stop {formatPrice(setup.stop_loss)} · Target {formatPrice(takeTarget)} · {setup.direction.toUpperCase()} paper trade
</p>
<div className="flex gap-2">
<button
onClick={confirmTake}
disabled={createTrade.isPending || !(takeShares > 0) || !(takeEntry > 0)}
className="flex-1 rounded-lg border border-blue-500/35 bg-blue-500/15 px-3 py-1.5 text-xs font-semibold text-blue-300 transition-colors hover:bg-blue-500/25 disabled:opacity-50"
>
{createTrade.isPending ? 'Taking…' : 'Confirm'}
</button>
<button
onClick={() => setTaking(false)}
className="rounded-lg border border-white/[0.08] px-3 py-1.5 text-xs text-gray-400 transition-colors hover:text-gray-200"
>
Cancel
</button>
</div>
</div>
)}
{/* Target ladder — open by default; clicking a row previews it on the rail */}
{setup.targets && setup.targets.length > 0 && (
<details className="mt-3" open>
<summary className="cursor-pointer text-[11px] font-medium text-gray-500 transition-colors hover:text-gray-300">
Targets ({setup.targets.length}) · select a row to preview it on the rail and use it when taking
</summary>
<div className="mt-2">
<TargetTable
setup={setup}
selectedPrice={activePrice}
onSelect={(t) => {
selectTargetPrice(t.price);
setTakeTarget(t.price);
}}
/>
</div>
</details>
)}
{setup.conflict_flags.length > 0 && (
<p className="mt-3 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-[11px] text-amber-300">
{setup.conflict_flags.join(' • ')}
</p>
)}
</div>
);
}
const RISK_PRESETS = [0.5, 1, 2, 3];
/** Compact, set-once sizing controls: a clean account field (no spinners) and a
* segmented risk-% selector — risk is almost always one of a few values. */
function RiskControls({ risk, update }: { risk: RiskSettings; update: (p: Partial<RiskSettings>) => void }) {
return (
<div className="flex flex-wrap items-center gap-2 text-xs text-gray-500">
<span>Sizing assumes a</span>
<span className="inline-flex items-center rounded-md border border-white/[0.08] bg-white/[0.03] px-2 py-0.5">
<span className="mr-0.5 text-gray-500">$</span>
<input
type="text"
inputMode="numeric"
value={risk.accountSize ? String(risk.accountSize) : ''}
onChange={(e) => update({ accountSize: Number(e.target.value.replace(/[^0-9]/g, '')) || 0 })}
placeholder="10000"
aria-label="Account size"
className="w-20 bg-transparent font-mono text-gray-100 outline-none"
/>
</span>
<span>account, risking</span>
<span className="inline-flex overflow-hidden rounded-md border border-white/[0.08]">
{RISK_PRESETS.map((p) => (
<button
key={p}
type="button"
onClick={() => update({ riskPct: p })}
className={`px-2 py-0.5 font-mono transition-colors ${
risk.riskPct === p
? 'bg-blue-400/15 text-blue-200'
: 'text-gray-400 hover:bg-white/[0.05] hover:text-gray-200'
}`}
>
{p}%
</button>
))}
</span>
<span>per trade.</span>
</div>
);
}
export function RecommendationPanel({ symbol, longSetup, shortSetup, currentPrice, nextEarningsDate, frameless = false, selectedTargets, onSelectTarget }: RecommendationPanelProps) {
const selFor = (setup?: TradeSetup) =>
setup && selectedTargets ? selectedTargets[setup.direction as 'long' | 'short'] : undefined;
const onSelFor = (setup?: TradeSetup) =>
setup && onSelectTarget
? (price: number) => onSelectTarget(setup.direction as 'long' | 'short', price)
: undefined;
const { settings: risk, update: updateRisk } = useRiskSettings();
const regime = useMarketRegime().data;
const summary = longSetup?.recommendation_summary ?? shortSetup?.recommendation_summary;
const earningsDays = nextEarningsDate ? daysUntil(nextEarningsDate) : null;
const action = (summary?.action ?? 'NEUTRAL') as TradeSetup['recommended_action'];
const preferredDirection = recommendationActionDirection(action);
const preferredSetup =
preferredDirection === 'long'
? longSetup
: preferredDirection === 'short'
? shortSetup
: undefined;
const alternativeSetup =
preferredDirection === 'long'
? shortSetup
: preferredDirection === 'short'
? longSetup
: undefined;
if (!longSetup && !shortSetup) {
return null;
}
// If the preferred setup has played out / been invalidated, the stored
// ticker-level bias and reasoning are stale — don't headline "Strong Long"
// above a "no current setup" card.
const preferredInactive = preferredSetup ? notActionableState(preferredSetup, currentPrice) : null;
const body = (
<div className="space-y-4">
{/* Verdict: action loud, the signal detail as a quiet subtitle */}
<div className="flex flex-wrap items-start justify-between gap-x-6 gap-y-2">
<div className="min-w-0">
{preferredInactive ? (
<span className="text-sm font-semibold text-gray-400">
No current setup <span className="font-normal text-gray-500">(last {preferredDirection} bias {recommendationActionLabel(action).toLowerCase()} {preferredInactive.invalidated ? 'invalidated' : 'played out'})</span>
</span>
) : (() => {
const reasoning = summary?.reasoning ?? '';
const idx = reasoning.indexOf(':');
const head = idx > 0 ? reasoning.slice(0, idx) : recommendationActionLabel(action);
const tail = idx > 0 ? reasoning.slice(idx + 1).trim() : reasoning;
return (
<>
<p className="flex flex-wrap items-baseline gap-x-3">
<span className="font-display text-lg font-semibold tracking-tight text-blue-300">{head}</span>
<span className={`text-xs font-semibold ${riskClass(summary?.risk_level ?? null)}`}>
Risk: {summary?.risk_level ?? '—'}
</span>
</p>
{tail && <p className="mt-0.5 text-xs leading-relaxed text-gray-500">{tail}</p>}
</>
);
})()}
</div>
<div className="shrink-0">
<RiskControls risk={risk} update={updateRisk} />
</div>
</div>
{earningsDays != null && earningsDays >= 0 && (
earningsDays <= EARNINGS_HORIZON_DAYS ? (
<p className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-[11px] text-amber-300">
Earnings in {earningsDays} day{earningsDays === 1 ? '' : 's'} ({nextEarningsDate}) inside the ~30-day
target horizon. A report can gap price through your stop or target; consider waiting or sizing down.
</p>
) : (
<p className="text-[11px] text-gray-500">Next earnings: {nextEarningsDate} ({earningsDays} days).</p>
)
)}
{preferredDirection !== 'neutral' && preferredSetup ? (
<div className="space-y-3">
<SetupCard setup={preferredSetup} action={action} currentPrice={currentPrice} risk={risk} regime={regime} selectedPrice={selFor(preferredSetup)} onSelectPrice={onSelFor(preferredSetup)} />
{alternativeSetup && (
<details>
<summary className="cursor-pointer text-xs font-medium text-gray-500 transition-colors hover:text-gray-300">
Alternative scenario ({alternativeSetup.direction.toUpperCase()})
</summary>
<div className="mt-3">
<SetupCard setup={alternativeSetup} action={action} currentPrice={currentPrice} risk={risk} regime={regime} selectedPrice={selFor(alternativeSetup)} onSelectPrice={onSelFor(alternativeSetup)} />
</div>
</details>
)}
</div>
) : (
<div className="grid gap-4 lg:grid-cols-2">
<SetupCard setup={longSetup} action={action} currentPrice={currentPrice} risk={risk} regime={regime} selectedPrice={selFor(longSetup)} onSelectPrice={onSelFor(longSetup)} />
<SetupCard setup={shortSetup} action={action} currentPrice={currentPrice} risk={risk} regime={regime} selectedPrice={selFor(shortSetup)} onSelectPrice={onSelFor(shortSetup)} />
</div>
)}
</div>
);
if (frameless) {
return (
<div>
<p className="section-index mb-3">Recommendation · {symbol.toUpperCase()}</p>
{body}
</div>
);
}
return (
<section>
<h2 className="mb-3 text-xs font-medium uppercase tracking-widest text-gray-500">Recommendation</h2>
<div className="glass p-5 space-y-4">{body}</div>
</section>
);
}