diff --git a/src-node/package-lock.json b/src-node/package-lock.json index 706395b31e..051245b909 100644 --- a/src-node/package-lock.json +++ b/src-node/package-lock.json @@ -1,12 +1,12 @@ { "name": "@phcode/node-core", - "version": "5.1.17-0", + "version": "5.1.18-0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@phcode/node-core", - "version": "5.1.17-0", + "version": "5.1.18-0", "hasInstallScript": true, "license": "GNU-AGPL3.0", "dependencies": { diff --git a/src/extensionsIntegrated/Phoenix/phoenix-tour.js b/src/extensionsIntegrated/Phoenix/phoenix-tour.js index c600789338..6635a58205 100644 --- a/src/extensionsIntegrated/Phoenix/phoenix-tour.js +++ b/src/extensionsIntegrated/Phoenix/phoenix-tour.js @@ -70,6 +70,8 @@ define(function (require, exports, module) { let $overlay = null; let _rafId = null; + let _resizeObserver = null; + let _positionHandler = null; let _timers = []; // Per-step: tracks a click on the highlighted target while the @@ -97,10 +99,22 @@ define(function (require, exports, module) { clearTimeout(_timers[i]); } _timers = []; + } + + function _stopTracking() { if (_rafId) { cancelAnimationFrame(_rafId); _rafId = null; } + if (_resizeObserver) { + _resizeObserver.disconnect(); + _resizeObserver = null; + } + if (_positionHandler) { + window.removeEventListener("scroll", _positionHandler, true); + window.removeEventListener("resize", _positionHandler); + _positionHandler = null; + } } /** @@ -169,6 +183,7 @@ define(function (require, exports, module) { function _teardown() { _clearTimers(); + _stopTracking(); _detachStepClickMetric(); _cancelStep2AIPeek(); if ($overlay) { @@ -185,8 +200,12 @@ define(function (require, exports, module) { } $overlay = $( '
' + - '
' + - '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + '
' + '
' + '
' + @@ -197,6 +216,22 @@ define(function (require, exports, module) { $overlay.appendTo(document.body); } + /** + * Restart the ring animations at the new target so every step gets a + * fresh ripple → settle sequence, not the leftover settled rings from + * the previous step. The reflow read between class toggles is what + * makes the CSS animations restart; without it the browser coalesces + * the remove+add and nothing replays. + */ + function _enterStep() { + if (!$overlay) { + return; + } + $overlay.removeClass("phoenix-tour-animating"); + void $overlay[0].offsetWidth; + $overlay.addClass("phoenix-tour-visible phoenix-tour-animating"); + } + function _setText(text) { if ($overlay) { // `text` may include `\n` for multi-line steps; CSS uses @@ -241,28 +276,53 @@ define(function (require, exports, module) { } function _trackTarget($target, placement) { - function update() { - if (!$overlay || !$target.length || !$target[0].isConnected) { - _rafId = null; - return; + // Tour targets are stationary toolbar buttons, so we position once + // and then react to scroll/resize/target-size changes via observers + // — avoids the compositor cost of a per-frame rAF loop on low-end + // hardware. The rAF below is only a short poll for targets that + // aren't laid out yet (zero-size on first call). + _stopTracking(); + if (!$overlay || !$target.length) { + return; + } + $overlay.attr("data-tip-placement", placement || "right"); + const targetEl = $target[0]; + + function position() { + if (!$overlay || !targetEl.isConnected) { + return false; } - const r = $target[0].getBoundingClientRect(); + const r = targetEl.getBoundingClientRect(); if (r.width === 0 && r.height === 0) { - _rafId = requestAnimationFrame(update); - return; + return false; } const cx = r.left + r.width / 2; const cy = r.top + r.height / 2; const el = $overlay[0]; el.style.left = cx + "px"; el.style.top = cy + "px"; - _rafId = requestAnimationFrame(update); + return true; } - if (_rafId) { - cancelAnimationFrame(_rafId); + + function attachObservers() { + _positionHandler = position; + if (typeof ResizeObserver !== "undefined") { + _resizeObserver = new ResizeObserver(position); + _resizeObserver.observe(targetEl); + } + window.addEventListener("scroll", _positionHandler, true); + window.addEventListener("resize", _positionHandler); } - $overlay.attr("data-tip-placement", placement || "right"); - update(); + + function pollUntilVisible() { + if (position()) { + _rafId = null; + attachObservers(); + return; + } + _rafId = requestAnimationFrame(pollUntilVisible); + } + pollUntilVisible(); } /** @@ -297,7 +357,7 @@ define(function (require, exports, module) { // demo is too quick to read. _setText(Strings.PHOENIX_TOUR_DESIGN_MODE); _setActions([]); // hidden during the auto-demo - $overlay.addClass("phoenix-tour-visible"); + _enterStep(); // Auto-demo: enter design mode, hold, exit, then show "Next". _timers.push(setTimeout(function () { @@ -352,6 +412,7 @@ define(function (require, exports, module) { } ]); _attachStepClickMetric(2, $tab); + _enterStep(); // Auto-peek the AI panel for a couple of seconds so the user gets // a glance at its contents, then revert. _runStep2AIPeek(); @@ -384,6 +445,7 @@ define(function (require, exports, module) { } } ]); + _enterStep(); // Intentionally do NOT advance on a real click of the target — only // the Next button advances. } @@ -477,6 +539,7 @@ define(function (require, exports, module) { } } ]); + _enterStep(); } function _shouldRun() { diff --git a/src/styles/Extn-PhoenixTour.less b/src/styles/Extn-PhoenixTour.less index 88ce05e8ff..50adfbdfd6 100644 --- a/src/styles/Extn-PhoenixTour.less +++ b/src/styles/Extn-PhoenixTour.less @@ -38,7 +38,18 @@ } } -.phoenix-tour-ring { +/* Two-phase ring system: + 1. ~3.2s of animated pulse (three staggered ripples) — the initial + attention-grab. + 2. Pulse fades out and a settled focus halo appears: two soft radial + gradient annuli (transparent centers, feathered edges) plus a crisp + ring snug around the target icon. Reads as a deliberate spotlight, + not a stalled ripple. Pulse is bounded (not infinite) so weak GPUs + aren't stuck rasterizing scaling box-shadowed elements forever. */ + +/* Phase 1: 3 staggered pulse rings, 1.6s each at 0.8s intervals → ripple + stays continuously visible from t=0 to t=3.2s. */ +.phoenix-tour-pulse-ring { position: absolute; left: 50%; top: 50%; @@ -48,12 +59,20 @@ border: 2px solid #4aa3ff; border-radius: 50%; box-shadow: 0 0 12px rgba(74, 163, 255, 0.45); - opacity: 0.9; - animation: phoenix-tour-pulse 1.6s ease-out infinite; + opacity: 0; + pointer-events: none; } -.phoenix-tour-ring-2 { - animation-delay: 0.8s; +.phoenix-tour-overlay.phoenix-tour-animating { + .phoenix-tour-pulse-ring { + animation: phoenix-tour-pulse 1.6s ease-out forwards; + } + .phoenix-tour-pulse-ring-2 { + animation-delay: 0.8s; + } + .phoenix-tour-pulse-ring-3 { + animation-delay: 1.6s; + } } @keyframes phoenix-tour-pulse { @@ -70,6 +89,76 @@ } } +/* Phase 2: settled focus halo. All three layers share a common animation + that fades them in (with a slight inward-settle stagger) once the pulse + phase ends, then idles. */ +.phoenix-tour-halo-outer, +.phoenix-tour-halo-mid, +.phoenix-tour-focus-ring { + position: absolute; + left: 50%; + top: 50%; + border-radius: 50%; + pointer-events: none; + opacity: 0; +} + +/* Wide soft outer glow — gentle "spotlight" reaching the surrounding UI. + Transparent at the very center so the inner layers and icon show + through, transparent again at the disc edge so it feathers into the + background instead of looking like a hard circle. */ +.phoenix-tour-halo-outer { + width: 180px; + height: 180px; + margin: -90px 0 0 -90px; + background: radial-gradient(circle closest-side, + transparent 38%, + rgba(74, 163, 255, 0.09) 55%, + rgba(74, 163, 255, 0.05) 75%, + transparent 100%); +} + +/* Mid annulus — denser ring of fill that gives the halo visible structure + without resorting to a hard outline. */ +.phoenix-tour-halo-mid { + width: 96px; + height: 96px; + margin: -48px 0 0 -48px; + background: radial-gradient(circle closest-side, + transparent 28%, + rgba(74, 163, 255, 0.20) 50%, + rgba(74, 163, 255, 0.10) 75%, + transparent 100%); +} + +/* Crisp focus ring at the icon — the unambiguous "this one" marker. */ +.phoenix-tour-focus-ring { + width: 38px; + height: 38px; + margin: -19px 0 0 -19px; + border: 2px solid rgba(74, 163, 255, 0.95); + background: rgba(74, 163, 255, 0.08); + box-shadow: 0 0 14px rgba(74, 163, 255, 0.55); +} + +.phoenix-tour-overlay.phoenix-tour-animating { + .phoenix-tour-halo-outer, + .phoenix-tour-halo-mid, + .phoenix-tour-focus-ring { + animation: phoenix-tour-settle 600ms ease-out forwards; + } + /* Settle inward: outer halo first, then mid, then the crisp ring snaps + in last — feels like the ripples collapsing into a focus indicator. */ + .phoenix-tour-halo-outer { animation-delay: 3.2s; } + .phoenix-tour-halo-mid { animation-delay: 3.35s; } + .phoenix-tour-focus-ring { animation-delay: 3.5s; } +} + +@keyframes phoenix-tour-settle { + 0% { opacity: 0; transform: scale(0.9); } + 100% { opacity: 1; transform: scale(1); } +} + /* Tooltip — sits below-right of the ring so it stays within the viewport even when the target is near the top of the window (both tour targets, the design-mode button and the AI sidebar tab, sit at the top). Solid