diff --git a/src/apps/epic/static/apps/epic/burger-btn.js b/src/apps/epic/static/apps/epic/burger-btn.js index 9eb3092..1bc16ae 100644 --- a/src/apps/epic/static/apps/epic/burger-btn.js +++ b/src/apps/epic/static/apps/epic/burger-btn.js @@ -46,6 +46,11 @@ btn.classList.add('active'); btn.setAttribute('aria-expanded', 'true'); fan.setAttribute('aria-hidden', 'false'); + // Handoff: once the sky is saved (#id_sky_btn .active = the burger has + // already pulsed its --priTk cue), opening the fan pulses the Sky + // sub-btn the same way — "now click me to reopen your sky". + var skyBtn = document.getElementById('id_sky_btn'); + if (skyBtn && skyBtn.classList.contains('active')) _pulseGlow(skyBtn); } function _close() { @@ -298,19 +303,23 @@ // The pulse fires here on load when the server flagged a saved sky // (#id_burger_btn[data-sky-glow]); the sky overlay also calls pulseSkyGlow() // at the SAVE moment (no reload) so the cue plays the instant you save. - function pulseSkyGlow() { - var btn = document.getElementById('id_burger_btn'); - if (!btn) return; - var pulses = 3, onMs = 220, offMs = 160; // thrice; rhymes w. flash-inactive + // Thrice --priTk pulse (.sky-saved-glow) on a target btn — same cadence as + // .flash-inactive. The burger gets it the moment the sky is saved; the Sky + // sub-btn gets it on the NEXT burger-open (the cue hands off burger → sub-btn + // so the user is led from "look at the burger" to "click the Sky btn"). + function _pulseGlow(el) { + if (!el) return; + var pulses = 3, onMs = 220, offMs = 160; (function pulse(n) { if (n <= 0) return; - btn.classList.add('sky-saved-glow'); + el.classList.add('sky-saved-glow'); setTimeout(function () { - btn.classList.remove('sky-saved-glow'); + el.classList.remove('sky-saved-glow'); setTimeout(function () { pulse(n - 1); }, offMs); }, onMs); }(pulses)); } + function pulseSkyGlow() { _pulseGlow(document.getElementById('id_burger_btn')); } window.pulseSkyGlow = pulseSkyGlow; function bindSkyBtn() { diff --git a/src/apps/epic/static/apps/epic/sig-select.js b/src/apps/epic/static/apps/epic/sig-select.js index 0a05886..7d1202b 100644 --- a/src/apps/epic/static/apps/epic/sig-select.js +++ b/src/apps/epic/static/apps/epic/sig-select.js @@ -29,9 +29,19 @@ var SigSelect = (function () { var _reservedFloats = {}; // key: role → portal element (thumbs-up, frozen) var _cursorPortal = null; - // CAST SKY click → page reload. Reassignable via setReload for tests so - // they can spy without actually reloading the Jasmine runner. - var _reload = function () { window.location.reload(); }; + // CAST SKY click crosses the SIG_SELECT → SKY_SELECT server-render boundary + // (the felt + phase-stack + sea-inject only exist once table_status is + // SKY_SELECT), so it reloads — but drops a sessionStorage flag first so the + // felt OPENS on arrival. That kills the old click→reload→click-again double- + // take: one click now yields the sky form (the _sky_overlay.html init reads + // the flag + clears it). sessionStorage (not a URL param) so the open survives + // the reload reliably regardless of redirect/query timing. DRAW SEA can inject + // in-place because it stays WITHIN the SKY_SELECT page; CAST SKY can't, so this + // is the closest seamless equivalent. Reassignable via setReload for tests. + var _reload = function () { + try { sessionStorage.setItem('sky-autoopen', '1'); } catch (e) {} + window.location.reload(); + }; function getCsrf() { var m = document.cookie.match(/csrftoken=([^;]+)/); diff --git a/src/static/tests/BurgerSpec.js b/src/static/tests/BurgerSpec.js index 7497d7f..d9ba801 100644 --- a/src/static/tests/BurgerSpec.js +++ b/src/static/tests/BurgerSpec.js @@ -340,6 +340,53 @@ describe("sky sub-btn reopen + sky-saved glow", () => { }); }); +// Burger → Sky-btn pulse handoff (CAST SKY cascade, 2026-06-07). Once the sky +// is saved the burger pulses --priTk; the cue then HANDS OFF to the Sky sub-btn, +// which pulses the same way on the next burger-OPEN ("now click me to reopen"). +describe("burger → sky-btn pulse handoff", () => { + let burgerBtn, fan, skyBtn, ac; + + beforeEach(() => { + jasmine.clock().install(); + burgerBtn = document.createElement("button"); + burgerBtn.id = "id_burger_btn"; + document.body.appendChild(burgerBtn); + fan = document.createElement("div"); + fan.id = "id_burger_fan"; + document.body.appendChild(fan); + skyBtn = document.createElement("button"); + skyBtn.id = "id_sky_btn"; + skyBtn.className = "burger-fan-btn"; + document.body.appendChild(skyBtn); + ac = bindBurger(); + }); + + afterEach(() => { + if (ac) ac.abort(); + jasmine.clock().uninstall(); + [burgerBtn, fan, skyBtn].forEach((el) => el && el.remove()); + }); + + it("pulses the Sky sub-btn on burger-open once the sky is saved (.active)", () => { + skyBtn.classList.add("active"); + burgerBtn.click(); // open the fan + expect(skyBtn.classList.contains("sky-saved-glow")).toBe(true); + }); + + it("does NOT pulse the Sky sub-btn on open before the sky is saved", () => { + burgerBtn.click(); + expect(skyBtn.classList.contains("sky-saved-glow")).toBe(false); + }); + + it("does not pulse on burger-CLOSE", () => { + skyBtn.classList.add("active"); + burgerBtn.click(); // open → pulses + jasmine.clock().tick(2000); // let the 3-pulse cycle finish + burgerBtn.click(); // close + expect(skyBtn.classList.contains("sky-saved-glow")).toBe(false); + }); +}); + // Voice persistence across my_sea reloads — bindVoiceBtn remembers the active // room in sessionStorage + silently re-joins it on the next page if voice is // still available (2026-05-29). diff --git a/src/static_src/scss/_burger.scss b/src/static_src/scss/_burger.scss index bdee991..fee9250 100644 --- a/src/static_src/scss/_burger.scss +++ b/src/static_src/scss/_burger.scss @@ -196,8 +196,10 @@ // btn.js toggles .sky-saved-glow three times (finite), in rhyme with the // 2-pulse --priRd .flash-inactive + the voice pulse above. The Sky sub-btn's // own .active state (real cloud icon, opacity 1) is handled by the generic -// .burger-fan-btn.active rules. -#id_burger_btn.sky-saved-glow { +// .burger-fan-btn.active rules. Shared by the burger (save-moment cue) AND the +// Sky sub-btn (the handoff cue on the next burger-open). +#id_burger_btn.sky-saved-glow, +#id_sky_btn.sky-saved-glow { color: rgba(var(--priTk), 1); border-color: rgba(var(--priTk), 1); box-shadow: diff --git a/src/static_src/scss/_room.scss b/src/static_src/scss/_room.scss index 91ffffe..57c9a9c 100644 --- a/src/static_src/scss/_room.scss +++ b/src/static_src/scss/_room.scss @@ -980,6 +980,29 @@ html .room-gate-page.room-gate-page .position-strip .gate-slot { pointer-events: justify-content: center; } +// Hex phase-button stack — CAST SKY + DRAW SEA share ONE grid cell so they can +// cross-fade IN PLACE during the post-save cascade (the sky-overlay JS toggles +// .hex-phase-btn--out). The grid sizes to the larger btn; .table-center centers +// it. Only the SKY_SELECT phase renders both; other phases use a lone button. +.hex-phase-stack { + display: grid; + justify-items: center; + align-items: center; +} + +.hex-phase-stack > .hex-phase-btn { + grid-area: 1 / 1; // stack both buttons in the same cell + transition: opacity 0.45s ease, transform 0.45s ease; +} + +// Eased-out state — invisible + inert, but still in layout so the partner btn +// can cross-fade in (display:none can't transition). +.hex-phase-btn--out { + opacity: 0; + transform: scale(0.7); + pointer-events: none; +} + // "Gravity settling . . ." / "Levity appraising . . ." shown after a polarity // group confirms their sigs while the other group is still selecting. // Pulsing opacity signals active waiting without being jarring. diff --git a/src/static_src/scss/_sky.scss b/src/static_src/scss/_sky.scss index 1fe0bf4..e12c5bb 100644 --- a/src/static_src/scss/_sky.scss +++ b/src/static_src/scss/_sky.scss @@ -57,6 +57,15 @@ html.sky-open .position-strip { visibility: hidden; } +// Post-save cascade ease-out — the felt fades to transparent over ~0.5s (the JS +// _FELT_FADE timer) BEFORE sky-open is removed, so the table-hex is revealed with +// a soft dissolve instead of a hard snap. Visibility is still on (sky-open) for +// the duration of the fade; the JS removes sky-open once the opacity lands at 0. +.sky-page.sky-page--cascade-out { + opacity: 0; + transition: opacity 0.5s ease; +} + // ── Backdrop ────────────────────────────────────────────────────────────────── .sky-backdrop { diff --git a/src/static_src/tests/BurgerSpec.js b/src/static_src/tests/BurgerSpec.js index 7497d7f..d9ba801 100644 --- a/src/static_src/tests/BurgerSpec.js +++ b/src/static_src/tests/BurgerSpec.js @@ -340,6 +340,53 @@ describe("sky sub-btn reopen + sky-saved glow", () => { }); }); +// Burger → Sky-btn pulse handoff (CAST SKY cascade, 2026-06-07). Once the sky +// is saved the burger pulses --priTk; the cue then HANDS OFF to the Sky sub-btn, +// which pulses the same way on the next burger-OPEN ("now click me to reopen"). +describe("burger → sky-btn pulse handoff", () => { + let burgerBtn, fan, skyBtn, ac; + + beforeEach(() => { + jasmine.clock().install(); + burgerBtn = document.createElement("button"); + burgerBtn.id = "id_burger_btn"; + document.body.appendChild(burgerBtn); + fan = document.createElement("div"); + fan.id = "id_burger_fan"; + document.body.appendChild(fan); + skyBtn = document.createElement("button"); + skyBtn.id = "id_sky_btn"; + skyBtn.className = "burger-fan-btn"; + document.body.appendChild(skyBtn); + ac = bindBurger(); + }); + + afterEach(() => { + if (ac) ac.abort(); + jasmine.clock().uninstall(); + [burgerBtn, fan, skyBtn].forEach((el) => el && el.remove()); + }); + + it("pulses the Sky sub-btn on burger-open once the sky is saved (.active)", () => { + skyBtn.classList.add("active"); + burgerBtn.click(); // open the fan + expect(skyBtn.classList.contains("sky-saved-glow")).toBe(true); + }); + + it("does NOT pulse the Sky sub-btn on open before the sky is saved", () => { + burgerBtn.click(); + expect(skyBtn.classList.contains("sky-saved-glow")).toBe(false); + }); + + it("does not pulse on burger-CLOSE", () => { + skyBtn.classList.add("active"); + burgerBtn.click(); // open → pulses + jasmine.clock().tick(2000); // let the 3-pulse cycle finish + burgerBtn.click(); // close + expect(skyBtn.classList.contains("sky-saved-glow")).toBe(false); + }); +}); + // Voice persistence across my_sea reloads — bindVoiceBtn remembers the active // room in sessionStorage + silently re-joins it on the next page if voice is // still available (2026-05-29). diff --git a/src/templates/apps/gameboard/_partials/_sky_overlay.html b/src/templates/apps/gameboard/_partials/_sky_overlay.html index bce3623..e6bf4a5 100644 --- a/src/templates/apps/gameboard/_partials/_sky_overlay.html +++ b/src/templates/apps/gameboard/_partials/_sky_overlay.html @@ -166,8 +166,26 @@ // ── Open / Close ────────────────────────────────────────────────────────── + // While the felt is up the burger Text sub-btn must be inert: its swipe + // machine (room-views.js) would otherwise drive DOWN into the reelhouse — but + // the aperture is scroll-locked, so the page half-loads a carousel view it + // can't actually reach (confusing UX). We toggle #id_text_btn OFF on open and + // restore its server-set baseline on close. + let _textBtnWasActive = false; + function _disableTextBtn() { + const tb = document.getElementById('id_text_btn'); + if (!tb) return; + _textBtnWasActive = tb.classList.contains('active'); + tb.classList.remove('active'); + } + function _restoreTextBtn() { + const tb = document.getElementById('id_text_btn'); + if (tb && _textBtnWasActive) tb.classList.add('active'); + } + function openSky() { document.documentElement.classList.add('sky-open'); + _disableTextBtn(); // Re-sync the room gear to the sky NVM pane (room-views.js owns the gear // pane-swap; sky-open toggles outside any scroll/view event it watches). if (window.RoomViews && window.RoomViews.syncGear) window.RoomViews.syncGear(); @@ -181,6 +199,7 @@ function closeSky() { document.documentElement.classList.remove('sky-open'); + _restoreTextBtn(); hideSuggestions(); if (window.RoomViews && window.RoomViews.syncGear) window.RoomViews.syncGear(); } @@ -424,21 +443,86 @@ SkyWheel.draw(svgEl, _lastChartData); } _ensureDelBtn(); - // The sky now lives in the burger fan: light its Sky sub-btn so it's a live - // reopen affordance immediately (server also renders it active after any - // reload). No-op if the burger isn't on this surface. - const skyBtn = document.getElementById('id_sky_btn'); - if (skyBtn) skyBtn.classList.add('active'); if (!wasSaved) { // First reveal: pin to the form section so the wheel slides in from above - // (the form "shunts down") rather than hard-cutting into place. + // (the form "shunts down"), then begin the post-save cascade. #id_sky_btn + // activation + the burger glow ride the cascade (not the save instant) so + // they land WITH the table-hex reveal — see _cascadeFeltOut. const formCol = overlay.querySelector('.sky-form-col'); if (formCol) overlay.scrollTop = formCol.offsetTop; - // Cue the burger thrice (--priTk) — "your sky lives here now". One-shot - // per save; the burger-btn.js load pulse covers the post-reload hex. - if (window.pulseSkyGlow) window.pulseSkyGlow(); + _scrollApertureToTop(); + _startSaveCascade(); + } else { + // Re-assert (WS re-fire / reopen-from-saved): keep the reopen affordance + // live; no cascade (the table-hex already advanced on the first save). + const skyBtn = document.getElementById('id_sky_btn'); + if (skyBtn) skyBtn.classList.add('active'); + _scrollApertureToTop(); } - _scrollApertureToTop(); + } + + // ── Post-save cascade ─────────────────────────────────────────────────────── + // After SAVE the gamer lingers on the freshly-drawn wheel ~3s; THEN the felt + // eases OUT to reveal the table-hex, the burger glow fires (your sky now lives + // in the burger fan), and 3s later the DRAW SEA btn eases IN — with the sea + // overlay injected so it's live with no reload. Each beat is its own timer. + const _CASCADE_LINGER = 3000, _FELT_FADE = 500, _SEA_DELAY = 3000; + + function _startSaveCascade() { + setTimeout(_cascadeFeltOut, _CASCADE_LINGER); + } + + function _cascadeFeltOut() { + overlay.classList.add('sky-page--cascade-out'); // CSS fades the felt out + setTimeout(() => { + document.documentElement.classList.remove('sky-open'); + overlay.classList.remove('sky-page--cascade-out'); + _restoreTextBtn(); + if (window.RoomViews && window.RoomViews.syncGear) window.RoomViews.syncGear(); + // CAST SKY is stale → ease it out; light the burger reopen affordance + + // fire the glow, all concurrent with the table-hex reveal. + const skyPhaseBtn = document.getElementById('id_pick_sky_btn'); + if (skyPhaseBtn) skyPhaseBtn.classList.add('hex-phase-btn--out'); + const skyBtn = document.getElementById('id_sky_btn'); + if (skyBtn) skyBtn.classList.add('active'); + if (window.pulseSkyGlow) window.pulseSkyGlow(); + setTimeout(_cascadeDrawSeaIn, _SEA_DELAY); + }, _FELT_FADE); + } + + function _cascadeDrawSeaIn() { + const seaPhaseBtn = document.getElementById('id_pick_sea_btn'); + if (seaPhaseBtn) seaPhaseBtn.classList.remove('hex-phase-btn--out'); // eases in + _injectSeaOverlay(); + } + + // Fetch + inject the DRAW SEA overlay so it's live without a reload. The sea + // partial carries its own inline