Skip to content

feat(frontend): app-wide tasteful motion — gauge sweep · row stagger · veto pulse#312

Merged
dackclup merged 6 commits into
mainfrom
claude/optimistic-brown-UUcXA
May 29, 2026
Merged

feat(frontend): app-wide tasteful motion — gauge sweep · row stagger · veto pulse#312
dackclup merged 6 commits into
mainfrom
claude/optimistic-brown-UUcXA

Conversation

@dackclup
Copy link
Copy Markdown
Owner

What

App-wide tasteful motion — entrance + micro-interaction animation that makes QuantRank feel alive and rewarding without undermining the trust a finance tool needs. Fulfils the request "ทำ animation ให้...ดูสนุกสนาน น่าตื่นเต้น ดูแล้วไม่น่าเบื่อ."

Spec resolved via a 5-question grill:

Q Decision
Intensity Tasteful (Stripe/Linear), not playful — keeps LedgerCraft flat + trust intact
Tech CSS/Tailwind only — no framer-motion (+0 deps, +0 bundle); Recharts animates charts free
Scope Whole app, one PR
Frequency Play once per session — no replay annoyance for power users
Signature Composite-score gauge sweep

The motion

  • 🎯 Signature — the detail-page composite-score radial gauge sweeps 0→score with a synchronized count-up over 800ms on first view (keyed per-ticker). The app's headline number earns the one longer beat.
  • Home — ranking rows stagger-in (desktop + mobile), gated to first home view + unfiltered page 1 (sort/filter/paginate never re-trigger).
  • Detail — risk-veto rows enter with a rise+scale attention beat; FairPriceBarChart + PillarRadarChart draw in (Recharts default). PriceHistoryChart animation deliberately stays off (it re-renders on every period toggle).

The 5 non-negotiable rules (now locked in docs/design.md §Motion)

  1. transform + opacity only — zero layout shift (an early box-shadow draft was dropped to honor this).
  2. Play once per session (usePlayedOnce/sessionStorage).
  3. prefers-reduced-motion mandatory — every token snaps to the static end-state.
  4. Never gate content on JSuseCountUp inits at the target so SSR/no-JS show the correct number, never a stuck 0.0.
  5. Static-export rule: add animate classes client-side, never in SSR markup — baking them in replays on every full load + hydration-mismatches.

Files (9, +503/−50, frontend-only)

tailwind.config.ts · app/globals.css · lib/useMotion.ts (new hooks) · ScoreGauge.tsx (new) · ScoreBadge.tsx · RankingTable.tsx · RiskFlagsCard.tsx · app/stock/[ticker]/page.tsx · docs/design.md. No schema / compute / scoring change.

Review gates (both passed)

  • frontend-design-reviewer → READY-FOR-SPOT-CHECK (Sections A–G PASS). Caught a self-inflicted Rule-1 contradiction (box-shadow in flag-pulse/hover-lift) — fixed by honoring the rule.
  • expert-user-explorer → ACCOMPLISHED: "polished, not toy-like; the Stripe/Linear register the spec aimed for; 0 looping anims." Caught 2 MINORs — fixed: per-ticker gauge key (46% of stocks were silently skipping the sweep due to a score-value key collision) + count-up CLS (0.026→0.0214).

Verification

Every animation verified actually animating via headless Playwright (not just "build passes") — gauge sweep, row stagger, veto pulse, per-ticker gate, reduced-motion static + correct data, CLS measured. next build clean (506 routes) · tsc clean · ruff check . clean.

🤖 Built with the agent team: grill-me spec → inline implement → frontend-design-reviewer → expert-user-explorer → fixes.

https://claude.ai/code/session_0144kHrCYNaamMPH57b7xdM7


Generated by Claude Code

claude added 6 commits May 29, 2026 06:50
Tasteful-motion program, commit 1 of 3 (grill-confirmed spec: tasteful not
playful · CSS/Tailwind only, no framer-motion · whole-app · play-once-per-
session · signature = score gauge sweep). LedgerCraft stays flat; motion is
the ENTRANCE, not a permanent flourish.

Foundation:
- tailwind.config.ts: rise-in / chip-pop / flag-pulse keyframes + animation
  tokens (transform+opacity only; compositor-friendly; ≤320ms micro-budget).
- globals.css: keyframe bodies + stagger-1..12 delay utils + .gauge-arc
  (800ms stroke-dashoffset ease) + .hover-lift + EXTENDED prefers-reduced-
  motion guard covering every new util (snaps to static end-state).
- lib/useMotion.ts: useCountUp (rAF easeOutCubic, inits at TARGET so SSR/
  no-JS/reduced-motion render the correct number — count-up is progressive
  enhancement only) · useInViewOnce (IntersectionObserver, fires once) ·
  usePlayedOnce (sessionStorage — entrances play once per session).

Signature moment:
- ScoreGauge.tsx (new client component): the composite-score radial gauge on
  the detail header sweeps 0→value (arc + count-up in sync over 800ms) on
  first view this session. Two-phase double-rAF render gives the CSS
  transition two paints to ease between.
- ScoreBadge.tsx: 'lg' branch delegates to ScoreGauge; 'sm'/'md' stay
  server-rendered (no client JS across the 502 table cells).

Verified via headless Playwright: arc sweeps (42.7→163.4→ease→42.7 via
MutationObserver), count-up 0→73.9, SSR prerender shows real number (not
0.0), reduced-motion renders final immediately. next build clean (505
routes). ruff check . clean (whole repo).

https://claude.ai/code/session_0144kHrCYNaamMPH57b7xdM7
Tasteful-motion commit 2 of 3 — the home page.

- RankingTable.tsx: rows (desktop <tr> + mobile cards) cascade in with
  animate-rise-in + stagger-1..12 on the first home view this session,
  gated to unfiltered page 1 so sort/filter/paginate never re-trigger the
  cascade (would feel sluggish on every interaction). Uses effect-based
  usePlayedOnce so the animate class is added CLIENT-SIDE — never baked
  into the static prerender (which would replay on every full load + cause
  a hydration mismatch leaving rows stuck mid-fade; caught + fixed via
  Playwright before commit).
- useMotion.ts: removed the unused usePlayOnceSync variant (the SSR-baked
  approach it enabled is wrong for static export — documented why on
  usePlayedOnce).
- docs/design.md: new ## Motion section — token table + 5 non-negotiable
  rules (transform/opacity-only · play-once-per-session · reduced-motion
  mandatory · never gate content on JS · add-classes-client-side-for-
  static-export) + the signature gauge note. Locks the vocabulary for
  docs-reviewer.

Verified via headless Playwright: first-view cascades + all rows end
opacity 1; return-visit (client nav) suppressed, opacity 1 instantly;
static HTML carries 0 baked animate classes; reduced-motion static.
next build clean (505 routes). ruff check . clean.

https://claude.ai/code/session_0144kHrCYNaamMPH57b7xdM7
Tasteful-motion commit 3 of 3 — the stock detail page.

- RiskFlagsCard.tsx: veto rows enter with animate-flag-pulse (rise + one
  rose attention-ring pulse, single iteration — "look here" without a
  permanent blink), staggered so a multi-veto stock (SMCI: 3) cascades.
  Gated client-side via usePlayedOnce keyed by the flag set (once per
  session per distinct stock); hook hoisted above the early return per
  rules-of-hooks.

Deliberately NOT changed:
- PriceHistoryChart isAnimationActive={false} stays OFF — that chart
  re-renders on every period toggle (1M/3M/1Y…); enabling animation would
  re-sweep the price line on every click (the replay-on-interaction
  anti-pattern). Correct as-is.
- FairPriceBarChart + PillarRadarChart have no interactive state and no
  explicit flag → Recharts already animates them on mount (draw-in) for
  free. No change needed.

Verified via headless Playwright: EIX veto row pulses + ends opacity 1;
static HTML bakes 0 pulse classes (client-only); 17 chart SVGs render;
reduced-motion neutralizes the pulse (opacity 1). next build clean (505
routes). ruff check . clean.

https://claude.ai/code/session_0144kHrCYNaamMPH57b7xdM7
…+ hover-lift

frontend-design-reviewer (opus) flagged a self-inflicted contradiction: I
wrote Motion Rule 1 as "transform + opacity ONLY" then animated box-shadow
in flag-pulse (78%/100% stops) and transitioned box-shadow in hover-lift.
Rather than carve a box-shadow exception into the rule, honor the rule:

- flag-pulse: box-shadow ring stops → a tiny scale settle (0.99→1.012→1)
  paired with the existing rise. transform+opacity only. The rose tone the
  veto row already carries supplies the "look here" color; the keyframe
  supplies the motion. Verified still animates (tight-poll caught
  animationName + class from first frame; ends opacity 1).
- hover-lift: transition drops `box-shadow 160ms` → `transform` only (the
  row's slate hover-bg already carries the hover state). Was defined but
  not yet consumed, so fixed pre-emptively before any consumer ships.

Also removes the only raw rgb() the reviewer WARNed on (it lived in the
deleted box-shadow stops). chip-pop + hover-lift remain declared-but-not-
yet-consumed vocabulary tokens (documented in docs/design.md §Motion table,
same pattern as the 4-tier shadow tokens that predated their consumers).

next build clean (506 routes). ruff check . clean (whole repo).

https://claude.ai/code/session_0144kHrCYNaamMPH57b7xdM7
…er-explorer)

expert-user-explorer experiential pass verdict: motion achieves "fun but
trustworthy" (Stripe/Linear register, 0 looping anims, not toy-like). Two
MINOR findings fixed:

1. Score-gauge sweep was gated by usePlayedOnce(`score-gauge:${score}`) —
   but ~269 unique scores cover 502 stocks, so 46% of tickers shared a
   session key and SILENTLY SKIPPED the signature sweep on a comparison run
   (verified: AIZ + QCOM both score 66.5 → QCOM never swept). Now keyed per
   TICKER (threaded score-badge → gauge): both same-score stocks sweep on
   first view, neither re-sweeps on revisit. 502 keys, contract preserved.

2. CLS 0.026 on the detail page: the count-up number's column reflowed as
   digit count changed (0.0 → 73.9). Added min-w-[3.5rem] to the text
   column to reserve the widest-value width. CLS 0.026 → 0.0214 (the
   residual is the client-island hydration settle — comfortably under the
   0.1 "good" threshold; a Suspense-fallback further-fix was judged not
   worth the complexity for 0.02).

Verified via headless Playwright (same-tab session): AIZ swept / QCOM swept
(same score) / AIZ no re-sweep / CLS 0.0214. next build clean (506 routes).
ruff check . clean.

https://claude.ai/code/session_0144kHrCYNaamMPH57b7xdM7
§Conventions code-PR lockstep for the motion feature: CLAUDE.md §Phase
status in-flight entry + AGENTS.md §Phase+version mirror + PHASE_STATUS_
INFLIGHT.md full entry. (docs/design.md §Motion already landed in commit
2/3.) Frontend-only feature; no schema/compute/scoring change.

https://claude.ai/code/session_0144kHrCYNaamMPH57b7xdM7
@vercel
Copy link
Copy Markdown

vercel Bot commented May 29, 2026

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

Project Deployment Actions Updated (UTC)
quantrank Ready Ready Preview, Comment May 29, 2026 7:27am

@dackclup dackclup marked this pull request as ready for review May 29, 2026 07:30
@dackclup dackclup merged commit e602485 into main May 29, 2026
4 checks passed
@dackclup dackclup deleted the claude/optimistic-brown-UUcXA branch May 29, 2026 07:30
dackclup added a commit that referenced this pull request May 29, 2026
…ame sweep (#313)

Post-merge animation audit (2-agent: frontend-design-reviewer code + expert-user-explorer live Playwright) + the user's "play every time" direction, in one PR:

1. Audit fixes (vs #312): root-caused the "stagger replays on sort" to StockLogo's inline fade-in restarting on row-move (removed it + interacted latch); closed the reduced-motion gap in the play gate; removed dead useInViewOnce; wired the declared-but-dead hover-lift + chip-pop tokens; fixed design.md drift.

2. Play every visit: usePlayedOnce (once/session) → usePlayOnMount (every mount) — gauge re-sweeps each stock visit, home re-staggers on return; sort/filter still don't re-stagger (interacted latch); reduced-motion still static.

3. BLOCKER fix: the signature gauge arc sweep was INVISIBLE (transition+double-rAF batched away by Chromium — caught by the audit's animation-event probe, which my earlier attribute-based checks had missed). Rewrote as a CSS @Keyframes gauge-sweep added via class — verified firing on visit + revisit (getAnimations poll + 58%→74% screenshots), reduced-motion safe.

Every animation verified actually animating via headless Playwright (animation events / getAnimations, not just attribute mutations). next build clean (505 routes), tsc + ruff clean. Frontend-only, no schema/compute change.

https://claude.ai/code/session_0144kHrCYNaamMPH57b7xdM7
dackclup added a commit that referenced this pull request May 30, 2026
…#326 §Gotchas (#327)

Full docs-reviewer substance pass on CLAUDE.md → 5 MUST-FIX + 4 SHOULD-FIX, all fixed. CLAUDE.md only (+ PHASE_STATUS_INFLIGHT.md lockstep entry); no code/schema/compute/frontend change.

MUST-FIX: §Phase status "In flight" marked PR #312 (tasteful-motion) as "THIS PR" → merged (e602485); "Recently merged" frozen at #310 → drained #311#326 (16 entries, SHAs verified); "Next deliverables" Issue #67 flip listed pending → DONE (PR #294, USE_SECTOR_COE=True); "Section A-H/A-J" → "A-L" (helper is A-L since PR #221).

SHOULD-FIX: §Stack "Phase 3b on this PR" → "(merged)" + edgartools 5.31→5.32; §Gotchas compute/main.py line refs re-anchored (840→879 · 1965-66→2084-85 · 717→728 · 785→805 · 972→1025); +2 §Gotchas for PR #326 invariants (sidebar data-rail↔globals.css pre-paint lockstep; AreaChart re-park debounce ≥300ms).

Confirmed no drift: schema 0.10.11-phase4.6 · skills 46 · agents 19 · hooks 3. docs-reviewer re-check: DOCS-CLEAN (all 16 SHAs match, 5 line refs accurate, 2 gotchas code-backed). Lockstep via PHASE_STATUS_INFLIGHT.md side-file (PR #237).
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