25 KiB
Contract: interaction runtime (P3)
Owner: P3. Files: device/rt/04-input.js, 05-attention.js, 06-states.js, 07-tooltip.js (and the retired prototype device/proto/input_mode.js). Sources: PLAN §1.4, §1.13, §2.3 P3; IM (capabilities/input-mode.md); CTL §4, §11; VP P-01 to P-12; D2 §10.
Status: see the "Status" section of docs/phase2/wp/P3.md. Interfaces below are stable from M1 and live from M3 (2026-10-07, evidence in wp/P3.md; IN-7 is scored by the criterion PLAN R2-8 amended, §4.1). This file only grows; behaviour changes are listed in §8.
Everything runs in SharedJSContext (P1's bundle). Nothing is persisted. Every class, attribute, listener, node and wrapper listed here is removed by remove() (G-REMOVE, RT-4).
0. Loading and flags
Module (define name) |
File | deps | flag |
|---|---|---|---|
input |
04-input.js |
— | wp.p3 |
attention |
05-attention.js |
input |
wp.p3 |
states |
06-states.js |
input |
wp.p3 |
tooltip |
07-tooltip.js |
input, attention |
wp.p3 |
- Each file calls
__LGS_RT.define({name, deps, flag, install(rt), remove()})(P1,contracts/runtime.md). If__LGS_RTis absent (runtime not loaded) the file does nothing. wp.p3gates the whole package. Off: none of the APIs below exist onrt, and no class is set anywhere. Tests turn it on with--flags wp.p3(P10), together with their own flag (--flags wp.p3,wp.c2a); a module that listsattentionindepsstaysblockedwhilewp.p3is off. Recommended default now that P3 is at M3: on (V1 decides indefaults.json; REQ P3->V1).haptics(S17, default off).rt.haptic()is a dry run unless this flag is exactlytrue. No test turns it on.tipQuick(default off and stays off: PLAN §1.17 S29, decided in R2-8; kept as a test switch). Off: tooltip timing per PLAN §1.13 / D2 §11 (in after 0.8 s, out starts 0.2 s after leave), which IN-7 tests. On: the P3 card's withdrawn literal timing (fully in at 0.8 s, gone ≤ 0.25 s after leave). Read live; changing it re-registers the tooltip owners (§4.1).- Consumers either list
'input'/'attention'/ … in their owndeps(P1 then skips them while P3 is off), or feature-test:if (rt.input) …. Never call Steam's getters for the input mode yourself (PLAN §1.4: one accessor).
1. Input mode: rt.input (module input)
1.1 DOM signals (every Steam popup window, including popups created later)
| Signal | Values | Source | Use it for |
|---|---|---|---|
html.lgs-input-pad / html.lgs-input-laser |
exactly one of the two | FocusNavController.NavigationSource (1 GAMEPAD, 2 KEYBOARD_SIMULATOR → pad; 3 MOUSE, 4 TOUCH, 5/6 pads → laser; 0 UNKNOWN → SteamVR's mode) |
Every state look: hover, focus, press, light spot |
html[data-lgs-vr-mode] |
"gamepad" / "laser"; absent when SteamVR's mode is unknown |
vrGamepadInput.IsInGamepadNav |
Steam's mode-dependent nodes (the laser-only Sort/Filter pill), glyph badges (gamepad only, VP P-26) |
- All popups share one mode (it is a SharedJSContext singleton, IM §2.4). A new popup is painted as soon as P1's window registry reports it (
rt.windows.track: once its<body>exists; P1's 2 s reconcile catches any window a callback missed). Without P1's registry (prototype path only) P3 hooksg_PopupManageritself and paints at creation, +250 ms and +1 s. - Painting is synchronous inside Steam's change notification, so the class flips in the same task as Steam's own
.gpfocusremoval. Measured: all 10 live popups flip on the first CDP laser move and back on the first pad press (IN-1).
CSS rules for every area (P4 and contexts):
/* laser: looks key on :hover only (VP P-01, P-02); never on .gpfocus, .gpfocuswithin or .Focusable alone */
html.lgs-input-laser X:hover { /* hover look, instant */ }
html.lgs-input-laser X.lgs-dwell:hover { /* lift, scale, depth: only after 80 ms of dwell (VP P-06) */ }
/* gamepad */
html.lgs-input-pad X.gpfocus { /* focus look */ }
/* runtime off (P3 flag off or loader failed): neither class is present. Fallbacks that keep T1 working: */
html:not(.lgs-input-laser) X.gpfocus { /* focus look */ }
html:not(.lgs-input-pad) X:hover { /* hover look */ }
1.2 API
| Member | Returns / does |
|---|---|
rt.input.mode |
'pad' or 'laser' (the stubbed value while a stub is active) |
rt.input.vrMode |
'gamepad', 'laser' or 'none' |
rt.input.source |
'gamepad', 'keyboard-sim', 'mouse', 'touch', 'lpad', 'rpad' or 'unknown' |
rt.input.state() |
{mode, vrMode, source, stub: null | {mode, vrMode, until}, live: {mode, vrMode, source}, windows, subscriptions: {navigationSource, vrOverlayFocus, mobxReaction}, sound: {bus, enum, uiSoundsEnabled}, haptics: {armed, available}} |
rt.input.onChange(fn) |
Returns off(). fn(state, previous, reason) runs when mode or vrMode changes. reason: 'source', 'vr-mode', 'vr-overlay-focus', 'poll', 'stub'. Exceptions in fn are logged and swallowed |
rt.input.stub(mode, {vrMode, ttlMs} = {}) |
Test hook. Forces mode ('pad' / 'laser') and optionally vrMode in our classes, attribute and rt.input values only. Steam's getters, NavigationSource and vrGamepadInput are never touched (HA AT-14d, WN AT-24, IN-2). Fires onChange with reason 'stub'. Expires after ttlMs (default 120 000). Returns restore(). rt.input.stub(null) clears it. P1's rt.test input-mode stub and P10's --mode laser|pad call this |
rt.input.windows() |
The live Steam popup windows P3 has hooked (array of Window) |
rt.input.surfaceOf(win) |
That window's kind as P1 reports it (main, bar, barpopup, …) |
rt.input.soundId(event) |
The ENavSound number rt.sound(event) would request, without playing anything |
rt.sound(event, {immediate} = {}) |
Plays a Steam UI sound through Steam's own bus (§5). Also rt.input.sound |
rt.haptic(kind, elOrWindow) |
Dry-run haptic plan (§6). Also rt.input.haptic |
rt.input.test.index(fake) |
Test hook. P3's own %{Token} resolution uses fake.selector(tok) instead of P1's class index (to simulate an index that is still incomplete); null restores it. The theme and other modules are not affected. Cleared on removal |
rt.input, rt.sound, rt.haptic (and rt.attend, rt.attention, rt.states, rt.tooltip below) are published with P1's rt.expose, so P1 deletes them whenever the package is removed or fails to install.
2. Attention: rt.attend, rt.attention (module attention)
Hover never moves Steam's focus [PROVEN, IM D-7], so every delayed reveal (Home card ramp and name plates, tooltips, the More circle, the frozen target) is fed by this one state machine:
- gamepad mode:
vgp_onfocus/vgp_onbluron the element (capture listeners on each popup document), or a T3 component'sonGamepadFocus/onGamepadBlurthroughrt.attention.feed; - laser mode:
mouseover/mouseoutplus dwell. A dwell or step timer fires only if the pointer is still on the element: for trusted events (the real laser, CDPInput.dispatchMouseEvent) that means it still matches:hover; for untrusted events (the lab's syntheticL.hover, which cannot set:hover) it means the lastmouseovertarget in that document is still inside it. SoL.hoverdrives attention, dwell and tooltips in a lab step with--mode laser(IN-3b); CSS keyed on.lgs-dwell:hoverstill needs real (CDP) hover.
2.1 Global laser dwell (no registration needed)
.lgs-dwellis set on the innermost element under the laser that matchesrt.attention.dwellSelector, after 80 ms of continuous hover, in laser mode only.- Default
dwellSelector:.Panel, button, [role="button"], [role="tab"], a[href], [data-lgs-dwell]. Not.Focusable(Steam removes it under the laser). Elements larger than half the window (page containers) are skipped. - Cleared at once when the pointer leaves the element, when the mode becomes pad, and on removal. A sweep faster than one target per 80 ms sets none (IN-3).
- One element per window at a time. P6's reporter keys laser lifts on
.lgs-dwell:hover. - If Steam re-renders the element under a still laser before the 80 ms are up (library cards swap a child on hover) and Chromium has not yet sent the new
mouseover(measured up to 420 ms late), the card now under the laser (from the document's:hoverchain) gets the dwell on time.
2.2 rt.attend(target, opts) → handle
target is an Element, a selector string (%{Token} allowed, resolved through Steam's class index; resolved on use and retried at most once a second until every token resolves, so a registration made while P1's index is still incomplete starts working once it is complete) or a predicate fn(el) → bool. A selector matches the event target or its closest ancestor; registrations are delegated, so virtualized rows that recycle need no re-registration.
| Option | Default | Meaning |
|---|---|---|
dwellMs |
80 |
Continuous attention before dwell fires |
steps |
[] |
Further thresholds in ms (e.g. [400, 800] for Home's name plate and card). Each fires step and sets a class |
padImmediate |
false |
Gamepad focus fires dwell and every step at once (for GP's data-lgs-tip-pad="now" style reveals) |
leaveMs |
0 |
Grace before leave. Attention returning to the same element within it cancels the leave and keeps the timers (Home's 0.3 s hysteresis). 0: leave fires in the same task as the event (no timer). The laser's leave (and grace) starts at the attended element's own mouseout, not at the next element's mouseover, which Steam's handlers can hold back by about 70 ms. A threshold that comes due during the grace waits (every input): it fires when the attention returns within the grace, right after onReenter, and never when it does not, so an element the laser or the D-pad only passed over takes no step (REQ C2a-R2->P3, 2026-10-07) |
surfaces |
all | Restrict to popup kinds, e.g. ['main'] (names as rt.windows reports them; main always works) |
classes |
true |
Set the classes below on the attended element |
onEnter(el, ev), onDwell(el, ev), onStep(el, ms, ev), onLeave(el, ev) |
— | Callbacks. ev = {source: 'laser' | 'pad' | 'feed', t, win}. Exceptions are logged and swallowed |
onReenter(el, ev) |
— | Attention came back within the leaveMs grace (the attention never ended, so no enter fires). ev adds reentry: true and reached: [ms…], the thresholds already reached, so a consumer that reacted to another element meanwhile can restore its state; then the thresholds that came due during the grace fire. Optional (2026-10-07) |
Classes (only with classes: true): lgs-attend from enter to leave; lgs-attend-<ms> from each threshold (dwellMs and every step) to leave, e.g. lgs-attend-800.
Order guarantee: enter → dwell → step… → leave, at most one leave per enter; reenter may come between them (any number of times), only while a leave grace was running. A mode switch ends every active attention of the old input with leave (no grace). When another input enters an element that is already attended (e.g. the laser arrives on an element a T3 component fed), the attention continues and now belongs to that input, whose leave ends it.
Handle: {off(), active() → [{el, since, source, reached: [ms…]}]}. off() fires no leave and removes its classes.
2.3 Other members
| Member | Does |
|---|---|
rt.attention.current(win = main) |
{el, source, since} of what the user attends in that window right now: the .gpfocus element in pad mode, the dwelled element in laser mode; null if nothing. For the More circle and the frozen target (C1a) |
rt.attention.feed(el, 'enter' | 'leave', source = 'feed') |
Drive registrations from a T3 component's onGamepadFocus / onGamepadBlur (HA AT-8(d)). Registrations whose target matches el behave as if attention arrived or left |
rt.attention.dwellSelector |
Read/write string (§2.1) |
rt.attention.dwellMs |
80 (read-only) |
rt.attention.dwellState(), rt.attention.registrations() |
Diagnostics for tests: the dwell per surface ({surface, on, ms, cls}), and the number of live rt.attend registrations |
3. States: rt.states (module states)
All listeners are passive, capture phase, on each popup document. P3 never calls preventDefault, stopPropagation or re-dispatches a Steam event.
| Output | Rule |
|---|---|
Light spot --hx, --hy |
Laser mode only. On pointermove, at most one write per animation frame, as percentages of the element's box ("37.5%"), inline on the innermost element matching rt.states.spotSelector under the pointer (default: the dwell selector plus [data-lgs-spot]). Removed when the pointer leaves that element, when the mode becomes pad (VP P-09), and on removal. Zero writes while the pointer is still (IN-5). CSS: radial-gradient(circle at var(--hx, 50%) var(--hy, 30%), …), drawn under the label (VP P-10) |
lgs-pressed |
Gamepad A only: on vgp_onbuttondown with detail.button === 1 (not repeats) on the focused element, set on e.target.closest(rt.states.pressSelector) or the target itself. Removed synchronously on the matching vgp_onbuttonup (button 1), or after 400 ms as a safety net. Other buttons are ignored (IN-4). The laser press uses CSS :active |
lgs-focus-in |
On every gamepad focus change (vgp_onfocus in pad mode), set on the newly focused element and on that window's %{FocusRing} node(s), in the same task as Steam's .gpfocus change; restarted if already present; removed 400 ms later. CSS keys the entry keyframe (P5 lgs-focus-in, .6 → 1 on hover-in) either on .gpfocus itself or on .lgs-focus-in; the ring needs the class because it moves without a class change |
lgs-ring-check |
On the window's %{FocusRing} node(s) while gamepad focus is on a .DialogCheckbox (CTL §4.5); removed at the next focus change |
lgs-focus-disabled |
On the gamepad-focused element while it is disabled (.Disabled, [disabled], [aria-disabled="true"], or gamepaddialog's Disabled class); removed when focus leaves it (CTL C-D15). Rows can use :has(.lgs-focus-disabled) |
| Member | Does |
|---|---|
rt.states.spotSelector, rt.states.pressSelector |
Read/write selector strings (%{Token} allowed) |
rt.states.addSpotHost(selector) |
An area's own spot host (e.g. a card's outer box whose CSS draws the spot), tried before the default list. Returns off() |
rt.states.stats() |
Counters for IN-4/IN-5: {spotWrites, spotClears, pressed, released, safetyClears, focusIn, ringCheck, disabledFocus} |
rt.states.selectors() |
Diagnostics: {spot, press, disabled, ring, hosts}, each {sel, resolved}. Every %{Token} selector here (%{FocusRing}, the gamepaddialog Disabled classes, area selectors) is resolved lazily and retried until P1's class index has all its tokens |
rt.states.test.feed(type, target, detail) |
Test hook. Runs P3's own handler for vgp_onbuttondown / vgp_onbuttonup / vgp_onfocus / vgp_onblur on a fake event object; nothing is dispatched to Steam. Returns {prevented, stopped} (always false: P3 never cancels) |
4. Tooltips: rt.tooltip (module tooltip)
4.1 In-window tooltips (new, T2)
- Owners: any element in a Steam window with a
data-lgs-tipattribute ("","below","above"), plus anything registered withrt.tooltip.register. Areas set the attribute from their own T2 code (C1a Back circle, GP action cluster with"above", …). - Text:
data-lgs-tip-text, elsearia-label, elsetitle. One line, ≤ 32 characters is the owner's job. Controls with visible text get no tooltip (VP P-12);register(…, {force: true})overrides the check for text that our CSS hides (E-BACK). - Timing (PLAN §1.13, D2 §11 "After 0.8 s, materialize 250 ms | 0.2 s, dematerialize 350 ms", MO §4.17): the materialize starts after 0.8 s of attention (laser hover or gamepad focus), the leave starts 0.2 s after leave or blur, in both modes; attention that returns within those 0.2 s keeps the tooltip (no flicker from laser jitter).
data-lgs-tip-pad="now"shows at once under gamepad focus. Materialize 250 ms (--lgs-motion-mat-in), dematerialize 350 ms (--lgs-motion-mat-out); Reduce Motion: P5's opacity-only versions (≤ 180 ms). Measured on computed opacity (2026-10-07,wp/P3.mdIN-7): glass visible from about 820 ms, label at 0.5 by about 985 ms and 0.9 by about 1050 ms; the leave starts at about 210 to 230 ms, the label is gone at about 400 to 420 ms and the capsule at about 560 to 610 ms (nodehidden).- P3-D4, decided (PLAN §1.19 R2-8): §1.13's 0.8 s and 0.2 s are delays, and IN-7's pass criterion is amended to match (PLAN §2.3 P3): nothing visible before 760 ms; the materialize starts 800 ± 40 ms after attention begins; label opacity ≥ 0.9 by 1100 ms; the leave starts 200 ± 40 ms after attention ends; label opacity 0 by 460 ms and the node
hiddenby 640 ms; attention back within 200 ms keeps the label ≥ 0.9 (no dip);data-lgs-tip-pad="now": label ≥ 0.9 within 300 ms of gamepad focus;m_mapTooltipsempty after cleanup. The card's earlier "label at 900 ms; gone ≤ 250 ms" is withdrawn; CTL C9 follows the same criterion. - Flag
tipQuick(off, S29; a test switch only) gives the withdrawn literal timing instead. The materialize starts at 0.55 s, so the tooltip is fully in at 0.8 s. There is no leave grace (leaveMs0), and the dematerialize runs on--lgs-d-reduce(180 ms). Attention that returns to the same owner while the tooltip is leaving brings it straight back (data-resume, no animation). Measured: the label is at 1.0 at 900 ms, and the capsule is gone about 190 to 220 ms after leaving, for both inputs.
- P3-D4, decided (PLAN §1.19 R2-8): §1.13's 0.8 s and 0.2 s are delays, and IN-7's pass criterion is amended to match (PLAN §2.3 P3): nothing visible before 760 ms; the materialize starts 800 ± 40 ms after attention begins; label opacity ≥ 0.9 by 1100 ms; the leave starts 200 ± 40 ms after attention ends; label opacity 0 by 460 ms and the node
- Node: one
div.lgs-tip.lgs-edge[data-lgs-mat="thick"]per window document (role="tooltip",aria-hidden="true",pointer-events: none,position: fixed,z-index: 7100), label in.lgs-tip-label. Placement 12 px below the owner (above with"above", or when the owner's bottom is below y 600), clamped 24 px inside the window. State:data-state="in" | "out" | "hidden",data-placement="below" | "above"; inlineleft,topand--lgs-maxside(P5's materialize swell). - Look: a thick-glass capsule
--lgs-tooltip-h(48 px) tall,--lgs-fs-subhead(20 px) Semibold, 20 px side padding: P4's--lgs-mat-thick-bg,--lgs-mat-thick-blur,--lgs-mat-thick-shadeand a 5 mm CSS shadow (CTL §11), edges from P4's edge hook on::before(E3 arcs; High Contrast gets P4's 2 px stroke). Motion: P5'slgs-mat-glass-in/outon the capsule andlgs-mat-content-in/outon the label. All of it in a per-window<style id="lgs-tip-style">of zero specificity (:where(), literal values only as fallbacks), so theme CSS can override any of it. No border, no outline. Live shotshots/p2_p3_tooltip_live.png, compared with the mockup inshots/p2_cmp_p3_tooltip.png.
| Member | Does |
|---|---|
rt.tooltip.register(target, {text, placement, padNow, force, surfaces}) |
target as in rt.attend. text: a string, an attribute name prefixed @ ('@aria-label'), or fn(el) → string. Returns off() |
rt.tooltip.state() |
{shown: [{surface, state, text, placement, rect, shownAt, why}], owners, barWrapper, mapTooltips, count, timing: {quick, startMs, inMs, graceMs, outMs}} |
rt.tooltip.hideAll() |
Hides every in-window tooltip at once |
rt.tooltip.show(el), rt.tooltip.hide(el) |
Test hooks: show an owner's tooltip now (no attention; same checks and look), and start its leave |
4.2 Bar tooltips (Steam's tooltip popup)
vrPooledPopupStore.ShowTooltip(el, text, params, fn)is wrapped soparams.unDelayMSis at least 800 (CTL P-C5). Nothing else changes; the original function is restored byremove().- The look is C3a's (
theme/35-hud.css). - Lab hygiene: every test that synthesizes hover ends with the pointer at (1400, 900) and checks that
vrPooledPopupStore.m_mapTooltipsis empty (IN-7).
5. Sounds: rt.sound(event)
Calls Steam's sound bus (PlayNavSound(eSound, bImmediate), found by needles; fallback SteamUIStore.m_GamepadUIAudioStore). Steam applies its own policy: Settings › Audio › UI sounds, a 50 ms rate limit, lower enum pre-empts (IM §6.3). Returns the ENavSound number requested, or null if refused.
event |
ENavSound | Use for |
|---|---|---|
'activate' |
DefaultOk (21) |
Our own controls' activation (A or trigger) |
'tab', 'segment' |
ChangeTabs (20) |
Tab or segment change |
'page' |
PagedNavigation (15) |
Paged section change |
'toggleOn', 'toggleOff' |
ToggleOn (16), ToggleOff (17) |
Our switches |
'sliderUp', 'sliderDown' |
SliderUp (18), SliderDown (19) |
Our sliders, per detent |
'sheetIn', 'sheetOut' |
ShowModal (11), HideModal (12) |
Our sheets and alerts |
'menuIn', 'menuOut' |
OpenSideMenu (22), CloseSideMenu (23) |
Side menus only |
'nav' |
BasicNav (24) |
Focus moves inside our own T3 views that Steam does not sound |
'fail' |
FailedNav (25) |
Our own dead-end moves |
any ENavSound name or number |
itself | — |
- Refused:
'hover'(and any call while the laser hovers is the caller's bug: no sound on hover, VP §4.4). Never duplicate a sound Steam already plays for its own Panels' focus change. - Tests intercept playback with
SteamUIStore.m_GamepadUIAudioStore.m_AudioPlaybackManager.PlayAudioURL(IM §9); nothing is ever played by a test.
6. Haptics: rt.haptic(kind, elOrWindow) (disarmed)
kind:'enter'(1 ButtonEnter),'leave'(2),'snap'(3),'slide'(4),'edge'(5), or the number.- Returns
{api, effect, available, armed, mode, fired}. It fires<popup>.SteamClient.OpenVR.TriggerOverlayHapticEffect(effect, 0)only when flaghaptics === true, the mode is laser, the element is not a Steam.Panel(Steam already pulses those), and ≥ 80 ms passed since the last pulse. Default:fired: false(dry run, S17).
7. Removal (IN-9, RT-4, G-REMOVE)
remove() of each module, in reverse order, leaves:
- no
lgs-input-*,data-lgs-vr-mode,lgs-dwell,lgs-attend*,lgs-pressed,lgs-focus-in,lgs-ring-check,lgs-focus-disabledon any node of any window; no inline--hx/--hy; - no
.lgs-tipnode and no#lgs-tip-style; vrPooledPopupStore.ShowTooltipidentical to Steam's original;- subscriber counts back to baseline:
NavigationSourcesubscribers,vrGamepadInputnavigation-type callbacks,g_PopupManagercreated/destroyed callbacks, the MobX reaction disposed; - no chunk record of ours in
webpackChunksteamui(Steam's require is taken once per page load and the record is spliced out at once); - no P3 member on
__LGS_RT(published throughrt.expose).
The same holds after a partial install (runtime.md §1 rule 2): every module sets its remove before its first subscription, and each Steam subscription registers its undo the moment it is made (also with P1's tracked rt.cleanup).
Memory: P3 never keeps a page alive. Class marks are held through a WeakMap and WeakRefs. The last pointer target, the spot host and the tooltip's last owner are weak references. Strong references exist only for the current dwell, a press (≤ 400 ms), a focus entry (≤ 400 ms), the focused disabled element and the owner of a shown tooltip, and each is dropped at the next input event. Live check: the leak test in wp/P3.md.
8. Changes
| Date | Change |
|---|---|
| 2026-10-07 | M1: first version |
| 2026-10-07 | M3: behaviour live. §1.1 paint timing through P1's registry; §1.2 surfaceOf, soundId, fuller state(); §2 untrusted (lab) hover drives attention; §2.3 diagnostics; §3 addSpotHost, test.feed, fuller stats(); §4 node classes, P4 material and P5 motion, test hooks |
| 2026-10-07 | Review R1 fixes. §0 flag tipQuick. §1.2 rt.expose, rt.input.test.index. §2.1 dwell survives a re-render under a still laser. §2.2 lazy %{Token} targets; leaveMs: 0 leaves in the same task; the laser's leave starts at mouseout; re-entry by another input hands the attention over. §3 selectors(), lazy tokens. §4.1 timing measured on opacity, deviation P3-D4, tipQuick, state().timing. §7 chunk record, partial installs, memory |
| 2026-10-07 | Round R2 (PLAN R2-8): P3-D4 decided; §0 and §4.1 cite the amended IN-7 criterion; tipQuick stays off (S29). No behaviour change |
| 2026-10-07 | Maintenance session 4 (REQ C2a-R2->P3): §2.2 a threshold that comes due during the leave grace waits and fires only if the attention returns within it; new optional callback onReenter with the reached thresholds. Tooltips follow (a tooltip whose 0.8 s falls in the 0.2 s grace shows on the return, and not at all without one: no flash) |