Skip to content
39 changes: 39 additions & 0 deletions frontend/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -474,3 +474,42 @@ code {
outline: 2px solid rgb(99 102 241);
outline-offset: 2px;
}

/* Stock-detail hero — container-query split (2026-05-31).
*
* The hero header lays out as TWO columns (name block left · stats block
* top-right) when there's room, and falls back to the vertical mobile-portrait
* stack when space is squeezed. The decision is driven by the hero's OWN width
* (`container-type: inline-size` on `.hero-card`), NOT the viewport — because
* the left sidebar (expanded 240px / collapsed 64px / mobile-drawer 0) eats a
* viewport-variable slice, so a viewport `md:`/`lg:` gate left a dead band
* where the sidebar was already a desktop rail but the hero still stacked
* (user-reported bug 2026-05-31). A container query measures the real space the
* hero has AFTER the sidebar's cut, so the row engages exactly when both
* columns fit and otherwise drops to the mobile stack — matching the user's
* "if it's too squeezed, just use the mobile layout" direction.
*
* Default (the JSX `flex flex-col` classes) = stacked. The query only ADDS the
* row behaviour, so browsers without @container support (pre-2023) keep the
* safe stacked layout. Threshold 46rem ≈ left name block + the ~18rem stats
* block + gap/padding headroom; below it neither column is starved, so the
* stack is the right call. */
.hero-card {
container: hero / inline-size;
}
@container hero (min-width: 46rem) {
.hero-split {
flex-direction: row;
align-items: flex-start;
justify-content: space-between;
}
/* Cap the left so the name block doesn't spread across an ultrawide card
(Tailwind max-w-2xl = 42rem). */
.hero-left {
max-width: 42rem;
}
/* Right-align the stats column's contents (mirrors the old md:items-end). */
.hero-right {
align-items: flex-end;
}
}
88 changes: 52 additions & 36 deletions frontend/app/stock/[ticker]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import Link from 'next/link';

import FairPriceCard from '@/components/FairPriceCard';
import { CurrentPriceLine } from '@/components/CurrentPriceLine';
import { FairPriceBarChart } from '@/components/FairPriceBarChart';
import { ManipulationRiskCard } from '@/components/ManipulationRiskCard';
import { RiskFlagsCard } from '@/components/RiskFlagsCard';
Expand Down Expand Up @@ -83,23 +82,29 @@ export default function StockDetailPage({
rank badge + sector chip on top row, big mono ticker, serif
company name, radial-gauge ScoreBadge + price + MoSCell on
the right side. */}
<header className="rounded border border-slate-200 bg-white p-5 dark:border-slate-800 dark:bg-slate-900 sm:p-6">
{/* Two-column only at lg+ (not sm). The expanded sidebar (240px)
consumes viewport width, so at md the hero's content area can be
< 470px — too narrow for the right stats block's ~360px intrinsic
width. Under the old `sm:flex-row` the left block (min-w-0) was
crushed to ~30–80px and its sector chip overflowed onto the score
gauge (2026-05-29 hero-overlap fix). Below XL the hero stacks
cleanly; the 2-col split waits for xl (1280) — NOT lg (1024) —
because at 1024 the sidebar leaves only ~666px of content width,
which crushes the left block to ~156px (responsive-density audit
2026-05-29). At xl the content is ~1040px → a balanced split, and
the left block is capped at `xl:max-w-2xl` so it doesn't spread
across 1000px+ on ultrawide. `justify-between` was a no-op (the
`flex-1` left child already consumes the free space) and is
dropped. The right block keeps min-w-0 as a shrink guard. */}
<div className="flex flex-col gap-5 xl:flex-row xl:items-start">
<div className="min-w-0 flex-1 xl:max-w-2xl">
<header className="hero-card rounded border border-slate-200 bg-white p-5 dark:border-slate-800 dark:bg-slate-900 sm:p-6">
{/* Two-column split driven by a CSS CONTAINER QUERY, not a viewport
breakpoint (globals.css `.hero-card`/`.hero-split`/`.hero-left`/
`.hero-right` under `@container hero (min-width: 46rem)`). Why a
container query: the sidebar (expanded 240px / collapsed 64px /
mobile-drawer 0px) changes the hero's ACTUAL width independently of
the viewport, so a viewport `md:`/`lg:` gate left a dead band where
the sidebar was a desktop rail but the hero still stacked (the bug
the user reported 2026-05-31). The container query measures the
hero's real inline-size AFTER the sidebar takes its cut, so the
split fires exactly when there's room — and when space is squeezed
(narrow viewport OR expanded sidebar) it falls back to the SAME
vertical mobile-portrait stack, per the user's "if it's squeezed,
just drop to the mobile layout" direction. Default (no @container
support / below threshold) = the stacked `flex flex-col`; the query
flips it to a `justify-between` row, caps the left at `max-w-2xl`,
and right-aligns the stats block. Inner guards (`min-w-0`,
`flex-wrap`, `truncate`) keep a long name/chip wrapping instead of
overflowing onto the gauges in the tight band. ~46rem threshold
chosen so both columns clear their min-content (left name block +
the ~290px stats block) before the row engages. */}
<div className="hero-split flex flex-col gap-5">
<div className="hero-left min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2 text-xs">
<span className="inline-flex items-center rounded-sm bg-slate-100 px-1.5 py-0.5 font-mono font-medium text-slate-600 dark:bg-slate-800 dark:text-slate-300">
#{detail.rank}
Expand All @@ -123,25 +128,36 @@ export default function StockDetailPage({
<p className="mt-1 font-slab text-2xl text-slate-700 dark:text-slate-300 sm:text-3xl">
{detail.name}
</p>
<CurrentPriceLine
ticker={detail.ticker}
fallbackPrice={detail.current_price}
/>
</div>
<div className="flex min-w-0 flex-col gap-3 xl:items-end">
{/* Top row: composite donut + MoS donut — paired because
both are summary statistics ("how good overall" / "how
cheap"). Both badges share the radial-gauge family
(ScoreBadge "lg" + MoSBadge); arc length = score/100
or |MoS|/100, color = sign-driven for MoS. `flex-wrap`
lets them sit side-by-side wherever there's room (≥ 375 px
cards) and stack vertically on the narrowest phones instead
of overflowing — under the prior `flex-nowrap`, an EIX-style
long MoS label ("UNDERVALUED") clipped the viewport edge at
320 px (2026-05-29 responsive audit M1). */}
<div className="flex flex-wrap items-center gap-3 sm:gap-5">
<ScoreBadge score={detail.composite_score} size="lg" ticker={detail.ticker} />
<MoSBadge mos={mosPct} />
<div className="hero-right flex min-w-0 flex-col gap-3">
{/* Top row: composite donut + MoS donut — paired summary stats
("how good overall" / "how cheap"). Side-by-side on EVERY width
via `grid-cols-2` (2026-05-31 — user wants them sharing one row
on mobile portrait, not stacking). Both badges share the
radial-gauge family AND the 800ms gauge-sweep + count-up motion
(ScoreBadge "lg" + MoSBadge); arc length = score/100 or
|MoS|/100, color = sign-driven for MoS. MoS sweeps clockwise
like the score when ≥ 0 and mirrors to counter-clockwise when
< 0 (overvalued reads as "runs the other way"). The grid tracks
(1fr 1fr) bound each badge so its label wraps WITHIN its track
instead of pushing the row wider — this is what fixes the 320px
clip the old `flex-nowrap` had (EIX-style long "UNDERVALUED"
label) without falling back to the `flex-wrap` vertical stack.
The two donuts are pulled TOGETHER at the card centerline:
score is `justify-self-end` in the left 1fr track, MoS is
`justify-self-start` in the right — so they sit adjacent in the
middle (just the grid gap between them) with equal, symmetric
outer margins on both edges. The 1fr tracks still bound each
label's wrap so nothing clips at 320px; `w-full` keeps the
centerline = the card's at every width, overriding the parent's
`hero-right` end-alignment for this row (2026-05-31). */}
<div className="grid w-full grid-cols-2 items-center gap-3 sm:gap-5">
<div className="justify-self-end">
<ScoreBadge score={detail.composite_score} size="lg" ticker={detail.ticker} />
</div>
<div className="justify-self-start">
<MoSBadge mos={mosPct} />
</div>
</div>
{/* 3-column metric row. `justify-evenly` distributes
equal space BEFORE / BETWEEN / AFTER the three columns
Expand Down
113 changes: 75 additions & 38 deletions frontend/components/MoSBadge.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,33 @@
import type { JSX } from 'react';
'use client';

// Donut variant of the Margin-of-Safety display, modeled on ScoreBadge
// size="lg". Used in the stock detail hero row next to the composite
// donut so the two summary stats — composite (quality) and MoS (price
// vs fair value) — share a visual family.
import type { CSSProperties, JSX } from 'react';
import { useCountUp, usePlayOnMount } from '@/lib/useMotion';

// Donut variant of the Margin-of-Safety display, modeled on ScoreGauge (the
// composite-score donut) so the two hero summary stats — composite (quality)
// and MoS (price vs fair value) — share one visual AND motion family. On every
// visit the arc sweeps empty → |MoS|/100 and the number counts 0 → MoS over the
// same 800ms ease-in-out window (the `gauge-sweep` @keyframes + useCountUp,
// identical to ScoreGauge). Reduced-motion → static final state (hooks resolve
// to the target at mount, the keyframe is disabled in globals.css).
//
// Color rule (per user spec):
// MoS >= 0 → emerald (undervalued = good)
// MoS < 0 → rose (overvalued = bad)
// DIRECTION (user spec 2026-05-31): the arc starts at 12 o'clock like the score
// gauge. Unlike the score (0–100, always one way), MoS is SIGNED:
// • MoS ≥ 0 → sweeps CLOCKWISE, the SAME direction as the score gauge
// (positive margin = upside; reads like a high score).
// • MoS < 0 → sweeps COUNTER-CLOCKWISE, the OPPOSITE direction
// (overvalued/downside visibly "runs the other way").
// Implemented by horizontally mirroring the gauge container (`-scale-x-100`)
// only when negative; the centered number is mirrored back so it stays
// readable. Reflecting the FINAL rendered ring is robust — it turns a clockwise
// sweep into a counter-clockwise one regardless of the svg's internal
// `-rotate-90`, with no fragile rotate∘scale transform composition. The
// gauge-sweep keyframe (dashoffset empty→final) plays inside the mirrored frame,
// so the sweep itself runs counter-clockwise.
//
// Arc length: |MoS| / 100, clamped to [0, 1] so values beyond ±100%
// simply max out the ring (deeply over- or under-valued tickers all
// show the same full ring; the numeric label below carries the
// magnitude).
// Color rule: MoS ≥ 0 → emerald (undervalued = good) / MoS < 0 → rose. Arc
// length = |MoS|/100 clamped to [0, 1]; values beyond ±100% max out the ring
// and the numeric label carries the magnitude.

const tierLabel = (mos: number): string => {
if (mos >= 50) return 'Cheap';
Expand All @@ -22,13 +37,28 @@ const tierLabel = (mos: number): string => {
return 'Expensive';
};

// Match the visual weight of ScoreBadge's accent (mid-saturation
// 600-tier). Using soft emerald + rose per the design-system Rule 1
// "no pure red / no pure green" guideline.
// Match ScoreBadge's mid-saturation accent. Soft emerald + rose per the
// design-system Rule 1 "no pure red / no pure green" guideline.
const accentColor = (mos: number): string =>
mos >= 0 ? '#059669' /* emerald-600 */ : '#e11d48'; /* rose-600 */

// Center label from a (possibly mid-count) value: sign + integer, clamped so it
// never overflows the 64×64 donut ("+99" / "−99" / ">+99" / "<−99").
const centerOf = (v: number): string =>
v < -99 ? '<−99' : v > 99 ? '>+99' : `${v < 0 ? '−' : '+'}${Math.abs(Math.round(v))}`;

// Right-side big label from a (possibly mid-count) value: matches ScoreGauge's
// `tabular-nums text-lg` weight; clamps the same long-tail values the rankings
// table uses.
const fullOf = (v: number): string =>
v < -99 ? '<−99%' : v > 500 ? '>+500%' : `${v < 0 ? '−' : '+'}${Math.abs(v).toFixed(0)}%`;

export function MoSBadge({ mos }: { mos: number | null | undefined }): JSX.Element {
// Hooks run unconditionally (before any early return) per the Rules of Hooks.
// For the null/NaN case the target is a harmless 0 and the result is ignored.
const play = usePlayOnMount(`mos-gauge:${mos ?? 'na'}`);
const shown = useCountUp(mos ?? 0, play, 800);

if (mos == null || Number.isNaN(mos)) {
return (
<div className="flex items-center gap-2">
Expand All @@ -47,7 +77,7 @@ export function MoSBadge({ mos }: { mos: number | null | undefined }): JSX.Eleme
<span className="text-base text-slate-300 dark:text-slate-600">—</span>
</div>
</div>
<div className="flex flex-col">
<div className="flex min-w-[3.5rem] flex-col">
<span className="text-[0.625rem] font-medium uppercase tracking-wider text-slate-500 dark:text-slate-400">
Margin of safety
</span>
Expand All @@ -65,26 +95,20 @@ export function MoSBadge({ mos }: { mos: number | null | undefined }): JSX.Eleme
const r = 26;
const circumference = 2 * Math.PI * r;
const frac = Math.max(0, Math.min(1, Math.abs(mos) / 100));
const accent = accentColor(mos);

// Compact center label: sign + integer (max 4 chars: "+99", "−99",
// ">+99", "<−99"). Avoids overflowing the 64×64 donut.
const sign = mos < 0 ? '−' : '+';
const centerLabel =
mos < -99 ? '<−99' :
mos > 99 ? '>+99' :
`${sign}${Math.abs(Math.round(mos))}`;

// Right-side big label: matches ScoreBadge's `tabular-nums text-lg`
// weight; clamps the same long-tail values the rankings table uses.
const fullLabel =
mos < -99 ? '<−99%' :
mos > 500 ? '>+500%' :
`${sign}${Math.abs(mos).toFixed(0)}%`;
const arcLen = circumference * frac;
// Arc renders at its FINAL dashoffset (correct on SSR / no-JS / reduced-motion
// / replay); when `play` flips true the `gauge-sweep` keyframe eases from the
// empty state (--gauge-from = full circumference) into this offset.
const dashOffset = circumference - arcLen;
const accent = accentColor(mos); // final sign drives color (no mid-count flicker)
const reverse = mos < 0; // negative → mirror the gauge to counter-clockwise

return (
<div className="flex items-center gap-2" title={`${mos.toFixed(1)}% margin of safety`}>
<div className="relative h-16 w-16 shrink-0">
{/* Negative MoS mirrors the whole gauge horizontally so the arc sweeps
counter-clockwise (opposite the score gauge); the number span is
mirrored back below so it stays readable. */}
<div className={`relative h-16 w-16 shrink-0${reverse ? ' -scale-x-100' : ''}`}>
<svg viewBox="0 0 64 64" className="h-16 w-16 -rotate-90">
<circle
cx="32"
Expand All @@ -102,21 +126,34 @@ export function MoSBadge({ mos }: { mos: number | null | undefined }): JSX.Eleme
stroke={accent}
strokeWidth="6"
strokeLinecap="round"
strokeDasharray={`${circumference * frac} ${circumference}`}
className={play ? 'gauge-sweep' : undefined}
strokeDasharray={circumference}
strokeDashoffset={dashOffset}
style={play ? ({ '--gauge-from': `${circumference}` } as CSSProperties) : undefined}
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className="font-mono text-sm font-semibold tabular-nums text-slate-900 dark:text-slate-100">
{centerLabel}
{/* text-sm here (vs ScoreGauge's text-base center) on purpose: the
MoS center label carries a sign char (+/−), so it needs the extra
room to stay inside the 64px donut at the fluid font-size ceiling. */}
<span
className={`font-mono text-sm font-semibold tabular-nums text-slate-900 dark:text-slate-100${
reverse ? ' -scale-x-100' : ''
}`}
>
{centerOf(shown)}
</span>
</div>
</div>
<div className="flex flex-col">
{/* min-w reserves space for the widest count-up value so the column
doesn't reflow as the digit count changes during the sweep
(mirrors ScoreGauge). */}
<div className="flex min-w-[3.5rem] flex-col">
<span className="text-[0.625rem] font-medium uppercase tracking-wider text-slate-500 dark:text-slate-400">
Margin of safety
</span>
<span className="font-mono text-lg font-semibold tabular-nums text-slate-900 dark:text-slate-100">
{fullLabel}
{fullOf(shown)}
</span>
<span className="text-[0.625rem] uppercase tracking-wider" style={{ color: accent }}>
{tierLabel(mos)}
Expand Down