Sea Select: post-completion cascade — felt eases out → DRAW SEA → SEED MAP — TDD

Mirror CAST SKY's post-save cascade for the sea phase. When the 6-card spread
completes (live FLIP of the 6th card / AUTO DRAW finishing): linger ~3s on the
felt → the felt eases OUT (`.sea-page--cascade-out`, revealing the table-hex) →
DRAW SEA gives way to SEED MAP + the sea glow fires on the burger (handoff →
sea_btn) → +3s → SEED MAP eases IN. Same shape as CAST SKY → sky-btn glow →
DRAW SEA.

- `_room_hex_center.html`: SEED MAP joins the hex-phase-stack; DRAW SEA goes
  --out once `hand_complete`, SEED MAP --out until then (a reload of a complete
  sea lands on SEED MAP server-side = the cascade's end-state). SEED MAP → the
  Voronoi map (roadmap step 21) is a stub — it only needs to APPEAR here
- `_sea_overlay.html`: `_setComplete(on, live)` runs `_startSeaCascade()` on the
  LIVE completion (FLIP / AUTO DRAW pass `live=true`; init does not, so a reload
  doesn't re-animate). The completion-glow IIFE no longer self-starts on the
  data-state transition — the cascade adds `glow-handoff` to the burger; the IIFE
  keeps only the burger → sea_btn → .sea-select handoff
- `.sea-page--cascade-out` SCSS (mirrors `.sky-page--cascade-out`)
- ITs: SEED MAP --out pre-completion (DRAW SEA in); SEED MAP in + DRAW SEA --out
  when hand_complete. 952 epic+gameboard ITs + PickSeaAsyncTransitionTest(3) green

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-06-07 23:03:17 -04:00
parent 0f57cae50d
commit cf84fdc992
4 changed files with 97 additions and 34 deletions

View File

@@ -4211,6 +4211,33 @@ class PickSeaUnifiedFeltTest(TestCase):
self.assertNotIn("sea-deck-stack--single", content)
self.assertNotIn("sea-stacks--single", content)
def test_seed_map_btn_out_until_hand_complete(self):
"""SEED MAP joins the hex phase stack (the post-completion cascade eases
it IN, mirroring CAST SKY → DRAW SEA). Pre-completion it renders --out
while DRAW SEA is in (user-spec 2026-06-07)."""
import lxml.html
parsed = lxml.html.fromstring(self.client.get(self.url).content)
[seed] = parsed.cssselect("#id_seed_map_btn")
self.assertIn("hex-phase-btn--out", seed.get("class", ""))
[sea] = parsed.cssselect("#id_pick_sea_btn")
self.assertNotIn("hex-phase-btn--out", sea.get("class", ""))
def test_seed_map_in_and_draw_sea_out_when_hand_complete(self):
"""A reload of an already-complete sea lands on SEED MAP server-side:
SEED MAP in, DRAW SEA --out (the cascade's end-state, no animation)."""
card_id = TarotCard.objects.filter(deck_variant=self.earthman).first().id
char = Character.objects.get(seat=self.pc_seat, confirmed_at__isnull=False)
char.celtic_cross = {"spread": "waite-smith", "hand": [
{"position": p, "card_id": card_id, "reversed": False, "polarity": "gravity"}
for p in ["cover", "cross", "crown", "lay", "loom", "leave"]]}
char.save(update_fields=["celtic_cross"])
import lxml.html
parsed = lxml.html.fromstring(self.client.get(self.url).content)
[seed] = parsed.cssselect("#id_seed_map_btn")
self.assertNotIn("hex-phase-btn--out", seed.get("class", ""))
[sea] = parsed.cssselect("#id_pick_sea_btn")
self.assertIn("hex-phase-btn--out", sea.get("class", ""))
class CarteSeatSwitchSkySeaTest(TestCase):
"""A CARTE owner (one gamer owns all 6 seats) must drive EACH seat through

View File

@@ -103,6 +103,15 @@ html.sea-open .position-strip {
visibility: hidden;
}
// Post-completion cascade ease-out — once the 6-card spread is complete the felt
// fades to transparent over ~0.5s (the JS _SEA_FELT_FADE timer) BEFORE sea-open
// is removed, revealing the table-hex with a soft dissolve (mirrors the CAST SKY
// post-save cascade's .sky-page--cascade-out).
.sea-page.sea-page--cascade-out {
opacity: 0;
transition: opacity 0.5s ease;
}
// ── Backdrop ──────────────────────────────────────────────────────────────────
.sky-backdrop {

View File

@@ -29,14 +29,17 @@ sky_confirmed, polarity_done, user_polarity, current_slot, …).
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" %}
{# 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. #}
{# All three phase btns render so the cascades can cross-fade in place #}
{# (no reload): CAST SKY → DRAW SEA on sky-save, then DRAW SEA → SEED MAP #}
{# on the 6-card spread completing. The server sets --out on the inactive #}
{# ones; the sky/sea cascades swap them. On a reload the server lands the #}
{# right one visible (DRAW SEA once sky_confirmed; SEED MAP once the sea #}
{# hand is complete), same as each cascade's end. SEED MAP → the Voronoi #}
{# map (roadmap step 21) is a stub for now — it only needs to APPEAR here. #}
<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>
<button id="id_pick_sea_btn" class="btn btn-primary hex-phase-btn{% if not sky_confirmed or hand_complete %} hex-phase-btn--out{% endif %}">DRAW<br>SEA</button>
<button id="id_seed_map_btn" class="btn btn-primary hex-phase-btn{% if not hand_complete %} hex-phase-btn--out{% endif %}">SEED<br>MAP</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>

View File

@@ -222,7 +222,7 @@ Each draw persists onto the seat's Character.celtic_cross via epic:sea_save.
function _setHasDrawn(on) {
if (delBtn) { delBtn.classList.toggle('btn-disabled', !on); delBtn.innerHTML = on ? 'DEL' : '×'; }
}
function _setComplete(on) {
function _setComplete(on, live) {
_locked = on;
overlay.classList.toggle('my-sea-picker--locked', on);
overlay.querySelectorAll('.sea-deck-stack .sea-stack-ok').forEach(function (btn) {
@@ -234,6 +234,45 @@ Each draw persists onto the seat's Character.celtic_cross via epic:sea_save.
actionBtn.classList.toggle('btn-disabled', on);
}
_hideOk();
// Live completion (manual FLIP of the 6th card / AUTO DRAW finishing) → run
// the post-completion cascade. NOT on init (a reload of an already-complete
// sea lands on the SEED MAP hex server-side, no animation).
if (on && live) _startSeaCascade();
}
// ── Post-completion cascade (mirrors CAST SKY's _startSaveCascade) ──────────
// The 6-card spread completes → linger ~3s on the felt → the felt eases OUT
// (revealing the table-hex) → DRAW SEA gives way to SEED MAP + the sea glow
// fires (burger → sea_btn handoff) → +3s → SEED MAP eases IN. Same shape as
// the CAST SKY → sky-btn glow → DRAW SEA sequence (user-spec 2026-06-07).
var _SEA_CASCADE_LINGER = 3000, _SEA_FELT_FADE = 500, _SEED_DELAY = 3000;
var _cascadeRun = false;
function _startSeaCascade() {
if (_cascadeRun) return;
_cascadeRun = true;
setTimeout(_cascadeFeltOut, _SEA_CASCADE_LINGER);
}
function _cascadeFeltOut() {
page.classList.add('sea-page--cascade-out'); // CSS fades the felt out
setTimeout(function () {
document.documentElement.classList.remove('sea-open');
page.classList.remove('sea-page--cascade-out');
_restorePhaseBtns();
if (window.RoomViews && window.RoomViews.syncGear) window.RoomViews.syncGear();
// DRAW SEA is stale → ease it out; keep the sea_btn active (reopen/review)
// + start the sea glow handoff on the burger, concurrent w. the hex reveal.
var seaPhaseBtn = document.getElementById('id_pick_sea_btn');
if (seaPhaseBtn) seaPhaseBtn.classList.add('hex-phase-btn--out');
var seaBtn = document.getElementById('id_sea_btn');
if (seaBtn) seaBtn.classList.add('active');
var burgerBtn = document.getElementById('id_burger_btn');
if (burgerBtn) burgerBtn.classList.add('glow-handoff');
setTimeout(_cascadeSeedMapIn, _SEED_DELAY);
}, _SEA_FELT_FADE);
}
function _cascadeSeedMapIn() {
var seedBtn = document.getElementById('id_seed_map_btn');
if (seedBtn) seedBtn.classList.remove('hex-phase-btn--out'); // eases in
}
function _collectHandFromDom() {
@@ -288,7 +327,7 @@ Each draw persists onto the seat's Character.celtic_cross via epic:sea_save.
_filled++;
if (_filled === 1) { _lockSpread(); _setHasDrawn(true); }
_postLock(_collectHandFromDom());
if (_filled >= order.length) _setComplete(true);
if (_filled >= order.length) _setComplete(true, true); // live → cascade
}
_hideOk();
});
@@ -320,7 +359,7 @@ Each draw persists onto the seat's Character.celtic_cross via epic:sea_save.
_setHasDrawn(true);
var idx = 0;
function placeNext() {
if (idx >= autoEntries.length) { _setComplete(true); return; }
if (idx >= autoEntries.length) { _setComplete(true, true); return; } // live → cascade
var e = autoEntries[idx++];
// Gameroom Sea Select always has BOTH polarity stacks (no single-stack
// fallback — that's a my_sea monodeck concern, not here).
@@ -493,12 +532,12 @@ Each draw persists onto the seat's Character.celtic_cross via epic:sea_save.
}());
</script>
{# ── Completion glow cue (mirrors Sky Select) — the SEA glow-machine does NOT #}
{# fire while drawing. It starts only once ALL slots are drawn (the action btn #}
{# flips to data-state="complete", DRAW SEA gives way to SEED MAP), then hands #}
{# off burger → sea_btn → .sea-select so the user can review their sea. The #}
{# cue starts on the live completion TRANSITION (manual FLIP or AUTO DRAW), not #}
{# on a reload of an already-complete sea. User-spec 2026-06-07. #}
{# ── Sea glow handoff — the SEA glow-machine does NOT fire while drawing. The #}
{# post-completion CASCADE (in the picker IIFE above) STARTS it on the burger #}
{# as the felt eases out (DRAW SEA → SEED MAP), mirroring CAST SKY's burger → #}
{# sky-btn cue. This IIFE only carries the handoff: burger → sea_btn → #}
{# .sea-select → end, so the user is led to review their sea. User-spec #}
{# 2026-06-07. #}
<script>
(function () {
var burgerBtn = document.getElementById('id_burger_btn');
@@ -507,16 +546,9 @@ Each draw persists onto the seat's Character.celtic_cross via epic:sea_save.
if (!burgerBtn || !seaBtn || !modal) return;
var seaSelect = modal.querySelector('.sea-select');
var actionBtn = document.getElementById('id_sea_action_btn');
if (!seaSelect || !actionBtn) return;
if (!seaSelect) return;
var started = false, glowDone = false;
function startGlow() {
if (started || glowDone) return;
started = true;
burgerBtn.classList.add('glow-handoff');
}
var glowDone = false;
function endGlow() {
glowDone = true;
burgerBtn.classList.remove('glow-handoff');
@@ -524,7 +556,8 @@ Each draw persists onto the seat's Character.celtic_cross via epic:sea_save.
seaSelect.classList.remove('glow-handoff');
}
// Handoff on clicks: burger → sea_btn → .sea-select → end.
// Handoff on clicks: burger → sea_btn → .sea-select → end. (The cascade adds
// `glow-handoff` to the burger to begin the sequence.)
burgerBtn.addEventListener('click', function () {
if (glowDone || !burgerBtn.classList.contains('glow-handoff')) return;
burgerBtn.classList.remove('glow-handoff');
@@ -539,14 +572,5 @@ Each draw persists onto the seat's Character.celtic_cross via epic:sea_save.
if (glowDone) return;
endGlow();
});
// Start the cue on the live completion transition (data-state → "complete").
var obs = new MutationObserver(function (mutations) {
for (var i = 0; i < mutations.length; i++) {
if (mutations[i].attributeName !== 'data-state') continue;
if (actionBtn.dataset.state === 'complete') startGlow();
}
});
obs.observe(actionBtn, { attributes: true, attributeFilter: ['data-state'] });
}());
</script>