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`