The code cited its plan instead of saying why: `E28 review J4`, `(R6 S3b)`, `S1 justified…`,
`deep-review round 5`. That plan lives in the private ops repo, so for every reader of this one the
citation was noise, and a sentence whose subject was the plan step ("round 2 showed…") hid its
lesson behind an id. Each one is gone and the "why" kept: a bare citation is deleted, a sentence
carrying a lesson is reworded around it ("a later attempt showed…").
Measured (`scripts/audit_metrics.py`, product code): 652 lines in 270 files carried an epic code by
the definition the next commit holds (507 / 241 by the old, two-digit-only one) -> 0. Across all
code, tests, scripts and configs: ~1100 epic-code lines plus ~320 sprint/round ids.
Behaviour unchanged, checked mechanically: every changed .py file has the same AST once docstrings
are blanked; css/sh/toml/yml/json files are identical once comments are stripped; the only
non-comment changes in TS/JS are test titles, one description string in a contract table, and five
ESLint messages, whose text is all the gates snapshot diff holds. Not touched: Markdown (docs and
the changelog keep their history), Alembic migrations, TV episode notation (`S2 · E5`), DESIGN.md
ids (`C1`, `B4`, `S3h`), tracker ids (`SEC-16`, `DX-17`).
One stale claim fixed on the way: test_hls_reaper's docstring said the player checkpoints "every
10 s whether or not it is playing", which plex/stream.py calls wrong; it now says what the code does.
81 lines
3.7 KiB
TypeScript
81 lines
3.7 KiB
TypeScript
import { useCallback, useEffect, useRef, useState, type FocusEvent } from "react";
|
|
|
|
/**
|
|
* "Peek open on hover or keyboard focus" for a floating panel — the shared brain behind the side
|
|
* panels, which sit slim until you point at them or tab into them.
|
|
*
|
|
* Returns `active` plus THREE handler groups so a consumer can scope hover and focus independently:
|
|
* - `hoverHandlers` — pointer enter/leave, with a short GRACE period on leave so the pointer can
|
|
* cross a gap between two separate peek regions (e.g. the docked tab and the expanded card, which
|
|
* are separate absolutely-positioned elements) without the panel collapsing mid-move. Put these on
|
|
* ONLY the visible affordances (the tab handle + the card), so approaching empty gutter next to the
|
|
* panel does not pop it open.
|
|
* - `focusHandlers` — focus/blur, tracked through the REACT tree so a portaled child menu still
|
|
* counts as "inside". Put these on the outer container so keyboard focus anywhere within holds it.
|
|
* - `handlers` — both groups merged, for the simple single-element case.
|
|
*
|
|
* The one focus case that misses: a portaled menu unmounts while focused — browsers fire NO blur when
|
|
* a focused node is removed, so focus silently falls to the body and would latch `focused` on forever.
|
|
* Hence the document-level safety net: when focus has landed nowhere, clear it.
|
|
*/
|
|
export function useHoverFocusWithin() {
|
|
const [hovered, setHovered] = useState(false);
|
|
const [focused, setFocused] = useState(false);
|
|
const leaveTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
|
|
const enter = useCallback(() => {
|
|
clearTimeout(leaveTimer.current);
|
|
setHovered(true);
|
|
}, []);
|
|
const leaveSoon = useCallback(() => {
|
|
clearTimeout(leaveTimer.current);
|
|
leaveTimer.current = setTimeout(() => setHovered(false), 120);
|
|
}, []);
|
|
useEffect(() => () => clearTimeout(leaveTimer.current), []);
|
|
|
|
useEffect(() => {
|
|
let timer: number | undefined;
|
|
const clearIfFocusLandedNowhere = () => {
|
|
const el = document.activeElement;
|
|
if (!el || el === document.body || el === document.documentElement) setFocused(false);
|
|
};
|
|
// Deferred a tick so activeElement has settled, and rescheduled rather than queued — only the
|
|
// latest state matters.
|
|
const soon = () => {
|
|
window.clearTimeout(timer);
|
|
timer = window.setTimeout(clearIfFocusLandedNowhere, 0);
|
|
};
|
|
document.addEventListener("focusout", soon);
|
|
document.addEventListener("click", soon);
|
|
return () => {
|
|
document.removeEventListener("focusout", soon);
|
|
document.removeEventListener("click", soon);
|
|
window.clearTimeout(timer);
|
|
};
|
|
}, []);
|
|
|
|
const hoverHandlers = { onMouseEnter: enter, onMouseLeave: leaveSoon };
|
|
const focusHandlers = {
|
|
onFocus: () => setFocused(true),
|
|
onBlur: (e: FocusEvent) => {
|
|
const to = e.relatedTarget as Element | null;
|
|
if (e.currentTarget.contains(to)) return;
|
|
// A dialog opened FROM inside the panel (the rail row's "Delete?" confirm) renders at the app
|
|
// root, so neither the DOM nor the React tree sees it as inside — and collapsing the panel under
|
|
// it unmounted the very row the dialog hands the focus back to, leaving a keyboard user on
|
|
// <body> after Cancel (measured in Chrome). Hold while the focus is in a dialog: when it
|
|
// closes, the focus either comes back in here (onFocus) or lands nowhere, which the
|
|
// document-level net below clears.
|
|
if (to?.closest('[aria-modal="true"]')) return;
|
|
setFocused(false);
|
|
},
|
|
};
|
|
|
|
return {
|
|
active: hovered || focused,
|
|
hoverHandlers,
|
|
focusHandlers,
|
|
handlers: { ...hoverHandlers, ...focusHandlers },
|
|
};
|
|
}
|