From 08aa4dc8192087b82c6f907594dfb29130fed69b Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Wed, 29 Apr 2026 01:12:06 -0400 Subject: [PATCH] =?UTF-8?q?PICK=20SEA=20Sprint=20C:=20sea=20stage=20card?= =?UTF-8?q?=20viewer=20=E2=80=94=20FLIP=20in,=20SPIN/FYI,=20deposit/re-exp?= =?UTF-8?q?and=20=E2=80=94=20TDD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sea.js: SeaDeal module — openStage() shows big card viewer w. flip-in animation; SPIN toggles stage-card--reversed; FYI shows energies/operations (Energy/Operation titles, PRV/NXT nav); backdrop click deposits card to slot; click deposited slot re-opens stage; resetHand() clears hand on DEL - sea_deck view: adds name_group/name_title/reversal/keywords_upright/keywords_reversed/ energies/operations to each card dict (full sig-select stage data set) - _sea_overlay.html: data-sea-user-polarity attr; sea stage HTML (sig-stage-card shell + fan-card-face-upright/reversal structure + sea-stat-block w. SPIN/FYI/PRV/NXT); FLIP click calls SeaDeal.openStage(); _fillPos removed (sea.js handles slot fill); _reset calls SeaDeal.resetHand() - room.html: sea.js included alongside sig-select.js - _card-deck.scss: sea-stage layout (fixed overlay, backdrop, content row); sea-stage-card w. @keyframes sea-flip-in (3D rotateY perspective); sea-stat-block scoped styles incl. SPIN/FYI btns, stat faces, sig-info FYI panel - SeaDealSpec.js: 20 Jasmine specs — openStage, SPIN, FYI, backdrop dismiss, slot re-expand Code architected by Disco DeDisco Git commit message Co-Authored-By: Claude Sonnet 4.6 --- src/apps/epic/static/apps/epic/sea.js | 270 ++++++++++++++++ src/apps/epic/views.py | 7 + src/static/tests/SeaDealSpec.js | 292 ++++++++++++++++++ src/static/tests/SpecRunner.html | 2 + src/static_src/scss/_button-pad.scss | 72 ++--- src/static_src/scss/_card-deck.scss | 178 ++++++++++- src/static_src/scss/_room.scss | 3 +- src/static_src/tests/SeaDealSpec.js | 292 ++++++++++++++++++ src/static_src/tests/SpecRunner.html | 2 + .../gameboard/_partials/_sea_overlay.html | 85 +++-- src/templates/apps/gameboard/room.html | 1 + 11 files changed, 1140 insertions(+), 64 deletions(-) create mode 100644 src/apps/epic/static/apps/epic/sea.js create mode 100644 src/static/tests/SeaDealSpec.js create mode 100644 src/static_src/tests/SeaDealSpec.js diff --git a/src/apps/epic/static/apps/epic/sea.js b/src/apps/epic/static/apps/epic/sea.js new file mode 100644 index 0000000..84ce09f --- /dev/null +++ b/src/apps/epic/static/apps/epic/sea.js @@ -0,0 +1,270 @@ +var SeaDeal = (function () { + 'use strict'; + + var overlay, stage, stageCard, statBlock; + var spinBtn, fyiBtn, bdrop; + var fyiPanel, fyiTitle, fyiType, fyiEffect, fyiIndex, fyiPrev, fyiNext; + + var _userPolarity = 'levity'; + var _seaHand = {}; // posSelector → {card, isLevity} + var _viewingPos = null; + var _infoData = []; + var _infoIdx = 0; + var _infoOpen = false; + var _spinOrigLabel, _fyiOrigLabel; + + // ── Keyword list ────────────────────────────────────────────────────────── + + function _populateList(listEl, items) { + if (!listEl) return; + listEl.innerHTML = (items || []).map(function (k) { + return '
  • ' + k + '
  • '; + }).join(''); + } + + // ── Stage card population ───────────────────────────────────────────────── + + function _populate(card, isLevity) { + var qualifier = isLevity + ? (card.levity_qualifier || '') + : (card.gravity_qualifier || ''); + var isMajor = card.arcana === 'MAJOR'; + var title = card.name_title || card.name || ''; + + // Corners + stageCard.querySelectorAll('.fan-corner-rank').forEach(function (el) { + el.textContent = card.corner_rank || ''; + }); + stageCard.querySelectorAll('.stage-suit-icon').forEach(function (el) { + if (card.suit_icon) { + el.className = 'fa-solid ' + card.suit_icon + ' stage-suit-icon'; + el.style.display = ''; + } else { + el.style.display = 'none'; + } + }); + + // Upright face + var nameGroupEl = stageCard.querySelector('.fan-card-name-group'); + if (nameGroupEl) nameGroupEl.textContent = card.name_group || ''; + var arcanaEl = stageCard.querySelector('.fan-card-arcana'); + if (arcanaEl) arcanaEl.textContent = isMajor ? 'Major Arcana' : 'Middle Arcana'; + stageCard.querySelector('.fan-card-name').textContent = isMajor ? title + ',' : title; + stageCard.querySelector('.sig-qualifier-above').textContent = isMajor ? '' : qualifier; + stageCard.querySelector('.sig-qualifier-below').textContent = isMajor ? qualifier : ''; + + // Reversal face (same slot-swap logic as sig-select) + var reversal = card.reversal || ''; + if (isMajor) { + stageCard.querySelector('.fan-card-reversal-qualifier').textContent = title + ','; + stageCard.querySelector('.fan-card-reversal-name').textContent = qualifier; + } else if (reversal) { + stageCard.querySelector('.fan-card-reversal-qualifier').textContent = reversal; + stageCard.querySelector('.fan-card-reversal-name').textContent = title; + } else { + stageCard.querySelector('.fan-card-reversal-qualifier').textContent = qualifier; + stageCard.querySelector('.fan-card-reversal-name').textContent = ''; + } + + // Keywords + _populateList(overlay.querySelector('#id_sea_stat_upright'), card.keywords_upright); + _populateList(overlay.querySelector('#id_sea_stat_reversed'), card.keywords_reversed); + + // FYI data (energies + operations) + _infoData = (card.energies || []).map(function (e) { + return { type: e.type, effect: e.effect, category: 'energies' }; + }).concat((card.operations || []).map(function (o) { + return { type: o.type, effect: o.effect, category: 'operations' }; + })); + _infoIdx = 0; + + // Reset SPIN + stageCard.classList.remove('stage-card--reversed'); + statBlock.classList.remove('is-reversed'); + _closeInfo(); + } + + // ── FYI info panel ──────────────────────────────────────────────────────── + + function _renderInfo() { + if (!fyiPanel) return; + if (_infoData.length === 0) { + fyiTitle.textContent = 'Energy'; + fyiTitle.className = 'sig-info-title sig-info-title--energies'; + if (fyiType) fyiType.textContent = ''; + if (fyiEffect) fyiEffect.innerHTML = 'No interactions defined.'; + fyiIndex.textContent = ''; + return; + } + var entry = _infoData[_infoIdx]; + var isEnergies = entry.category === 'energies'; + fyiTitle.textContent = isEnergies ? 'Energy' : 'Operation'; + fyiTitle.className = 'sig-info-title sig-info-title--' + entry.category; + if (fyiType) fyiType.textContent = entry.type || ''; + if (fyiEffect) fyiEffect.innerHTML = entry.effect || ''; + fyiIndex.textContent = _infoData.length > 1 + ? (_infoIdx + 1) + ' / ' + _infoData.length : ''; + } + + function _openInfo() { + _infoOpen = true; + _renderInfo(); + if (fyiPanel) fyiPanel.style.display = ''; + if (spinBtn) { spinBtn.classList.add('btn-disabled'); spinBtn.textContent = '×'; } + if (fyiBtn) { fyiBtn.classList.add('btn-disabled'); fyiBtn.textContent = '×'; } + stage.classList.add('sea-info-open'); + } + + function _closeInfo() { + _infoOpen = false; + if (fyiPanel) fyiPanel.style.display = 'none'; + if (spinBtn) { spinBtn.classList.remove('btn-disabled'); spinBtn.textContent = _spinOrigLabel || 'SPIN'; } + if (fyiBtn) { fyiBtn.classList.remove('btn-disabled'); fyiBtn.textContent = _fyiOrigLabel || 'FYI'; } + if (stage) stage.classList.remove('sea-info-open'); + } + + // ── Slot fill ───────────────────────────────────────────────────────────── + + function _fillSlot(posSelector, card, isLevity) { + var cell = overlay.querySelector(posSelector); + if (!cell) return; + var slot = cell.querySelector('.sea-card-slot'); + if (!slot) return; + slot.classList.remove('sea-card-slot--empty'); + slot.classList.add('sea-card-slot--filled'); + slot.classList.add(isLevity ? 'sea-card-slot--levity' : 'sea-card-slot--gravity'); + slot.dataset.cardId = String(card.id); + slot.dataset.posKey = posSelector; + slot.innerHTML = + '' + card.corner_rank + '' + + (card.suit_icon ? '' : ''); + } + + // ── Show / hide stage ───────────────────────────────────────────────────── + + function _showStage() { + stage.style.display = ''; + stageCard.classList.add('sea-stage-card--shown'); + } + + function _hideStage() { + stage.style.display = 'none'; + stageCard.classList.remove('sea-stage-card--shown'); + _viewingPos = null; + _closeInfo(); + } + + // ── Public API ───────────────────────────────────────────────────────────── + + function openStage(card, posSelector, isLevity) { + _viewingPos = posSelector; + _seaHand[posSelector] = { card: card, isLevity: isLevity }; + _populate(card, isLevity); + _fillSlot(posSelector, card, isLevity); + _showStage(); + } + + // ── Init ────────────────────────────────────────────────────────────────── + + function init() { + overlay = document.getElementById('id_sea_overlay'); + if (!overlay) return; + + stage = overlay.querySelector('#id_sea_stage'); + stageCard = stage && stage.querySelector('.sea-stage-card'); + statBlock = stage && stage.querySelector('.sea-stat-block'); + if (!stage || !stageCard || !statBlock) return; + + _userPolarity = overlay.dataset.seaUserPolarity || 'levity'; + + spinBtn = statBlock.querySelector('.sea-spin-btn'); + fyiBtn = statBlock.querySelector('.sea-fyi-btn'); + bdrop = stage.querySelector('.sea-stage-backdrop'); + + fyiPanel = overlay.querySelector('#id_sea_fyi_panel'); + fyiTitle = fyiPanel && fyiPanel.querySelector('.sig-info-title'); + fyiType = fyiPanel && fyiPanel.querySelector('.sig-info-type'); + fyiEffect = fyiPanel && fyiPanel.querySelector('.sig-info-effect'); + fyiIndex = fyiPanel && fyiPanel.querySelector('.sig-info-index'); + fyiPrev = statBlock.querySelector('.sea-fyi-prev'); + fyiNext = statBlock.querySelector('.sea-fyi-next'); + + _spinOrigLabel = spinBtn ? spinBtn.textContent : 'SPIN'; + _fyiOrigLabel = fyiBtn ? fyiBtn.textContent : 'FYI'; + + // SPIN + if (spinBtn) { + spinBtn.addEventListener('click', function () { + if (spinBtn.classList.contains('btn-disabled')) return; + statBlock.classList.toggle('is-reversed'); + stageCard.classList.toggle('stage-card--reversed'); + }); + } + + // FYI + if (fyiBtn) { + fyiBtn.addEventListener('click', function () { + if (fyiBtn.classList.contains('btn-disabled')) return; + _infoOpen ? _closeInfo() : _openInfo(); + }); + } + + // FYI nav + if (fyiPrev) { + fyiPrev.addEventListener('click', function () { + _infoIdx = (_infoIdx - 1 + _infoData.length) % _infoData.length; + _renderInfo(); + }); + } + if (fyiNext) { + fyiNext.addEventListener('click', function () { + _infoIdx = (_infoIdx + 1) % _infoData.length; + _renderInfo(); + }); + } + + // Backdrop dismiss + if (bdrop) { + bdrop.addEventListener('click', _hideStage); + } + + // Click on deposited slot → re-open + overlay.addEventListener('click', function (e) { + var slot = e.target.closest('.sea-card-slot--filled'); + if (!slot) return; + var pos = slot.dataset.posKey; + if (!pos || !_seaHand[pos]) return; + var h = _seaHand[pos]; + _viewingPos = pos; + _populate(h.card, h.isLevity); + _showStage(); + }); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } + + function resetHand() { + _seaHand = {}; + _viewingPos = null; + _hideStage(); + } + + return { + openStage: openStage, + resetHand: resetHand, + _testInit: function () { + overlay = null; stage = null; stageCard = null; statBlock = null; + spinBtn = null; fyiBtn = null; bdrop = null; + fyiPanel = null; fyiTitle = null; fyiType = null; + fyiEffect = null; fyiIndex = null; fyiPrev = null; fyiNext = null; + _userPolarity = 'levity'; + _seaHand = {}; _viewingPos = null; + _infoData = []; _infoIdx = 0; _infoOpen = false; + init(); + }, + }; +}()); diff --git a/src/apps/epic/views.py b/src/apps/epic/views.py index 2b7e8bf..741ae26 100644 --- a/src/apps/epic/views.py +++ b/src/apps/epic/views.py @@ -1141,8 +1141,15 @@ def sea_deck(request, room_id): 'number': c.number, 'corner_rank': c.corner_rank, 'suit_icon': c.suit_icon, + 'name_group': c.name_group, + 'name_title': c.name_title, 'levity_qualifier': c.levity_qualifier, 'gravity_qualifier': c.gravity_qualifier, + 'reversal': c.reversal, + 'keywords_upright': c.keywords_upright, + 'keywords_reversed': c.keywords_reversed, + 'energies': c.energies, + 'operations': c.operations, } available = list( diff --git a/src/static/tests/SeaDealSpec.js b/src/static/tests/SeaDealSpec.js new file mode 100644 index 0000000..9d388b3 --- /dev/null +++ b/src/static/tests/SeaDealSpec.js @@ -0,0 +1,292 @@ +describe("SeaDeal", () => { + let testDiv, overlay, stage, stageCard, statBlock; + + const CARD = { + id: 99, name: "Queen of Crowns", + arcana: "MIDDLE", suit: "CROWNS", number: 13, + corner_rank: "Q", suit_icon: "fa-crown", + name_group: "", name_title: "Queen of Crowns", + levity_qualifier: "Elevated", gravity_qualifier: "Graven", + reversal: "Vacant", + keywords_upright: ["nurturing", "practical", "abundance"], + keywords_reversed: ["financial dependence", "smothering"], + energies: [{ type: "LIBIDO", effect: "Energy entry." }], + operations: [{ type: "COVER", effect: "Operation entry." }], + }; + + function makeFixture({ polarity = "levity" } = {}) { + testDiv = document.createElement("div"); + testDiv.innerHTML = ` +
    + + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + + + +
    + `; + document.body.appendChild(testDiv); + overlay = testDiv.querySelector("#id_sea_overlay"); + stage = testDiv.querySelector("#id_sea_stage"); + stageCard = testDiv.querySelector(".sea-stage-card"); + statBlock = testDiv.querySelector(".sea-stat-block"); + SeaDeal._testInit(); + } + + afterEach(() => { + if (testDiv) testDiv.remove(); + // Purge any stale overlays left by tests that called makeFixture() twice + document.querySelectorAll('#id_sea_overlay').forEach(el => el.remove()); + }); + + // ── openStage ────────────────────────────────────────────────────────── // + + describe("openStage()", () => { + beforeEach(() => makeFixture()); + + it("makes #id_sea_stage visible", () => { + SeaDeal.openStage(CARD, ".sea-pos-cover", true); + expect(stage.style.display).not.toBe("none"); + }); + + it("populates corner rank on stage card", () => { + SeaDeal.openStage(CARD, ".sea-pos-cover", true); + const rank = stageCard.querySelector(".fan-card-corner--tl .fan-corner-rank"); + expect(rank.textContent).toBe("Q"); + }); + + it("shows suit icon on stage card", () => { + SeaDeal.openStage(CARD, ".sea-pos-cover", true); + const icon = stageCard.querySelector(".fan-card-corner--tl .stage-suit-icon"); + expect(icon.style.display).not.toBe("none"); + expect(icon.classList.contains("fa-crown")).toBe(true); + }); + + it("populates upright card name for non-major", () => { + SeaDeal.openStage(CARD, ".sea-pos-cover", true); + expect(stageCard.querySelector(".fan-card-name").textContent).toBe("Queen of Crowns"); + }); + + it("puts levity qualifier in qualifier-above for non-major", () => { + SeaDeal.openStage(CARD, ".sea-pos-cover", true); + expect(stageCard.querySelector(".sig-qualifier-above").textContent).toBe("Elevated"); + }); + + it("puts gravity qualifier in qualifier-above for non-major gravity", () => { + // Re-init with gravity polarity (no double-append; beforeEach already ran) + overlay.dataset.seaUserPolarity = "gravity"; + SeaDeal._testInit(); + stageCard = testDiv.querySelector(".sea-stage-card"); + SeaDeal.openStage(CARD, ".sea-pos-cover", false); + expect(stageCard.querySelector(".sig-qualifier-above").textContent).toBe("Graven"); + }); + + it("puts reversal word in reversal-qualifier slot", () => { + SeaDeal.openStage(CARD, ".sea-pos-cover", true); + expect(stageCard.querySelector(".fan-card-reversal-qualifier").textContent).toBe("Vacant"); + }); + + it("populates upright keywords in stat block", () => { + SeaDeal.openStage(CARD, ".sea-pos-cover", true); + const items = testDiv.querySelectorAll("#id_sea_stat_upright li"); + expect(items.length).toBe(3); + expect(items[0].textContent).toBe("nurturing"); + }); + + it("populates reversed keywords in stat block", () => { + SeaDeal.openStage(CARD, ".sea-pos-cover", true); + const items = testDiv.querySelectorAll("#id_sea_stat_reversed li"); + expect(items.length).toBe(2); + }); + + it("fills the target position slot", () => { + SeaDeal.openStage(CARD, ".sea-pos-cover", true); + const slot = testDiv.querySelector(".sea-pos-cover .sea-card-slot"); + expect(slot.classList.contains("sea-card-slot--filled")).toBe(true); + }); + + it("resets SPIN to upright when opening a new card", () => { + SeaDeal.openStage(CARD, ".sea-pos-cover", true); + stageCard.classList.add("stage-card--reversed"); + statBlock.classList.add("is-reversed"); + SeaDeal.openStage(CARD, ".sea-pos-cross", true); + expect(stageCard.classList.contains("stage-card--reversed")).toBe(false); + expect(statBlock.classList.contains("is-reversed")).toBe(false); + }); + }); + + // ── SPIN in sea stage ─────────────────────────────────────────────────── // + + describe("SPIN btn in sea stage", () => { + beforeEach(() => { + makeFixture(); + SeaDeal.openStage(CARD, ".sea-pos-cover", true); + }); + + it("toggles is-reversed on stat block", () => { + testDiv.querySelector(".sea-spin-btn").dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(statBlock.classList.contains("is-reversed")).toBe(true); + }); + + it("toggles stage-card--reversed on stage card", () => { + testDiv.querySelector(".sea-spin-btn").dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(stageCard.classList.contains("stage-card--reversed")).toBe(true); + }); + + it("second SPIN click restores upright", () => { + const btn = testDiv.querySelector(".sea-spin-btn"); + btn.dispatchEvent(new MouseEvent("click", { bubbles: true })); + btn.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(statBlock.classList.contains("is-reversed")).toBe(false); + }); + }); + + // ── FYI in sea stage ──────────────────────────────────────────────────── // + + describe("FYI btn in sea stage", () => { + beforeEach(() => { + makeFixture(); + SeaDeal.openStage(CARD, ".sea-pos-cover", true); + }); + + it("FYI click shows the info panel", () => { + testDiv.querySelector(".sea-fyi-btn").dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(testDiv.querySelector("#id_sea_fyi_panel").style.display).not.toBe("none"); + }); + + it("shows first energy entry title as 'Energy'", () => { + testDiv.querySelector(".sea-fyi-btn").dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(testDiv.querySelector(".sig-info-title").textContent).toBe("Energy"); + }); + + it("shows first entry type", () => { + testDiv.querySelector(".sea-fyi-btn").dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(testDiv.querySelector(".sig-info-type").textContent).toBe("LIBIDO"); + }); + + it("NXT advances to operation entry", () => { + testDiv.querySelector(".sea-fyi-btn").dispatchEvent(new MouseEvent("click", { bubbles: true })); + testDiv.querySelector(".sea-fyi-next").dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(testDiv.querySelector(".sig-info-title").textContent).toBe("Operation"); + expect(testDiv.querySelector(".sig-info-type").textContent).toBe("COVER"); + }); + }); + + // ── Backdrop dismiss ──────────────────────────────────────────────────── // + + describe("stage backdrop click", () => { + beforeEach(() => { + makeFixture(); + SeaDeal.openStage(CARD, ".sea-pos-cover", true); + }); + + it("hides the sea stage", () => { + testDiv.querySelector(".sea-stage-backdrop").dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(stage.style.display).toBe("none"); + }); + + it("leaves the slot filled after dismiss", () => { + testDiv.querySelector(".sea-stage-backdrop").dispatchEvent(new MouseEvent("click", { bubbles: true })); + const slot = testDiv.querySelector(".sea-pos-cover .sea-card-slot"); + expect(slot.classList.contains("sea-card-slot--filled")).toBe(true); + }); + }); + + // ── Re-open from deposited slot ───────────────────────────────────────── // + + describe("clicking a deposited slot", () => { + beforeEach(() => { + makeFixture(); + SeaDeal.openStage(CARD, ".sea-pos-cover", true); + // Dismiss first + testDiv.querySelector(".sea-stage-backdrop").dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + it("re-opens the sea stage", () => { + expect(stage.style.display).toBe("none"); + const slot = testDiv.querySelector(".sea-pos-cover .sea-card-slot"); + slot.dispatchEvent(new MouseEvent("click", { bubbles: true })); + 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 })); + const rank = stageCard.querySelector(".fan-card-corner--tl .fan-corner-rank"); + expect(rank.textContent).toBe("Q"); + }); + }); +}); diff --git a/src/static/tests/SpecRunner.html b/src/static/tests/SpecRunner.html index 63366ce..06b3ab2 100644 --- a/src/static/tests/SpecRunner.html +++ b/src/static/tests/SpecRunner.html @@ -22,6 +22,7 @@ + @@ -32,6 +33,7 @@ + diff --git a/src/static_src/scss/_button-pad.scss b/src/static_src/scss/_button-pad.scss index 1957890..7eeb23a 100644 --- a/src/static_src/scss/_button-pad.scss +++ b/src/static_src/scss/_button-pad.scss @@ -402,6 +402,42 @@ // DOFF btn &.btn-unequip { + color: rgba(var(--priId), 1); + border-color: rgba(var(--priId), 1); + background-color: rgba(var(--terId), 1); + box-shadow: + 0.1rem 0.1rem 0.12rem rgba(var(--terId), 0.25), + 0.12rem 0.12rem 0.25rem rgba(0, 0, 0, 0.25), + 0.25rem 0.25rem 0.25rem rgba(var(--terId), 0.12) + ; + + &:hover { + text-shadow: + 0.1rem 0.1rem 0.1rem rgba(0, 0, 0, 0.25), + 0 0 1rem rgba(var(--priId), 1) + ; + box-shadow: + 0.12rem 0.12rem 0.5rem rgba(0, 0, 0, 0.25), + 0 0 0.5rem rgba(var(--priId), 0.12) + ; + } + + &:active { + border: 0.18rem solid rgba(var(--priId), 1); + text-shadow: + -0.1rem -0.1rem 0.25rem rgba(0, 0, 0, 0.25), + 0 0 0.12rem rgba(var(--priId), 1) + ; + box-shadow: + -0.1rem -0.1rem 0.12rem rgba(var(--terId), 0.25), + -0.1rem -0.1rem 0.12rem rgba(0, 0, 0, 0.25), + 0 0 0.5rem rgba(var(--priId), 0.12) + ; + } + } + + // FLIP btn + &.btn-reveal { color: rgba(var(--priMe), 1); border-color: rgba(var(--priMe), 1); background-color: rgba(var(--terMe), 1); @@ -436,42 +472,6 @@ } } - // FLIP btn - &.btn-reveal { - color: rgba(var(--priCy), 1); - border-color: rgba(var(--priCy), 1); - background-color: rgba(var(--terCy), 1); - box-shadow: - 0.1rem 0.1rem 0.12rem rgba(var(--terCy), 0.25), - 0.12rem 0.12rem 0.25rem rgba(0, 0, 0, 0.25), - 0.25rem 0.25rem 0.25rem rgba(var(--terCy), 0.12) - ; - - &:hover { - text-shadow: - 0.1rem 0.1rem 0.1rem rgba(0, 0, 0, 0.25), - 0 0 1rem rgba(var(--priCy), 1) - ; - box-shadow: - 0.12rem 0.12rem 0.5rem rgba(0, 0, 0, 0.25), - 0 0 0.5rem rgba(var(--priCy), 0.12) - ; - } - - &:active { - border: 0.18rem solid rgba(var(--priCy), 1); - text-shadow: - -0.1rem -0.1rem 0.25rem rgba(0, 0, 0, 0.25), - 0 0 0.12rem rgba(var(--priCy), 1) - ; - box-shadow: - -0.1rem -0.1rem 0.12rem rgba(var(--terCy), 0.25), - -0.1rem -0.1rem 0.12rem rgba(0, 0, 0, 0.25), - 0 0 0.5rem rgba(var(--priCy), 0.12) - ; - } - } - // SPIN btn &.btn-reverse { color: rgba(var(--priCy), 1); diff --git a/src/static_src/scss/_card-deck.scss b/src/static_src/scss/_card-deck.scss index 26aedc7..4c33389 100644 --- a/src/static_src/scss/_card-deck.scss +++ b/src/static_src/scss/_card-deck.scss @@ -606,6 +606,14 @@ html:has(.sig-backdrop) { // Polarity qualifier: same colour as the card title in this context .sig-qualifier-above, .sig-qualifier-below { color: rgba(var(--quiUser), 1); } + // Upright + reversal title glow — levity + .sig-stage-card .fan-card-name, + .sig-stage-card .sig-qualifier-above, + .sig-stage-card .sig-qualifier-below, + .sig-stage-card .fan-card-reversal-name, + .sig-stage-card .fan-card-reversal-qualifier { + text-shadow: 0 1px 1px rgba(0,0,0,0.55), 0 0 0.55rem rgba(var(--ninUser), 0.7); + } // card-ref spans inside the caution tooltip — must match the base rule's // .sig-stat-block .sig-info-effect .card-ref specificity (0,3,0) to win. .sig-info-effect .card-ref { color: rgba(var(--quiUser), 1); } @@ -624,6 +632,14 @@ html:has(.sig-backdrop) { // Polarity qualifier: terUser for gravity (quiUser is levity's equivalent) .sig-qualifier-above, .sig-qualifier-below { color: rgba(var(--terUser), 1); } + // Upright + reversal title glow — gravity + .sig-stage-card .fan-card-name, + .sig-stage-card .sig-qualifier-above, + .sig-stage-card .sig-qualifier-below, + .sig-stage-card .fan-card-reversal-name, + .sig-stage-card .fan-card-reversal-qualifier { + text-shadow: 1px 1px 0 rgba(0,0,0,1), 0 0 0.25rem rgba(var(--ninUser), 0.25); + } // Cursor colours live in .sig-cursor-float[data-role] rules (portal elements) } @@ -785,7 +801,9 @@ $sea-card-h: 6.5rem; .sea-card-slot { width: $sea-card-w; height: $sea-card-h; - border: 0.15rem dashed rgba(var(--terUser), 0.45); + background-color: rgba(var(--duoUser), 1); + border: 0.15rem dashed rgba(var(--terUser), 1); + box-shadow: 0 0 2px rgba(var(--priUser), 0.5); border-radius: 0.3rem; display: flex; align-items: center; @@ -843,13 +861,16 @@ $sea-card-h: 6.5rem; .sea-pos-cross .sea-card-slot { transform: rotate(90deg); } -// Sig card in center slot — compact rank + icon display +// Sig card in center slot — compact rank + icon display; tilted CCW so Cover slot peeks through .sea-sig-card { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 0.2rem; + transform: rotate(-5deg); + position: relative; + z-index: 2; .fan-corner-rank { font-size: 1.2rem; @@ -975,8 +996,10 @@ $sea-card-h: 6.5rem; .sea-stack-ok { position: absolute; top: 50%; - left: 50%; - transform: translate(-50%, -50%); + left: 0; + right: 0; + margin: 0 auto; + transform: translateY(-50%); z-index: 5; } @@ -1033,11 +1056,154 @@ $_glow-gravity: 0 0 0.8rem 0.15rem rgba(var(--quaUser), 0.6); // NVM button — same positioning as .natus-modal-wrap > .btn-cancel .sea-modal-wrap > .btn-cancel { position: absolute; - top: -0.75rem; - right: -0.75rem; + top: -1rem; + right: -1rem; z-index: 10; } +// ── Sea stage — big card viewer ─────────────────────────────────────────────── + +.sea-stage { + position: fixed; + inset: 0; + z-index: 200; + display: flex; + align-items: center; + justify-content: center; +} + +.sea-stage-backdrop { + position: absolute; + inset: 0; + cursor: pointer; +} + +.sea-stage-content { + position: relative; + z-index: 1; + display: flex; + flex-direction: row; + align-items: flex-end; + gap: 0.75rem; + padding: 1.5rem; +} + +// Stage card — size matches sig-select stage (--sig-card-w driven by inline style) +.sea-stage-card { + flex-shrink: 0; + width: var(--sig-card-w, 140px); + height: auto; + aspect-ratio: 5 / 8; + border-radius: 0.5rem; + background: rgba(var(--priUser), 1); + border: 0.15rem solid rgba(var(--secUser), 0.6); + display: flex; + flex-direction: column; + position: relative; + padding: 0.25rem; + overflow: hidden; + transform-style: preserve-3d; + + // Flip-in animation when stage opens + &--shown { + animation: sea-flip-in 0.35s ease forwards; + } + + .fan-card-corner--tl, + .fan-card-corner--br { + display: flex; + flex-direction: column; + align-items: center; + line-height: 1.1; + gap: 0.1rem; + .fan-corner-rank { font-size: calc(var(--sig-card-w, 140px) * 0.133); font-weight: 700; } + i { font-size: calc(var(--sig-card-w, 140px) * 0.1); } + } + + .fan-card-face { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 0.25rem 0.15rem; + gap: 0.2rem; + + .fan-card-face-upright { display: flex; flex-direction: column; align-items: center; gap: 0.15rem; } + .fan-card-face-reversal { display: flex; flex-direction: column; align-items: center; gap: 0.15rem; padding-top: 0.1rem; } + .fan-card-name-group { font-size: calc(var(--sig-card-w, 140px) * 0.073); opacity: 0.6; } + .sig-qualifier-above, + .sig-qualifier-below, + .fan-card-reversal-qualifier { font-size: calc(var(--sig-card-w, 140px) * 0.093); font-weight: 600; color: rgba(var(--quiUser), 1); transition: opacity 0.2s; } + .fan-card-name, + .fan-card-reversal-name { font-size: calc(var(--sig-card-w, 140px) * 0.093); font-weight: 600; color: rgba(var(--quiUser), 1); transition: opacity 0.2s; } + .fan-card-arcana { font-size: calc(var(--sig-card-w, 140px) * 0.067); text-transform: uppercase; letter-spacing: 0.06em; opacity: 0.5; } + .fan-card-reversal-qualifier, + .fan-card-reversal-name { transform: rotate(180deg); opacity: 0.25; } + } + + &.stage-card--reversed { + transform: rotate(180deg); + .fan-card-reversal-qualifier, + .fan-card-reversal-name { opacity: 1; } + .fan-card-name, + .sig-qualifier-above, + .sig-qualifier-below { opacity: 0.25; } + } +} + +@keyframes sea-flip-in { + 0% { transform: perspective(600px) rotateY(-90deg) scale(0.4); opacity: 0; } + 60% { transform: perspective(600px) rotateY(8deg) scale(1.03); opacity: 1; } + 100% { transform: perspective(600px) rotateY(0deg) scale(1); opacity: 1; } +} + +// Sea stat block — reuses sig-select stat-block sizing, scoped to sea-stage +.sea-stage-content .sea-stat-block { + flex: 0 0 auto; + width: var(--sig-card-w, 140px); + height: calc(var(--sig-card-w, 140px) * 8 / 5); + background: rgba(var(--priUser), 0.85); + border-radius: 0.4rem; + border: 0.1rem solid rgba(var(--terUser), 0.15); + position: relative; + display: block; + + .sea-spin-btn { position: absolute; top: -1rem; right: -1rem; margin: 0; z-index: 50; } + .sea-fyi-btn { position: absolute; top: 1.25rem; right: -1rem; margin: 0; z-index: 50; } + + .stat-face { display: none; padding: calc(var(--sig-card-w, 140px) * 0.37) calc(var(--sig-card-w, 140px) * 0.1) calc(var(--sig-card-w, 140px) * 0.08); } + .stat-face--upright { display: block; } + &.is-reversed { .stat-face--upright { display: none; } .stat-face--reversed { display: block; } } + + .stat-face-label { font-size: calc(var(--sig-card-w, 140px) * 0.063); text-transform: uppercase; letter-spacing: 0.09em; opacity: 0.4; margin: 0 0 calc(var(--sig-card-w, 140px) * 0.07); } + .stat-keywords { list-style: none; padding: 0; margin: 0; + li { font-size: calc(var(--sig-card-w, 140px) * 0.083); padding: calc(var(--sig-card-w, 140px) * 0.042) 0; opacity: 0.85; border-bottom: 0.05rem solid rgba(var(--terUser), 0.12); &:last-child { border-bottom: none; } } + } + + .sig-info { + position: absolute; + inset: 0; + z-index: 60; + background-color: rgba(var(--tooltip-bg), 0.6); + backdrop-filter: blur(6px); + border-radius: 0.4rem; + border: 0.1rem solid rgba(var(--priYl), 0.35); + padding: 0.75rem; + flex-direction: column; + gap: 0.4rem; + overflow-y: auto; + display: none; + &[style*="display"]:not([style*="none"]) { display: flex; } + } + + .sea-fyi-prev, + .sea-fyi-next { display: inline-flex; position: absolute; bottom: -1rem; margin: 0; z-index: 70; } + .sea-fyi-prev { left: -1rem; } + .sea-fyi-next { right: -1rem; } +} + @media (orientation: landscape) { html.sea-open body .container .navbar, html.sea-open body #id_footer { diff --git a/src/static_src/scss/_room.scss b/src/static_src/scss/_room.scss index c449c52..dcfaf62 100644 --- a/src/static_src/scss/_room.scss +++ b/src/static_src/scss/_room.scss @@ -57,7 +57,8 @@ html:has(.gate-backdrop) { html:has(.gate-backdrop) #id_aperture_fill, html:has(.sig-backdrop) #id_aperture_fill, -html:has(.role-select-backdrop) #id_aperture_fill { +html:has(.role-select-backdrop) #id_aperture_fill, +html.sea-open #id_aperture_fill { opacity: 1; } diff --git a/src/static_src/tests/SeaDealSpec.js b/src/static_src/tests/SeaDealSpec.js new file mode 100644 index 0000000..9d388b3 --- /dev/null +++ b/src/static_src/tests/SeaDealSpec.js @@ -0,0 +1,292 @@ +describe("SeaDeal", () => { + let testDiv, overlay, stage, stageCard, statBlock; + + const CARD = { + id: 99, name: "Queen of Crowns", + arcana: "MIDDLE", suit: "CROWNS", number: 13, + corner_rank: "Q", suit_icon: "fa-crown", + name_group: "", name_title: "Queen of Crowns", + levity_qualifier: "Elevated", gravity_qualifier: "Graven", + reversal: "Vacant", + keywords_upright: ["nurturing", "practical", "abundance"], + keywords_reversed: ["financial dependence", "smothering"], + energies: [{ type: "LIBIDO", effect: "Energy entry." }], + operations: [{ type: "COVER", effect: "Operation entry." }], + }; + + function makeFixture({ polarity = "levity" } = {}) { + testDiv = document.createElement("div"); + testDiv.innerHTML = ` +
    + + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + + + +
    + `; + document.body.appendChild(testDiv); + overlay = testDiv.querySelector("#id_sea_overlay"); + stage = testDiv.querySelector("#id_sea_stage"); + stageCard = testDiv.querySelector(".sea-stage-card"); + statBlock = testDiv.querySelector(".sea-stat-block"); + SeaDeal._testInit(); + } + + afterEach(() => { + if (testDiv) testDiv.remove(); + // Purge any stale overlays left by tests that called makeFixture() twice + document.querySelectorAll('#id_sea_overlay').forEach(el => el.remove()); + }); + + // ── openStage ────────────────────────────────────────────────────────── // + + describe("openStage()", () => { + beforeEach(() => makeFixture()); + + it("makes #id_sea_stage visible", () => { + SeaDeal.openStage(CARD, ".sea-pos-cover", true); + expect(stage.style.display).not.toBe("none"); + }); + + it("populates corner rank on stage card", () => { + SeaDeal.openStage(CARD, ".sea-pos-cover", true); + const rank = stageCard.querySelector(".fan-card-corner--tl .fan-corner-rank"); + expect(rank.textContent).toBe("Q"); + }); + + it("shows suit icon on stage card", () => { + SeaDeal.openStage(CARD, ".sea-pos-cover", true); + const icon = stageCard.querySelector(".fan-card-corner--tl .stage-suit-icon"); + expect(icon.style.display).not.toBe("none"); + expect(icon.classList.contains("fa-crown")).toBe(true); + }); + + it("populates upright card name for non-major", () => { + SeaDeal.openStage(CARD, ".sea-pos-cover", true); + expect(stageCard.querySelector(".fan-card-name").textContent).toBe("Queen of Crowns"); + }); + + it("puts levity qualifier in qualifier-above for non-major", () => { + SeaDeal.openStage(CARD, ".sea-pos-cover", true); + expect(stageCard.querySelector(".sig-qualifier-above").textContent).toBe("Elevated"); + }); + + it("puts gravity qualifier in qualifier-above for non-major gravity", () => { + // Re-init with gravity polarity (no double-append; beforeEach already ran) + overlay.dataset.seaUserPolarity = "gravity"; + SeaDeal._testInit(); + stageCard = testDiv.querySelector(".sea-stage-card"); + SeaDeal.openStage(CARD, ".sea-pos-cover", false); + expect(stageCard.querySelector(".sig-qualifier-above").textContent).toBe("Graven"); + }); + + it("puts reversal word in reversal-qualifier slot", () => { + SeaDeal.openStage(CARD, ".sea-pos-cover", true); + expect(stageCard.querySelector(".fan-card-reversal-qualifier").textContent).toBe("Vacant"); + }); + + it("populates upright keywords in stat block", () => { + SeaDeal.openStage(CARD, ".sea-pos-cover", true); + const items = testDiv.querySelectorAll("#id_sea_stat_upright li"); + expect(items.length).toBe(3); + expect(items[0].textContent).toBe("nurturing"); + }); + + it("populates reversed keywords in stat block", () => { + SeaDeal.openStage(CARD, ".sea-pos-cover", true); + const items = testDiv.querySelectorAll("#id_sea_stat_reversed li"); + expect(items.length).toBe(2); + }); + + it("fills the target position slot", () => { + SeaDeal.openStage(CARD, ".sea-pos-cover", true); + const slot = testDiv.querySelector(".sea-pos-cover .sea-card-slot"); + expect(slot.classList.contains("sea-card-slot--filled")).toBe(true); + }); + + it("resets SPIN to upright when opening a new card", () => { + SeaDeal.openStage(CARD, ".sea-pos-cover", true); + stageCard.classList.add("stage-card--reversed"); + statBlock.classList.add("is-reversed"); + SeaDeal.openStage(CARD, ".sea-pos-cross", true); + expect(stageCard.classList.contains("stage-card--reversed")).toBe(false); + expect(statBlock.classList.contains("is-reversed")).toBe(false); + }); + }); + + // ── SPIN in sea stage ─────────────────────────────────────────────────── // + + describe("SPIN btn in sea stage", () => { + beforeEach(() => { + makeFixture(); + SeaDeal.openStage(CARD, ".sea-pos-cover", true); + }); + + it("toggles is-reversed on stat block", () => { + testDiv.querySelector(".sea-spin-btn").dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(statBlock.classList.contains("is-reversed")).toBe(true); + }); + + it("toggles stage-card--reversed on stage card", () => { + testDiv.querySelector(".sea-spin-btn").dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(stageCard.classList.contains("stage-card--reversed")).toBe(true); + }); + + it("second SPIN click restores upright", () => { + const btn = testDiv.querySelector(".sea-spin-btn"); + btn.dispatchEvent(new MouseEvent("click", { bubbles: true })); + btn.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(statBlock.classList.contains("is-reversed")).toBe(false); + }); + }); + + // ── FYI in sea stage ──────────────────────────────────────────────────── // + + describe("FYI btn in sea stage", () => { + beforeEach(() => { + makeFixture(); + SeaDeal.openStage(CARD, ".sea-pos-cover", true); + }); + + it("FYI click shows the info panel", () => { + testDiv.querySelector(".sea-fyi-btn").dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(testDiv.querySelector("#id_sea_fyi_panel").style.display).not.toBe("none"); + }); + + it("shows first energy entry title as 'Energy'", () => { + testDiv.querySelector(".sea-fyi-btn").dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(testDiv.querySelector(".sig-info-title").textContent).toBe("Energy"); + }); + + it("shows first entry type", () => { + testDiv.querySelector(".sea-fyi-btn").dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(testDiv.querySelector(".sig-info-type").textContent).toBe("LIBIDO"); + }); + + it("NXT advances to operation entry", () => { + testDiv.querySelector(".sea-fyi-btn").dispatchEvent(new MouseEvent("click", { bubbles: true })); + testDiv.querySelector(".sea-fyi-next").dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(testDiv.querySelector(".sig-info-title").textContent).toBe("Operation"); + expect(testDiv.querySelector(".sig-info-type").textContent).toBe("COVER"); + }); + }); + + // ── Backdrop dismiss ──────────────────────────────────────────────────── // + + describe("stage backdrop click", () => { + beforeEach(() => { + makeFixture(); + SeaDeal.openStage(CARD, ".sea-pos-cover", true); + }); + + it("hides the sea stage", () => { + testDiv.querySelector(".sea-stage-backdrop").dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(stage.style.display).toBe("none"); + }); + + it("leaves the slot filled after dismiss", () => { + testDiv.querySelector(".sea-stage-backdrop").dispatchEvent(new MouseEvent("click", { bubbles: true })); + const slot = testDiv.querySelector(".sea-pos-cover .sea-card-slot"); + expect(slot.classList.contains("sea-card-slot--filled")).toBe(true); + }); + }); + + // ── Re-open from deposited slot ───────────────────────────────────────── // + + describe("clicking a deposited slot", () => { + beforeEach(() => { + makeFixture(); + SeaDeal.openStage(CARD, ".sea-pos-cover", true); + // Dismiss first + testDiv.querySelector(".sea-stage-backdrop").dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + it("re-opens the sea stage", () => { + expect(stage.style.display).toBe("none"); + const slot = testDiv.querySelector(".sea-pos-cover .sea-card-slot"); + slot.dispatchEvent(new MouseEvent("click", { bubbles: true })); + 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 })); + const rank = stageCard.querySelector(".fan-card-corner--tl .fan-corner-rank"); + expect(rank.textContent).toBe("Q"); + }); + }); +}); diff --git a/src/static_src/tests/SpecRunner.html b/src/static_src/tests/SpecRunner.html index 63366ce..06b3ab2 100644 --- a/src/static_src/tests/SpecRunner.html +++ b/src/static_src/tests/SpecRunner.html @@ -22,6 +22,7 @@ + @@ -32,6 +33,7 @@ + diff --git a/src/templates/apps/gameboard/_partials/_sea_overlay.html b/src/templates/apps/gameboard/_partials/_sea_overlay.html index a32d116..c42d297 100644 --- a/src/templates/apps/gameboard/_partials/_sea_overlay.html +++ b/src/templates/apps/gameboard/_partials/_sea_overlay.html @@ -5,7 +5,8 @@
    + data-sea-deck-url="{% url 'epic:sea_deck' room.id %}" + data-sea-user-polarity="{{ user_polarity }}">
    @@ -78,13 +79,13 @@ DECKS
    - +
    Gravity
    - +
    Levity
    @@ -109,6 +110,59 @@
    {# /.sea-modal-wrap #} + + {# ── Sea stage — big card viewer ─────────────────────────────────────────── #} + +
    {# /.sea-overlay #} + {% endblock scripts %}