Skip to content

feat(frontend): stock-detail hero rework — MoS gauge sign-aware sweep · container-query desktop split · drop hero price line#332

Merged
dackclup merged 7 commits into
mainfrom
claude/sharp-turing-q3A5J
May 31, 2026
Merged

feat(frontend): stock-detail hero rework — MoS gauge sign-aware sweep · container-query desktop split · drop hero price line#332
dackclup merged 7 commits into
mainfrom
claude/sharp-turing-q3A5J

Conversation

@dackclup
Copy link
Copy Markdown
Owner

Summary

On the stock detail hero (mobile portrait), the Margin of Safety donut now sits in the same row as the Composite Score donut and gets the score gauge's sweep + count-up motion — direction-aware on the sign.

Direction (the careful part)

The MoS arc starts at 12 o'clock like the score gauge. Unlike the score (0–100, always one way), MoS is signed:

  • MoS ≥ 0 → arc sweeps clockwise, the same direction as the score gauge (positive margin = upside, reads like a high score)
  • MoS < 0 → arc sweeps counter-clockwise, mirrored against the score gauge ("overvalued runs the other way")

329 / 502 of the current universe is negative MoS, so the counter-clockwise case is the common one, not an edge.

Changes (2 files)

  • frontend/components/MoSBadge.tsx → now a client component; adds usePlayOnMount + useCountUp + the gauge-sweep keyframe + --gauge-from (mirrors ScoreGauge.tsx exactly, so the two hero donuts share one motion family). Direction handled by -scale-x-100 on the gauge container when mos < 0 (reflects the rendered ring CW→CCW — robust, no fragile rotate ∘ scale composition with the svg's internal -rotate-90); the centered number span is mirrored back so it stays readable. Count-up handles negatives (counts down); hooks run before the null early-return (Rules of Hooks). Accent stays emerald (≥0) / rose (<0).
  • frontend/app/stock/[ticker]/page.tsx → the gauge-pair container flex flex-wrapgrid grid-cols-2 so the two donuts share one row at every width. The 1fr tracks bound each badge so its label wraps within its track — this is what fixes the 320px clip the old flex-nowrap had (EIX-style long "UNDERVALUED" label) without falling back to the vertical flex-wrap stack.

Verification

Gate Result
tsc --noEmit ✅ exit 0
next build ✅ exit 0 (506 routes)
ruff check . ✅ (no Python touched)
Reduced-motion ✅ preserved via the shared useMotion hooks + globals.css prefers-reduced-motion guard

What this PR does NOT touch

  • No compute/ / schema / scoring / valuation — frontend-only, no JSON shape change.
  • The score gauge, the table-cell MoSCell, and the sm/md ScoreBadge variants are untouched.

📱 Please spot-check the Vercel preview on device

  • Positive MoS (clockwise, same as score): /stock/CF (+15%)
  • Negative MoS (counter-clockwise, mirrored): mild /stock/UPS (−7.9%) or /stock/AWK (−8.6%); deep/clamped /stock/TSLA (−1362% → full ring + "<−99")
  • Confirm: the two donuts sit side-by-side without clipping at narrow widths, and the negative ring visibly sweeps the opposite way from the score.

frontend-design-reviewer (sonnet) is reviewing the diff + responsive behavior in parallel; findings will land as follow-up commits.

https://claude.ai/code/session_0148EoMmL6zakDWqHXjqQ9yq


Generated by Claude Code

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
@vercel
Copy link
Copy Markdown

vercel Bot commented May 31, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
quantrank Ready Ready Preview, Comment May 31, 2026 8:52am

…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
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
…etail

Per spot-check: drop the <CurrentPriceLine> (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
… block

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
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
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
@dackclup dackclup changed the title feat(frontend): MoS gauge shares the score row + sign-aware sweep animation feat(frontend): stock-detail hero rework — MoS gauge sign-aware sweep · container-query desktop split · drop hero price line May 31, 2026
@dackclup dackclup marked this pull request as ready for review May 31, 2026 08:59
@dackclup dackclup merged commit 43838c6 into main May 31, 2026
4 checks passed
@dackclup dackclup deleted the claude/sharp-turing-q3A5J branch May 31, 2026 08:59
dackclup added a commit that referenced this pull request May 31, 2026
…t + sign-aware MoS) (#333)

PR #332 (stock-detail hero rework) merged as a fast spot-check-driven UI
iteration and skipped the CLAUDE.md/AGENTS.md substance lockstep. Add the two
invariants future editors must not regress:

1. The hero's two-column (name-left / stats-top-right) vs stacked decision is
   driven by a CSS CONTAINER QUERY on the hero's own inline-size, NOT a viewport
   md:/lg: breakpoint — because the sidebar eats a viewport-variable width slice
   (the dead-band bug that prompted the rework). JSX default is the stacked
   flex-col; @container only adds the row; raw CSS, no plugin/dep.
2. The MoS gauge arc is sign-aware: >=0 clockwise (like the score gauge), <0
   counter-clockwise via -scale-x-100 on the container with the number span
   mirrored back. 329/502 of the universe is negative, so CCW is the common case.

CLAUDE.md §Gotchas holds the full rationale; AGENTS.md §Code style mirrors each
as a pointer (PR #327 precedent). PHASE_STATUS_INFLIGHT.md entry per the
ship-with-every-PR convention. Doc-only — the hero code already shipped in #332.

https://claude.ai/code/session_0148EoMmL6zakDWqHXjqQ9yq

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants