mirror of
https://github.com/MichaelScottsman/LiquidAss-Frame.git
synced 2026-10-22 00:01:11 +02:00
378 lines
19 KiB
JavaScript
378 lines
19 KiB
JavaScript
// Glass Shell phase 2, P3 interaction runtime: tooltips (PLAN §1.13, CTL §11, VP P-12).
|
|
// Contract: docs/phase2/contracts/interaction.md §4. Evidence: docs/phase2/wp/P3.md.
|
|
//
|
|
// In-window: one div.lgs-tip per Steam window, for icon-only owners ([data-lgs-tip] or
|
|
// rt.tooltip.register). In after 0.8 s of attention (laser dwell or gamepad focus),
|
|
// out 0.2 s after leave; [data-lgs-tip-pad="now"] shows at once under gamepad focus.
|
|
// Flag tipQuick (off; P3-D4): the card's literal IN-7 timing instead, fully in at 0.8 s
|
|
// and gone 0.18 s after leave.
|
|
// Bar: vrPooledPopupStore.ShowTooltip gets unDelayMS >= 800 (CTL P-C5); restored on removal.
|
|
(function lgsP3Tooltip() {
|
|
'use strict';
|
|
const RT = (typeof __LGS_RT !== 'undefined') ? __LGS_RT : window.__LGS_RT;
|
|
if (!RT || typeof RT.define !== 'function') return;
|
|
|
|
const H = window;
|
|
const IN_MS = 800; // PLAN §1.13: 0.8 s in, both inputs
|
|
const OUT_MS = 200; // 0.2 s out
|
|
const MAT_IN_MS = 250; // materialize-in (D2 §11.2)
|
|
const MAT_OUT_MS = 350; // materialize-out
|
|
const QUICK_OUT_MS = 180; // flag tipQuick: dematerialize on --lgs-d-reduce, no grace
|
|
const BAR_DELAY_MS = 800;
|
|
const GAP = 12; // px between owner and tip
|
|
const INSET = 24; // clamp inside the window
|
|
const TIP_H = 48;
|
|
const STYLE_ID = 'lgs-tip-style';
|
|
const MARK = Symbol.for('lgs.p3.tooltipWrapper');
|
|
|
|
// Base layout, look and motion. :where() keeps specificity at 0 so theme CSS (P4 / C3a) can
|
|
// restyle it. Material: P4's thick glass tokens, edges from P4's edge hook (class lgs-edge +
|
|
// data-lgs-mat="thick": E3 arcs on ::before), no border or outline (VP P-42). Motion: P5's
|
|
// small-glass materialize keyframes and tokens, which are Reduce-Motion-correct by themselves.
|
|
// Literal values are fallbacks for a theme without the tokens.
|
|
const CSS = `
|
|
:where(.lgs-tip) {
|
|
position: fixed; left: 0; top: 0; z-index: 7100; pointer-events: none; box-sizing: border-box; margin: 0;
|
|
display: flex; align-items: center; height: var(--lgs-tooltip-h, ${TIP_H}px); padding: 0 20px;
|
|
border-radius: var(--lgs-r-capsule, 999px); border: 0; outline: 0;
|
|
max-width: calc(100vw - ${2 * INSET}px); white-space: nowrap;
|
|
font-family: var(--lgs-font, "LGS Inter", "Motiva Sans", Arial, sans-serif);
|
|
font-size: var(--lgs-fs-subhead, 20px); font-weight: 600; line-height: 24px;
|
|
letter-spacing: 0; text-transform: none; font-style: normal;
|
|
color: var(--lgs-text-1, rgb(255 255 255 / .96));
|
|
background: var(--lgs-mat-thick-bg, linear-gradient(180deg, rgb(255 255 255 / .08), rgb(255 255 255 / 0) 30%), rgb(36 37 42 / .40));
|
|
-webkit-backdrop-filter: var(--lgs-mat-thick-blur, blur(30px) saturate(1.5));
|
|
backdrop-filter: var(--lgs-mat-thick-blur, blur(30px) saturate(1.5));
|
|
box-shadow: var(--lgs-mat-thick-shade, inset 0 -12px 20px -12px rgb(0 0 0 / .20)), var(--lgs-shadow-5mm, 0 2px 6px rgb(0 0 0 / .30));
|
|
transform-origin: 50% 0%;
|
|
}
|
|
:where(.lgs-tip)[data-placement="above"] { transform-origin: 50% 100%; }
|
|
:where(.lgs-tip) > .lgs-tip-label { overflow: hidden; text-overflow: ellipsis; }
|
|
:where(.lgs-tip)[data-state="hidden"] { display: none; }
|
|
:where(.lgs-tip)[data-state="measure"] { visibility: hidden; animation: none; }
|
|
:where(.lgs-tip)[data-state="in"] { animation: lgs-mat-glass-in var(--lgs-motion-mat-in, ${MAT_IN_MS}ms linear) backwards; }
|
|
:where(.lgs-tip)[data-state="in"] > .lgs-tip-label { animation: lgs-mat-content-in var(--lgs-motion-mat-in, ${MAT_IN_MS}ms linear) backwards; }
|
|
:where(.lgs-tip)[data-state="out"] { animation: lgs-mat-glass-out var(--lgs-motion-mat-out, ${MAT_OUT_MS}ms linear) forwards; }
|
|
:where(.lgs-tip)[data-state="out"] > .lgs-tip-label { animation: lgs-mat-content-out var(--lgs-d-mat-out, ${MAT_OUT_MS}ms) linear forwards; }
|
|
:where(.lgs-tip)[data-state="in"][data-resume], :where(.lgs-tip)[data-state="in"][data-resume] > .lgs-tip-label { animation: none; }
|
|
:where(.lgs-tip)[data-state="out"][data-quick], :where(.lgs-tip)[data-state="out"][data-quick] > .lgs-tip-label { animation-duration: var(--lgs-d-reduce, ${QUICK_OUT_MS}ms); }`;
|
|
|
|
let R = null;
|
|
let live = null;
|
|
|
|
function log(level, msg, data) {
|
|
try {
|
|
const f = R && (level === 'error' ? R.error : level === 'warn' ? R.warn : R.log);
|
|
if (typeof f === 'function') { f(msg, data); return; }
|
|
} catch (_) { /* fall through */ }
|
|
try { H.console[level === 'error' ? 'error' : 'log']('[lgs tooltip] ' + msg, data === undefined ? '' : data); } catch (_) { /* none */ }
|
|
}
|
|
const PUB = () => (H.__LGS_RT && typeof H.__LGS_RT.define === 'function') ? H.__LGS_RT : RT;
|
|
// Per-document hooks through P1's window registry (scoped, tracked), else P3's own hub.
|
|
function onDocOf(rt, input) {
|
|
const RW = rt && rt.windows;
|
|
if (RW && typeof RW.track === 'function') return (fn) => RW.track((e) => fn(e.win, e.doc, e.kind));
|
|
return (fn) => input.hub.onDoc(fn);
|
|
}
|
|
// A member on the public runtime object (PLAN §1.4 one accessor) through P1's tracked rt.expose,
|
|
// which deletes it when the module is removed; plain assignment (undone by off) only without it.
|
|
function expose(rt, name, value, offs) {
|
|
const P = PUB();
|
|
if (rt && rt !== P && typeof rt.expose === 'function') {
|
|
try { offs.push(rt.expose(name, value)); return; } catch (err) { log('warn', 'rt.expose(' + name + ') refused; assigning', String(err)); }
|
|
}
|
|
P[name] = value;
|
|
offs.push(() => { if (P[name] === value) delete P[name]; });
|
|
}
|
|
function inputOf(rt) {
|
|
try { if (rt && typeof rt.use === 'function') return rt.use('input'); } catch (_) { /* not via use */ }
|
|
return (rt && rt.input) || (PUB() && PUB().input) || null;
|
|
}
|
|
function flagOn(name) {
|
|
try {
|
|
const f = R && R.flags;
|
|
if (!f) return false;
|
|
if (typeof f.enabled === 'function') return !!f.enabled(name);
|
|
if (typeof f.get === 'function') return !!f.get(name);
|
|
return !!f[name];
|
|
} catch (_) { return false; }
|
|
}
|
|
|
|
RT.define({
|
|
name: 'tooltip',
|
|
deps: ['input', 'attention'],
|
|
flag: 'wp.p3',
|
|
install(rt) {
|
|
R = rt || RT;
|
|
const input = inputOf(R);
|
|
let att = null;
|
|
try { att = typeof R.use === 'function' ? R.use('attention') : null; } catch (_) { att = null; }
|
|
const attendFn = (att && att.attend) || R.attend || (PUB() && PUB().attend);
|
|
if (!input || !input.hub || typeof attendFn !== 'function') throw new Error('tooltip: rt.input / rt.attend (P3) missing');
|
|
const hub = input.hub;
|
|
const onDoc = onDocOf(R, input);
|
|
const lazySel = input.lazySel;
|
|
const st = { regs: [], tips: new Map(), offs: [], bar: null, shown: 0, quick: flagOn('tipQuick') };
|
|
live = st;
|
|
// Fail closed (runtime.md §1 rule 2): set before any listener, registration or wrapper below.
|
|
st.remove = () => {
|
|
for (const off of st.offs.splice(0).reverse()) { try { off(); } catch (_) { /* gone */ } }
|
|
for (const t of st.tips.values()) { H.clearTimeout(t.timer); try { t.node.remove(); } catch (_) { /* gone */ } }
|
|
st.tips.clear();
|
|
st.regs.length = 0;
|
|
if (st.bar) {
|
|
const { store, own, orig, wrapper } = st.bar;
|
|
try {
|
|
if (store.ShowTooltip === wrapper) {
|
|
if (own) store.ShowTooltip = orig;
|
|
else delete store.ShowTooltip;
|
|
}
|
|
} catch (_) { /* gone */ }
|
|
st.bar = null;
|
|
}
|
|
// Belt and braces: no tip node or style left in any live popup.
|
|
for (const w of hub.windows()) {
|
|
try {
|
|
for (const n of w.document.querySelectorAll('.lgs-tip, #' + STYLE_ID)) n.remove();
|
|
} catch (_) { /* gone */ }
|
|
}
|
|
};
|
|
const WR = typeof H.WeakRef === 'function' ? H.WeakRef : null;
|
|
const weak = (el) => (el && WR ? new WR(el) : el);
|
|
const strong = (r) => (r && WR && r instanceof WR ? r.deref() || null : r);
|
|
|
|
// ---------------------------------------------- owners
|
|
function regFor(el) {
|
|
for (const r of st.regs) { try { if (r.self(el)) return r; } catch (_) { /* bad selector */ } }
|
|
return null;
|
|
}
|
|
function isOwner(el) {
|
|
if (!el || el.nodeType !== 1) return false;
|
|
if (el.hasAttribute('data-lgs-tip')) return true;
|
|
return !!regFor(el);
|
|
}
|
|
function textFor(el, reg) {
|
|
let t = '';
|
|
try {
|
|
if (reg && reg.text) {
|
|
if (typeof reg.text === 'function') t = reg.text(el);
|
|
else if (typeof reg.text === 'string' && reg.text.startsWith('@')) t = el.getAttribute(reg.text.slice(1));
|
|
else t = reg.text;
|
|
}
|
|
if (!t) t = el.getAttribute('data-lgs-tip-text') || el.getAttribute('aria-label') || el.getAttribute('title') || '';
|
|
} catch (_) { t = ''; }
|
|
return String(t || '').replace(/\s+/g, ' ').trim();
|
|
}
|
|
function hasVisibleText(el) {
|
|
try { return !!(el.innerText && el.innerText.trim()); } catch (_) { return false; }
|
|
}
|
|
function placementFor(el, reg) {
|
|
const a = el.getAttribute('data-lgs-tip');
|
|
if (a === 'above' || a === 'below') return a;
|
|
if (reg && (reg.placement === 'above' || reg.placement === 'below')) return reg.placement;
|
|
return 'auto';
|
|
}
|
|
function padNow(el, reg) { return el.getAttribute('data-lgs-tip-pad') === 'now' || !!(reg && reg.padNow); }
|
|
|
|
// ---------------------------------------------- the per-window tip node
|
|
function tipFor(doc) {
|
|
let t = st.tips.get(doc);
|
|
if (t && t.node.isConnected) return t;
|
|
if (!doc.getElementById(STYLE_ID)) {
|
|
const s = doc.createElement('style');
|
|
s.id = STYLE_ID;
|
|
s.textContent = CSS;
|
|
(doc.head || doc.documentElement).appendChild(s);
|
|
}
|
|
const node = doc.createElement('div');
|
|
node.className = 'lgs-tip lgs-edge';
|
|
node.setAttribute('data-lgs-mat', 'thick'); // P4's edge hook: E3 arcs of thick glass on ::before
|
|
node.setAttribute('role', 'tooltip');
|
|
node.setAttribute('aria-hidden', 'true');
|
|
node.setAttribute('data-state', 'hidden');
|
|
const label = doc.createElement('span');
|
|
label.className = 'lgs-tip-label';
|
|
node.appendChild(label);
|
|
(doc.body || doc.documentElement).appendChild(node);
|
|
t = { doc, node, label, owner: null, lastOwner: null, timer: 0, shownAt: 0 };
|
|
st.tips.set(doc, t);
|
|
return t;
|
|
}
|
|
function show(el, why) {
|
|
if (live !== st || !el || !el.isConnected) return false;
|
|
const reg = regFor(el);
|
|
const text = textFor(el, reg);
|
|
if (!text) return false;
|
|
if (!(reg && reg.force) && hasVisibleText(el)) return false; // VP P-12: icon-only owners
|
|
const doc = el.ownerDocument;
|
|
const w = doc.defaultView;
|
|
const t = tipFor(doc);
|
|
H.clearTimeout(t.timer);
|
|
const same = t.owner === el && t.node.getAttribute('data-state') === 'in';
|
|
t.owner = el;
|
|
if (t.label.textContent !== text) t.label.textContent = text;
|
|
const r = el.getBoundingClientRect();
|
|
let place = placementFor(el, reg);
|
|
if (place === 'auto') place = (r.bottom > w.innerHeight * (600 / 720) || r.bottom + GAP + TIP_H + INSET > w.innerHeight) ? 'above' : 'below';
|
|
if (!same) { t.node.removeAttribute('data-resume'); t.node.setAttribute('data-state', 'measure'); } // laid out, invisible, no animation
|
|
const tw = Math.min(t.node.offsetWidth || 0, w.innerWidth - 2 * INSET) || 120;
|
|
const x = Math.round(Math.max(INSET, Math.min(w.innerWidth - INSET - tw, r.left + r.width / 2 - tw / 2)));
|
|
const y = Math.round(place === 'below' ? r.bottom + GAP : r.top - GAP - TIP_H);
|
|
t.node.style.left = x + 'px';
|
|
t.node.style.top = Math.max(INSET / 2, Math.min(w.innerHeight - TIP_H - INSET / 2, y)) + 'px';
|
|
t.node.style.setProperty('--lgs-maxside', String(Math.round(Math.max(tw, TIP_H)))); // P5's materialize swell s0
|
|
t.node.setAttribute('data-placement', place);
|
|
if (!same) { t.node.setAttribute('data-state', 'in'); t.shownAt = H.performance.now(); st.shown++; }
|
|
t.why = why;
|
|
return true;
|
|
}
|
|
function hide(el, immediate) {
|
|
for (const t of st.tips.values()) {
|
|
if (el && t.owner !== el) continue;
|
|
if (t.node.getAttribute('data-state') === 'hidden') { t.owner = null; continue; }
|
|
H.clearTimeout(t.timer);
|
|
t.lastOwner = weak(t.owner); // weak: only for tipQuick's return within the leave
|
|
t.owner = null;
|
|
t.node.removeAttribute('data-resume');
|
|
if (immediate) { t.node.setAttribute('data-state', 'hidden'); continue; }
|
|
if (st.quick) t.node.setAttribute('data-quick', ''); else t.node.removeAttribute('data-quick');
|
|
t.node.setAttribute('data-state', 'out');
|
|
t.hiddenAt = H.performance.now();
|
|
t.timer = H.setTimeout(() => { if (!t.owner) t.node.setAttribute('data-state', 'hidden'); }, st.quick ? QUICK_OUT_MS : MAT_OUT_MS);
|
|
}
|
|
}
|
|
// tipQuick only (no leave grace there): attention that returns to the same owner while its tooltip
|
|
// is still leaving brings it straight back, with no new delay, so laser jitter cannot flicker it.
|
|
function resume(el) {
|
|
for (const t of st.tips.values()) {
|
|
if (strong(t.lastOwner) !== el || !t.node.isConnected || t.node.getAttribute('data-state') !== 'out' || !el.isConnected) continue;
|
|
H.clearTimeout(t.timer);
|
|
t.owner = el;
|
|
t.node.setAttribute('data-resume', '');
|
|
t.node.setAttribute('data-state', 'in');
|
|
t.why = 'resume';
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------- attention: 0.8 s in, 0.2 s out (both inputs)
|
|
// Default (PLAN §1.13, D2 §11 "After 0.8 s, materialize 250 ms | 0.2 s, dematerialize 350 ms", MO
|
|
// §4.17, Apple's 0.8 s / 0.2 s reveal delays): the materialize starts at 0.8 s, the leave starts
|
|
// 0.2 s after attention ends. Flag tipQuick (P3-D4): the card's literal IN-7 instead, fully in
|
|
// at 0.8 s (start at 0.8 - 0.25 s) and gone 0.18 s after leave (no grace, see resume()).
|
|
function attendOpts() {
|
|
return {
|
|
dwellMs: st.quick ? IN_MS - MAT_IN_MS : IN_MS,
|
|
leaveMs: st.quick ? 0 : OUT_MS,
|
|
classes: false,
|
|
onEnter(el, ev) {
|
|
const padLike = ev.source === 'pad' || (ev.source === 'feed' && input.mode === 'pad');
|
|
if (padLike && padNow(el, regFor(el))) { show(el, 'pad-now'); return; }
|
|
if (st.quick) resume(el);
|
|
},
|
|
onDwell(el, ev) { show(el, ev.source); },
|
|
onLeave(el) { hide(el, false); },
|
|
};
|
|
}
|
|
let handle = attendFn(isOwner, attendOpts());
|
|
st.offs.push(() => handle.off());
|
|
try {
|
|
if (R.flags && typeof R.flags.on === 'function') {
|
|
const offQ = R.flags.on('tipQuick', () => {
|
|
if (live !== st) return;
|
|
const q = flagOn('tipQuick');
|
|
if (q === st.quick) return;
|
|
st.quick = q;
|
|
try { handle.off(); } catch (_) { /* gone */ }
|
|
handle = attendFn(isOwner, attendOpts());
|
|
});
|
|
if (typeof offQ === 'function') st.offs.push(offQ);
|
|
}
|
|
} catch (err) { log('warn', 'flag tipQuick not watched', String(err)); }
|
|
// A route change or a scroll can move the owner away; Steam removes it from the DOM: hide.
|
|
st.offs.push(onDoc((w, doc) => {
|
|
const onScroll = () => { const t = st.tips.get(doc); if (t && t.owner) hide(t.owner, true); };
|
|
doc.addEventListener('scroll', onScroll, { capture: true, passive: true });
|
|
return () => {
|
|
doc.removeEventListener('scroll', onScroll, { capture: true, passive: true });
|
|
const t = st.tips.get(doc);
|
|
if (t) { H.clearTimeout(t.timer); try { t.node.remove(); } catch (_) { /* gone */ } st.tips.delete(doc); }
|
|
try { const s = doc.getElementById(STYLE_ID); if (s) s.remove(); } catch (_) { /* gone */ }
|
|
};
|
|
}));
|
|
|
|
// ---------------------------------------------- bar tooltips: Steam's own popup, 0.8 s (CTL P-C5)
|
|
try {
|
|
const store = H.vrPooledPopupStore
|
|
|| (H.SteamUIStore && H.SteamUIStore.WindowStore && H.SteamUIStore.WindowStore.VRGamepadUIMainWindowInstance
|
|
&& H.SteamUIStore.WindowStore.VRGamepadUIMainWindowInstance.VRPooledPopupStore);
|
|
if (store && typeof store.ShowTooltip === 'function' && !store.ShowTooltip[MARK]) {
|
|
const own = Object.prototype.hasOwnProperty.call(store, 'ShowTooltip');
|
|
const orig = store.ShowTooltip;
|
|
const wrapper = function lgsShowTooltip(el, text, params, fn) {
|
|
let p = params;
|
|
try {
|
|
if (live === st && !(p && p.unDelayMS >= BAR_DELAY_MS)) p = Object.assign({}, p || {}, { unDelayMS: BAR_DELAY_MS });
|
|
} catch (_) { p = params; }
|
|
return orig.call(this, el, text, p, fn);
|
|
};
|
|
wrapper[MARK] = true;
|
|
store.ShowTooltip = wrapper;
|
|
st.bar = { store, own, orig, wrapper };
|
|
} else if (!store) {
|
|
log('error', 'vrPooledPopupStore not found: bar tooltips keep Steam\'s delay');
|
|
}
|
|
} catch (err) { log('error', 'bar wrapper failed', String(err)); }
|
|
|
|
const tooltip = {
|
|
register(target, opts) {
|
|
if (live !== st) return () => {};
|
|
const o = opts || {};
|
|
let self;
|
|
if (typeof target === 'string') { const get = lazySel(target); self = (el) => { const sel = get(); return !!sel && el.matches(sel); }; }
|
|
else if (typeof target === 'function') self = (el) => !!target(el);
|
|
else if (target && target.nodeType === 1) self = (el) => el === target;
|
|
else throw new Error('tooltip.register: target must be an Element, a selector or a function');
|
|
const reg = { self, text: o.text, placement: o.placement, padNow: !!o.padNow, force: !!o.force };
|
|
st.regs.push(reg);
|
|
return () => { const i = st.regs.indexOf(reg); if (i >= 0) st.regs.splice(i, 1); };
|
|
},
|
|
state() {
|
|
const shown = [];
|
|
for (const t of st.tips.values()) {
|
|
const s = t.node.getAttribute('data-state');
|
|
if (s === 'hidden') continue;
|
|
const r = t.node.getBoundingClientRect();
|
|
shown.push({
|
|
surface: hub.surfaceOf(t.doc.defaultView), state: s, text: t.label.textContent, placement: t.node.getAttribute('data-placement'),
|
|
rect: { x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height) },
|
|
shownAt: t.shownAt, why: t.why,
|
|
});
|
|
}
|
|
let mapTooltips = null;
|
|
try { mapTooltips = st.bar ? st.bar.store.m_mapTooltips.size : null; } catch (_) { mapTooltips = null; }
|
|
return {
|
|
shown, owners: st.regs.length, barWrapper: !!(st.bar && st.bar.store.ShowTooltip === st.bar.wrapper), mapTooltips, count: st.shown,
|
|
timing: st.quick ? { quick: true, startMs: IN_MS - MAT_IN_MS, inMs: MAT_IN_MS, graceMs: 0, outMs: QUICK_OUT_MS }
|
|
: { quick: false, startMs: IN_MS, inMs: MAT_IN_MS, graceMs: OUT_MS, outMs: MAT_OUT_MS },
|
|
};
|
|
},
|
|
hideAll() { hide(null, true); },
|
|
// Test hooks.
|
|
show(el) { return show(el, 'test'); },
|
|
hide(el) { hide(el || null, false); },
|
|
};
|
|
st.api = tooltip;
|
|
expose(R, 'tooltip', tooltip, st.offs);
|
|
return tooltip;
|
|
},
|
|
remove() {
|
|
const st = live;
|
|
live = null;
|
|
if (st && st.remove) st.remove();
|
|
const P = PUB();
|
|
if (P && st && P.tooltip === st.api) delete P.tooltip;
|
|
R = null;
|
|
},
|
|
});
|
|
})();
|