From e084bcc2d54505c1ad3917f5d9bf110ba1d3e364 Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Wed, 29 Apr 2026 02:30:59 -0400 Subject: [PATCH] =?UTF-8?q?PICK=20SEA=20slot=20interaction:=20polarity=20c?= =?UTF-8?q?ard=20bg/border,=20cross-slot=20opacity=20fix,=20two-step=20tap?= =?UTF-8?q?;=20=5FhideOk=20ReferenceError=20removed=20from=20sea.js;=20Jas?= =?UTF-8?q?mine=20spec=20updated=20for=20two-step;=20migration=200012=20PE?= =?UTF-8?q?NTACLES=20cleanup=20=E2=80=94=20TDD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code architected by Disco DeDisco Git commit message Co-Authored-By: Claude Sonnet 4.6 --- .../migrations/0012_delete_stray_pentacles.py | 27 ++++++ src/apps/epic/static/apps/epic/sea.js | 64 +++++++++++--- src/apps/epic/static/apps/epic/sig-select.js | 2 +- src/static/tests/SeaDealSpec.js | 23 +++-- src/static/tests/SigSelectSpec.js | 4 +- src/static_src/scss/_card-deck.scss | 88 ++++++++++++++----- src/static_src/tests/SeaDealSpec.js | 23 +++-- src/static_src/tests/SigSelectSpec.js | 4 +- .../gameboard/_partials/_sea_overlay.html | 3 +- 9 files changed, 186 insertions(+), 52 deletions(-) create mode 100644 src/apps/epic/migrations/0012_delete_stray_pentacles.py diff --git a/src/apps/epic/migrations/0012_delete_stray_pentacles.py b/src/apps/epic/migrations/0012_delete_stray_pentacles.py new file mode 100644 index 0000000..8e281c3 --- /dev/null +++ b/src/apps/epic/migrations/0012_delete_stray_pentacles.py @@ -0,0 +1,27 @@ +"""Delete 4 stray PENTACLES court cards from the Earthman deck. + +These survived the migration collapse; the Earthman deck uses +BRANDS/GRAILS/BLADES/CROWNS only. +""" +from django.db import migrations + + +def delete_pentacles(apps, schema_editor): + TarotCard = apps.get_model("epic", "TarotCard") + DeckVariant = apps.get_model("epic", "DeckVariant") + try: + earthman = DeckVariant.objects.get(slug="earthman") + except DeckVariant.DoesNotExist: + return + TarotCard.objects.filter(deck_variant=earthman, suit="PENTACLES").delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ("epic", "0011_nomad_schizo_icons"), + ] + + operations = [ + migrations.RunPython(delete_pentacles, reverse_code=migrations.RunPython.noop), + ] diff --git a/src/apps/epic/static/apps/epic/sea.js b/src/apps/epic/static/apps/epic/sea.js index 84ce09f..51a8dd9 100644 --- a/src/apps/epic/static/apps/epic/sea.js +++ b/src/apps/epic/static/apps/epic/sea.js @@ -63,7 +63,7 @@ var SeaDeal = (function () { stageCard.querySelector('.fan-card-reversal-name').textContent = title; } else { stageCard.querySelector('.fan-card-reversal-qualifier').textContent = qualifier; - stageCard.querySelector('.fan-card-reversal-name').textContent = ''; + stageCard.querySelector('.fan-card-reversal-name').textContent = title; } // Keywords @@ -109,7 +109,7 @@ var SeaDeal = (function () { function _openInfo() { _infoOpen = true; _renderInfo(); - if (fyiPanel) fyiPanel.style.display = ''; + if (fyiPanel) fyiPanel.style.display = 'flex'; if (spinBtn) { spinBtn.classList.add('btn-disabled'); spinBtn.textContent = '×'; } if (fyiBtn) { fyiBtn.classList.add('btn-disabled'); fyiBtn.textContent = '×'; } stage.classList.add('sea-info-open'); @@ -142,12 +142,22 @@ var SeaDeal = (function () { // ── Show / hide stage ───────────────────────────────────────────────────── - function _showStage() { + function _showStage(isLevity) { stage.style.display = ''; + stage.classList.toggle('sea-stage--levity', !!isLevity); + stage.classList.toggle('sea-stage--gravity', !isLevity); stageCard.classList.add('sea-stage-card--shown'); } function _hideStage() { + // Reveal the deposited card in its slot (opacity 0 → 0.6 transition) + if (_viewingPos) { + var cell = overlay.querySelector(_viewingPos); + if (cell) { + var slot = cell.querySelector('.sea-card-slot--filled'); + if (slot) slot.classList.add('sea-card-slot--visible'); + } + } stage.style.display = 'none'; stageCard.classList.remove('sea-stage-card--shown'); _viewingPos = null; @@ -161,7 +171,7 @@ var SeaDeal = (function () { _seaHand[posSelector] = { card: card, isLevity: isLevity }; _populate(card, isLevity); _fillSlot(posSelector, card, isLevity); - _showStage(); + _showStage(isLevity); } // ── Init ────────────────────────────────────────────────────────────────── @@ -197,6 +207,9 @@ var SeaDeal = (function () { spinBtn.addEventListener('click', function () { if (spinBtn.classList.contains('btn-disabled')) return; statBlock.classList.toggle('is-reversed'); + // Remove animation fill, force reflow so the transition has a start state + stageCard.classList.remove('sea-stage-card--shown'); + stageCard.getBoundingClientRect(); // flush layout stageCard.classList.toggle('stage-card--reversed'); }); } @@ -209,6 +222,18 @@ var SeaDeal = (function () { }); } + // Clicking the FYI panel itself dismisses it (same as sig-select caution) + if (fyiPanel) { + fyiPanel.addEventListener('click', function (e) { + if (!e.target.closest('.sea-fyi-prev') && !e.target.closest('.sea-fyi-next')) { + _closeInfo(); + } + }); + } + + // Clicking the FYI panel closes it (same pattern as sig-select) + if (fyiPanel) fyiPanel.addEventListener('click', _closeInfo); + // FYI nav if (fyiPrev) { fyiPrev.addEventListener('click', function () { @@ -228,16 +253,34 @@ var SeaDeal = (function () { bdrop.addEventListener('click', _hideStage); } - // Click on deposited slot → re-open + // Overlay click — handle slot focus/open two-step tap pattern + // (deck-stack _hideOk is registered separately from the inline template script) overlay.addEventListener('click', function (e) { var slot = e.target.closest('.sea-card-slot--filled'); - if (!slot) return; + if (!slot) { + // Clicked outside any filled slot — unfocus all + overlay.querySelectorAll('.sea-card-slot--focused').forEach(function (s) { + s.classList.remove('sea-card-slot--focused'); + }); + return; + } + var pos = slot.dataset.posKey; if (!pos || !_seaHand[pos]) return; - var h = _seaHand[pos]; - _viewingPos = pos; - _populate(h.card, h.isLevity); - _showStage(); + + if (slot.classList.contains('sea-card-slot--focused')) { + // Second tap/click — open modal + var h = _seaHand[pos]; + _viewingPos = pos; + _populate(h.card, h.isLevity); + _showStage(h.isLevity); + } else { + // First tap/click — focus (persist opacity 1) + overlay.querySelectorAll('.sea-card-slot--focused').forEach(function (s) { + s.classList.remove('sea-card-slot--focused'); + }); + slot.classList.add('sea-card-slot--focused'); + } }); } @@ -256,6 +299,7 @@ var SeaDeal = (function () { return { openStage: openStage, resetHand: resetHand, + reinit: init, // call after overlay is injected into the DOM _testInit: function () { overlay = null; stage = null; stageCard = null; statBlock = null; spinBtn = null; fyiBtn = null; bdrop = null; diff --git a/src/apps/epic/static/apps/epic/sig-select.js b/src/apps/epic/static/apps/epic/sig-select.js index 29b8173..5408dcb 100644 --- a/src/apps/epic/static/apps/epic/sig-select.js +++ b/src/apps/epic/static/apps/epic/sig-select.js @@ -157,7 +157,7 @@ var SigSelect = (function () { stageCard.querySelector('.fan-card-reversal-name').textContent = title; } else { stageCard.querySelector('.fan-card-reversal-qualifier').textContent = qualifier; - stageCard.querySelector('.fan-card-reversal-name').textContent = ''; + stageCard.querySelector('.fan-card-reversal-name').textContent = title; } // Populate stat block keyword faces and reset to upright diff --git a/src/static/tests/SeaDealSpec.js b/src/static/tests/SeaDealSpec.js index 9d388b3..9a7ad19 100644 --- a/src/static/tests/SeaDealSpec.js +++ b/src/static/tests/SeaDealSpec.js @@ -265,26 +265,33 @@ describe("SeaDeal", () => { }); }); - // ── Re-open from deposited slot ───────────────────────────────────────── // + // ── Re-open from deposited slot (two-step tap) ────────────────────────── // describe("clicking a deposited slot", () => { + let slot; beforeEach(() => { makeFixture(); SeaDeal.openStage(CARD, ".sea-pos-cover", true); - // Dismiss first + // Dismiss first — adds --visible to slot testDiv.querySelector(".sea-stage-backdrop").dispatchEvent(new MouseEvent("click", { bubbles: true })); + slot = testDiv.querySelector(".sea-pos-cover .sea-card-slot"); }); - it("re-opens the sea stage", () => { - expect(stage.style.display).toBe("none"); - const slot = testDiv.querySelector(".sea-pos-cover .sea-card-slot"); + it("first click focuses the slot (does not yet open stage)", () => { slot.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(slot.classList.contains("sea-card-slot--focused")).toBeTrue(); + expect(stage.style.display).toBe("none"); + }); + + it("re-opens the sea stage on second click", () => { + slot.dispatchEvent(new MouseEvent("click", { bubbles: true })); // focus + slot.dispatchEvent(new MouseEvent("click", { bubbles: true })); // open expect(stage.style.display).not.toBe("none"); }); - it("re-populates stage with the same card rank", () => { - const slot = testDiv.querySelector(".sea-pos-cover .sea-card-slot"); - slot.dispatchEvent(new MouseEvent("click", { bubbles: true })); + it("re-populates stage with the same card rank on second click", () => { + slot.dispatchEvent(new MouseEvent("click", { bubbles: true })); // focus + slot.dispatchEvent(new MouseEvent("click", { bubbles: true })); // open const rank = stageCard.querySelector(".fan-card-corner--tl .fan-corner-rank"); expect(rank.textContent).toBe("Q"); }); diff --git a/src/static/tests/SigSelectSpec.js b/src/static/tests/SigSelectSpec.js index a07bd8a..39b6228 100644 --- a/src/static/tests/SigSelectSpec.js +++ b/src/static/tests/SigSelectSpec.js @@ -558,12 +558,12 @@ describe("SigSelect", () => { expect(stageCard.querySelector(".fan-card-reversal-name").textContent).toBe("Elevated"); }); - it("non-major without data-reversal: reversal-name empty, qualifier mirrors polarity", () => { + it("non-major without data-reversal: qualifier mirrors polarity, name repeats card title", () => { makeFixture({ polarity: "levity", userRole: "PC" }); // fixture default: Minor Arcana, no reversal word hover(); expect(stageCard.querySelector(".fan-card-reversal-qualifier").textContent).toBe("Elevated"); - expect(stageCard.querySelector(".fan-card-reversal-name").textContent).toBe(""); + expect(stageCard.querySelector(".fan-card-reversal-name").textContent).toBe(card.dataset.nameTitle); }); }); diff --git a/src/static_src/scss/_card-deck.scss b/src/static_src/scss/_card-deck.scss index 4c33389..5adc783 100644 --- a/src/static_src/scss/_card-deck.scss +++ b/src/static_src/scss/_card-deck.scss @@ -813,36 +813,50 @@ $sea-card-h: 6.5rem; } .sea-card-slot--crossing { - width: $sea-card-h; // rotated — swap w/h - height: $sea-card-w; + // Keep portrait dimensions; rotate(90deg) in .sea-pos-cross supplies the landscape visual. + // Swapping w/h here caused flex-shrink to squish the longer edge to fit the 4rem container. + width: $sea-card-w; + height: $sea-card-h; } .sea-card-slot--filled { - border-style: solid; + // Start invisible; transition to .sea-card-slot--visible on deposit + opacity: 0; + transition: opacity 1s ease; + border: 0.15rem solid transparent; + border-radius: 0.3rem; flex-direction: column; - gap: 0.2rem; + gap: 0.15rem; - .fan-corner-rank { - font-size: 1.15rem; - font-weight: 700; - line-height: 1; - } + .fan-corner-rank { font-size: 1.15rem; font-weight: 700; line-height: 1; } i { font-size: 0.9rem; } } -// Levity drawn card — standard polarity (priUser bg, secUser text) +// Levity drawn card — secUser bg, priUser text + border (matches stage card polarity) .sea-card-slot--filled.sea-card-slot--levity { - background: rgba(var(--priUser), 1); + color: rgba(var(--priUser), 0.9); + background: rgba(var(--secUser), 0.85); + border-color: rgba(var(--priUser), 1); +} +// Gravity drawn card — priUser bg, secUser text + border +.sea-card-slot--filled.sea-card-slot--gravity { + color: rgba(var(--secUser), 0.9); + background: rgba(var(--priUser), 0.85); border-color: rgba(var(--secUser), 0.6); - color: rgba(var(--secUser), 0.85); } -// Gravity drawn card — inverted polarity (secUser bg, priUser text) -.sea-card-slot--filled.sea-card-slot--gravity { - background: rgba(var(--secUser), 0.9); - border-color: rgba(var(--priUser), 0.4); - color: rgba(var(--priUser), 0.9); -} +// Deposited — fully opaque by default; Cover/Cross are semi-transparent +.sea-card-slot--visible { opacity: 1; transition: opacity 1s ease; } + +.sea-pos-cover .sea-card-slot--visible { opacity: 0.3; } +.sea-pos-cross .sea-card-slot--visible { opacity: 0.15; } + +// Hover: reveal fully (snappy) +.sea-pos-cover .sea-card-slot--visible:hover, +.sea-pos-cross .sea-card-slot--visible:hover { opacity: 1; transition: opacity 0.15s ease; } + +// Focused (first tap): persist at opacity 1 until clicked outside +.sea-card-slot--focused { opacity: 1 !important; transition: opacity 0.15s ease; } // Cover + Cross — absolutely overlaid on the Sig card in .sea-pos-center .sea-pos-center { position: relative; } @@ -855,10 +869,16 @@ $sea-card-h: 6.5rem; align-items: center; justify-content: center; pointer-events: none; - + .sea-card-slot { pointer-events: auto; } } +.sea-pos-cover { z-index: 3; } // above sig (z-index: 2) +.sea-pos-cross { z-index: 4; } // above cover +// Empty Cover/Cross slots are invisible — they reveal only once a card is deposited +.sea-pos-cover .sea-card-slot--empty, +.sea-pos-cross .sea-card-slot--empty { opacity: 0; pointer-events: none; } + .sea-pos-cross .sea-card-slot { transform: rotate(90deg); } // Sig card in center slot — compact rank + icon display; tilted CCW so Cover slot peeks through @@ -1076,6 +1096,8 @@ $_glow-gravity: 0 0 0.8rem 0.15rem rgba(var(--quaUser), 0.6); position: absolute; inset: 0; cursor: pointer; + background: rgba(0, 0, 0, 0.3); + backdrop-filter: blur(4px); } .sea-stage-content { @@ -1103,6 +1125,7 @@ $_glow-gravity: 0 0 0.8rem 0.15rem rgba(var(--quaUser), 0.6); padding: 0.25rem; overflow: hidden; transform-style: preserve-3d; + transition: transform 0.4s ease; // Flip-in animation when stage opens &--shown { @@ -1159,6 +1182,32 @@ $_glow-gravity: 0 0 0.8rem 0.15rem rgba(var(--quaUser), 0.6); 100% { transform: perspective(600px) rotateY(0deg) scale(1); opacity: 1; } } +// Sea stage card title — polarity-specific colour + shared glow +$_sea-title-shadow: 1px 1px 0 rgba(0,0,0,1), 0 0 0.25rem rgba(var(--ninUser), 0.25); +$_sea-title-els: '.fan-card-name, .sig-qualifier-above, .sig-qualifier-below, .fan-card-reversal-name, .fan-card-reversal-qualifier'; + +.sea-stage--levity .sea-stage-card { + background: rgba(var(--secUser), 1); + border-color: rgba(var(--priUser), 1); + color: rgba(var(--priUser), 1); + .fan-card-arcana, + .fan-card-corner { + color: rgba(var(--priUser), 1); + } + .fan-card-name, .sig-qualifier-above, .sig-qualifier-below, + .fan-card-reversal-name, .fan-card-reversal-qualifier { + color: rgba(var(--quiUser), 1); + text-shadow: $_sea-title-shadow; + } +} +.sea-stage--gravity .sea-stage-card { + .fan-card-name, .sig-qualifier-above, .sig-qualifier-below, + .fan-card-reversal-name, .fan-card-reversal-qualifier { + color: rgba(var(--terUser), 1); + text-shadow: $_sea-title-shadow; + } +} + // Sea stat block — reuses sig-select stat-block sizing, scoped to sea-stage .sea-stage-content .sea-stat-block { flex: 0 0 auto; @@ -1195,7 +1244,6 @@ $_glow-gravity: 0 0 0.8rem 0.15rem rgba(var(--quaUser), 0.6); gap: 0.4rem; overflow-y: auto; display: none; - &[style*="display"]:not([style*="none"]) { display: flex; } } .sea-fyi-prev, diff --git a/src/static_src/tests/SeaDealSpec.js b/src/static_src/tests/SeaDealSpec.js index 9d388b3..9a7ad19 100644 --- a/src/static_src/tests/SeaDealSpec.js +++ b/src/static_src/tests/SeaDealSpec.js @@ -265,26 +265,33 @@ describe("SeaDeal", () => { }); }); - // ── Re-open from deposited slot ───────────────────────────────────────── // + // ── Re-open from deposited slot (two-step tap) ────────────────────────── // describe("clicking a deposited slot", () => { + let slot; beforeEach(() => { makeFixture(); SeaDeal.openStage(CARD, ".sea-pos-cover", true); - // Dismiss first + // Dismiss first — adds --visible to slot testDiv.querySelector(".sea-stage-backdrop").dispatchEvent(new MouseEvent("click", { bubbles: true })); + slot = testDiv.querySelector(".sea-pos-cover .sea-card-slot"); }); - it("re-opens the sea stage", () => { - expect(stage.style.display).toBe("none"); - const slot = testDiv.querySelector(".sea-pos-cover .sea-card-slot"); + it("first click focuses the slot (does not yet open stage)", () => { slot.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(slot.classList.contains("sea-card-slot--focused")).toBeTrue(); + expect(stage.style.display).toBe("none"); + }); + + it("re-opens the sea stage on second click", () => { + slot.dispatchEvent(new MouseEvent("click", { bubbles: true })); // focus + slot.dispatchEvent(new MouseEvent("click", { bubbles: true })); // open expect(stage.style.display).not.toBe("none"); }); - it("re-populates stage with the same card rank", () => { - const slot = testDiv.querySelector(".sea-pos-cover .sea-card-slot"); - slot.dispatchEvent(new MouseEvent("click", { bubbles: true })); + it("re-populates stage with the same card rank on second click", () => { + slot.dispatchEvent(new MouseEvent("click", { bubbles: true })); // focus + slot.dispatchEvent(new MouseEvent("click", { bubbles: true })); // open const rank = stageCard.querySelector(".fan-card-corner--tl .fan-corner-rank"); expect(rank.textContent).toBe("Q"); }); diff --git a/src/static_src/tests/SigSelectSpec.js b/src/static_src/tests/SigSelectSpec.js index a07bd8a..39b6228 100644 --- a/src/static_src/tests/SigSelectSpec.js +++ b/src/static_src/tests/SigSelectSpec.js @@ -558,12 +558,12 @@ describe("SigSelect", () => { expect(stageCard.querySelector(".fan-card-reversal-name").textContent).toBe("Elevated"); }); - it("non-major without data-reversal: reversal-name empty, qualifier mirrors polarity", () => { + it("non-major without data-reversal: qualifier mirrors polarity, name repeats card title", () => { makeFixture({ polarity: "levity", userRole: "PC" }); // fixture default: Minor Arcana, no reversal word hover(); expect(stageCard.querySelector(".fan-card-reversal-qualifier").textContent).toBe("Elevated"); - expect(stageCard.querySelector(".fan-card-reversal-name").textContent).toBe(""); + expect(stageCard.querySelector(".fan-card-reversal-name").textContent).toBe(card.dataset.nameTitle); }); }); diff --git a/src/templates/apps/gameboard/_partials/_sea_overlay.html b/src/templates/apps/gameboard/_partials/_sea_overlay.html index c42d297..0a62598 100644 --- a/src/templates/apps/gameboard/_partials/_sea_overlay.html +++ b/src/templates/apps/gameboard/_partials/_sea_overlay.html @@ -231,7 +231,7 @@ _filled = 0; _hideOk(); overlay.querySelectorAll('.sea-card-slot').forEach(s => { - s.classList.remove('sea-card-slot--filled', 'sea-card-slot--levity', 'sea-card-slot--gravity'); + s.classList.remove('sea-card-slot--filled', 'sea-card-slot--visible', 'sea-card-slot--focused', 'sea-card-slot--levity', 'sea-card-slot--gravity'); s.classList.add('sea-card-slot--empty'); s.innerHTML = ''; delete s.dataset.cardId; @@ -277,5 +277,6 @@ if (delBtn) delBtn.addEventListener('click', _reset); _fetchDeck(); + if (window.SeaDeal) SeaDeal.reinit(); })();