From 235182cb96bb980412368fe6b656935beb15cca2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 31 May 2026 06:36:40 +0000 Subject: [PATCH 1/7] feat(frontend): MoS gauge shares the score row + sign-aware sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On the stock detail hero, move the Margin-of-Safety donut into the SAME row as the Composite Score donut (mobile portrait) and give it the score gauge's sweep + count-up motion — direction-aware on the sign: - MoS >= 0 -> arc sweeps clockwise, the same direction as the score gauge - MoS < 0 -> arc sweeps counter-clockwise, mirrored (overvalued "runs the other way"). 329/502 of the current universe is negative, so the counter-clockwise case is the common one, not an edge. - frontend/components/MoSBadge.tsx -> client component; adds usePlayOnMount + useCountUp + the gauge-sweep keyframe + --gauge-from (mirrors ScoreGauge). Direction via -scale-x-100 on the gauge container when mos<0 (reflects the rendered ring CW->CCW), centered number mirrored back so it stays readable. Count-up handles negatives; hooks run before the null early-return. - frontend/app/stock/[ticker]/page.tsx -> gauge pair flex-wrap -> grid-cols-2 so the two donuts share one row at every width; 1fr tracks bound each badge so its label wraps within its track (no 320px flex-nowrap clip). tsc --noEmit clean; next build 506 routes; ruff clean. Reduced-motion path preserved via the shared hooks + globals.css guard. https://claude.ai/code/session_0148EoMmL6zakDWqHXjqQ9yq --- frontend/app/stock/[ticker]/page.tsx | 25 +++--- frontend/components/MoSBadge.tsx | 110 ++++++++++++++++++--------- 2 files changed, 86 insertions(+), 49 deletions(-) diff --git a/frontend/app/stock/[ticker]/page.tsx b/frontend/app/stock/[ticker]/page.tsx index 35b00484f..d3c0554dc 100644 --- a/frontend/app/stock/[ticker]/page.tsx +++ b/frontend/app/stock/[ticker]/page.tsx @@ -129,17 +129,20 @@ export default function StockDetailPage({ />
- {/* 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). */} -
+ {/* 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. */} +
diff --git a/frontend/components/MoSBadge.tsx b/frontend/components/MoSBadge.tsx index b4d87fb05..5c3c587d0 100644 --- a/frontend/components/MoSBadge.tsx +++ b/frontend/components/MoSBadge.tsx @@ -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'; @@ -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 (
@@ -47,7 +77,7 @@ export function MoSBadge({ mos }: { mos: number | null | undefined }): JSX.Eleme
-
+
Margin of safety @@ -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 (
-
+ {/* 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. */} +
- - {centerLabel} + + {centerOf(shown)}
-
+ {/* 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). */} +
Margin of safety - {fullLabel} + {fullOf(shown)} {tierLabel(mos)} From f34e06f772bc03d8167581111776ae4b393d9d1d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 31 May 2026 06:56:10 +0000 Subject: [PATCH 2/7] fix(frontend): balance the score<->MoS gauge pair (centered, equal L/R) every width Spot-check follow-up: the two hero donuts were left-aligned in their grid tracks (score hugged the card's left edge, empty gap on the right). Center each donut inside its own 1fr half via `w-full grid-cols-2 justify-items-center` so the pair is symmetric about the card centerline with equal outer margins at EVERY breakpoint (`w-full` also overrides the parent's `xl:items-end` shrink for this row). Also documents the MoSBadge center-label `text-sm` divergence from ScoreGauge (the +/- sign char needs the room) per the frontend-design-reviewer WARN. tsc --noEmit clean; next build 506 routes. https://claude.ai/code/session_0148EoMmL6zakDWqHXjqQ9yq --- frontend/app/stock/[ticker]/page.tsx | 9 +++++++-- frontend/components/MoSBadge.tsx | 3 +++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/frontend/app/stock/[ticker]/page.tsx b/frontend/app/stock/[ticker]/page.tsx index d3c0554dc..ce1bf9631 100644 --- a/frontend/app/stock/[ticker]/page.tsx +++ b/frontend/app/stock/[ticker]/page.tsx @@ -141,8 +141,13 @@ export default function StockDetailPage({ (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. */} -
+ label) without falling back to the `flex-wrap` vertical stack. + `w-full` + `justify-items-center` center each donut inside its + own 1fr half, so the score↔MoS pair stays balanced left↔right + (equal outer margins, symmetric about the card centerline) at + EVERY breakpoint — including xl, where `w-full` overrides the + parent's `xl:items-end` shrink for this row (2026-05-31). */} +
diff --git a/frontend/components/MoSBadge.tsx b/frontend/components/MoSBadge.tsx index 5c3c587d0..4d3342524 100644 --- a/frontend/components/MoSBadge.tsx +++ b/frontend/components/MoSBadge.tsx @@ -133,6 +133,9 @@ export function MoSBadge({ mos }: { mos: number | null | undefined }): JSX.Eleme />
+ {/* 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. */} Date: Sun, 31 May 2026 07:13:14 +0000 Subject: [PATCH 3/7] fix(frontend): pull score + MoS donuts together at the card center Spot-check follow-up: justify-items-center spread each donut to its own half-center (too far apart). Keep grid-cols-2 (1fr tracks still bound each label's wrap so nothing clips at 320px) but pull the pair to the centerline -- score justify-self-end in the left track, MoS justify-self-start in the right -- so the two sit adjacent in the middle with symmetric outer margins. tsc --noEmit clean; next build 506 routes. https://claude.ai/code/session_0148EoMmL6zakDWqHXjqQ9yq --- frontend/app/stock/[ticker]/page.tsx | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/frontend/app/stock/[ticker]/page.tsx b/frontend/app/stock/[ticker]/page.tsx index ce1bf9631..d8638a343 100644 --- a/frontend/app/stock/[ticker]/page.tsx +++ b/frontend/app/stock/[ticker]/page.tsx @@ -142,14 +142,21 @@ export default function StockDetailPage({ 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. - `w-full` + `justify-items-center` center each donut inside its - own 1fr half, so the score↔MoS pair stays balanced left↔right - (equal outer margins, symmetric about the card centerline) at - EVERY breakpoint — including xl, where `w-full` overrides the - parent's `xl:items-end` shrink for this row (2026-05-31). */} -
- - + 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 + `xl:items-end` shrink for this row (2026-05-31). */} +
+
+ +
+
+ +
{/* 3-column metric row. `justify-evenly` distributes equal space BEFORE / BETWEEN / AFTER the three columns From 11ad20a9e64ea54ba88951f6024a15b082527065 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 31 May 2026 07:23:15 +0000 Subject: [PATCH 4/7] fix(frontend): remove the price line under the ticker name on stock detail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per spot-check: drop the (live price + past-day change) from the hero, just below the company name. The hero now reads ticker → name → (score + MoS donuts), with current price still surfaced inside the price chart + fair-price sections below. Removed the now-unused import; `formatPrice` + `detail.current_price` stay (still used by Fair value / Target / chart / loss-chance). `CurrentPriceLine` component itself is kept — RankingTable still uses it. tsc --noEmit clean; next build 506 routes. https://claude.ai/code/session_0148EoMmL6zakDWqHXjqQ9yq --- frontend/app/stock/[ticker]/page.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/frontend/app/stock/[ticker]/page.tsx b/frontend/app/stock/[ticker]/page.tsx index d8638a343..15faa6fb5 100644 --- a/frontend/app/stock/[ticker]/page.tsx +++ b/frontend/app/stock/[ticker]/page.tsx @@ -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'; @@ -123,10 +122,6 @@ export default function StockDetailPage({

{detail.name}

-
{/* Top row: composite donut + MoS donut — paired summary stats From 0f5a66599fe150ddcd0a641fa574d93c39623fad Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 31 May 2026 07:46:41 +0000 Subject: [PATCH 5/7] =?UTF-8?q?fix(frontend):=20desktop=20hero=20=E2=80=94?= =?UTF-8?q?=20stats=20block=20top-right=20opposite=20the=20name=20block?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per user direction: on desktop, lay out score · MoS · fair value · target · loss chance in the top-RIGHT, opposite the logo / ticker / company-name block on the left. The hero already had this 2-column split but gated it at xl (1280px), so common desktop widths (and any width with the sidebar expanded) still stacked it. Lower the split lg(1024)<-xl(1280): `xl:flex-row/items-start/items-end/max-w-2xl` -> `lg:`. Safe now because the ~360px live-price block was removed from the hero last commit, so the right stats block is narrower than when the xl gate was set (2026-05-29 responsive-density audit, which measured the price-carrying hero). Guards retained: left `min-w-0` (long name wraps, no chip-overflow onto gauges) + `lg:max-w-2xl` (no ultrawide spread); right `min-w-0` shrink guard. Comment documents the gotcha-override + the bump-back-to-xl escape hatch. tsc --noEmit clean; next build 506 routes. https://claude.ai/code/session_0148EoMmL6zakDWqHXjqQ9yq --- frontend/app/stock/[ticker]/page.tsx | 37 ++++++++++++++-------------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/frontend/app/stock/[ticker]/page.tsx b/frontend/app/stock/[ticker]/page.tsx index 15faa6fb5..ad2a27555 100644 --- a/frontend/app/stock/[ticker]/page.tsx +++ b/frontend/app/stock/[ticker]/page.tsx @@ -83,22 +83,23 @@ export default function StockDetailPage({ company name, radial-gauge ScoreBadge + price + MoSCell on the right side. */}
- {/* 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. */} -
-
+ {/* Two-column split at lg+ (1024px) — "desktop": logo/ticker/name on + the left, the stats block (score · MoS · fair value · target · loss + chance) on the top-right, opposite each other (user direction + 2026-05-31). Below lg the hero stacks vertically (mobile / narrow + tablet). The split was previously gated at xl (1280) because the + hero ALSO carried a ~360px live-price block, which at lg + an + expanded 240px sidebar (~666px content) crushed the left block + (2026-05-29 responsive-density audit). That price block was removed + from the hero (2026-05-31), so the stats block is now narrower and + the split is safe at lg. Guards retained: left keeps `min-w-0` so a + long company name WRAPS instead of overflowing onto the gauges + (the old chip-overflow failure mode), and is capped at + `lg:max-w-2xl` so it doesn't spread on ultrawide; the right block + keeps `min-w-0` as a shrink guard. If the expanded sidebar at + 1024–1279 ever feels tight, bump these back to `xl:`. */} +
+
#{detail.rank} @@ -123,7 +124,7 @@ export default function StockDetailPage({ {detail.name}

-
+
{/* 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 @@ -144,7 +145,7 @@ export default function StockDetailPage({ 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 - `xl:items-end` shrink for this row (2026-05-31). */} + `lg:items-end` shrink for this row (2026-05-31). */}
From 680d5817c7a1b82f3a37b2123ab441b04b683770 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 31 May 2026 08:19:15 +0000 Subject: [PATCH 6/7] fix(frontend): anchor desktop stats block to the card's right edge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per user: on wide desktop the stats block should sit flush to the RIGHT edge, opposite the name block. Add `lg:justify-between` to the hero flex row — without it the stats block sat just after the `lg:max-w-2xl`-capped left block with trailing space on the right (>=1440px). Also corrects the comment: the removed live-price block lived in the LEFT column (under the name), not the right. tsc --noEmit clean; next build 506 routes. https://claude.ai/code/session_0148EoMmL6zakDWqHXjqQ9yq --- frontend/app/stock/[ticker]/page.tsx | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/frontend/app/stock/[ticker]/page.tsx b/frontend/app/stock/[ticker]/page.tsx index ad2a27555..607f69ffa 100644 --- a/frontend/app/stock/[ticker]/page.tsx +++ b/frontend/app/stock/[ticker]/page.tsx @@ -90,15 +90,18 @@ export default function StockDetailPage({ tablet). The split was previously gated at xl (1280) because the hero ALSO carried a ~360px live-price block, which at lg + an expanded 240px sidebar (~666px content) crushed the left block - (2026-05-29 responsive-density audit). That price block was removed - from the hero (2026-05-31), so the stats block is now narrower and - the split is safe at lg. Guards retained: left keeps `min-w-0` so a - long company name WRAPS instead of overflowing onto the gauges - (the old chip-overflow failure mode), and is capped at - `lg:max-w-2xl` so it doesn't spread on ultrawide; the right block - keeps `min-w-0` as a shrink guard. If the expanded sidebar at + (2026-05-29 responsive-density audit). That live-price block (it + lived in the LEFT block, under the name) was removed from the hero + (2026-05-31), easing the left column, so the split is safe at lg. + Guards retained: left keeps `min-w-0` so a long company name WRAPS + instead of overflowing onto the gauges (the old chip-overflow + failure mode), and is capped at `lg:max-w-2xl` so it doesn't spread + on ultrawide; the right block keeps `min-w-0` as a shrink guard. + `lg:justify-between` anchors the stats block to the card's RIGHT + edge (it would otherwise sit just after the capped left block with + trailing space on wide screens). If the expanded sidebar at 1024–1279 ever feels tight, bump these back to `xl:`. */} -
+
From 00d4372658ad6bc9a056793637e466694911f72e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 31 May 2026 08:51:30 +0000 Subject: [PATCH 7/7] fix(frontend): hero split driven by container query, not viewport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop "stats top-right opposite the name" split was gated on a VIEWPORT breakpoint (lg/md), but the left sidebar (expanded 240px / collapsed 64px / mobile-drawer 0) eats a viewport-variable slice — so there was a dead band where the sidebar was already a desktop rail but the hero still stacked (user-reported: stats not moving to the top-right on desktop). Switch the hero to a CSS CONTAINER QUERY that measures the hero's OWN width after the sidebar takes its cut: - frontend/app/stock/[ticker]/page.tsx -> hero hooks `hero-card` / `hero-split` / `hero-left` / `hero-right`; the JSX default is the stacked `flex flex-col` (mobile-portrait), the query only ADDS the row behaviour. - frontend/app/globals.css -> `.hero-card { container: hero / inline-size }` + `@container hero (min-width: 46rem)` flips to a `justify-between` row, caps the left at max-w-2xl, right-aligns the stats column. Result: stats sit top-right whenever the hero actually has room (any sidebar state, any device), and when space is squeezed it falls back to the SAME vertical mobile-portrait stack -- per the user's "if it's too squeezed, drop to the mobile layout" direction. Native CSS @container (Chrome/Safari/Firefox 2023+); pre-2023 degrades to the safe stacked layout. No new dependency. tsc --noEmit clean; next build 506 routes (@container compiled into built CSS). https://claude.ai/code/session_0148EoMmL6zakDWqHXjqQ9yq --- frontend/app/globals.css | 39 ++++++++++++++++++++++ frontend/app/stock/[ticker]/page.tsx | 48 +++++++++++++++------------- 2 files changed, 64 insertions(+), 23 deletions(-) diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 37edec4be..02e6f024e 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -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; + } +} diff --git a/frontend/app/stock/[ticker]/page.tsx b/frontend/app/stock/[ticker]/page.tsx index 607f69ffa..954bb1b79 100644 --- a/frontend/app/stock/[ticker]/page.tsx +++ b/frontend/app/stock/[ticker]/page.tsx @@ -82,27 +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. */} -
- {/* Two-column split at lg+ (1024px) — "desktop": logo/ticker/name on - the left, the stats block (score · MoS · fair value · target · loss - chance) on the top-right, opposite each other (user direction - 2026-05-31). Below lg the hero stacks vertically (mobile / narrow - tablet). The split was previously gated at xl (1280) because the - hero ALSO carried a ~360px live-price block, which at lg + an - expanded 240px sidebar (~666px content) crushed the left block - (2026-05-29 responsive-density audit). That live-price block (it - lived in the LEFT block, under the name) was removed from the hero - (2026-05-31), easing the left column, so the split is safe at lg. - Guards retained: left keeps `min-w-0` so a long company name WRAPS - instead of overflowing onto the gauges (the old chip-overflow - failure mode), and is capped at `lg:max-w-2xl` so it doesn't spread - on ultrawide; the right block keeps `min-w-0` as a shrink guard. - `lg:justify-between` anchors the stats block to the card's RIGHT - edge (it would otherwise sit just after the capped left block with - trailing space on wide screens). If the expanded sidebar at - 1024–1279 ever feels tight, bump these back to `xl:`. */} -
-
+
+ {/* 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. */} +
+
#{detail.rank} @@ -127,7 +129,7 @@ export default function StockDetailPage({ {detail.name}

-
+
{/* 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 @@ -148,7 +150,7 @@ export default function StockDetailPage({ 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 - `lg:items-end` shrink for this row (2026-05-31). */} + `hero-right` end-alignment for this row (2026-05-31). */}