feat(backtest): make the tiles answer "is that good?", and stop the layout jumping

Five UI problems, all reported from using the page.

Expanding "How this is measured" shoved every control down, because the
disclosure and the run controls shared one flex row. They no longer do: run
status and the controls that start a new run sit together on one line, and the
explainer is below them where growing it moves nothing.

A long strategy name wrapped the dropdown trigger onto three lines and dragged
the row out of alignment. The trigger now truncates with the full text on hover
— a wrapping dropdown is broken anywhere, so the fix is in the primitive — and
the twelve-character "Production: " prefix is a bullet.

"Sortino 2.72" answered nothing. Each risk-adjusted metric now carries a meter:
a track showing where the value sits, ticks at the band edges, and the band word.
Colour never travels alone. Bands are deliberately stricter than textbook ranges
because this universe is today's survivors replayed backward, which flatters
every ratio — that caveat is stated next to them rather than left implied.

The two tile rows were different sizes, which read as inconsistent rather than
as hierarchy. Every tile is the same size now and grouping carries the ranking:
top row is raw outcome and takes no meters, second row is risk-adjusted ratios
and all take meters. Sharpe moved down to join them — it is one of those ratios,
and leaving it above made it the only metered tile in a row of bare ones.

The recommendation led with a long bold sentence that describes the
configuration, not a verdict, while the actual findings were small grey text.
Findings now come first, each split into label and detail on the colon the
backend strings already carry, and the configuration is a footer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 23:25:11 +02:00
co-authored by Claude Opus 5
parent 28b3273150
commit 3a2d548610
6 changed files with 273 additions and 86 deletions
@@ -4,15 +4,14 @@ import type { BacktestRecommendation } from '../../lib/types';
/**
* The verdict, ahead of the tuning detail.
*
* All eight findings used to render as equal-weight bullets, so "does this
* strategy work" sat in the same visual register as "which cutoff scored best".
* `topic` splits them: the three that answer the question stay inline, the rest
* collapse.
* Two problems this solves. All eight findings used to render as equal-weight
* bullets, so "does this strategy work" sat in the same register as "which
* cutoff scored best". And the headline — which is a *description of the
* config*, not a verdict — was the loudest thing on the card while every actual
* finding was small grey text.
*
* No topic chips — every backend string already self-prefixes ("Gate: …",
* "Robustness: …"), so a chip would render "GATE │ Gate: …", and stripping the
* prefix would drop real information ("(3y)" carries the lookback, "Legacy"
* qualifies the diagnostic).
* So: findings first, each split into a label and its detail; the config
* description demoted to a footer where it belongs.
*/
const PRIMARY_TOPICS = new Set(['production', 'benchmark', 'robustness']);
@@ -25,6 +24,43 @@ function isWarning(text: string): boolean {
return text.includes('WARNING') || text.includes('LAGS');
}
/**
* Every backend string self-prefixes ("Gate: keep the R:R floor…"), so the
* prefix IS the label — no need for a chip that would just repeat it, and no
* need to reword anything server-side. Split on the first colon; if a string
* ever stops carrying one, it renders whole as detail.
*/
function splitLabel(text: string): { label: string | null; detail: string } {
const at = text.indexOf(': ');
if (at === -1 || at > 48) return { label: null, detail: text };
return { label: text.slice(0, at), detail: text.slice(at + 2) };
}
function Finding({ text, primary }: { text: string; primary: boolean }) {
const warn = isWarning(text);
const { label, detail } = splitLabel(text);
return (
<li className="flex flex-col gap-0.5 sm:flex-row sm:gap-3">
{label && (
<span
className={`shrink-0 text-[11px] font-semibold uppercase tracking-wider sm:w-44 sm:pt-0.5 ${
warn ? 'text-amber-400' : 'text-gray-500'
}`}
>
{label}
</span>
)}
<span
className={`${primary ? 'text-sm' : 'text-xs'} ${
warn ? 'text-amber-300' : primary ? 'text-gray-200' : 'text-gray-400'
}`}
>
{detail}
</span>
</li>
);
}
export function BacktestRecommendationCard({
recommendation,
}: {
@@ -45,30 +81,34 @@ export function BacktestRecommendationCard({
<div className="glass border border-blue-400/20 p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="section-index">What this backtest recommends</p>
{warningCount > 0 && (
{warningCount > 0 ? (
<span className="rounded-full border border-amber-400/40 bg-amber-400/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-amber-300">
{warningCount} warning{warningCount > 1 ? 's' : ''}
</span>
) : (
<span className="rounded-full border border-emerald-400/30 bg-emerald-400/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-emerald-300">
no warnings
</span>
)}
</div>
{recommendation.headline && (
<p className="mt-1.5 text-sm font-semibold text-gray-100">{recommendation.headline}</p>
)}
{primary.length > 0 && (
<ul className="mt-3 space-y-1.5 border-t border-white/[0.06] pt-3">
<ul className="mt-3 space-y-2.5">
{primary.map((item) => (
<li
key={item.topic + item.text}
className={`text-xs ${isWarning(item.text) ? 'text-amber-400' : 'text-gray-300'}`}
>
{item.text}
</li>
<Finding key={item.topic + item.text} text={item.text} primary />
))}
</ul>
)}
{/* The config description, demoted: it says what the strategy IS, which
is context for the findings above rather than a finding itself. */}
{recommendation.headline && (
<div className="mt-3 border-t border-white/[0.06] pt-3">
<p className="section-index">Configuration under test</p>
<p className="mt-1 text-xs leading-relaxed text-gray-500">{recommendation.headline}</p>
</div>
)}
{recommendation.note && (
<p className="mt-2 text-[11px] text-gray-600">{recommendation.note}</p>
)}
@@ -77,12 +117,10 @@ export function BacktestRecommendationCard({
{/* Outside the card body on purpose: Disclosure renders its own glass-sm
panel, so nesting it inside the bordered card double-frames it. */}
{secondary.length > 0 && (
<Disclosure summary={`Gate, exit and cutoff detail (${secondary.length})`}>
<ul className="space-y-1.5">
<Disclosure summary={`Gate and cutoff detail (${secondary.length})`}>
<ul className="space-y-2">
{secondary.map((item) => (
<li key={item.topic + item.text} className="text-xs text-gray-400">
{item.text}
</li>
<Finding key={item.topic + item.text} text={item.text} primary={false} />
))}
</ul>
</Disclosure>