/* TraitName — clickable/hoverable trait name that shows a hint window with description. Stays open as long as the mouse is over the trait name OR the hint window itself. */ const TraitName = ({ trait, detriment = false, suffix = ".", className = "", label = null }) => { const [open, setOpen] = React.useState(false); const [pos, setPos] = React.useState({ left: 0, top: 0, arrowLeft: 18 }); const triggerRef = React.useRef(null); const hintRef = React.useRef(null); const closeTimerRef = React.useRef(null); // We allow a tiny grace period so moving the cursor from the trigger to the // hint window doesn't cause a flicker-close. Both trigger and hint cancel // the timer on enter and start it on leave. const cancelClose = () => { if (closeTimerRef.current) { clearTimeout(closeTimerRef.current); closeTimerRef.current = null; } }; const scheduleClose = () => { cancelClose(); closeTimerRef.current = setTimeout(() => setOpen(false), 80); }; // Re-measure & clamp. Wrapped so we can call from the layout effect AND // from scroll/resize. Stored in a ref to avoid re-creating the listener. const measure = React.useCallback(() => { if (!hintRef.current || !triggerRef.current) return; const PAD = 8; const trig = triggerRef.current.getBoundingClientRect(); const hint = hintRef.current.getBoundingClientRect(); // documentElement.clientWidth excludes the vertical scrollbar gutter, // which window.innerWidth sometimes includes. Using clientWidth keeps // the popover from poking into the scrollbar lane. const vw = document.documentElement.clientWidth || window.innerWidth; const vh = document.documentElement.clientHeight || window.innerHeight; // Default: under trigger, left-aligned with trigger. let left = trig.left; let top = trig.bottom + 6; let placement = "below"; // Flip above if there's no room below and there's room above. if (top + hint.height + PAD > vh && trig.top - 6 - hint.height >= PAD) { top = trig.top - 6 - hint.height; placement = "above"; } // Clamp horizontally inside the viewport if (left + hint.width + PAD > vw) left = vw - hint.width - PAD; if (left < PAD) left = PAD; // Clamp vertically (in case neither above nor below quite fits — better // to overlap the trigger than escape the viewport). if (top + hint.height + PAD > vh) top = vh - hint.height - PAD; if (top < PAD) top = PAD; // Arrow follows trigger center, clamped within the popover's edges. const triggerCenter = trig.left + trig.width / 2; const arrowLeft = Math.max(12, Math.min(hint.width - 24, triggerCenter - left - 6)); setPos({ left: left + window.scrollX, top: top + window.scrollY, arrowLeft, placement, }); }, []); // After the hint mounts, measure & clamp. React.useLayoutEffect(() => { if (!open) return; measure(); }, [open, measure]); React.useEffect(() => () => cancelClose(), []); // Recalculate on scroll/resize while open. React.useEffect(() => { if (!open) return; window.addEventListener("scroll", measure, true); window.addEventListener("resize", measure); return () => { window.removeEventListener("scroll", measure, true); window.removeEventListener("resize", measure); }; }, [open, measure]); const onEnter = () => { cancelClose(); setOpen(true); }; const reading = TRAIT_READING[trait.id]; return ( <> {label != null ? label : (trait.name + suffix)} {open && ReactDOM.createPortal(