From 319b787109728a95b088bac9620ff95e69b748bd Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Fri, 8 May 2026 12:24:11 -0400 Subject: [PATCH] =?UTF-8?q?sky.html:=20snap-binary=20aperture=20scroll=20(?= =?UTF-8?q?wheel=20=E2=86=94=20form,=20full=20aperture=20each);=20SAVE=20S?= =?UTF-8?q?KY=20animates=20scrollTop=20back=20to=200=20=E2=80=94=20TDD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit post-save the .sky-page aperture flips into scroll-snap-y-mandatory mode: wheel-col & form-col each fill the aperture & carry scroll-snap-align:start, so vertical scroll toggles between them rather than free-flowing through both. Modal-body uses display:contents so the cols become direct flex children of .sky-page (where min-height:100% resolves against the explicit aperture height); wheel-col's aspect-ratio/max-height caps are released under body.sky-saved so the section actually fills the aperture instead of clipping at 480px. SAVE SKY's success branch calls _scrollApertureToTop(), a 280ms RAF loop w. ease-out cubic so the user lands back on the wheel after confirming from the form section. New FT class MySkyApertureSnapScrollTest covers (T1) snap-type:y mandatory + scroll-snap-align:start on both cols, (T2) scrollTop returns to 0 after SAVE SKY click; both red before the SCSS+JS, green after. Snap behavior is gated on body.sky-saved (set by sky_view based on user.sky_chart_data) so the pre-save form-only flow is untouched. Code architected by Disco DeDisco Git commit message Co-Authored-By: Claude Sonnet 4.6 --- src/apps/dashboard/views.py | 2 +- src/functional_tests/test_applet_my_sky.py | 95 ++++++++++++++++++++++ src/static_src/scss/_sky.scss | 42 ++++++++++ src/templates/apps/dashboard/sky.html | 29 ++++++- 4 files changed, 166 insertions(+), 2 deletions(-) diff --git a/src/apps/dashboard/views.py b/src/apps/dashboard/views.py index 4fd5062..e3147d6 100644 --- a/src/apps/dashboard/views.py +++ b/src/apps/dashboard/views.py @@ -374,7 +374,7 @@ def sky_view(request): "saved_birth_lat": request.user.sky_birth_lat, "saved_birth_lon": request.user.sky_birth_lon, "saved_birth_tz": request.user.sky_birth_tz, - "page_class": "page-sky", + "page_class": "page-sky" + (" sky-saved" if chart_data else ""), }) diff --git a/src/functional_tests/test_applet_my_sky.py b/src/functional_tests/test_applet_my_sky.py index af147fd..d4537bc 100644 --- a/src/functional_tests/test_applet_my_sky.py +++ b/src/functional_tests/test_applet_my_sky.py @@ -372,6 +372,101 @@ class MySkyTimezoneRefreshTest(FunctionalTest): )) +class MySkyApertureSnapScrollTest(FunctionalTest): + """Once sky data is saved, the .sky-page aperture is a snap-binary scroller + (wheel section + form section, each filling the aperture). Clicking SAVE + SKY animates the aperture back to the top (the wheel).""" + + def setUp(self): + super().setUp() + Applet.objects.get_or_create( + slug="my-sky", + defaults={"name": "My Sky", "grid_cols": 6, "grid_rows": 6, "context": "dashboard"}, + ) + self.gamer = User.objects.create(email="stargazer@test.io") + self.gamer.sky_chart_data = _CHART_FIXTURE + self.gamer.sky_birth_place = "Baltimore, MD, US" + self.gamer.sky_birth_tz = "America/New_York" + self.gamer.sky_birth_lat = 39.2904 + self.gamer.sky_birth_lon = -76.6122 + self.gamer.sky_birth_dt = datetime( + 1990, 6, 15, 12, 0, tzinfo=zoneinfo.ZoneInfo("UTC") + ) + self.gamer.save() + self.sky_url = self.live_server_url + "/dashboard/sky/" + + # ── T1 ─────────────────────────────────────────────────────────────────── + + def test_aperture_has_snap_y_mandatory_when_sky_saved(self): + """When sky is saved, the .sky-page aperture has scroll-snap-type:y mandatory + and both wheel-col & form-col have scroll-snap-align:start. Without these + the layout is a free scroll instead of the binary wheel<->form toggle.""" + self.browser.set_window_size(820, 520) + self.create_pre_authenticated_session("stargazer@test.io") + self.browser.get(self.sky_url) + self.wait_for(lambda: self.browser.find_element(By.CLASS_NAME, "sky-page")) + + styles = self.browser.execute_script(""" + const ap = document.querySelector('.sky-page'); + const wheel = document.querySelector('.sky-page .sky-wheel-col'); + const form = document.querySelector('.sky-page .sky-form-col'); + return { + snapType: getComputedStyle(ap).scrollSnapType, + wheelAlign: getComputedStyle(wheel).scrollSnapAlign, + formAlign: getComputedStyle(form).scrollSnapAlign, + }; + """) + self.assertIn("y", styles["snapType"]) + self.assertIn("mandatory", styles["snapType"]) + self.assertEqual(styles["wheelAlign"], "start") + self.assertEqual(styles["formAlign"], "start") + + # ── T2 ─────────────────────────────────────────────────────────────────── + + def test_save_sky_scrolls_aperture_back_to_top(self): + """Clicking SAVE SKY from the form section animates the aperture's + scrollTop back to 0 (the wheel).""" + self.browser.set_window_size(820, 520) + self.create_pre_authenticated_session("stargazer@test.io") + self.browser.get(self.sky_url) + self.wait_for(lambda: self.browser.find_element(By.CLASS_NAME, "sky-page")) + + # Mock /sky/save so the click resolves without real server work + self.browser.execute_script(""" + window._origFetch = window.fetch; + window.fetch = function(url, opts) { + if (typeof url === 'string' && url.includes('/sky/save')) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({saved: true}), + }); + } + return window._origFetch(url, opts); + }; + """) + + # Scroll the aperture down to the form section + self.browser.execute_script( + "document.querySelector('.sky-page').scrollTop = 9999;" + ) + self.wait_for(lambda: self.assertGreater( + self.browser.execute_script( + "return document.querySelector('.sky-page').scrollTop;" + ), + 10, + )) + + self.browser.find_element(By.ID, "id_sky_confirm").click() + + # After save resolves, aperture scrollTop animates back to 0 + self.wait_for(lambda: self.assertEqual( + self.browser.execute_script( + "return Math.round(document.querySelector('.sky-page').scrollTop);" + ), + 0, + )) + + class MySkyWheelConjunctionTest(FunctionalTest): """Tick lines, z-raise, and dual tooltip for conjunct planets.""" diff --git a/src/static_src/scss/_sky.scss b/src/static_src/scss/_sky.scss index ddf9d5f..9d2e7e5 100644 --- a/src/static_src/scss/_sky.scss +++ b/src/static_src/scss/_sky.scss @@ -963,6 +963,48 @@ body.page-sky { overflow-y: visible; } +// ── Snap-binary aperture (post-save) ────────────────────────────────────────── +// Once a sky is saved, the .sky-page aperture flips into scroll-snap mode: +// the wheel section + form section each fill the aperture, so scrolling toggles +// between them rather than free-flowing. SAVE SKY (in sky.html's click handler) +// animates the aperture back to the top after a successful save. +// +// modal-body keeps height:100% (= aperture height), so its two flex children +// using min-height:100% each resolve to a full aperture each → modal-body's +// content overflows itself → .sky-page (overflow-y:auto) becomes the scroller. + +body.sky-saved { + .sky-page { + scroll-snap-type: y mandatory; + } + + // modal-body acts as a layout pass-through so the wheel & form cols become + // direct flex children of .sky-page, where `min-height: 100%` resolves + // against the aperture height (.sky-page has flex:1 + min-height:0 so its + // height is explicit in the parent flex column). + .sky-page .sky-modal-body { + display: contents; + } + + .sky-page .sky-wheel-col, + .sky-page .sky-form-col { + scroll-snap-align: start; + scroll-snap-stop: always; + min-height: 100%; + flex: 0 0 auto; + } + + // Release the wheel-col aspect-ratio cap so the section fills the aperture; + // .sky-svg inside still renders square (its own aspect-ratio:1/1) and stays + // centered via the col's align-items:center. + .sky-page .sky-wheel-col { + aspect-ratio: auto; + max-width: none; + max-height: none; + width: 100%; + } +} + // ── Sidebar z-index sink (landscape sidebars must go below backdrop) ─────────── @media (orientation: landscape) { diff --git a/src/templates/apps/dashboard/sky.html b/src/templates/apps/dashboard/sky.html index 0d3d64e..db07af8 100644 --- a/src/templates/apps/dashboard/sky.html +++ b/src/templates/apps/dashboard/sky.html @@ -304,13 +304,40 @@ if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }) - .then(data => { setStatus('Sky saved!'); Note.handleSaveResponse(data); }) + .then(data => { + setStatus('Sky saved!'); + Note.handleSaveResponse(data); + _scrollApertureToTop(); + }) .catch(err => { setStatus(`Save failed: ${err.message}`, 'error'); confirmBtn.disabled = false; }); }); + // ── Snap-back scroll on save ──────────────────────────────────────────── + // .sky-page is scroll-snap-y-mandatory (post-save). After SAVE SKY the user + // should land back on the wheel section even if they clicked from the form + // section. cubic-bezier(0.2, 0.9, 0.4, 1) — fast start, gentle settle. + + function _scrollApertureToTop() { + if (overlay.scrollTop === 0) return; + const start = overlay.scrollTop; + const startTime = performance.now(); + const DURATION = 280; + const ease = (t) => { + // ease-out cubic — visually equivalent to cubic-bezier(0, 0, 0.4, 1). + const u = 1 - t; + return 1 - u * u * u; + }; + function step(now) { + const t = Math.min(1, (now - startTime) / DURATION); + overlay.scrollTop = Math.max(0, start * (1 - ease(t))); + if (t < 1) requestAnimationFrame(step); + } + requestAnimationFrame(step); + } + // ── CSRF ───────────────────────────────────────────────────────────────── function _getCsrf() {