From d9fe6b64e7e3f1168e36893d79de36831bbc98d3 Mon Sep 17 00:00:00 2001 From: The Daniel Date: Mon, 27 Apr 2026 18:26:31 -0400 Subject: [PATCH] feat(detail): sticky minimized market header for both market types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the user scrolls into the comments section the full-size title is offscreen and the "what am I commenting on" context is lost. Two new components keep that context visible: - \`StickyMarketHeader\` (binary) — back arrow + truncated title + Yes / No percentage pills. - \`StickyGroupHeader\` (multi-outcome) — back arrow + truncated group title + selected-outcome pill (color-tied to the group's palette index, matches the chart legend / outcome list / trading panel header) + a +N badge for the remaining outcomes. Both share identical implementation: - \`position: fixed\` driven by \`useIsInView\` on the H1 wrapper and \`useElementRect\` (ResizeObserver) on the left grid column. The bar mounts only when the title intrudes into the top of the viewport; \`left\` and \`width\` track the column so the bar never bleeds across the right-column trading panel. - Earlier mount trigger: IntersectionObserver \`rootMargin\` of \`-80px 0 0 0\` so the H1 is treated as out of view as soon as it enters the bar's reserved area at the top — covers the macOS 32px strip + bar height — instead of waiting until it's fully past. - Banner overhangs the column by 16px on each side (half of the 32px column gutter, so the right edge lands at the gutter midpoint without crowding the trading panel column). Inner horizontal padding compensates so the back arrow / title / pills sit at the same x-coords as the column edges; only the chrome reaches out further. - New \`.sticky-overlay-safe-top\` utility pins the bar at \`top: 0\` and on macOS adds enough top padding to clear the 32px traffic-light / overlay strip, so the bar's translucent background fills the entire top of the frame instead of leaving the macOS strip transparent. Padding is owned entirely by the class to avoid a shorthand-vs-longhand cascade fight with Tailwind's \`py-*\`. - Background \`bg-slate-950/65\` + \`backdrop-blur\` matches the rest of the app's sticky chrome (TopShell, settings drawer). Also fixes a few UK spellings in nearby comments now that we're standardizing on US English (\`colour\` → \`color\`, \`minimised\` → \`minimized\`). --- src/components/detail/DetailPage.tsx | 45 ++- src/components/detail/MarketHeader.tsx | 282 +++++++++++++++++- .../detail/comments/CommentProfileDialog.tsx | 2 +- src/components/group/GroupChart.tsx | 2 +- src/components/group/GroupDetailPage.tsx | 60 ++-- src/style.css | 17 ++ 6 files changed, 380 insertions(+), 28 deletions(-) diff --git a/src/components/detail/DetailPage.tsx b/src/components/detail/DetailPage.tsx index 242f5325..07a23fb2 100644 --- a/src/components/detail/DetailPage.tsx +++ b/src/components/detail/DetailPage.tsx @@ -1,5 +1,5 @@ import { invoke } from "@tauri-apps/api/core"; -import { useCallback, useMemo } from "react"; +import { useCallback, useMemo, useRef } from "react"; import { useRedeemExpiredTokens, useRedeemTokens, @@ -18,7 +18,12 @@ import { import { generateMockPriceHistory } from "../../utils-react/mock-price-history"; import MarketChart from "../chart/MarketChart"; import { CommentsSection } from "./comments/CommentsSection"; -import { MarketHeaderBottom, MarketHeaderTop } from "./MarketHeader"; +import { + MarketHeaderBottom, + MarketHeaderTop, + StickyMarketHeader, + useIsInView, +} from "./MarketHeader"; import TradingPanel from "./TradingPanel"; export default function DetailPage() { @@ -76,6 +81,19 @@ export default function DetailPage() { [market], ); + // Hooks must run on every render path; declared before the + // not-found early-return so React's hook order stays stable. + // `titleVisibility.ref` attaches to the wrapper around + // MarketHeaderTop so the sticky minimized bar appears once the + // full-size H1 starts intruding into the bar's reserved area + // at the top of the viewport (negative top rootMargin covers + // the macOS strip + bar height). `leftColumnRef` gives the + // fixed-positioned bar a column-width to track. + const titleVisibility = useIsInView({ + rootMargin: "-80px 0px 0px 0px", + }); + const leftColumnRef = useRef(null); + if (!market) { return (
@@ -120,13 +138,18 @@ export default function DetailPage() {
)} - {/* Header above grid — matches group market layout so trading panel - aligns with the chart rather than the top of the title block */} - + {/* Header above grid — matches group market layout so trading + panel aligns with the chart rather than the top of the + title block. The ref/observer feeds the sticky bar's + visibility below; the bar mounts only when this wrapper + has scrolled out of view. */} +
+ +
{/* Left column */} -
+
+ + {/* Fixed-positioned, so its location in the tree doesn't + affect layout. Mounted only when the full-size H1 has + scrolled out of view; column-rect tracking keeps the + bar's left+width aligned with the left content column. */} + ); } diff --git a/src/components/detail/MarketHeader.tsx b/src/components/detail/MarketHeader.tsx index 6e35d41d..141b450e 100644 --- a/src/components/detail/MarketHeader.tsx +++ b/src/components/detail/MarketHeader.tsx @@ -1,5 +1,6 @@ +import { useEffect, useRef, useState } from "react"; import { useStore } from "../../store"; -import type { Market } from "../../types"; +import type { Market, MarketGroup } from "../../types"; import { formatSettlementDateTime, formatTimeRemaining, @@ -8,6 +9,7 @@ import { getEstimatedSettlementDate, getPositionContracts, } from "../../utils-react/market"; +import { OUTCOME_COLORS } from "../group/GroupChart"; import { categoryIcon } from "../layout/TopShell"; import { MarketActionsMenu } from "./MarketActionsMenu"; @@ -160,3 +162,281 @@ export default function MarketHeader({ market }: { market: Market }) { ); } + +/** Hook + ref pair to detect when an element scrolls out of the + * viewport. Used by the detail page to know when the full-size + * H1 has scrolled past the top so it can render the minimized + * sticky bar. + * + * `rootMargin` shifts the intersection root from the actual + * viewport. The detail page passes a negative top margin so the + * bar trips a little earlier — as soon as the title intrudes + * into the bar's reserved area at the top of the viewport, + * rather than only after the title is fully scrolled past. + * + * Default `true` so the first-paint state is "title visible, + * bar hidden" until the observer fires. */ +export function useIsInView( + options: { rootMargin?: string } = {}, +): { + ref: React.RefObject; + inView: boolean; +} { + const { rootMargin } = options; + const ref = useRef(null); + const [inView, setInView] = useState(true); + useEffect(() => { + const el = ref.current; + if (!el) return; + const observer = new IntersectionObserver( + ([entry]) => setInView(entry?.isIntersecting ?? false), + { threshold: 0, rootMargin }, + ); + observer.observe(el); + return () => observer.disconnect(); + }, [rootMargin]); + return { ref, inView }; +} + +/** Pixels the sticky bar's banner extends past the column on each + * side. 16px = half of the 32px (`gap-8`) inter-column gutter, so + * the right edge lands at the midpoint of the gap and visually + * doesn't crowd the right-column trading panel. The same + * symmetric overhang on the left keeps the bar centered around + * its content. Compensated with matching extra horizontal + * padding so the bar's content (back arrow, title, pills) stays + * aligned with the column — only the background / border / + * rounded chrome reaches out further. */ +const STICKY_BAR_BG_OVERHANG = 16; +/** Built-in horizontal padding the bar's content uses inside the + * banner — the original `px-3` Tailwind utility's value. Kept as a + * constant so the inline-style padding math stays readable. */ +const STICKY_BAR_INNER_PADDING_X = 12; + +/** Track an element's bounding-rect left+width so a `position: + * fixed` bar can match the host column's horizontal position. + * Updates on element resize, page scroll (scrollbar appearance + * shifts horizontal layout on some setups), and window resize. + * Returns null until the ref attaches, so the consumer can + * short-circuit before its first paint and avoid flashing at the + * wrong x-coordinate. */ +function useElementRect( + ref: React.RefObject, +): { left: number; width: number } | null { + const [rect, setRect] = useState<{ left: number; width: number } | null>( + null, + ); + useEffect(() => { + const el = ref.current; + if (!el) return; + const update = () => { + const r = el.getBoundingClientRect(); + setRect({ left: r.left, width: r.width }); + }; + update(); + const observer = new ResizeObserver(update); + observer.observe(el); + window.addEventListener("resize", update); + return () => { + observer.disconnect(); + window.removeEventListener("resize", update); + }; + }, [ref]); + return rect; +} + +/** + * Compact title bar that appears at the top of the viewport once + * the full-size H1 has scrolled past — keeps "what am I commenting + * on" visible while the user scrolls through the chart, resolution + * info, and comment list. + * + * Uses `position: fixed` (not `sticky`) so it's completely absent + * from the layout when the title is in view — no leading row of + * empty space, no flicker when scrolling past. `columnRef` is the + * left grid column's bounding box so the bar's left+width match + * that column and don't bleed across the right-column trading + * panel. + */ +export function StickyMarketHeader({ + market, + visible, + columnRef, +}: { + market: Market; + visible: boolean; + columnRef: React.RefObject; +}) { + const yesPct = + market.yesPrice != null ? Math.round(market.yesPrice * 100) : null; + const noPct = yesPct != null ? 100 - yesPct : null; + const rect = useElementRect(columnRef); + if (!visible || rect == null) return null; + return ( +
+ +

+ {market.question} +

+
+ {yesPct != null && ( + + Yes {yesPct}% + + )} + {noPct != null && ( + + No {noPct}% + + )} +
+
+ ); +} + +/** + * Multi-outcome counterpart to `StickyMarketHeader`. Same chrome, + * same sticky / overlay-safe positioning rules. The right side + * shows the currently-selected outcome — falling back to the + * leading outcome (highest yesPrice) when none is explicitly + * selected — plus a `+N` badge for the rest. Tracking the + * selection means the bar stays in sync with whichever outcome + * the user is reading / trading on the right column. + */ +export function StickyGroupHeader({ + group, + selectedOutcomeId, + visible, + columnRef, +}: { + group: MarketGroup; + selectedOutcomeId: string | null; + visible: boolean; + columnRef: React.RefObject; +}) { + // Resolve the featured outcome via index lookup (not just `find`) + // because the index drives the palette color — the rest of the + // group UI (chart legend, outcome list, trading panel header) + // ties an outcome to `OUTCOME_COLORS[index % palette.length]`, + // and the pill should match so the user reads the bar as the + // same entity they're trading on. + const selectedIndex = group.outcomes.findIndex( + (o) => o.id === selectedOutcomeId, + ); + const fallbackIndex = group.outcomes.reduce( + (best, o, i, arr) => (o.yesPrice > arr[best].yesPrice ? i : best), + 0, + ); + const featuredIndex = + selectedIndex >= 0 + ? selectedIndex + : group.outcomes.length > 0 + ? fallbackIndex + : -1; + const featured = featuredIndex >= 0 ? group.outcomes[featuredIndex] : null; + const featuredColor = + featuredIndex >= 0 + ? OUTCOME_COLORS[featuredIndex % OUTCOME_COLORS.length] + : null; + const featuredPct = + featured != null ? Math.round(featured.yesPrice * 100) : null; + const otherCount = Math.max(group.outcomes.length - 1, 0); + const rect = useElementRect(columnRef); + if (!visible || rect == null) return null; + return ( +
+ +

+ {group.title} +

+ {featured != null && featuredPct != null && featuredColor != null && ( +
+ {/* Inline style instead of a Tailwind utility because the + palette is an array of arbitrary hex values, not a + fixed Tailwind token set. The `1f` suffix on the + background hex is ~12% alpha — keeps the pill subtle + while letting the outcome's accent read at a glance. */} + + {featured.name} {featuredPct}% + + {otherCount > 0 && ( + + +{otherCount} + + )} +
+ )} +
+ ); +} diff --git a/src/components/detail/comments/CommentProfileDialog.tsx b/src/components/detail/comments/CommentProfileDialog.tsx index 3ae59dc6..1cccf47d 100644 --- a/src/components/detail/comments/CommentProfileDialog.tsx +++ b/src/components/detail/comments/CommentProfileDialog.tsx @@ -258,7 +258,7 @@ export function CommentProfileDialog({ > ({ + rootMargin: "-80px 0px 0px 0px", + }); + const leftColumnRef = useRef(null); + if (!group) { return (
@@ -657,30 +668,33 @@ export default function GroupDetailPage() {
- {/* Title + stats */} -

- {group.title} -

-
- - - {group.traderCount.toLocaleString()} - {" "} - traders - - · - - Closes{" "} - - {blocksLeft > 0 ? formatTimeRemaining(blocksLeft) : "Expired"} + {/* Title + stats — wrapped so the observer fires when this + block scrolls past, mounting the minimized sticky bar. */} +
+

+ {group.title} +

+
+ + + {group.traderCount.toLocaleString()} + {" "} + traders - + · + + Closes{" "} + + {blocksLeft > 0 ? formatTimeRemaining(blocksLeft) : "Expired"} + + +
{/* Two-column layout */}
{/* Left: chart + outcome list + description */} -
+
{/* Search */} @@ -797,6 +811,14 @@ export default function GroupDetailPage() { })()}
+ + {/* Fixed-positioned, see StickyMarketHeader for layout rationale. */} +
); } diff --git a/src/style.css b/src/style.css index eaceb664..71f18a11 100644 --- a/src/style.css +++ b/src/style.css @@ -576,6 +576,23 @@ button:focus-visible, animation: deadcat-notification-pulse 2s ease-out both; } +/* Sticky chrome that pins to viewport top with its background + extended up into the macOS traffic-light strip. The element pins + at `top: 0` (so the bar's bg covers the entire strip) and gets + `padding-top: calc(32px + 0.5rem)` on macOS only — 32px to + clear the close/min/max buttons + overlay gradient, plus the + matching 0.5rem inner padding the non-macOS variant uses on + top. Padding is owned entirely by this class to avoid a + shorthand-vs-longhand cascade fight with Tailwind's `py-*`. */ +.sticky-overlay-safe-top { + top: 0; + padding-top: 0.5rem; + padding-bottom: 0.5rem; +} +:root[data-os="macos"] .sticky-overlay-safe-top { + padding-top: calc(32px + 0.5rem); +} + /* Edge-fade indicators for the horizontally-scrolling category row. Two pseudo-elements (`::before` for the left, `::after` for the right) are toggled by `data-overflow-left` / `data-overflow-right`