Compare commits

...

5 Commits

Author SHA1 Message Date
Disco DeDisco
ce4cb03af7 DRAW SEA async-transition FT: reload fallback for non-felt-save sky confirm — TDD
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
Running the corequisite channels FTs surfaced a real gap the cascade introduced:
a sky confirm that did NOT come through the felt's own save (a direct POST, or
another browser of the same seat) left _lastChartData null, so _onSkyConfirmed's
_activateSavedState returned early → no transition at all (the old code reloaded).

- _sky_overlay.html: _onSkyConfirmed now reloads when _lastChartData is absent
  (the pre-cascade behaviour, preserved for non-felt-save confirm paths); the
  felt-save path still eases to DRAW SEA via the cascade (no reload).
- test_game_room_select_sea.py: the async-transition assertion updated for the
  phase-stack — CAST SKY is now present-but-`--out` (hidden in the shared grid
  cell), not removed from the DOM, so assert the `hex-phase-btn--out` class on
  CAST SKY + its ABSENCE on DRAW SEA rather than `find_elements(...) == []`.

Corequisite FTs run green: select_sea async-transition (3) + deal (9) channels;
dash my_sky async-save + aperture-snap (3) — the shared body.sky-saved apparatus
is untouched by the room-scoped felt SCSS. select_sky FTs already green. The sig
SRG7 reveal FT is unaffected (it waits for the SIG_SELECT hidden CAST SKY, which
that branch still renders; it never clicks, so the _reload change isn't reached).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:36:05 -04:00
Disco DeDisco
d5e4fc53f0 CAST SKY cascade: felt eases out → glow → DRAW SEA eases in; burger handoff; reload-into-open — TDD
Post-save choreography + the polish asks from this session.

- Post-save cascade (_sky_overlay.html): after SAVE the gamer lingers on the
  wheel ~3s, then the felt eases OUT (fade, .sky-page--cascade-out) to reveal the
  table-hex; the burger fires its --priTk glow + #id_sky_btn goes active (both
  ride the cascade now, not the save instant); 3s later the DRAW SEA btn eases IN
  and the sea overlay is injected so it's live with NO reload.
- Hex phase-stack (room.html + _room.scss): CAST SKY + DRAW SEA share one grid
  cell (.hex-phase-stack) so they cross-fade in place (.hex-phase-btn--out); the
  server seeds --out on the inactive one, the cascade swaps them. A confirmed
  reload lands DRAW SEA visible / CAST SKY out, same as the cascade end.
- Seamless sea injection: _injectSeaOverlay fetches sea_partial (the URL already
  existed, unused) + re-creates its <script>s so the overlay's own init (openSea
  + SeaDeal.reinit) runs — DRAW SEA opens with no reload. (Bridge until Sea Select
  is itself hollowed into a felt.)
- Burger → Sky-btn handoff (burger-btn.js + _burger.scss): _pulseGlow generalized;
  once saved, the next burger-OPEN pulses #id_sky_btn --priTk ("now click me to
  reopen"). Jasmine specs added (BurgerSpec).
- #id_text_btn disabled while the felt is up (openSky/closeSky) — its swipe
  machine would otherwise half-load the scroll-locked reelhouse.
- Reload-into-open (sig-select.js + _sky_overlay.html): the SIG_SELECT→SKY_SELECT
  CAST SKY click crosses a server-render boundary (felt/phase-stack/sea-inject
  only exist in SKY_SELECT), so it still reloads — but drops a sessionStorage
  flag first so the felt OPENS on arrival. Kills the old click→reload→click-again
  double-take (the "first click reloads" report). DRAW SEA can inject in-place
  (stays within SKY_SELECT); CAST SKY can't, so this is the seamless equivalent.

Tests: BurgerSpec handoff specs + full Jasmine green; 32 render ITs + 930
epic+gameboard green. Cascade timing live-verified by the user.

[[feedback-scss-import-order-specificity]]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:24:32 -04:00
Disco DeDisco
94cd9db3a4 CAST SKY felt: fix invisible form — (0,2,0) chain + drop the modal-era aperture-fill
Two live-only stacking/layout bugs in the in-room felt (caught via Claudezilla
DOM inspection — neither is reachable by IT/Jasmine):

- .sky-page--room (0,1,0) lost the source-order tie to the LATER base
  `.sky-page { position: relative }` (also 0,1,0), so the felt stayed
  position:relative + flex:1 and collapsed to width 0 as a flex child of the
  hex-pane — the form vanished onto a 0-wide column. Chained to
  `.sky-page.sky-page--room` (0,2,0) so it wins regardless of order.
  [[feedback-scss-import-order-specificity]]
- Dropped `html.sky-open { #id_aperture_fill { opacity: 1 } }` — that modal-era
  backdrop is a full-cover --duoUser div at z-90; the old dark modal sat above
  it (z-120), but the felt sits at z-5, so the fill painted an opaque green
  sheet OVER the felt + form. Its opacity transition is why the form "flashed
  then vanished after <0.5s". The felt is its own --duoUser surface + covers the
  hex on its own, so the fill stays transparent now.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:29:04 -04:00
Disco DeDisco
1f874de459 CAST SKY: unify w. My Sky — inline --duoUser felt + scroll-snap wheel, gear NVM, burger reopen glow — TDD
Replaces the root-level dark Gaussian CAST SKY modal with the my_sky / sky.html
apparatus, mirroring the Sig Select unify (71c0069). Sky data stays seat-bound
(Character.seat), never a pos-circle.

- room.html: the sky overlay moves INTO .room-hex-pane on --duoUser felt
  (has-sky-stage), my_sea-style; rendered through the confirmed state too so
  the burger can reopen the saved wheel. Sky tooltips stay at root.
- _sky_overlay.html: drops .sky-backdrop / .sky-modal-wrap / .sky-modal / header
  + the in-felt NVM; reuses the shared .sky-page form/wheel (.sky-page--room).
  No live preview — the wheel only paints after SAVE SKY (my_sky parity); SAVE
  adds body.sky-saved → the felt flips to scroll-snap (form shunts to page 2,
  ease to the wheel on page 1). saved_sky_json primes the reopen draw. Inert
  STUB hook for the post-character-creation form lock (roadmap step 21).
- _sky.scss: in-room felt fill + open/close (html.sky-open); hides the position
  strip while the felt is up for a clean homogeneous surface.
- _room.scss: html.sky-open pins .room-aperture.is-scrollable (overflow hidden,
  snap none) so the ATLAS/SCROLL/YARN/POST/PULSE reelhouse is unreachable while
  casting; restored the instant the felt closes.
- _room_gear.html + room-views.js: NVM moves into a new .room-menu-sky gear pane
  (→ epic:room, which re-renders DRAW SEA if saved else CAST SKY); syncGear()
  shows it while sky-open.
- _burger.html + _burger.scss + burger-btn.js: the Sky sub-btn goes .active once
  saved (sky_btn_active = sky_confirmed) — concurrent w. a thrice --priTk burger
  pulse (.sky-saved-glow, rhymes w. .flash-inactive); an active click reopens
  the wheel via window.openSkyFelt.
- epic/views.py: sky_btn_active + saved_sky_json ctx off the seat's confirmed
  Character; the acting gamer's WS auto-reload is dropped (SAVE reveals the
  wheel in place; the gear NVM does the nav to DRAW SEA).
- Tests: PickSkyUnifiedFeltTest + PickSeaRenderingTest ITs (930 epic+gameboard
  green); BurgerSpec sky-glow/reopen Jasmine (full suite green); PickSky
  LocalStorageTest + PickSkyDelTest FTs reworked to the post-save flow.

[[project-deck-segment-model]] [[feedback-scss-import-order-specificity]]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 16:54:33 -04:00
Disco DeDisco
75301ca84d Test runner: retry the SQLite teardown on Windows PermissionError (local-only nicety)
On local Windows + SQLite, DiscoverRunner's teardown ends in
os.remove(test_db.sqlite3), which raises PermissionError [WinError 32] when
another handle still holds the file (a concurrent run, a lingering connection,
AV / Search indexer) — crashing an otherwise-green run at the very end.

RobustCompressorTestRunner.teardown_databases now retries super() up to 10x with
a 0.1s sleep, then leaves the stale file for the next run to overwrite rather
than fail. Mirrors the _robust_save PermissionError retry already in the runner.

CI-neutral: CI is Postgres on Linux — teardown is DROP DATABASE (no file remove),
and Linux unlinks open files without error — so the loop never triggers there.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 15:08:55 -04:00
17 changed files with 976 additions and 171 deletions

View File

@@ -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() {
@@ -291,13 +296,57 @@
}
window.bindVoiceBtn = bindVoiceBtn;
// ── Sky sub-btn (CAST SKY reopen) + sky-saved glow ──────────────────────
// Once the sky is saved the burger pulses thrice in --priTk (a finite cue
// "your sky lives here") and the Sky sub-btn goes .active → an active click
// reopens the saved wheel via the sky overlay's exposed window.openSkyFelt.
// 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.
// 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;
el.classList.add('sky-saved-glow');
setTimeout(function () {
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() {
var skyBtn = document.getElementById('id_sky_btn');
if (!skyBtn) return;
// Active click → reopen the saved wheel. No stopPropagation: the burger's
// delegated handler then closes the fan (its .active behaviour). Inactive
// clicks fall through to that handler's 2-pulse flash.
skyBtn.addEventListener('click', function () {
if (!skyBtn.classList.contains('active')) return;
if (window.openSkyFelt) window.openSkyFelt();
});
// Server flagged a saved sky on this load → play the thrice cue once.
var burger = document.getElementById('id_burger_btn');
if (burger && burger.dataset.skyGlow) pulseSkyGlow();
}
window.bindSkyBtn = bindSkyBtn;
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function () {
bindBurger();
bindVoiceBtn();
bindSkyBtn();
});
} else {
bindBurger();
bindVoiceBtn();
bindSkyBtn();
}
}());

View File

@@ -89,6 +89,7 @@
var paneDefault = roomMenu && roomMenu.querySelector('.room-menu-default');
var paneScroll = roomMenu && roomMenu.querySelector('.room-menu-scroll');
var paneAtlas = roomMenu && roomMenu.querySelector('.room-menu-atlas');
var paneSky = roomMenu && roomMenu.querySelector('.room-menu-sky');
var GEARLESS = { yarn: true, post: true, pulse: true };
var onReelhouse = false;
@@ -98,6 +99,7 @@
if (paneDefault) paneDefault.style.display = pane === paneDefault ? 'contents' : 'none';
if (paneScroll) paneScroll.style.display = pane === paneScroll ? '' : 'none';
if (paneAtlas) paneAtlas.style.display = pane === paneAtlas ? '' : 'none';
if (paneSky) paneSky.style.display = pane === paneSky ? '' : 'none';
}
function closeRoomMenu() {
if (roomMenu) roomMenu.style.display = 'none';
@@ -105,6 +107,14 @@
}
function updateGear() {
if (!gearBtn) return;
// CAST SKY felt open → the gear is the sky NVM pane (returns to the
// hex), regardless of scroll position: the aperture is pinned to the
// hex pane while the felt is up, so onReelhouse stays false anyway.
if (document.documentElement.classList.contains('sky-open')) {
gearBtn.classList.remove('gear-disabled');
showPane(paneSky);
return;
}
var disabled = onReelhouse && GEARLESS[current];
gearBtn.classList.toggle('gear-disabled', !!disabled);
if (disabled) { closeRoomMenu(); return; }
@@ -112,6 +122,10 @@
else if (current === 'scroll') showPane(paneScroll);
else if (current === 'atlas') showPane(paneAtlas);
}
// Exposed so the CAST SKY felt (sky overlay JS) can re-sync the gear pane
// the moment it opens/closes (html.sky-open toggles outside any scroll/
// view event that would otherwise drive updateGear).
window.RoomViews.syncGear = updateGear;
function setActiveView(view) {
current = view;

View File

@@ -29,9 +29,19 @@ var SigSelect = (function () {
var _reservedFloats = {}; // key: role → portal <i> 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=([^;]+)/);

View File

@@ -2480,6 +2480,66 @@ class PickSkyRenderingTest(TestCase):
self.assertNotContains(response, 'id="id_sky_delete_btn"')
class PickSkyUnifiedFeltTest(TestCase):
"""CAST SKY unified with the my_sky apparatus: the dark Gaussian modal
(.sky-backdrop + .sky-modal-wrap) is replaced by a --duoUser felt .sky-page
rendered INSIDE .room-hex-pane (my_sea-style, mirroring the Sig Select
SigSelectUnifiedStageTest precedent). Founder is PC (levity), sky not yet
confirmed → the felt form is the active surface."""
def setUp(self):
self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self)
self.room.table_status = Room.SKY_SELECT
self.room.save()
self.sig_card = TarotCard.objects.get(
deck_variant=self.earthman, arcana="MIDDLE", suit="BRANDS", number=11
)
pc_seat = TableSeat.objects.get(room=self.room, role="PC")
pc_seat.significator = self.sig_card
pc_seat.save()
self.url = reverse("epic:room", kwargs={"room_id": self.room.id})
def test_sky_felt_in_hex_pane_no_dark_backdrop(self):
content = self.client.get(self.url).content.decode()
# Felt modifier on the hex pane; the old dark Gaussian chrome is gone.
self.assertIn("has-sky-stage", content)
self.assertNotIn("sky-backdrop", content)
self.assertNotIn("sky-modal-wrap", content)
# The --duoUser felt wrapper (shared .sky-page class) is present.
self.assertIn("sky-page", content)
def test_sky_overlay_lives_inside_hex_pane(self):
content = self.client.get(self.url).content.decode()
# The overlay sits INSIDE the hex pane, before the scroll/views pane —
# so it fills the hex (my_sea-style) instead of floating over the page.
hex_pos = content.find("room-hex-pane")
overlay_pos = content.find("id_sky_overlay")
scroll_pos = content.find("room-scroll-pane")
self.assertNotEqual(overlay_pos, -1)
self.assertLess(hex_pos, overlay_pos)
self.assertLess(overlay_pos, scroll_pos)
def test_sky_felt_absent_once_confirmed(self):
# A confirmed sky flips the hex to DRAW SEA — the felt form must not
# render alongside the sea overlay.
pc_seat = TableSeat.objects.get(room=self.room, role="PC")
Character.objects.create(seat=pc_seat, confirmed_at=timezone.now())
content = self.client.get(self.url).content.decode()
self.assertNotIn("has-sky-stage", content)
def test_gear_has_sky_nvm_pane_returning_to_hex(self):
# NVM moved off the felt into the room gear's own sky pane; its NVM
# returns to the table-hex (epic:room), where the server re-renders
# DRAW SEA (if saved) or CAST SKY (if not).
content = self.client.get(self.url).content.decode()
self.assertIn("room-menu-sky", content)
hex_url = reverse("epic:room", kwargs={"room_id": self.room.id})
sky_pane = content[content.find("room-menu-sky"):]
sky_pane = sky_pane[: sky_pane.find("</div>")]
self.assertIn(hex_url, sky_pane)
self.assertIn("NVM", sky_pane)
# ── SEA_SELECT rendering ──────────────────────────────────────────────────────
class PickSeaRenderingTest(TestCase):
@@ -2524,6 +2584,37 @@ class PickSeaRenderingTest(TestCase):
response = self.client.get(self.url)
self.assertContains(response, "id_sea_overlay")
# ── Burger Sky sub-btn reopen + glow (Phase 5) ──────────────────────────
def test_sky_btn_active_and_glow_flag_when_confirmed(self):
self._confirm_sky()
response = self.client.get(self.url)
self.assertTrue(response.context["sky_btn_active"])
content = response.content.decode()
# Burger carries the load-pulse flag; the Sky sub-btn goes active.
self.assertIn("data-sky-glow", content)
# The felt stays in the DOM (hidden) so the burger can reopen the wheel.
self.assertIn("id_sky_overlay", content)
def test_sky_btn_inactive_and_no_glow_when_not_confirmed(self):
response = self.client.get(self.url)
self.assertFalse(response.context.get("sky_btn_active"))
self.assertNotIn("data-sky-glow", response.content.decode())
def test_saved_sky_json_primes_reopen_when_confirmed(self):
char = self._confirm_sky()
char.chart_data = {"planets": {"Sun": {"sign": "Gemini"}}}
char.save()
response = self.client.get(self.url)
# The confirmed chart is handed to the felt so the burger reopen draws
# the saved wheel without a fresh PySwiss round-trip.
self.assertIn("Gemini", response.context["saved_sky_json"])
def test_saved_sky_json_is_null_literal_when_not_confirmed(self):
# The felt's `const _savedSky = {{ saved_sky_json|...|safe }};` must be
# valid JS even with no saved sky — a bare `null`, never empty.
response = self.client.get(self.url)
self.assertEqual(response.context["saved_sky_json"], "null")
def test_sea_overlay_select_defaults_to_waite_smith_for_levity(self):
self._confirm_sky()
response = self.client.get(self.url)

View File

@@ -648,6 +648,16 @@ def _role_select_context(room, user, seat_param=None):
sky_confirmed = confirmed_char is not None
ctx["sky_confirmed"] = sky_confirmed
ctx["user_seat_role"] = _canonical_seat.role if _canonical_seat else ''
# Burger-fan Sky sub-btn: ACTIVE once the sky is saved (confirmed) — the
# burger then pulses thrice (--priTk) on load + the sub-btn re-opens the
# saved wheel. `saved_sky_json` primes that reopen so the felt draws the
# confirmed chart without a fresh PySwiss round-trip (mirrors My Sky).
ctx["sky_btn_active"] = sky_confirmed
ctx["saved_sky_json"] = (
json.dumps(confirmed_char.chart_data)
if (sky_confirmed and confirmed_char.chart_data)
else "null"
)
if sky_confirmed:
# Fall back to seat.significator for Characters created before the sync was added
ctx["my_tray_sig"] = confirmed_char.significator or _canonical_seat.significator

View File

@@ -96,4 +96,21 @@ class RobustCompressorTestRunner(DiscoverRunner):
"WHERE datname = %s AND pid <> pg_backend_pid()",
[test_db_name],
)
super().teardown_databases(old_config, **kwargs)
# On local Windows + SQLite, super() ends in os.remove(test_db.sqlite3),
# which raises PermissionError [WinError 32] when another handle still
# holds the file (a concurrent run, a lingering connection, AV / Search
# indexer). Retry briefly, then leave the stale file for the next run to
# overwrite rather than crash an otherwise-green run. CI is Postgres on
# Linux — DROP DATABASE, no file remove, and Linux unlinks open files
# without error — so this loop never triggers there. Mirrors
# _robust_save's PermissionError retry.
for attempt in range(10):
try:
super().teardown_databases(old_config, **kwargs)
return
except PermissionError:
if attempt == 9:
print("teardown_databases: test DB file still locked after "
"retries — leaving it for the next run to overwrite.")
return
time.sleep(0.1)

View File

@@ -92,19 +92,29 @@ class PickSeaAsyncTransitionTest(ChannelsFunctionalTest):
""")
def test_pick_sea_btn_visible_after_sky_confirm(self):
"""Confirming sky reloads the room to the hex w. DRAW SEA replacing
CAST SKY; the sea overlay is NOT auto-opened."""
"""Confirming sky lands the room on the hex w. DRAW SEA in place of CAST
SKY; the sea overlay is NOT auto-opened. (A direct-POST confirm has no
captured chart on this browser, so _onSkyConfirmed reloads to the server-
rendered DRAW SEA hex; the felt-save path eases there via the cascade.)"""
self.create_pre_authenticated_session("founder@test.io")
self.browser.get(self.room_url)
# Sky not yet confirmed — CAST SKY btn present.
# Sky not yet confirmed — CAST SKY is the live (non --out) phase btn.
self.wait_for(lambda: self.browser.find_element(By.ID, "id_pick_sky_btn"))
self._confirm_sky()
# Page reloads → hex shows DRAW SEA in place of CAST SKY.
self.wait_for(lambda: self.browser.find_element(By.ID, "id_pick_sea_btn"))
self.assertEqual(self.browser.find_elements(By.ID, "id_pick_sky_btn"), [])
# DRAW SEA becomes the live phase btn; CAST SKY stays in the phase-stack
# but goes --out (the buttons cross-fade in one grid cell now, so the
# stale CAST SKY is hidden via the class, not removed from the DOM).
self.wait_for(lambda: self.assertNotIn(
"hex-phase-btn--out",
self.browser.find_element(By.ID, "id_pick_sea_btn").get_attribute("class"),
))
self.assertIn(
"hex-phase-btn--out",
self.browser.find_element(By.ID, "id_pick_sky_btn").get_attribute("class"),
)
# Sea overlay is NOT auto-opened — it only appears once the gamer
# clicks DRAW SEA.

View File

@@ -95,32 +95,12 @@ class PickSkyLocalStorageTest(FunctionalTest):
""")
# ------------------------------------------------------------------ #
# T1 — fields survive NVM (close + reopen, same page load) #
# ------------------------------------------------------------------ #
def test_form_fields_repopulated_after_nvm(self):
self.create_pre_authenticated_session(self.founder_email)
self.browser.get(self.room_url)
self._open_overlay()
self._fill_form()
# Close via NVM
self.browser.find_element(By.ID, "id_sky_cancel").click()
# Reopen
self._open_overlay()
values = self._field_values()
self.assertEqual(values["date"], "2008-05-27")
self.assertEqual(values["lat"], "38.3754")
self.assertEqual(values["lon"], "-76.6955")
self.assertEqual(values["place"], "Morganza, MD")
self.assertEqual(values["tz"], "America/New_York")
# ------------------------------------------------------------------ #
# T2 — fields survive a page refresh #
# Fields survive a page refresh #
# ------------------------------------------------------------------ #
# (The in-felt NVM was removed in the 2026-06-07 CAST SKY unification —
# NVM now lives in the room gear menu and RELOADS back to the hex, so
# the close-and-reopen-without-reload path no longer exists. localStorage
# durability across a reload is exactly what this refresh case asserts.)
def test_form_fields_repopulated_after_page_refresh(self):
self.create_pre_authenticated_session(self.founder_email)
@@ -145,11 +125,12 @@ class PickSkyLocalStorageTest(FunctionalTest):
class PickSkyDelTest(FunctionalTest):
"""CAST SKY overlay gets a DEL btn at the wheel center: clicking opens the
global guard portal; OK clears the wheel SVG, resets the form fields, &
purges the localStorage entry that would otherwise rehydrate the form on
the next overlay open / page refresh. No server hit (the wheel here is
purely a preview — un-saved data lives only in localStorage)."""
"""CAST SKY felt gets a DEL btn at the wheel center AFTER SAVE (the wheel
only paints once saved now — my_sky parity, 2026-06-07): clicking DEL opens
the global guard portal; OK clears the wheel SVG, resets the form fields,
drops the saved state (body.sky-saved), & purges the localStorage entry that
would otherwise rehydrate the form. The save/delete round-trips are mocked
so the test stays client-focused."""
def setUp(self):
super().setUp()
@@ -173,13 +154,13 @@ class PickSkyDelTest(FunctionalTest):
self.create_pre_authenticated_session(self.founder_email)
self.browser.get(self.room_url)
# Open CAST SKY modal
# Open CAST SKY felt
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_pick_sky_btn"))
self.browser.execute_script("arguments[0].click()", btn)
self.wait_for(lambda: self.browser.find_element(By.ID, "id_sky_overlay"))
# Mock /sky/preview so schedulePreview resolves & SkyWheel.draw paints
# children into #id_sky_svg without hitting PySwiss.
# Mock /sky/preview (enables SAVE + captures the chart), /sky/save (reveal
# the wheel) & /sky/delete (DEL purge) so the test stays client-focused.
self.browser.execute_script("""
const FIXTURE = """ + _json.dumps(_PICK_SKY_CHART_FIXTURE) + """;
window._origFetch = window.fetch;
@@ -187,11 +168,17 @@ class PickSkyDelTest(FunctionalTest):
if (typeof url === 'string' && url.includes('/sky/preview')) {
return Promise.resolve({ok:true, json:()=>Promise.resolve(FIXTURE)});
}
if (typeof url === 'string' && url.includes('/sky/save')) {
return Promise.resolve({ok:true, json:()=>Promise.resolve({id:1, confirmed:true})});
}
if (typeof url === 'string' && url.includes('/sky/delete')) {
return Promise.resolve({ok:true, json:()=>Promise.resolve({deleted:true})});
}
return window._origFetch(url, opts);
};
""")
# Fill form → triggers schedulePreview → wheel renders
# Fill form → triggers schedulePreview (no draw pre-save — my_sky parity)
self.browser.execute_script("""
document.getElementById('id_nf_date').value = '2008-05-27';
document.getElementById('id_nf_lat').value = '38.3754';
@@ -203,10 +190,21 @@ class PickSkyDelTest(FunctionalTest):
);
""")
# Wait for the wheel to render (svg has children)
# SAVE SKY enables once the (mocked) preview resolves → click it → the
# wheel reveals + the DEL btn injects (post-save, not on preview).
save_btn = self.browser.find_element(By.ID, "id_sky_confirm")
self.wait_for(lambda: self.assertFalse(save_btn.get_attribute("disabled")))
# JS-click — the felt fills the aperture and SAVE sits in the felt's own
# scroll, so Selenium's scroll-into-view trips; the click itself is glue.
self.browser.execute_script("arguments[0].click()", save_btn)
# Wait for the wheel to render (svg has children) + saved state engaged.
self.wait_for(lambda: self.assertTrue(
self.browser.find_elements(By.CSS_SELECTOR, "#id_sky_svg > *")
))
self.assertIn("sky-saved", self.browser.execute_script(
"return document.body.className;"
))
# localStorage was populated by _saveForm during typing
ls_key = self._ls_key()
@@ -214,12 +212,14 @@ class PickSkyDelTest(FunctionalTest):
f"return localStorage.getItem({_json.dumps(ls_key)});"
))
# DEL btn → guard portal → OK
del_btn = self.browser.find_element(By.ID, "id_sky_delete_btn")
del_btn.click()
# DEL btn → guard portal → OK (JS-click — same felt-scroll glue as SAVE)
del_btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_sky_delete_btn"))
self.browser.execute_script("arguments[0].click()", del_btn)
portal = self.wait_for(lambda: self.browser.find_element(By.ID, "id_guard_portal"))
self.wait_for(lambda: self.assertIn("active", portal.get_attribute("class")))
portal.find_element(By.CSS_SELECTOR, ".guard-yes").click()
self.browser.execute_script(
"arguments[0].click()", portal.find_element(By.CSS_SELECTOR, ".guard-yes")
)
# After OK: SVG empty, form fields blank, localStorage entry purged,
# and the DEL btn itself is gone — re-injection only happens on the
@@ -232,6 +232,11 @@ class PickSkyDelTest(FunctionalTest):
self.browser.find_elements(By.ID, "id_sky_delete_btn"),
"DEL btn should be removed from the DOM after clear",
)
# The felt returns to form-alone — the saved state is dropped so the
# wheel page collapses + the scroll-snap disengages.
self.assertNotIn("sky-saved", self.browser.execute_script(
"return document.body.className;"
))
values = self.browser.execute_script("""
return {
date: document.getElementById('id_nf_date').value,

View File

@@ -250,6 +250,143 @@ describe("Burger", () => {
});
});
// Sky sub-btn reopen + sky-saved glow (CAST SKY unification, 2026-06-07).
// Once the sky is saved the burger pulses thrice in --priTk (.sky-saved-glow,
// a finite cue) and the Sky sub-btn goes .active → an active click reopens the
// saved wheel via the sky overlay's exposed window.openSkyFelt.
describe("sky sub-btn reopen + sky-saved glow", () => {
let burgerBtn, skyBtn, origOpen;
beforeEach(() => {
jasmine.clock().install();
burgerBtn = document.createElement("button");
burgerBtn.id = "id_burger_btn";
document.body.appendChild(burgerBtn);
skyBtn = document.createElement("button");
skyBtn.id = "id_sky_btn";
skyBtn.className = "burger-fan-btn";
document.body.appendChild(skyBtn);
origOpen = window.openSkyFelt;
window.openSkyFelt = jasmine.createSpy("openSkyFelt");
});
afterEach(() => {
jasmine.clock().uninstall();
window.openSkyFelt = origOpen;
burgerBtn.remove();
skyBtn.remove();
});
describe("pulseSkyGlow()", () => {
it("adds .sky-saved-glow to the burger on the first pulse", () => {
window.pulseSkyGlow();
expect(burgerBtn.classList.contains("sky-saved-glow")).toBe(true);
});
it("clears the glow after the ON window (~220ms)", () => {
window.pulseSkyGlow();
jasmine.clock().tick(240);
expect(burgerBtn.classList.contains("sky-saved-glow")).toBe(false);
});
it("pulses a third time (still glowing into the 3rd ON window)", () => {
window.pulseSkyGlow();
// Two full cycles: 2 × (220 ON + 160 OFF) = 760ms → 3rd ON begins.
jasmine.clock().tick(770);
expect(burgerBtn.classList.contains("sky-saved-glow")).toBe(true);
});
it("settles back to default after the 3 pulses (~1.2s total)", () => {
window.pulseSkyGlow();
jasmine.clock().tick(1300);
expect(burgerBtn.classList.contains("sky-saved-glow")).toBe(false);
});
it("no-ops when the burger btn is absent", () => {
burgerBtn.remove();
expect(() => window.pulseSkyGlow()).not.toThrow();
});
});
describe("bindSkyBtn()", () => {
it("active Sky-btn click reopens the saved wheel (window.openSkyFelt)", () => {
skyBtn.classList.add("active");
bindSkyBtn();
skyBtn.click();
expect(window.openSkyFelt).toHaveBeenCalled();
});
it("inactive Sky-btn click does NOT reopen (left to the flash stub)", () => {
bindSkyBtn(); // skyBtn has no .active
skyBtn.click();
expect(window.openSkyFelt).not.toHaveBeenCalled();
});
it("pulses the burger on load when the server flagged a saved sky", () => {
burgerBtn.dataset.skyGlow = "1";
bindSkyBtn();
expect(burgerBtn.classList.contains("sky-saved-glow")).toBe(true);
});
it("does NOT pulse on load without the data-sky-glow flag", () => {
bindSkyBtn();
expect(burgerBtn.classList.contains("sky-saved-glow")).toBe(false);
});
it("no-ops when the Sky btn is absent", () => {
skyBtn.remove();
expect(() => bindSkyBtn()).not.toThrow();
});
});
});
// 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).

View File

@@ -189,6 +189,24 @@
0 0 1.2rem 0.3rem rgba(var(--ninUser), 0.35);
}
// ── Sky-saved glow (CAST SKY → SAVE SKY) ──────────────────────────────
//
// Once the sky is saved, the burger pulses thrice in --priTk (border + icon)
// to cue "your sky lives here now — reopen it via the Sky sub-btn". burger-
// 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. 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:
0 0 0.5rem 0.1rem rgba(var(--priTk), 0.75),
0 0 1.2rem 0.3rem rgba(var(--priTk), 0.35);
}
// ── Voice affordance glow + pulse (Phase 3, my-sea voice) ─────────────
//
// Distinct from the sea-btn's --priYl `.glow-handoff` draw nudge: voice uses

View File

@@ -91,6 +91,18 @@ html.sea-open #id_aperture_fill {
}
}
// While the CAST SKY felt is summoned (html.sky-open) the outer aperture must
// NOT scroll down to the reelhouse carousel (ATLAS/SCROLL/YARN/POST/PULSE) —
// the felt's OWN form↔wheel scroll-snap (.sky-page--room) is the only scroll
// in play. Pin the aperture to the hex pane (which the felt fills); restored
// the instant the felt closes (sky-open removed). overflow:hidden alone leaves
// the aperture parked on whichever pane it was showing — and CAST SKY is only
// reachable from the hex pane, so it pins to the hex every time.
html.sky-open .room-aperture.is-scrollable {
overflow-y: hidden;
scroll-snap-type: none;
}
.room-scroll-pane {
display: flex;
flex-direction: column;
@@ -968,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.

View File

@@ -9,8 +9,61 @@
html.sky-open {
overflow: hidden;
// NB: the modal-era `#id_aperture_fill { opacity: 1 }` backdrop is GONE here.
// It's a full-cover --duoUser div at z-90; the old dark modal sat above it
// (z-120), but the inline felt sits at z-5, so lighting the fill painted an
// opaque green sheet OVER the felt + form (the fill's opacity transition is
// why the form "flashed then vanished"). The felt is its own --duoUser
// surface + covers the hex on its own, so the fill stays transparent now.
}
#id_aperture_fill { opacity: 1; }
// ── In-room CAST SKY felt (unified with my_sky / sky.html) ────────────────────
// The .sky-page apparatus rendered INSIDE .room-hex-pane (my_sea-style, like
// .sig-overlay) instead of the old fixed dark modal. The hex pane becomes a
// positioning context only while the felt shows, so the absolute-filling felt
// scopes to the pane (not the viewport) and the position-strip's root stacking
// stays untouched in every other phase. Hidden until the CAST SKY btn adds
// html.sky-open; reuses every dashboard .sky-page form/wheel rule below.
.room-hex-pane.has-sky-stage {
position: relative;
}
// NB: chained to (0,2,0) — the base `.sky-page { position: relative }` block
// lives LATER in this file, so a bare `.sky-page--room` (0,1,0) loses the tie on
// source order and the felt stays position:relative → it collapses to width 0 as
// a flex child of the hex-pane (the form vanishes onto a 0-wide column). The
// chain wins regardless of order. [[feedback-scss-import-order-specificity]]
.sky-page.sky-page--room {
position: absolute;
inset: 0;
// Within the hex-pane stacking context (matches .sig-overlay's z:5), above
// the hex/seats it covers but below the root-level position strip (z-130).
z-index: 5;
// Hidden until opened — pointer-events off so the hidden felt can't eat the
// CAST SKY btn click beneath it.
visibility: hidden;
pointer-events: none;
}
html.sky-open .sky-page.sky-page--room {
visibility: visible;
pointer-events: auto;
}
// While the felt is up, hide the position strip (a z-130 hex-pane sibling that
// would otherwise float its circles over the form) so the felt reads as a clean
// homogeneous surface — restored the instant the felt closes.
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 ──────────────────────────────────────────────────────────────────

View File

@@ -250,6 +250,143 @@ describe("Burger", () => {
});
});
// Sky sub-btn reopen + sky-saved glow (CAST SKY unification, 2026-06-07).
// Once the sky is saved the burger pulses thrice in --priTk (.sky-saved-glow,
// a finite cue) and the Sky sub-btn goes .active → an active click reopens the
// saved wheel via the sky overlay's exposed window.openSkyFelt.
describe("sky sub-btn reopen + sky-saved glow", () => {
let burgerBtn, skyBtn, origOpen;
beforeEach(() => {
jasmine.clock().install();
burgerBtn = document.createElement("button");
burgerBtn.id = "id_burger_btn";
document.body.appendChild(burgerBtn);
skyBtn = document.createElement("button");
skyBtn.id = "id_sky_btn";
skyBtn.className = "burger-fan-btn";
document.body.appendChild(skyBtn);
origOpen = window.openSkyFelt;
window.openSkyFelt = jasmine.createSpy("openSkyFelt");
});
afterEach(() => {
jasmine.clock().uninstall();
window.openSkyFelt = origOpen;
burgerBtn.remove();
skyBtn.remove();
});
describe("pulseSkyGlow()", () => {
it("adds .sky-saved-glow to the burger on the first pulse", () => {
window.pulseSkyGlow();
expect(burgerBtn.classList.contains("sky-saved-glow")).toBe(true);
});
it("clears the glow after the ON window (~220ms)", () => {
window.pulseSkyGlow();
jasmine.clock().tick(240);
expect(burgerBtn.classList.contains("sky-saved-glow")).toBe(false);
});
it("pulses a third time (still glowing into the 3rd ON window)", () => {
window.pulseSkyGlow();
// Two full cycles: 2 × (220 ON + 160 OFF) = 760ms → 3rd ON begins.
jasmine.clock().tick(770);
expect(burgerBtn.classList.contains("sky-saved-glow")).toBe(true);
});
it("settles back to default after the 3 pulses (~1.2s total)", () => {
window.pulseSkyGlow();
jasmine.clock().tick(1300);
expect(burgerBtn.classList.contains("sky-saved-glow")).toBe(false);
});
it("no-ops when the burger btn is absent", () => {
burgerBtn.remove();
expect(() => window.pulseSkyGlow()).not.toThrow();
});
});
describe("bindSkyBtn()", () => {
it("active Sky-btn click reopens the saved wheel (window.openSkyFelt)", () => {
skyBtn.classList.add("active");
bindSkyBtn();
skyBtn.click();
expect(window.openSkyFelt).toHaveBeenCalled();
});
it("inactive Sky-btn click does NOT reopen (left to the flash stub)", () => {
bindSkyBtn(); // skyBtn has no .active
skyBtn.click();
expect(window.openSkyFelt).not.toHaveBeenCalled();
});
it("pulses the burger on load when the server flagged a saved sky", () => {
burgerBtn.dataset.skyGlow = "1";
bindSkyBtn();
expect(burgerBtn.classList.contains("sky-saved-glow")).toBe(true);
});
it("does NOT pulse on load without the data-sky-glow flag", () => {
bindSkyBtn();
expect(burgerBtn.classList.contains("sky-saved-glow")).toBe(false);
});
it("no-ops when the Sky btn is absent", () => {
skyBtn.remove();
expect(() => bindSkyBtn()).not.toThrow();
});
});
});
// 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).

View File

@@ -8,7 +8,7 @@
{# sub-btns are inactive; clicking an inactive sub-btn flashes a brief #}
{# --priRd glow (twice, fast cadence) — burger-btn.js owns the delegated #}
{# click + flash. #}
<button id="id_burger_btn" type="button" aria-label="Open burger menu" aria-expanded="false"{% if sea_first_draw_pending %} class="glow-handoff"{% endif %}>
<button id="id_burger_btn" type="button" aria-label="Open burger menu" aria-expanded="false"{% if sea_first_draw_pending %} class="glow-handoff"{% endif %}{% if sky_btn_active %} data-sky-glow="1"{% endif %}>
<i class="fa-solid fa-burger"></i>
</button>
<div id="id_burger_fan" aria-hidden="true">
@@ -23,7 +23,10 @@
<i class="fa-solid fa-headset burger-fan-icon--on"></i>
<i class="fa-solid fa-ban burger-fan-icon--off"></i>
</button>
<button id="id_sky_btn" type="button" class="burger-fan-btn" aria-label="Sky">
{# Sky sub-btn → reopens the saved CAST SKY wheel. `.active` once the sky is #}
{# saved (sky_btn_active, set by epic.room_view); burger-btn.js binds the #}
{# active click → window.openSkyFelt(). Inactive elsewhere (flash stub). #}
<button id="id_sky_btn" type="button" class="burger-fan-btn{% if sky_btn_active %} active{% endif %}" aria-label="Sky">
<i class="fa-solid fa-cloud burger-fan-icon--on"></i>
<i class="fa-solid fa-ban burger-fan-icon--off"></i>
</button>

View File

@@ -24,6 +24,12 @@
{% endif %}
</div>
{% if scroll_filter %}
{# Sky pane — shown by room-views.js (updateGear) while the CAST SKY felt is #}
{# open (html.sky-open). NVM returns to the table-hex: epic:room re-renders #}
{# the hex with DRAW SEA if the sky was saved (confirmed), else CAST SKY. #}
<div class="room-menu-sky" style="display:none">
<a href="{% url 'epic:room' room.id %}" class="btn btn-cancel">NVM</a>
</div>
<div class="room-menu-scroll" style="display:none">
<form id="id_scroll_filter_form">
<label><input type="checkbox" name="labels" value="frame" checked> Frame</label>

View File

@@ -1,11 +1,14 @@
{% load static %}
{# CAST SKY overlay — natal chart entry + D3 wheel preview #}
{# Included in room.html when table_status == "SKY_SELECT" #}
{# Opens when user clicks #id_pick_sky_btn; html.sky-open controls #}
{# visibility via CSS — backdrop-filter blur + centred modal. #}
{# CAST SKY felt — natal chart entry + D3 wheel, unified with the my_sky / #}
{# sky.html apparatus (2026-06-07). Renders INSIDE .room-hex-pane on edge-to- #}
{# edge --duoUser felt (no dark Gaussian backdrop / modal), my_sea-style. The #}
{# .sky-page wrapper REUSES sky.html's dashboard form/wheel rules; the #}
{# .sky-page--room modifier scopes the in-room fill + open/close visibility. #}
{# Opens when the user clicks #id_pick_sky_btn (html.sky-open). Pre-save the #}
{# form shows alone; on SAVE the felt flips into scroll-snap (form shunts down, #}
{# wheel takes page 1). NVM lives in the room gear menu (not on the felt). #}
<div class="sky-backdrop"></div>
<div class="sky-overlay"
<div class="sky-page sky-page--room"
id="id_sky_overlay"
data-preview-url="{% url 'epic:sky_preview' room.id %}"
data-save-url="{% url 'epic:sky_save' room.id %}"
@@ -13,98 +16,84 @@
data-sea-partial-url="{% url 'epic:sea_partial' room.id %}"
data-user-seat-role="{{ user_seat_role }}">
<div class="sky-modal-wrap">
<div class="sky-modal">
<div class="sky-modal-body">
<header class="sky-modal-header">
<h2>SKY <span>SELECT</span></h2>
<p>Enter your birth details to generate your natal chart.</p>
</header>
{# ── Form column ──────────────────────────────────────── #}
<div class="sky-form-col">
<div class="sky-modal-body">
{# form-main scrolls independently; confirm btn stays pinned below it #}
<div class="sky-form-main">
<form id="id_sky_form" autocomplete="off">
{# ── Form column ──────────────────────────────────────── #}
<div class="sky-form-col">
<div class="sky-field">
<label for="id_nf_date">Birth date</label>
<input id="id_nf_date" name="date" type="date" required>
</div>
{# form-main scrolls independently; confirm btn stays pinned below it #}
<div class="sky-form-main">
<form id="id_sky_form" autocomplete="off">
<div class="sky-field">
<label for="id_nf_time">Birth time</label>
<input id="id_nf_time" name="time" type="time" value="12:00">
<small>Local time at birth place. Use 12:00 if unknown.</small>
</div>
<div class="sky-field">
<label for="id_nf_date">Birth date</label>
<input id="id_nf_date" name="date" type="date" required>
<div class="sky-field sky-place-field">
<label for="id_nf_place">Birth place</label>
<div class="sky-place-wrap">
<input id="id_nf_place" name="place" type="text"
placeholder="Start typing a city…"
autocomplete="off">
<button type="button" id="id_nf_geolocate"
class="btn btn-secondary btn-sm"
title="Use device location">
<i class="fa-solid fa-location-crosshairs"></i>
</button>
</div>
<div id="id_nf_suggestions" class="sky-suggestions" hidden></div>
</div>
<div class="sky-field">
<label for="id_nf_time">Birth time</label>
<input id="id_nf_time" name="time" type="time" value="12:00">
<small>Local time at birth place. Use 12:00 if unknown.</small>
<div class="sky-field sky-coords">
<div>
<label>Latitude</label>
<input id="id_nf_lat" name="lat" type="text"
placeholder="—" readonly tabindex="-1">
</div>
<div class="sky-field sky-place-field">
<label for="id_nf_place">Birth place</label>
<div class="sky-place-wrap">
<input id="id_nf_place" name="place" type="text"
placeholder="Start typing a city…"
autocomplete="off">
<button type="button" id="id_nf_geolocate"
class="btn btn-secondary btn-sm"
title="Use device location">
<i class="fa-solid fa-location-crosshairs"></i>
</button>
</div>
<div id="id_nf_suggestions" class="sky-suggestions" hidden></div>
<div>
<label>Longitude</label>
<input id="id_nf_lon" name="lon" type="text"
placeholder="—" readonly tabindex="-1">
</div>
</div>
<div class="sky-field sky-coords">
<div>
<label>Latitude</label>
<input id="id_nf_lat" name="lat" type="text"
placeholder="—" readonly tabindex="-1">
</div>
<div>
<label>Longitude</label>
<input id="id_nf_lon" name="lon" type="text"
placeholder="—" readonly tabindex="-1">
</div>
</div>
<div class="sky-field">
<label for="id_nf_tz">Timezone</label>
<input id="id_nf_tz" name="tz" type="text"
placeholder="auto-detected from coordinates"
readonly tabindex="-1">
</div>
<div class="sky-field">
<label for="id_nf_tz">Timezone</label>
<input id="id_nf_tz" name="tz" type="text"
placeholder="auto-detected from coordinates"
readonly tabindex="-1">
</div>
</form>
</form>
<div id="id_sky_status" class="sky-status"></div>
</div>{# /.sky-form-main #}
<div id="id_sky_status" class="sky-status"></div>
</div>{# /.sky-form-main #}
<button type="button" id="id_sky_confirm" class="btn btn-primary" disabled>
Save Sky
</button>
<button type="button" id="id_sky_confirm" class="btn btn-primary" disabled>
Save Sky
</button>
</div>
</div>
{# ── Wheel column ─────────────────────────────────────── #}
{# DEL btn is JS-injected after the wheel paints (see the SAVE #}
{# success handler) — keeping it out of the template means a #}
{# pre-save form can never show a DEL action against a non- #}
{# existent wheel. #}
<div class="sky-wheel-col">
<svg id="id_sky_svg" class="sky-svg"></svg>
</div>
{# ── Wheel column ─────────────────────────────────────── #}
{# DEL btn is JS-injected after the wheel paints (see schedule #}
{# Preview success handler) — keeping it out of the template #}
{# means a blank CAST SKY modal can never show a DEL action #}
{# against a non-existent wheel. #}
<div class="sky-wheel-col">
<svg id="id_sky_svg" class="sky-svg"></svg>
</div>
</div>{# /.sky-modal-body #}
</div>{# /.sky-modal-body #}
</div>{# /.sky-modal #}
{# NVM: circle btn centered on the top-right corner of the modal #}
<button type="button" id="id_sky_cancel" class="btn btn-cancel btn-sm">NVM</button>
</div>{# /.sky-modal-wrap #}
</div>{# /.sky-overlay #}
</div>{# /.sky-page--room #}
<script src="{% static 'apps/gameboard/d3.min.js' %}"></script>
<script src="{% static 'apps/gameboard/sky-wheel.js' %}"></script>
@@ -117,6 +106,8 @@
const svgEl = document.getElementById('id_sky_svg');
const statusEl = document.getElementById('id_sky_status');
const confirmBtn = document.getElementById('id_sky_confirm');
// NVM now lives in the room gear menu (the felt refactor dropped the in-modal
// cancel btn) — guarded everywhere so a null lookup never throws.
const cancelBtn = document.getElementById('id_sky_cancel');
const geoBtn = document.getElementById('id_nf_geolocate');
const placeInput = document.getElementById('id_nf_place');
@@ -142,7 +133,7 @@
// Preload zodiac SVG icons eagerly — they'll be cached before any draw() call.
// To swap an icon, replace the .svg file in zodiac-signs/ and hard-refresh.
SkyWheel.preload();
const _preloadReady = SkyWheel.preload();
// ── localStorage persistence ──────────────────────────────────────────────
// Key scoped to room so multiple rooms don't clobber each other.
@@ -175,10 +166,32 @@
// ── 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();
// If the wheel is empty but the form has enough data (restored from
// localStorage), kick off a fresh preview so the animation plays.
// localStorage), kick off a fresh preview so SAVE re-enables (no draw
// until saved — my_sky parity).
if (!svgEl.querySelector('*') && _formReady()) {
schedulePreview();
}
@@ -186,13 +199,14 @@
function closeSky() {
document.documentElement.classList.remove('sky-open');
_restoreTextBtn();
hideSuggestions();
if (window.RoomViews && window.RoomViews.syncGear) window.RoomViews.syncGear();
}
const pickSkyBtn = document.getElementById('id_pick_sky_btn');
if (pickSkyBtn) pickSkyBtn.addEventListener('click', openSky);
cancelBtn.addEventListener('click', closeSky);
overlay.addEventListener('click', (e) => { if (e.target === overlay) closeSky(); });
if (cancelBtn) cancelBtn.addEventListener('click', closeSky);
// ── Status helper ─────────────────────────────────────────────────────────
@@ -347,12 +361,18 @@
setStatus('');
confirmBtn.disabled = false;
if (svgEl.querySelector('*')) {
SkyWheel.redraw(data);
} else {
SkyWheel.draw(svgEl, data);
// my_sky parity — pre-save the form shows ALONE on the felt; the wheel
// only paints AFTER SAVE SKY (_activateSavedState). _lastChartData is
// captured above so SAVE has the chart payload + can reveal the wheel.
// Once saved, live edits re-draw in place (the wheel page stays live).
if (_skySaved()) {
if (svgEl.querySelector('*')) {
SkyWheel.redraw(data);
} else {
SkyWheel.draw(svgEl, data);
}
_ensureDelBtn();
}
_ensureDelBtn();
})
.catch(err => {
if (seq !== _fetchSeq) return;
@@ -361,6 +381,10 @@
});
}
function _skySaved() {
return document.body.classList.contains('sky-saved');
}
// ── Save ──────────────────────────────────────────────────────────────────
confirmBtn.addEventListener('click', () => {
@@ -389,10 +413,13 @@
return r.json();
})
.then(data => {
if (!data.confirmed) {
setStatus('Sky saved!');
}
// Confirmed state is driven by the room:sky_confirmed WS event
setStatus('Sky saved!');
// my_sky parity — reveal the wheel on the felt instead of reloading.
// The form shunts to page 2; the wheel slides in as page 1 and we ease
// to it. The gamer lingers on their saved wheel; the gear NVM returns
// to the hex (which the server re-renders with DRAW SEA — the save
// already set confirmed_at, so `sky_confirmed` is true server-side).
_activateSavedState();
})
.catch(err => {
setStatus(`Save failed: ${err.message}`, 'error');
@@ -400,16 +427,134 @@
});
});
// ── Sky confirmed → close sky & reload to land on hex w. DRAW SEA ──────
//
// The gamer should witness the table hex (now showing DRAW SEA in place of
// CAST SKY) before opting into the sea overlay. We reload the room page —
// the server-side template will re-render with `sky_confirmed=True` so the
// hex's btn flips automatically, and the user clicks DRAW SEA to continue.
// ── Reveal the saved wheel on the felt (no reload) ────────────────────────
// Adds body.sky-saved so _sky.scss flips the felt into scroll-snap (wheel
// page 1 via .sky-wheel-col order:-1, form page 2), draws the wheel from the
// captured chart, injects DEL, then pins to the form section + eases to the
// wheel so the reveal animates in (mirrors sky.html's _activateSavedState).
function _activateSavedState() {
if (!_lastChartData) return;
const wasSaved = document.body.classList.contains('sky-saved');
document.body.classList.add('sky-saved');
if (svgEl.querySelector('*')) {
SkyWheel.redraw(_lastChartData);
} else {
SkyWheel.draw(svgEl, _lastChartData);
}
_ensureDelBtn();
if (!wasSaved) {
// First reveal: pin to the form section so the wheel slides in from above
// (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;
_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();
}
}
// ── 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 <script> (openSea binding on #id_pick_sea_btn
// + SeaDeal.reinit) — innerHTML won't execute it, so each <script> is re-
// created. Idempotent + best-effort (a gear NVM / refresh recovers via the
// server-rendered confirmed overlay).
function _injectSeaOverlay() {
const url = overlay.dataset.seaPartialUrl;
const container = document.getElementById('id_sea_inject');
if (!url || !container || container.dataset.injected) return;
fetch(url, { credentials: 'same-origin' })
.then((r) => { if (!r.ok) throw new Error(r.status); return r.text(); })
.then((html) => {
container.dataset.injected = '1';
const tpl = document.createElement('template');
tpl.innerHTML = html;
Array.prototype.slice.call(tpl.content.childNodes).forEach((node) => {
if (node.nodeName === 'SCRIPT') {
const s = document.createElement('script');
if (node.src) s.src = node.src;
else s.textContent = node.textContent;
container.appendChild(s);
} else {
container.appendChild(node);
}
});
})
.catch(() => { /* transient — server render recovers on next load */ });
}
// Ease the felt's scroll back to the wheel page (top) after a save —
// ease-out cubic, ~280ms. No-op if already at the top.
function _scrollApertureToTop() {
if (overlay.scrollTop === 0) return;
const start = overlay.scrollTop;
const startTime = performance.now();
const DURATION = 280;
const ease = (t) => { 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);
}
// ── Sky confirmed (self-targeted WS) ──────────────────────────────────────
// Two cases:
// • This browser captured the chart (the gamer saved via the felt) → the
// save .then() already ran the cascade; this WS re-fire is idempotent
// (re-asserts the saved state, no second cascade, no reload).
// • No captured chart (a direct POST confirm, or another browser of the same
// seat) → the cascade can't run, so reload to land on the server-rendered
// DRAW SEA hex (the pre-cascade behaviour, preserved for these paths).
function _onSkyConfirmed() {
closeSky();
window.location.reload();
if (_lastChartData) {
_activateSavedState();
} else {
window.location.reload();
}
}
// ── DEL btn — JS-injected after the wheel paints; absent on a blank modal
@@ -456,6 +601,9 @@
_lastChartData = null;
confirmBtn.disabled = true;
setStatus('');
// Drop the saved state so the felt returns to form-alone (wheel page
// collapses, scroll-snap disengages) — the seat's Character was purged.
document.body.classList.remove('sky-saved');
try { localStorage.removeItem(LS_KEY); } catch (_) {}
if (_delBtn) {
_delBtn.remove();
@@ -472,10 +620,19 @@
return m ? m[1] : '';
}
// ── Restore persisted form data ────────────────────────────────────────────
// Called after all functions are defined. Wheel draw is deferred to
// openSky() so the animation plays when the modal opens, not silently
// in the background on page load.
// ── Reopen affordance ──────────────────────────────────────────────────────
// Exposed so the burger fan's #id_sky_btn (active once the sky is saved) can
// reopen the saved wheel. burger-btn.js calls window.openSkyFelt().
window.openSkyFelt = openSky;
// ── Restore persisted form data + prime a saved (confirmed) wheel ───────────
// Pre-save the wheel draw is deferred to openSky (form shows alone). Once the
// sky is confirmed the server hands us the saved chart (saved_sky_json) so the
// felt is primed in its saved state — body.sky-saved engages the scroll-snap
// and the wheel is drawn ready, so the burger reopen lands straight on it
// (mirrors My Sky's saved-on-load draw).
const _savedSky = {{ saved_sky_json|default:"null"|safe }};
// WS: server broadcasts sky_confirmed when any gamer confirms their sky.
// Only act when the event's seat_role matches this browser's seat.
@@ -487,5 +644,38 @@
});
_restoreForm();
if (_savedSky) {
_lastChartData = _savedSky;
document.body.classList.add('sky-saved');
confirmBtn.disabled = false;
_preloadReady.then(() => {
if (!svgEl.querySelector('*')) SkyWheel.draw(svgEl, _savedSky);
_ensureDelBtn();
});
}
// Reload-into-open: the SIG_SELECT → SKY_SELECT transition (sig-select.js's
// CAST SKY click) drops a sessionStorage flag then reloads here, so the felt
// OPENS on arrival — one click yields the form instead of the old click→reload
// →click-again. Read once + clear so a later manual refresh doesn't re-open.
try {
if (sessionStorage.getItem('sky-autoopen') === '1') {
sessionStorage.removeItem('sky-autoopen');
openSky();
}
} catch (_) { /* sessionStorage unavailable — non-fatal */ }
// ── STUB: lock the form once character creation completes ───────────────────
// Character creation ends after DRAW SEA → the Voronoi map (roadmap step 21),
// which isn't built yet. When it lands the server will flag the finished state
// and the re-opened sky becomes READ-ONLY (view your wheel, can't re-cast). For
// now this is an inert hook — `_skyLocked` is always false until that phase
// exists. TODO: drive `_skyLocked` from a server ctx flag + lock the gear DEL.
const _skyLocked = false;
if (_skyLocked) {
form.querySelectorAll('input').forEach((el) => { el.disabled = true; });
confirmBtn.disabled = true;
}
})();
</script>

View File

@@ -36,7 +36,7 @@
{# so the green-felt _sig_select_overlay fills it (my_sea-style), covering #}
{# the hex/seats behind. Dismissing the overlay (this gamer's sigs done) #}
{# reveals the hex + waiting message underneath. #}
<div class="room-pane room-hex-pane{% if room.table_status == "SIG_SELECT" and user_polarity and not polarity_done and viewer_cost_current %} has-sig-stage{% endif %}">
<div class="room-pane room-hex-pane{% if room.table_status == "SIG_SELECT" and user_polarity and not polarity_done and viewer_cost_current %} has-sig-stage{% endif %}{% if room.table_status == "SKY_SELECT" and not sky_confirmed and viewer_cost_current %} has-sky-stage{% endif %}">
<div class="room-shell">
<div id="id_game_table" class="room-table">
{# SCAN SIGS advances the whole table past role-select — gated on #}
@@ -83,11 +83,15 @@
onclick="window.location.href='{% url 'epic:room_gate' room.id %}{% if current_slot %}?seat={{ current_slot }}{% endif %}'">GATE<br>VIEW</button>
{% else %}
{% if room.table_status == "SKY_SELECT" %}
{% if sky_confirmed %}
<button id="id_pick_sea_btn" class="btn btn-primary">DRAW<br>SEA</button>
{% else %}
<button id="id_pick_sky_btn" class="btn btn-primary">CAST<br>SKY</button>
{% endif %}
{# Both phase btns render so the post-save cascade can #}
{# cross-fade CAST SKY → DRAW SEA in place (no reload). The #}
{# server sets --out on the inactive one; sky-select.js's #}
{# cascade swaps them. On a confirmed reload the server lands #}
{# DRAW SEA visible + CAST SKY out, same as the cascade end. #}
<div class="hex-phase-stack">
<button id="id_pick_sky_btn" class="btn btn-primary hex-phase-btn{% if sky_confirmed %} hex-phase-btn--out{% endif %}">CAST<br>SKY</button>
<button id="id_pick_sea_btn" class="btn btn-primary hex-phase-btn{% if not sky_confirmed %} hex-phase-btn--out{% endif %}">DRAW<br>SEA</button>
</div>
{% elif room.table_status == "SIG_SELECT" %}
<button id="id_pick_sky_btn" class="btn btn-primary" style="display:none">CAST<br>SKY</button>
{% if polarity_done %}
@@ -121,6 +125,16 @@
{% if room.table_status == "SIG_SELECT" and user_polarity and not polarity_done and viewer_cost_current %}
{% include "apps/gameboard/_partials/_sig_select_overlay.html" %}
{% endif %}
{# CAST SKY felt — natal chart entry, my_sea-style --duoUser felt that #}
{# fills the hex pane (replaces the old root-level dark modal). Hidden #}
{# until the CAST SKY btn (or, once saved, the burger Sky sub-btn) adds #}
{# html.sky-open. Rendered through the confirmed state too so the saved #}
{# wheel stays re-openable from the burger; `has-sky-stage` (the active #}
{# entry surface) is dropped once confirmed — the hex then shows DRAW #}
{# SEA and the felt sits hidden, primed with the saved chart. #}
{% if room.table_status == "SKY_SELECT" and viewer_cost_current %}
{% include "apps/gameboard/_partials/_sky_overlay.html" %}
{% endif %}
{# Position circles scroll away WITH the hex (they live inside the hex #}
{# pane, not at room-page root). Neither the aperture nor the pane sets #}
{# z-index/transform, so the strip's z-130 still resolves in the root #}
@@ -150,20 +164,26 @@
{# their trigger-btn ids in JS) must not render alongside it. (The Sig #}
{# Select stage now lives INSIDE the hex pane above — my_sea-style felt.) #}
{# Sky (Pick Sky) overlay — natal chart entry #}
{% if room.table_status == "SKY_SELECT" and not sky_confirmed and viewer_cost_current %}
{% include "apps/gameboard/_partials/_sky_overlay.html" %}
{% endif %}
{# Sky tooltip: sibling of .sky-overlay, not inside .sky-modal-wrap (which has transform) #}
{% if room.table_status == "SKY_SELECT" and not sky_confirmed and viewer_cost_current %}
{# Sky tooltips — rendered at room-page root so they escape the felt's #}
{# overflow clip (the wheel's planet-hover tooltips are position:fixed). #}
{# Present through the confirmed state too (the burger reopen shows the #}
{# saved wheel, whose planet hovers need these portals). #}
{% if room.table_status == "SKY_SELECT" and viewer_cost_current %}
<div id="id_sky_tooltip" class="tt" style="display:none;"></div>
<div id="id_sky_tooltip_2" class="tt" style="display:none;"></div>
{% endif %}
{# Sea (Pick Sea) overlay — Celtic Cross spread entry #}
{# Sea (Pick Sea) overlay — Celtic Cross spread entry. Server-rendered #}
{# once the sky is confirmed (the reload/refresh path). #}
{% if room.table_status == "SKY_SELECT" and sky_confirmed and viewer_cost_current %}
{% include "apps/gameboard/_partials/_sea_overlay.html" %}
{% endif %}
{# No-reload path: the post-save cascade fetches sea_partial + injects the #}
{# DRAW SEA overlay HERE (executing its inline init) so DRAW SEA is live #}
{# without a reload. Empty until the cascade's _injectSeaOverlay runs. #}
{% if room.table_status == "SKY_SELECT" and not sky_confirmed and viewer_cost_current %}
<div id="id_sea_inject"></div>
{% endif %}
{# Gamer-needed stub — a seat lapsed past its renewal grace and was #}
{# auto-BYE'd, so the table no longer fills all six. Minimal stub #}