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
@@ -109,6 +110,59 @@
{# /.sea-modal-wrap #}
+
+ {# ── Sea stage — big card viewer ─────────────────────────────────────────── #}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{# /.sea-overlay #}
+
{% endblock scripts %}