/* 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(
{trait.name} {trait.tag && {trait.tag}} {detriment && detriment}
{trait.description}
{reading && (
{reading}
)}
, document.body, )} ); }; window.TraitName = TraitName; /* LanguageChip — same hover-popover pattern but for a language id. */ const LanguageChip = ({ id }) => { const lang = LANGUAGES[id] || { name: id, description: "Unknown tongue." }; // Re-use TraitName by giving it a trait-shaped object. const trait = { id: "lang_" + id, name: lang.name, description: lang.description }; return ; }; window.LanguageChip = LanguageChip; /* SkillChip — hover-popover for a skill id. Shows label, governing ability, and the codex-flavored description. */ const SkillChip = ({ id, suffix = "", className = "", labelOverride = null }) => { const name = SKILL_LABEL[id] || id; const desc = SKILL_DESC[id] || "A skill of Theriapolis."; const ab = SKILL_ABILITY[id]; const trait = { id: "skill_" + id, name, description: desc, tag: ab, }; return ; }; window.SkillChip = SkillChip; /* BonusPill — small badge showing a numeric bonus, with a hover popover that lists the sources of the bonus (clade, species, etc.) */ const BonusPill = ({ total, sources, ability }) => { if (!sources || sources.length === 0) return null; const breakdown = sources.map(s => `${signed(s.value)} from ${s.source}`).join(" · "); const trait = { id: "bonus_" + ability, name: ability + " modifier", description: breakdown, }; return ( = 0 ? "pos" : "neg")} /> ); }; window.BonusPill = BonusPill;