Compare commits

...

7 Commits

Author SHA1 Message Date
Disco DeDisco
e084bcc2d5 PICK SEA slot interaction: polarity card bg/border, cross-slot opacity fix, two-step tap; _hideOk ReferenceError removed from sea.js; Jasmine spec updated for two-step; migration 0012 PENTACLES cleanup — TDD
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 02:30:59 -04:00
Disco DeDisco
08aa4dc819 PICK SEA Sprint C: sea stage card viewer — FLIP in, SPIN/FYI, deposit/re-expand — TDD
- 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 <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 01:12:06 -04:00
Disco DeDisco
2af59b3a7f tarot card icons + ranks; sig fallback for pre-sync Characters; DECKS label sizing — TDD
- migration 0011: The Nomad (0) → fa-hat-cowboy; The Schizo (1) → fa-hat-wizard
- corner_rank: non-MAJOR pip card 1 → 'A' (Ace); court unchanged (M/J/Q/K); TDD
- 17 unit model tests for corner_rank + suit_icon
- _role_select_context: my_tray_sig falls back to seat.significator when
  confirmed_char.significator is None (Characters created before natus_save sync)
- _card-deck.scss: DECKS label bigger (1rem, 0.32em letter-spacing) to fill
  stack height; sea-stack-name: opacity 0.6, scaleY(1.5), margin-top -0.4rem
  partially under face; sea-stack-face z-index:1

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 00:20:55 -04:00
Disco DeDisco
6d75b9541f PICK SEA styling: deck backs, card rank+icon display, fa-hand-dots Major Arcana — TDD
- migration 0010: icon='fa-hand-dots' for all Earthman Major Arcana number >= 2
  (Nomad/Schizo kept empty for distinct icons later)
- sea_deck view: switch from .values() to model instances; serializes corner_rank +
  suit_icon computed properties alongside DB fields
- sea overlay JS: _fillPos() renders <span class=fan-corner-rank> + <i fa-solid> HTML;
  tracks levity/gravity source via sea-card-slot--levity/gravity class; _reset() strips
  polarity classes; _showOk/_hideOk toggle sea-deck-stack--active
- template: gravity deck before levity; OK btn inside .sea-stack-face (absolute center);
  DECKS label (vertical-rl CCW) on stacks left; Gravity/Levity names under each pile
- _card-deck.scss: .sea-stacks-label (vertical-rl); .sea-stack-ok (absolute center on face);
  .sea-stack-name w. --quaUser/--terUser; glow on hover+:active+--active class —
  --ninUser for levity, --quaUser for gravity; sea-sig-card compact rank+icon display
- sea_partial view: ctx['room'] fix carried in from Sprint B

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 23:30:07 -04:00
Disco DeDisco
132e60864e PICK SEA Sprint B: deck stacks, OK btn, card draw, LOCK HAND/DEL — TDD
- _sea_overlay.html: DEAL btn replaced by two .sea-deck-stack--levity/gravity
  piles; .sea-pos-cover + .sea-pos-cross overlaid on center sig slot; LOCK HAND
  (disabled) + DEL (.btn-danger) in .sea-form-actions; data-sea-deck-url attr
- sea overlay inline JS: _fetchDeck() loads shuffled piles from sea_deck endpoint;
  stack click → _showOk(); click elsewhere → _hideOk(); OK click → _fillPos()
  in next spread-order position; DEL → _reset(); LOCK HAND enables at 6 fills
- SPREAD_ORDER constants for waite-smith + escape-velocity spread types
- sea_deck view: shuffles full equipped deck minus all seated Significators,
  splits into levity (first half) + gravity (second half) JSON arrays
- epic:sea_deck URL registered
- sea_partial view: ctx['room'] = room added (fixes NoReverseMatch for sea_deck URL)
- _card-deck.scss: .sea-card-slot--filled; .sea-pos-cover/cross absolute overlay;
  .sea-deck-stack + .sea-stack-face; .sea-form-actions layout; removed old DEAL rule
- 9 Sprint B FTs green; 3 Sprint A FTs green; 730 ITs green

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 23:02:49 -04:00
Disco DeDisco
ff3e4d295c PICK SEA Sprint B FTs: deck stacks, OK btn, card draw, LOCK HAND/DEL — red — TDD
9 tests in PickSeaDealTest: DEAL btn absent; LOCK HAND present+disabled;
DEL present; two deck stacks (.sea-deck-stack--levity/gravity); stack click
shows .sea-stack-ok; elsewhere hides it; OK click fills .sea-pos-cover;
6 draws enables LOCK HAND; DEL clears .sea-card-slot--filled positions.
All 9 fail red — no implementation yet.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 22:50:39 -04:00
Disco DeDisco
39e12d6a3d PICK SEA Sprint A: async sky→sea transition via WS room:sky_confirmed — TDD
- natus_save: group_send room:sky_confirmed after confirm (carries seat_role)
- consumer: sky_confirmed handler rebroadcasts to room group
- _notify_sky_confirmed() helper mirrors _notify_pick_sky_available
- sea_partial view: renders _sea_overlay.html partial for in-page injection (403 if not sky_confirmed)
- epic:sea_partial URL registered
- _natus_overlay.html: data-user-seat-role attr; _onSkyConfirmed() fetches sea partial,
  removes natus overlay + backdrop, injects sea HTML, toggles sea-open on html root;
  room:sky_confirmed WS listener calls _onSkyConfirmed only for matching seat role
- user_seat_role added to SKY_SELECT context
- FT: PickSeaAsyncTransitionTest (3 tests, ChannelsFunctionalTest) — sea overlay,
  natus gone, sea-open class — all green

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 22:16:38 -04:00
24 changed files with 2195 additions and 75 deletions

View File

@@ -90,5 +90,8 @@ class RoomConsumer(AsyncJsonWebsocketConsumer):
async def pick_sky_available(self, event): async def pick_sky_available(self, event):
await self.send_json(event) await self.send_json(event)
async def sky_confirmed(self, event):
await self.send_json(event)
async def cursor_move(self, event): async def cursor_move(self, event):
await self.send_json(event) await self.send_json(event)

View File

@@ -0,0 +1,49 @@
"""Assign fa-hand-dots icon to all Earthman Major Arcana cards with number >= 2.
Cards 0 (The Nomad) and 1 (The Schizo) keep their existing icon value so they
can receive distinct icons later. All other Major Arcana groups (Popes, Implicit
Virtues, Elements, Realms, Explicit Virtues, Zodiac, Lunars, Planets, Inner Rings,
polarity-split finals) default to fa-hand-dots until per-group icons are assigned.
"""
from django.db import migrations
def assign_hand_dots(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,
arcana="MAJOR",
number__gte=2,
icon="",
).update(icon="fa-hand-dots")
def clear_hand_dots(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,
arcana="MAJOR",
number__gte=2,
icon="fa-hand-dots",
).update(icon="")
class Migration(migrations.Migration):
dependencies = [
("epic", "0009_schizo_card_ref_spans"),
]
operations = [
migrations.RunPython(assign_hand_dots, reverse_code=clear_hand_dots),
]

View File

@@ -0,0 +1,43 @@
"""Assign individual icons to The Nomad (0) and The Schizo (1).
All other Major Arcana already have fa-hand-dots from migration 0010.
"""
from django.db import migrations
ICONS = {0: 'fa-hat-cowboy', 1: 'fa-hat-wizard'}
def assign_icons(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
for number, icon in ICONS.items():
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=number
).update(icon=icon)
def clear_icons(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, arcana="MAJOR", number__in=list(ICONS.keys())
).update(icon="")
class Migration(migrations.Migration):
dependencies = [
("epic", "0010_major_arcana_hand_dots_icon"),
]
operations = [
migrations.RunPython(assign_icons, reverse_code=clear_icons),
]

View File

@@ -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),
]

View File

@@ -285,7 +285,9 @@ class TarotCard(models.Model):
if self.arcana == self.MAJOR: if self.arcana == self.MAJOR:
return self._to_roman(self.number) return self._to_roman(self.number)
court = {11: 'M', 12: 'J', 13: 'Q', 14: 'K'} court = {11: 'M', 12: 'J', 13: 'Q', 14: 'K'}
return court.get(self.number, str(self.number)) if self.number in court:
return court[self.number]
return 'A' if self.number == 1 else str(self.number)
def emanation_for(self, polarity): def emanation_for(self, polarity):
"""Return the upright title for a given polarity ('levity' or 'gravity'). """Return the upright title for a given polarity ('levity' or 'gravity').

View File

@@ -0,0 +1,314 @@
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 '<li>' + k + '</li>';
}).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 = title;
}
// 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 = '<em>No interactions defined.</em>';
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 = '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');
}
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 =
'<span class="fan-corner-rank">' + card.corner_rank + '</span>' +
(card.suit_icon ? '<i class="fa-solid ' + card.suit_icon + '"></i>' : '');
}
// ── Show / hide stage ─────────────────────────────────────────────────────
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;
_closeInfo();
}
// ── Public API ─────────────────────────────────────────────────────────────
function openStage(card, posSelector, isLevity) {
_viewingPos = posSelector;
_seaHand[posSelector] = { card: card, isLevity: isLevity };
_populate(card, isLevity);
_fillSlot(posSelector, card, isLevity);
_showStage(isLevity);
}
// ── 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');
// 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');
});
}
// FYI
if (fyiBtn) {
fyiBtn.addEventListener('click', function () {
if (fyiBtn.classList.contains('btn-disabled')) return;
_infoOpen ? _closeInfo() : _openInfo();
});
}
// 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 () {
_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);
}
// 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) {
// 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;
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');
}
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
function resetHand() {
_seaHand = {};
_viewingPos = null;
_hideStage();
}
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;
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();
},
};
}());

View File

@@ -157,7 +157,7 @@ var SigSelect = (function () {
stageCard.querySelector('.fan-card-reversal-name').textContent = title; stageCard.querySelector('.fan-card-reversal-name').textContent = title;
} else { } else {
stageCard.querySelector('.fan-card-reversal-qualifier').textContent = qualifier; 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 // Populate stat block keyword faces and reset to upright

View File

@@ -1855,6 +1855,16 @@ class PickSeaRenderingTest(TestCase):
self.assertIn("user_polarity", response.context) self.assertIn("user_polarity", response.context)
self.assertEqual(response.context["user_polarity"], "levity") # PC is levity self.assertEqual(response.context["user_polarity"], "levity") # PC is levity
def test_my_tray_sig_falls_back_to_seat_when_char_sig_is_none(self):
"""Characters created before the sig-sync fix have significator=None; fall back to seat."""
Character.objects.create(
seat=self.pc_seat,
significator=None,
confirmed_at=timezone.now(),
)
response = self.client.get(self.url)
self.assertEqual(response.context["my_tray_sig"], self.sig_card)
def test_my_tray_sig_comes_from_character_significator_when_confirmed(self): def test_my_tray_sig_comes_from_character_significator_when_confirmed(self):
"""When sky_confirmed, my_tray_sig reads from Character.significator (not TableSeat).""" """When sky_confirmed, my_tray_sig reads from Character.significator (not TableSeat)."""
char = Character.objects.create( char = Character.objects.create(

View File

@@ -0,0 +1,72 @@
from django.test import SimpleTestCase
from apps.epic.models import TarotCard
def _card(arcana, number, suit='', icon=''):
c = TarotCard()
c.arcana = arcana
c.number = number
c.suit = suit
c.icon = icon
return c
class TarotCardCornerRankTest(SimpleTestCase):
"""TarotCard.corner_rank — alphanumeric display labels."""
def test_major_arcana_0_gives_0(self):
self.assertEqual(_card('MAJOR', 0).corner_rank, '0')
def test_major_arcana_1_gives_roman_I(self):
self.assertEqual(_card('MAJOR', 1).corner_rank, 'I')
def test_major_arcana_2_gives_roman_II(self):
self.assertEqual(_card('MAJOR', 2).corner_rank, 'II')
def test_non_major_pip_1_gives_A(self):
"""Ace — pip card number 1 should show 'A', not '1'."""
self.assertEqual(_card('MIDDLE', 1, 'BRANDS').corner_rank, 'A')
def test_non_major_pip_2_gives_2(self):
self.assertEqual(_card('MIDDLE', 2, 'BRANDS').corner_rank, '2')
def test_non_major_pip_10_gives_10(self):
self.assertEqual(_card('MIDDLE', 10, 'CROWNS').corner_rank, '10')
def test_court_maid_gives_M(self):
self.assertEqual(_card('MIDDLE', 11, 'GRAILS').corner_rank, 'M')
def test_court_jack_gives_J(self):
self.assertEqual(_card('MIDDLE', 12, 'BLADES').corner_rank, 'J')
def test_court_queen_gives_Q(self):
self.assertEqual(_card('MIDDLE', 13, 'BRANDS').corner_rank, 'Q')
def test_court_king_gives_K(self):
self.assertEqual(_card('MIDDLE', 14, 'CROWNS').corner_rank, 'K')
class TarotCardSuitIconTest(SimpleTestCase):
"""TarotCard.suit_icon — icon class resolution."""
def test_major_with_icon_returns_icon(self):
self.assertEqual(_card('MAJOR', 0, icon='fa-hat-cowboy').suit_icon, 'fa-hat-cowboy')
def test_major_without_icon_returns_empty(self):
self.assertEqual(_card('MAJOR', 5).suit_icon, '')
def test_brands_returns_wand_sparkles(self):
self.assertEqual(_card('MIDDLE', 11, 'BRANDS').suit_icon, 'fa-wand-sparkles')
def test_grails_returns_trophy(self):
self.assertEqual(_card('MIDDLE', 11, 'GRAILS').suit_icon, 'fa-trophy')
def test_blades_returns_gun(self):
self.assertEqual(_card('MIDDLE', 11, 'BLADES').suit_icon, 'fa-gun')
def test_crowns_returns_crown(self):
self.assertEqual(_card('MIDDLE', 11, 'CROWNS').suit_icon, 'fa-crown')
def test_icon_override_takes_priority_over_suit(self):
self.assertEqual(_card('MIDDLE', 11, 'CROWNS', icon='fa-star').suit_icon, 'fa-star')

View File

@@ -27,4 +27,6 @@ urlpatterns = [
path('room/<uuid:room_id>/tarot/deal', views.tarot_deal, name='tarot_deal'), path('room/<uuid:room_id>/tarot/deal', views.tarot_deal, name='tarot_deal'),
path('room/<uuid:room_id>/natus/preview', views.natus_preview, name='natus_preview'), path('room/<uuid:room_id>/natus/preview', views.natus_preview, name='natus_preview'),
path('room/<uuid:room_id>/natus/save', views.natus_save, name='natus_save'), path('room/<uuid:room_id>/natus/save', views.natus_save, name='natus_save'),
path('room/<uuid:room_id>/sea/partial', views.sea_partial, name='sea_partial'),
path('room/<uuid:room_id>/sea/deck', views.sea_deck, name='sea_deck'),
] ]

View File

@@ -126,6 +126,13 @@ def _notify_pick_sky_available(room_id):
) )
def _notify_sky_confirmed(room_id, seat_role):
async_to_sync(get_channel_layer().group_send)(
f'room_{room_id}',
{'type': 'sky_confirmed', 'seat_role': seat_role},
)
SLOT_ROLE_LABELS = {1: "PC", 2: "NC", 3: "EC", 4: "SC", 5: "AC", 6: "BC"} SLOT_ROLE_LABELS = {1: "PC", 2: "NC", 3: "EC", 4: "SC", 5: "AC", 6: "BC"}
_SIG_SEAT_ORDERING = Case( _SIG_SEAT_ORDERING = Case(
@@ -362,8 +369,10 @@ def _role_select_context(room, user):
) )
sky_confirmed = confirmed_char is not None sky_confirmed = confirmed_char is not None
ctx["sky_confirmed"] = sky_confirmed ctx["sky_confirmed"] = sky_confirmed
ctx["user_seat_role"] = _canonical_seat.role if _canonical_seat else ''
if sky_confirmed: if sky_confirmed:
ctx["my_tray_sig"] = confirmed_char.significator # Fall back to seat.significator for Characters created before the sync was added
ctx["my_tray_sig"] = confirmed_char.significator or _canonical_seat.significator
return ctx return ctx
@@ -1093,5 +1102,74 @@ def natus_save(request, room_id):
char.confirmed_at = timezone.now() char.confirmed_at = timezone.now()
char.save() char.save()
if char.is_confirmed:
_notify_sky_confirmed(room_id, seat.role)
return JsonResponse({'id': char.id, 'confirmed': char.is_confirmed}) return JsonResponse({'id': char.id, 'confirmed': char.is_confirmed})
@login_required
def sea_deck(request, room_id):
"""Shuffled deck lists (levity + gravity halves) for PICK SEA draw.
Excludes all Significators already claimed by seated gamers.
Returns {levity: [{id, name, arcana, suit, number, levity_qualifier,
gravity_qualifier}], gravity: [...]}
"""
import random as _random
room = Room.objects.get(id=room_id)
seat = _canonical_user_seat(room, request.user)
if seat is None:
return HttpResponse(status=403)
deck = seat.deck_variant
if not deck:
return JsonResponse({'levity': [], 'gravity': []})
sig_ids = set(
room.table_seats.exclude(significator__isnull=True)
.values_list('significator_id', flat=True)
)
def _card_dict(c):
return {
'id': c.id,
'name': c.name,
'arcana': c.arcana,
'suit': c.suit,
'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(
TarotCard.objects.filter(deck_variant=deck).exclude(id__in=sig_ids)
)
_random.shuffle(available)
mid = len(available) // 2
return JsonResponse({
'levity': [_card_dict(c) for c in available[:mid]],
'gravity': [_card_dict(c) for c in available[mid:]],
})
@login_required
def sea_partial(request, room_id):
"""Return the rendered sea overlay partial for in-page injection after sky confirm."""
room = Room.objects.get(id=room_id)
ctx = _role_select_context(room, request.user)
if not ctx.get('sky_confirmed'):
return HttpResponse(status=403)
ctx['room'] = room
return render(request, 'apps/gameboard/_partials/_sea_overlay.html', ctx)

View File

@@ -0,0 +1,337 @@
"""Functional tests for the PICK SEA overlay — Celtic Cross draw."""
from django.urls import reverse
from django.utils import timezone
from selenium.webdriver.common.by import By
from apps.applets.models import Applet
from apps.epic.models import Character, GateSlot, Room, TableSeat, TarotCard, DeckVariant
from apps.lyric.models import User
from .base import ChannelsFunctionalTest
def _make_sky_confirmed_room(live_server_url, user, earthman):
"""Create a SKY_SELECT room with one gamer seated and sig assigned.
Returns (room, seat). The Character is NOT yet confirmed — call
_confirm_sky() in the browser to trigger the async transition.
"""
room = Room.objects.create(
name="Sea Test Room", table_status=Room.SKY_SELECT, owner=user
)
slot = room.gate_slots.get(slot_number=1)
slot.gamer = user
slot.status = GateSlot.FILLED
slot.save()
room.gate_status = Room.OPEN
room.save()
sig_card = TarotCard.objects.filter(deck_variant=earthman, arcana="MAJOR").first()
seat = TableSeat.objects.create(
room=room, gamer=user, role="PC", slot_number=1,
deck_variant=earthman, significator=sig_card,
)
return room, seat
class PickSeaAsyncTransitionTest(ChannelsFunctionalTest):
"""After sky confirm, PICK SEA overlay appears without a page refresh."""
def setUp(self):
super().setUp()
self.browser.set_window_size(800, 1200)
Applet.objects.get_or_create(
slug="new-game", defaults={"name": "New Game", "context": "gameboard"}
)
Applet.objects.get_or_create(
slug="my-games", defaults={"name": "My Games", "context": "gameboard"}
)
from apps.lyric.models import User
gamer, _ = User.objects.get_or_create(email="founder@test.io")
earthman, _ = DeckVariant.objects.get_or_create(
slug="earthman",
defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True},
)
gamer.unlocked_decks.add(earthman)
gamer.equipped_deck = earthman
gamer.save(update_fields=["equipped_deck"])
self.gamer = gamer
self.room, self.seat = _make_sky_confirmed_room(self.live_server_url, gamer, earthman)
self.room_url = self.live_server_url + reverse(
"epic:room", kwargs={"room_id": self.room.id}
)
self.natus_save_url = self.live_server_url + reverse(
"epic:natus_save", kwargs={"room_id": self.room.id}
)
def _confirm_sky(self):
"""POST to natus_save with action=confirm from browser JS (bypasses chart form)."""
# Wait for the room WS connection to be ready before triggering confirm
self.wait_for(lambda: self.browser.execute_script(
"return !!(window._roomSocket && window._roomSocket.readyState === 1);"
))
self.browser.execute_script(f"""
const csrf = (document.cookie.match(/csrftoken=([^;]+)/) || ['',''])[1];
fetch('{self.natus_save_url}', {{
method: 'POST',
credentials: 'same-origin',
headers: {{'Content-Type': 'application/json', 'X-CSRFToken': csrf}},
body: JSON.stringify({{
birth_dt: '1990-06-15T09:00:00Z',
birth_lat: 51.5, birth_lon: -0.1,
birth_place: 'London', house_system: 'O',
chart_data: {{}}, action: 'confirm',
}}),
}});
""")
def test_sea_overlay_appears_without_page_refresh(self):
"""Confirming sky replaces the natus overlay with the sea overlay in-place."""
self.create_pre_authenticated_session("founder@test.io")
self.browser.get(self.room_url)
# Sky not yet confirmed — PICK SKY btn present, no sea overlay
self.wait_for(lambda: self.browser.find_element(By.ID, "id_pick_sky_btn"))
self.assertEqual(self.browser.find_elements(By.ID, "id_sea_overlay"), [])
self._confirm_sky()
# Sea overlay appears without page refresh
sea_overlay = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_sea_overlay")
)
self.assertTrue(sea_overlay.is_displayed())
def test_natus_overlay_not_visible_after_sky_confirm(self):
"""Natus overlay is removed from the DOM after sky confirm."""
self.create_pre_authenticated_session("founder@test.io")
self.browser.get(self.room_url)
self.wait_for(lambda: self.browser.find_element(By.ID, "id_pick_sky_btn"))
self._confirm_sky()
# Sea overlay must appear first (confirms transition happened)
self.wait_for(lambda: self.browser.find_element(By.ID, "id_sea_overlay"))
natus = self.browser.find_elements(By.ID, "id_natus_overlay")
self.assertTrue(not natus or not natus[0].is_displayed())
def test_sea_open_class_on_html_after_confirm(self):
"""html.sea-open is set after sky confirm, giving the sea overlay its backdrop."""
self.create_pre_authenticated_session("founder@test.io")
self.browser.get(self.room_url)
self.wait_for(lambda: self.browser.find_element(By.ID, "id_pick_sky_btn"))
self._confirm_sky()
self.wait_for(lambda: self.browser.find_element(By.ID, "id_sea_overlay"))
has_sea_open = self.browser.execute_script(
"return document.documentElement.classList.contains('sea-open');"
)
self.assertTrue(has_sea_open)
# ── Helpers for PICK SEA deal tests ──────────────────────────────────────────
def _seed_earthman_cards(earthman, count=20):
"""Seed enough Middle Arcana cards for the deck piles."""
suits = ["BRANDS", "GRAILS", "BLADES", "CROWNS"]
nums = [11, 12, 13, 14]
for suit in suits:
for num in nums:
TarotCard.objects.get_or_create(
deck_variant=earthman,
slug=f"m{num}-{suit.lower()}-em",
defaults={"arcana": "MIDDLE", "suit": suit, "number": num,
"name": f"Card {num} {suit}"},
)
def _make_sea_ready_room(earthman):
"""Create a SKY_SELECT room with a confirmed Character ready for PICK SEA.
Returns (room, gamer, seat, char, room_url).
"""
gamer, _ = User.objects.get_or_create(email="founder@test.io")
gamer.unlocked_decks.add(earthman)
gamer.equipped_deck = earthman
gamer.save(update_fields=["equipped_deck"])
room = Room.objects.create(
name="Sea Deal Room", table_status=Room.SKY_SELECT, owner=gamer
)
slot = room.gate_slots.get(slot_number=1)
slot.gamer = gamer
slot.status = GateSlot.FILLED
slot.save()
room.gate_status = Room.OPEN
room.save()
sig_card = TarotCard.objects.filter(deck_variant=earthman, arcana="MAJOR").first()
seat = TableSeat.objects.create(
room=room, gamer=gamer, role="PC", slot_number=1,
deck_variant=earthman, significator=sig_card,
)
char = Character.objects.create(
seat=seat, significator=sig_card, confirmed_at=timezone.now()
)
return room, gamer, seat, char
class PickSeaDealTest(ChannelsFunctionalTest):
"""PICK SEA deck stacks, OK btn interaction, card draw, and LOCK HAND."""
def setUp(self):
super().setUp()
self.browser.set_window_size(800, 1200)
Applet.objects.get_or_create(
slug="new-game", defaults={"name": "New Game", "context": "gameboard"}
)
Applet.objects.get_or_create(
slug="my-games", defaults={"name": "My Games", "context": "gameboard"}
)
# Major Arcana sig card
earthman, _ = DeckVariant.objects.get_or_create(
slug="earthman",
defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True},
)
TarotCard.objects.get_or_create(
deck_variant=earthman, slug="the-schizo-em",
defaults={"arcana": "MAJOR", "number": 1, "name": "The Schizo",
"levity_qualifier": "Enlightened", "gravity_qualifier": "Engraven"},
)
_seed_earthman_cards(earthman)
self.room, self.gamer, self.seat, self.char = _make_sea_ready_room(earthman)
self.room_url = self.live_server_url + reverse(
"epic:room", kwargs={"room_id": self.room.id}
)
def _load_sea_overlay(self):
"""Navigate to room page and open the sea overlay."""
self.create_pre_authenticated_session("founder@test.io")
self.browser.get(self.room_url)
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_pick_sea_btn"))
self.browser.execute_script("arguments[0].click()", btn)
self.wait_for(lambda: self.browser.find_element(By.ID, "id_sea_overlay"))
# ── Button presence ───────────────────────────────────────────────── #
def test_deal_btn_absent(self):
"""DEAL btn replaced by deck stacks + LOCK HAND."""
self._load_sea_overlay()
self.assertEqual(self.browser.find_elements(By.ID, "id_sea_deal"), [])
def test_lock_hand_btn_present_and_disabled(self):
"""LOCK HAND btn is present but disabled before any cards are drawn."""
self._load_sea_overlay()
lock_btn = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_sea_lock_hand")
)
self.assertFalse(lock_btn.is_enabled())
def test_del_btn_present(self):
"""DEL btn is always present in the sea overlay."""
self._load_sea_overlay()
self.wait_for(lambda: self.browser.find_element(By.ID, "id_sea_del"))
# ── Deck stacks ───────────────────────────────────────────────────── #
def test_two_deck_stacks_present(self):
"""Both levity and gravity deck stacks are visible."""
self._load_sea_overlay()
self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, ".sea-deck-stack--levity"
))
self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, ".sea-deck-stack--gravity"
))
def test_clicking_stack_shows_ok_btn(self):
"""Clicking a deck stack reveals its OK btn."""
self._load_sea_overlay()
stack = self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, ".sea-deck-stack--levity"
))
self.browser.execute_script("arguments[0].click()", stack)
ok_btn = self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, ".sea-deck-stack--levity .sea-stack-ok"
))
self.assertTrue(ok_btn.is_displayed())
def test_clicking_elsewhere_hides_ok_btn(self):
"""Clicking outside a focused stack dismisses the OK btn."""
self._load_sea_overlay()
stack = self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, ".sea-deck-stack--levity"
))
self.browser.execute_script("arguments[0].click()", stack)
self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, ".sea-deck-stack--levity .sea-stack-ok"
).is_displayed())
# Click the sea cards column (not a stack)
col = self.browser.find_element(By.CSS_SELECTOR, ".sea-cards-col")
self.browser.execute_script("arguments[0].click()", col)
self.wait_for(lambda: not any(
el.is_displayed()
for el in self.browser.find_elements(By.CSS_SELECTOR, ".sea-stack-ok")
))
# ── Card draw ─────────────────────────────────────────────────────── #
def test_ok_click_fills_cover_position(self):
"""First OK click places a card in the Cover position."""
self._load_sea_overlay()
stack = self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, ".sea-deck-stack--levity"
))
self.browser.execute_script("arguments[0].click()", stack)
ok_btn = self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, ".sea-deck-stack--levity .sea-stack-ok"
))
self.browser.execute_script("arguments[0].click()", ok_btn)
self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, ".sea-pos-cover .sea-card-slot--filled"
))
def test_lock_hand_enables_after_six_draws(self):
"""LOCK HAND btn becomes enabled once all 6 positions are filled."""
self._load_sea_overlay()
for _ in range(6):
stack = self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, ".sea-deck-stack--levity"
))
self.browser.execute_script("arguments[0].click()", stack)
ok_btn = self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, ".sea-deck-stack--levity .sea-stack-ok"
))
self.browser.execute_script("arguments[0].click()", ok_btn)
lock_btn = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_sea_lock_hand")
)
self.assertTrue(lock_btn.is_enabled())
def test_del_clears_drawn_cards(self):
"""DEL btn clears all drawn cards and resets positions to empty."""
self._load_sea_overlay()
# Draw one card
stack = self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, ".sea-deck-stack--levity"
))
self.browser.execute_script("arguments[0].click()", stack)
ok_btn = self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, ".sea-deck-stack--levity .sea-stack-ok"
))
self.browser.execute_script("arguments[0].click()", ok_btn)
self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, ".sea-pos-cover .sea-card-slot--filled"
))
# DEL clears it
del_btn = self.browser.find_element(By.ID, "id_sea_del")
self.browser.execute_script("arguments[0].click()", del_btn)
self.wait_for(lambda: not self.browser.find_elements(
By.CSS_SELECTOR, ".sea-card-slot--filled"
))

View File

@@ -0,0 +1,299 @@
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 = `
<div id="id_sea_overlay" data-sea-user-polarity="${polarity}">
<!-- Cross grid (simplified) -->
<div class="sea-cross">
<div class="sea-cross-cell sea-pos-crown">
<div class="sea-card-slot sea-card-slot--empty"></div>
</div>
<div class="sea-cross-cell sea-pos-past">
<div class="sea-card-slot sea-card-slot--empty"></div>
</div>
<div class="sea-cross-cell sea-pos-center">
<div class="sea-sig-card"></div>
<div class="sea-pos-cover">
<div class="sea-card-slot sea-card-slot--empty"></div>
</div>
<div class="sea-pos-cross">
<div class="sea-card-slot sea-card-slot--empty"></div>
</div>
</div>
<div class="sea-cross-cell sea-pos-future">
<div class="sea-card-slot sea-card-slot--empty"></div>
</div>
<div class="sea-cross-cell sea-pos-root">
<div class="sea-card-slot sea-card-slot--empty"></div>
</div>
</div>
<!-- Sea stage (big card viewer) -->
<div class="sea-stage" id="id_sea_stage" style="display:none">
<div class="sea-stage-backdrop"></div>
<div class="sea-stage-content">
<div class="sig-stage-card sea-stage-card" style="--sig-card-w:140px">
<div class="fan-card-corner fan-card-corner--tl">
<span class="fan-corner-rank"></span>
<i class="fa-solid stage-suit-icon" style="display:none"></i>
</div>
<div class="fan-card-face">
<div class="fan-card-face-upright">
<p class="fan-card-name-group"></p>
<p class="sig-qualifier-above"></p>
<h3 class="fan-card-name"></h3>
<p class="sig-qualifier-below"></p>
</div>
<p class="fan-card-arcana"></p>
<div class="fan-card-face-reversal">
<p class="fan-card-reversal-name"></p>
<p class="fan-card-reversal-qualifier"></p>
</div>
</div>
<div class="fan-card-corner fan-card-corner--br">
<span class="fan-corner-rank"></span>
<i class="fa-solid stage-suit-icon" style="display:none"></i>
</div>
</div>
<div class="sig-stat-block sea-stat-block">
<button class="btn btn-reverse sea-spin-btn" type="button">SPIN</button>
<button class="btn btn-info sea-fyi-btn" type="button">FYI</button>
<div class="stat-face stat-face--upright">
<p class="stat-face-label">Emanation</p>
<ul class="stat-keywords" id="id_sea_stat_upright"></ul>
</div>
<div class="stat-face stat-face--reversed">
<p class="stat-face-label">Reversal</p>
<ul class="stat-keywords" id="id_sea_stat_reversed"></ul>
</div>
<div class="sig-info" id="id_sea_fyi_panel" style="display:none">
<div class="sig-info-header">
<h4 class="sig-info-title"></h4>
<p class="sig-info-type"></p>
</div>
<p class="sig-info-effect"></p>
<span class="sig-info-index"></span>
</div>
<button class="btn btn-nav-left sea-fyi-prev" type="button">PRV</button>
<button class="btn btn-nav-right sea-fyi-next" type="button">NXT</button>
</div>
</div>
</div>
</div>
`;
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 (two-step tap) ────────────────────────── //
describe("clicking a deposited slot", () => {
let slot;
beforeEach(() => {
makeFixture();
SeaDeal.openStage(CARD, ".sea-pos-cover", true);
// 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("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 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");
});
});
});

View File

@@ -558,12 +558,12 @@ describe("SigSelect", () => {
expect(stageCard.querySelector(".fan-card-reversal-name").textContent).toBe("Elevated"); 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" }); makeFixture({ polarity: "levity", userRole: "PC" });
// fixture default: Minor Arcana, no reversal word // fixture default: Minor Arcana, no reversal word
hover(); hover();
expect(stageCard.querySelector(".fan-card-reversal-qualifier").textContent).toBe("Elevated"); 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);
}); });
}); });

View File

@@ -22,6 +22,7 @@
<script src="RoleSelectSpec.js"></script> <script src="RoleSelectSpec.js"></script>
<script src="TraySpec.js"></script> <script src="TraySpec.js"></script>
<script src="SigSelectSpec.js"></script> <script src="SigSelectSpec.js"></script>
<script src="SeaDealSpec.js"></script>
<script src="NatusWheelSpec.js"></script> <script src="NatusWheelSpec.js"></script>
<script src="NoteSpec.js"></script> <script src="NoteSpec.js"></script>
<script src="NotePageSpec.js"></script> <script src="NotePageSpec.js"></script>
@@ -32,6 +33,7 @@
<script src="/static/apps/epic/role-select.js"></script> <script src="/static/apps/epic/role-select.js"></script>
<script src="/static/apps/epic/tray.js"></script> <script src="/static/apps/epic/tray.js"></script>
<script src="/static/apps/epic/sig-select.js"></script> <script src="/static/apps/epic/sig-select.js"></script>
<script src="/static/apps/epic/sea.js"></script>
<script src="/static/apps/gameboard/d3.min.js"></script> <script src="/static/apps/gameboard/d3.min.js"></script>
<script src="/static/apps/gameboard/natus-wheel.js"></script> <script src="/static/apps/gameboard/natus-wheel.js"></script>
<!-- Jasmine env config (optional) --> <!-- Jasmine env config (optional) -->

View File

@@ -402,6 +402,42 @@
// DOFF btn // DOFF btn
&.btn-unequip { &.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); color: rgba(var(--priMe), 1);
border-color: rgba(var(--priMe), 1); border-color: rgba(var(--priMe), 1);
background-color: rgba(var(--terMe), 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 // SPIN btn
&.btn-reverse { &.btn-reverse {
color: rgba(var(--priCy), 1); color: rgba(var(--priCy), 1);

View File

@@ -606,6 +606,14 @@ html:has(.sig-backdrop) {
// Polarity qualifier: same colour as the card title in this context // Polarity qualifier: same colour as the card title in this context
.sig-qualifier-above, .sig-qualifier-above,
.sig-qualifier-below { color: rgba(var(--quiUser), 1); } .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 // 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-stat-block .sig-info-effect .card-ref specificity (0,3,0) to win.
.sig-info-effect .card-ref { color: rgba(var(--quiUser), 1); } .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) // Polarity qualifier: terUser for gravity (quiUser is levity's equivalent)
.sig-qualifier-above, .sig-qualifier-above,
.sig-qualifier-below { color: rgba(var(--terUser), 1); } .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) // Cursor colours live in .sig-cursor-float[data-role] rules (portal elements)
} }
@@ -785,7 +801,9 @@ $sea-card-h: 6.5rem;
.sea-card-slot { .sea-card-slot {
width: $sea-card-w; width: $sea-card-w;
height: $sea-card-h; 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; border-radius: 0.3rem;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -795,8 +813,92 @@ $sea-card-h: 6.5rem;
} }
.sea-card-slot--crossing { .sea-card-slot--crossing {
width: $sea-card-h; // rotated — swap w/h // Keep portrait dimensions; rotate(90deg) in .sea-pos-cross supplies the landscape visual.
height: $sea-card-w; // 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 {
// 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.15rem;
.fan-corner-rank { font-size: 1.15rem; font-weight: 700; line-height: 1; }
i { font-size: 0.9rem; }
}
// Levity drawn card — secUser bg, priUser text + border (matches stage card polarity)
.sea-card-slot--filled.sea-card-slot--levity {
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);
}
// 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; }
.sea-pos-cover,
.sea-pos-cross {
position: absolute;
inset: 0;
display: flex;
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
.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;
font-weight: 700;
line-height: 1;
color: rgba(var(--secUser), 0.85);
}
i { font-size: 1rem; color: rgba(var(--secUser), 0.75); }
} }
// .sig-stage-card is normally scoped inside .sig-stage — re-apply the card shell // .sig-stage-card is normally scoped inside .sig-stage — re-apply the card shell
@@ -867,18 +969,289 @@ $sea-card-h: 6.5rem;
option { background: rgba(var(--priUser), 1); } option { background: rgba(var(--priUser), 1); }
} }
.sea-form-col > #id_sea_deal { // Deck stacks — DECKS label + gravity + levity piles
.sea-stacks {
display: flex;
align-items: center;
gap: 0.75rem;
margin: 1rem 0;
}
.sea-stacks-label {
writing-mode: vertical-rl;
transform: rotate(180deg);
text-transform: uppercase;
// Fill the full card height ($sea-card-h: 6.5rem) with 5 letters
font-size: 1rem;
letter-spacing: 0.32em;
font-weight: 700;
opacity: 0.5;
white-space: nowrap;
flex-shrink: 0;
align-self: center;
}
.sea-deck-stack {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.35rem;
cursor: pointer;
}
.sea-stack-face {
position: relative;
width: $sea-card-w;
height: $sea-card-h;
border-radius: 0.3rem;
border: 0.15rem solid;
display: flex;
align-items: center;
justify-content: center;
transition: box-shadow 0.15s;
z-index: 1; // sits above the name label
}
.sea-stack-ok {
position: absolute;
top: 50%;
left: 0;
right: 0;
margin: 0 auto;
transform: translateY(-50%);
z-index: 5;
}
.sea-deck-stack { gap: 0; } // remove gap so name slides under the face
.sea-stack-name {
font-size: 0.65rem;
letter-spacing: 0.08em;
text-transform: uppercase;
font-weight: 600;
opacity: 0.6;
// Pull top of label partially under the stack face
// margin-top: -0.1rem;
transform: scaleY(1.2);
transform-origin: top center;
z-index: 0;
}
.sea-deck-stack--gravity .sea-stack-name { color: rgba(var(--quaUser), 1); }
.sea-deck-stack--levity .sea-stack-name { color: rgba(var(--terUser), 1); }
// Deck backs — face-down pile colour identifies polarity
$_sea-shadow: 1px 2px 0 rgba(0,0,0,0.7), 0 4px 0 rgba(0,0,0,0.18), 2px 5px 5px rgba(0,0,0,0.5);
$_glow-levity: 0 0 0.8rem 0.15rem rgba(var(--ninUser), 0.6);
$_glow-gravity: 0 0 0.8rem 0.15rem rgba(var(--quaUser), 0.6);
.sea-deck-stack--levity .sea-stack-face {
background: rgba(var(--terUser), 0.88);
border-color: rgba(var(--ninUser), 0.65);
box-shadow: $_sea-shadow;
}
.sea-deck-stack--gravity .sea-stack-face {
background: rgba(var(--quiUser), 0.88);
border-color: rgba(var(--quaUser), 0.65);
box-shadow: $_sea-shadow;
}
// Glow on hover, :active, and while OK is showing (--active class set by JS)
.sea-deck-stack--levity:hover .sea-stack-face,
.sea-deck-stack--levity:active .sea-stack-face,
.sea-deck-stack--levity.sea-deck-stack--active .sea-stack-face { box-shadow: $_sea-shadow, $_glow-levity; }
.sea-deck-stack--gravity:hover .sea-stack-face,
.sea-deck-stack--gravity:active .sea-stack-face,
.sea-deck-stack--gravity.sea-deck-stack--active .sea-stack-face { box-shadow: $_sea-shadow, $_glow-gravity; }
// Form action row — LOCK HAND + DEL side by side at the bottom
.sea-form-actions {
display: flex;
gap: 0.5rem;
margin-top: auto; margin-top: auto;
padding-top: 0.75rem;
} }
// NVM button — same positioning as .natus-modal-wrap > .btn-cancel // NVM button — same positioning as .natus-modal-wrap > .btn-cancel
.sea-modal-wrap > .btn-cancel { .sea-modal-wrap > .btn-cancel {
position: absolute; position: absolute;
top: -0.75rem; top: -1rem;
right: -0.75rem; right: -1rem;
z-index: 10; 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;
background: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(4px);
}
.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;
transition: transform 0.4s ease;
// 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 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;
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;
}
.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) { @media (orientation: landscape) {
html.sea-open body .container .navbar, html.sea-open body .container .navbar,
html.sea-open body #id_footer { html.sea-open body #id_footer {

View File

@@ -57,7 +57,8 @@ html:has(.gate-backdrop) {
html:has(.gate-backdrop) #id_aperture_fill, html:has(.gate-backdrop) #id_aperture_fill,
html:has(.sig-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; opacity: 1;
} }

View File

@@ -0,0 +1,299 @@
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 = `
<div id="id_sea_overlay" data-sea-user-polarity="${polarity}">
<!-- Cross grid (simplified) -->
<div class="sea-cross">
<div class="sea-cross-cell sea-pos-crown">
<div class="sea-card-slot sea-card-slot--empty"></div>
</div>
<div class="sea-cross-cell sea-pos-past">
<div class="sea-card-slot sea-card-slot--empty"></div>
</div>
<div class="sea-cross-cell sea-pos-center">
<div class="sea-sig-card"></div>
<div class="sea-pos-cover">
<div class="sea-card-slot sea-card-slot--empty"></div>
</div>
<div class="sea-pos-cross">
<div class="sea-card-slot sea-card-slot--empty"></div>
</div>
</div>
<div class="sea-cross-cell sea-pos-future">
<div class="sea-card-slot sea-card-slot--empty"></div>
</div>
<div class="sea-cross-cell sea-pos-root">
<div class="sea-card-slot sea-card-slot--empty"></div>
</div>
</div>
<!-- Sea stage (big card viewer) -->
<div class="sea-stage" id="id_sea_stage" style="display:none">
<div class="sea-stage-backdrop"></div>
<div class="sea-stage-content">
<div class="sig-stage-card sea-stage-card" style="--sig-card-w:140px">
<div class="fan-card-corner fan-card-corner--tl">
<span class="fan-corner-rank"></span>
<i class="fa-solid stage-suit-icon" style="display:none"></i>
</div>
<div class="fan-card-face">
<div class="fan-card-face-upright">
<p class="fan-card-name-group"></p>
<p class="sig-qualifier-above"></p>
<h3 class="fan-card-name"></h3>
<p class="sig-qualifier-below"></p>
</div>
<p class="fan-card-arcana"></p>
<div class="fan-card-face-reversal">
<p class="fan-card-reversal-name"></p>
<p class="fan-card-reversal-qualifier"></p>
</div>
</div>
<div class="fan-card-corner fan-card-corner--br">
<span class="fan-corner-rank"></span>
<i class="fa-solid stage-suit-icon" style="display:none"></i>
</div>
</div>
<div class="sig-stat-block sea-stat-block">
<button class="btn btn-reverse sea-spin-btn" type="button">SPIN</button>
<button class="btn btn-info sea-fyi-btn" type="button">FYI</button>
<div class="stat-face stat-face--upright">
<p class="stat-face-label">Emanation</p>
<ul class="stat-keywords" id="id_sea_stat_upright"></ul>
</div>
<div class="stat-face stat-face--reversed">
<p class="stat-face-label">Reversal</p>
<ul class="stat-keywords" id="id_sea_stat_reversed"></ul>
</div>
<div class="sig-info" id="id_sea_fyi_panel" style="display:none">
<div class="sig-info-header">
<h4 class="sig-info-title"></h4>
<p class="sig-info-type"></p>
</div>
<p class="sig-info-effect"></p>
<span class="sig-info-index"></span>
</div>
<button class="btn btn-nav-left sea-fyi-prev" type="button">PRV</button>
<button class="btn btn-nav-right sea-fyi-next" type="button">NXT</button>
</div>
</div>
</div>
</div>
`;
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 (two-step tap) ────────────────────────── //
describe("clicking a deposited slot", () => {
let slot;
beforeEach(() => {
makeFixture();
SeaDeal.openStage(CARD, ".sea-pos-cover", true);
// 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("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 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");
});
});
});

View File

@@ -558,12 +558,12 @@ describe("SigSelect", () => {
expect(stageCard.querySelector(".fan-card-reversal-name").textContent).toBe("Elevated"); 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" }); makeFixture({ polarity: "levity", userRole: "PC" });
// fixture default: Minor Arcana, no reversal word // fixture default: Minor Arcana, no reversal word
hover(); hover();
expect(stageCard.querySelector(".fan-card-reversal-qualifier").textContent).toBe("Elevated"); 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);
}); });
}); });

View File

@@ -22,6 +22,7 @@
<script src="RoleSelectSpec.js"></script> <script src="RoleSelectSpec.js"></script>
<script src="TraySpec.js"></script> <script src="TraySpec.js"></script>
<script src="SigSelectSpec.js"></script> <script src="SigSelectSpec.js"></script>
<script src="SeaDealSpec.js"></script>
<script src="NatusWheelSpec.js"></script> <script src="NatusWheelSpec.js"></script>
<script src="NoteSpec.js"></script> <script src="NoteSpec.js"></script>
<script src="NotePageSpec.js"></script> <script src="NotePageSpec.js"></script>
@@ -32,6 +33,7 @@
<script src="/static/apps/epic/role-select.js"></script> <script src="/static/apps/epic/role-select.js"></script>
<script src="/static/apps/epic/tray.js"></script> <script src="/static/apps/epic/tray.js"></script>
<script src="/static/apps/epic/sig-select.js"></script> <script src="/static/apps/epic/sig-select.js"></script>
<script src="/static/apps/epic/sea.js"></script>
<script src="/static/apps/gameboard/d3.min.js"></script> <script src="/static/apps/gameboard/d3.min.js"></script>
<script src="/static/apps/gameboard/natus-wheel.js"></script> <script src="/static/apps/gameboard/natus-wheel.js"></script>
<!-- Jasmine env config (optional) --> <!-- Jasmine env config (optional) -->

View File

@@ -8,7 +8,9 @@
<div class="natus-overlay" <div class="natus-overlay"
id="id_natus_overlay" id="id_natus_overlay"
data-preview-url="{% url 'epic:natus_preview' room.id %}" data-preview-url="{% url 'epic:natus_preview' room.id %}"
data-save-url="{% url 'epic:natus_save' room.id %}"> data-save-url="{% url 'epic:natus_save' room.id %}"
data-sea-partial-url="{% url 'epic:sea_partial' room.id %}"
data-user-seat-role="{{ user_seat_role }}">
<div class="natus-modal-wrap"> <div class="natus-modal-wrap">
<div class="natus-modal"> <div class="natus-modal">
@@ -369,9 +371,11 @@
if (!r.ok) throw new Error(`HTTP ${r.status}`); if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json(); return r.json();
}) })
.then(() => { .then(data => {
setStatus('Sky saved!'); if (!data.confirmed) {
setTimeout(closeNatus, 1200); setStatus('Sky saved!');
}
// Confirmed state is driven by the room:sky_confirmed WS event
}) })
.catch(err => { .catch(err => {
setStatus(`Save failed: ${err.message}`, 'error'); setStatus(`Save failed: ${err.message}`, 'error');
@@ -379,6 +383,28 @@
}); });
}); });
// ── Sky confirmed → inject sea partial ───────────────────────────────────
const SEA_PARTIAL_URL = overlay.dataset.seaPartialUrl;
function _onSkyConfirmed() {
fetch(SEA_PARTIAL_URL, { credentials: 'same-origin' })
.then(r => r.text())
.then(html => {
// Remove natus overlay + backdrop; inject sea partial before body close
var backdrop = document.querySelector('.natus-backdrop');
if (backdrop) backdrop.remove();
overlay.remove();
document.documentElement.classList.remove('natus-open');
document.body.insertAdjacentHTML('beforeend', html);
document.documentElement.classList.add('sea-open');
})
.catch(() => {
// Fallback: just close natus and let page refresh handle the transition
closeNatus();
});
}
// ── CSRF ────────────────────────────────────────────────────────────────── // ── CSRF ──────────────────────────────────────────────────────────────────
function _getCsrf() { function _getCsrf() {
@@ -391,6 +417,15 @@
// openNatus() so the animation plays when the modal opens, not silently // openNatus() so the animation plays when the modal opens, not silently
// in the background on page load. // in the background on page load.
// WS: server broadcasts sky_confirmed when any gamer confirms their sky.
// Only act when the event's seat_role matches this browser's seat.
const MY_SEAT_ROLE = overlay.dataset.userSeatRole;
window.addEventListener('room:sky_confirmed', function (e) {
if (MY_SEAT_ROLE && e.detail.seat_role && e.detail.seat_role !== MY_SEAT_ROLE) return;
_onSkyConfirmed();
});
_restoreForm(); _restoreForm();
})(); })();
</script> </script>

View File

@@ -4,7 +4,9 @@
{# Layout is the reverse of PICK SKY: cards left (transparent), form right #} {# Layout is the reverse of PICK SKY: cards left (transparent), form right #}
<div class="sea-backdrop"></div> <div class="sea-backdrop"></div>
<div class="sea-overlay" id="id_sea_overlay"> <div class="sea-overlay" id="id_sea_overlay"
data-sea-deck-url="{% url 'epic:sea_deck' room.id %}"
data-sea-user-polarity="{{ user_polarity }}">
<div class="sea-modal-wrap"> <div class="sea-modal-wrap">
<div class="sea-modal"> <div class="sea-modal">
@@ -19,39 +21,39 @@
{# ── Cards column (transparent) ───────────────────────────── #} {# ── Cards column (transparent) ───────────────────────────── #}
<div class="sea-cards-col"> <div class="sea-cards-col">
<div class="sea-cross"> <div class="sea-cross">
{# Crown — position 3 #} {# Crown — CC pos 3 / EV pos 5 #}
<div class="sea-cross-cell sea-pos-crown"> <div class="sea-cross-cell sea-pos-crown">
<div class="sea-card-slot sea-card-slot--empty"></div> <div class="sea-card-slot sea-card-slot--empty"></div>
</div> </div>
{# Past — position 4 #} {# Beneath (past)CC pos 4 / EV pos 3 #}
<div class="sea-cross-cell sea-pos-past"> <div class="sea-cross-cell sea-pos-past">
<div class="sea-card-slot sea-card-slot--empty"></div> <div class="sea-card-slot sea-card-slot--empty"></div>
</div> </div>
{# Center — Significator (already placed) #} {# Center — Significator (always placed) + Cover + Cross overlaid #}
<div class="sea-cross-cell sea-pos-center"> <div class="sea-cross-cell sea-pos-center">
<div class="sig-stage-card" style="--sig-card-w: 4rem"> <div class="sig-stage-card sea-sig-card" style="--sig-card-w: 4rem">
{% if my_tray_sig %} {% if my_tray_sig %}
<div class="fan-card-face"> <span class="fan-corner-rank">{{ my_tray_sig.corner_rank }}</span>
{% if my_tray_sig.arcana == "MAJOR" %} {% if my_tray_sig.suit_icon %}<i class="fa-solid {{ my_tray_sig.suit_icon }}"></i>{% endif %}
<p class="fan-card-name">{{ my_tray_sig.name_title }}</p>
<p class="sig-qualifier-below">{% if user_polarity == "levity" %}{{ my_tray_sig.levity_qualifier }}{% else %}{{ my_tray_sig.gravity_qualifier }}{% endif %}</p>
{% else %}
<p class="sig-qualifier-above">{% if user_polarity == "levity" %}{{ my_tray_sig.levity_qualifier }}{% else %}{{ my_tray_sig.gravity_qualifier }}{% endif %}</p>
<p class="fan-card-name">{{ my_tray_sig.name_title }}</p>
{% endif %}
</div>
{% endif %} {% endif %}
</div> </div>
{# Cover — CC/EV pos 1, stacked face-up on Sig #}
<div class="sea-pos-cover">
<div class="sea-card-slot sea-card-slot--empty"></div>
</div>
{# Cross — CC/EV pos 2, rotated 90° on Cover #}
<div class="sea-pos-cross">
<div class="sea-card-slot sea-card-slot--empty sea-card-slot--crossing"></div>
</div>
</div> </div>
{# Future — position 5 #} {# Before (future)CC pos 5 / EV pos 6 #}
<div class="sea-cross-cell sea-pos-future"> <div class="sea-cross-cell sea-pos-future">
<div class="sea-card-slot sea-card-slot--empty"></div> <div class="sea-card-slot sea-card-slot--empty"></div>
</div> </div>
{# Root — position 1 #} {# Behind (root)CC pos 6 / EV pos 4 #}
<div class="sea-cross-cell sea-pos-root"> <div class="sea-cross-cell sea-pos-root">
<div class="sea-card-slot sea-card-slot--empty"></div> <div class="sea-card-slot sea-card-slot--empty"></div>
</div> </div>
{# Crossing — position 2 (rotated) deferred; re-add once layout is finalized #}
</div> </div>
</div> </div>
@@ -71,11 +73,33 @@
{% endif %} {% endif %}
</select> </select>
</div> </div>
{# Two face-down deck piles — tap to proffer OK #}
<div class="sea-stacks">
<span class="sea-stacks-label">DECKS</span>
<div class="sea-deck-stack sea-deck-stack--gravity">
<div class="sea-stack-face">
<button class="btn btn-reveal sea-stack-ok" type="button">FLIP</button>
</div>
<span class="sea-stack-name">Gravity</span>
</div>
<div class="sea-deck-stack sea-deck-stack--levity">
<div class="sea-stack-face">
<button class="btn btn-reveal sea-stack-ok" type="button">FLIP</button>
</div>
<span class="sea-stack-name">Levity</span>
</div>
</div>
</div> </div>
<button type="button" id="id_sea_deal" class="btn btn-primary" disabled> <div class="sea-form-actions">
Deal <button type="button" id="id_sea_lock_hand" class="btn btn-primary" disabled>
</button> LOCK HAND
</button>
<button type="button" id="id_sea_del" class="btn btn-danger">
DEL
</button>
</div>
</div> </div>
@@ -86,6 +110,59 @@
<button type="button" id="id_sea_cancel" class="btn btn-cancel btn-sm">NVM</button> <button type="button" id="id_sea_cancel" class="btn btn-cancel btn-sm">NVM</button>
</div>{# /.sea-modal-wrap #} </div>{# /.sea-modal-wrap #}
{# ── Sea stage — big card viewer ─────────────────────────────────────────── #}
<div class="sea-stage" id="id_sea_stage" style="display:none">
<div class="sea-stage-backdrop"></div>
<div class="sea-stage-content">
<div class="sig-stage-card sea-stage-card" style="--sig-card-w:140px">
<div class="fan-card-corner fan-card-corner--tl">
<span class="fan-corner-rank"></span>
<i class="fa-solid stage-suit-icon" style="display:none"></i>
</div>
<div class="fan-card-face">
<div class="fan-card-face-upright">
<p class="fan-card-name-group"></p>
<p class="sig-qualifier-above"></p>
<h3 class="fan-card-name"></h3>
<p class="sig-qualifier-below"></p>
</div>
<p class="fan-card-arcana"></p>
<div class="fan-card-face-reversal">
<p class="fan-card-reversal-name"></p>
<p class="fan-card-reversal-qualifier"></p>
</div>
</div>
<div class="fan-card-corner fan-card-corner--br">
<span class="fan-corner-rank"></span>
<i class="fa-solid stage-suit-icon" style="display:none"></i>
</div>
</div>
<div class="sig-stat-block sea-stat-block">
<button class="btn btn-reverse sea-spin-btn" type="button">SPIN</button>
<button class="btn btn-info sea-fyi-btn" type="button">FYI</button>
<div class="stat-face stat-face--upright">
<p class="stat-face-label">Emanation</p>
<ul class="stat-keywords" id="id_sea_stat_upright"></ul>
</div>
<div class="stat-face stat-face--reversed">
<p class="stat-face-label">Reversal</p>
<ul class="stat-keywords" id="id_sea_stat_reversed"></ul>
</div>
<div class="sig-info" id="id_sea_fyi_panel" style="display:none">
<div class="sig-info-header">
<h4 class="sig-info-title"></h4>
<p class="sig-info-type"></p>
</div>
<p class="sig-info-effect"></p>
<span class="sig-info-index"></span>
</div>
<button class="btn btn-nav-left sea-fyi-prev" type="button">PRV</button>
<button class="btn btn-nav-right sea-fyi-next" type="button">NXT</button>
</div>
</div>
</div>
</div>{# /.sea-overlay #} </div>{# /.sea-overlay #}
<script> <script>
@@ -106,6 +183,100 @@
const pickSeaBtn = document.getElementById('id_pick_sea_btn'); const pickSeaBtn = document.getElementById('id_pick_sea_btn');
if (pickSeaBtn) pickSeaBtn.addEventListener('click', openSea); if (pickSeaBtn) pickSeaBtn.addEventListener('click', openSea);
cancelBtn.addEventListener('click', closeSea); cancelBtn.addEventListener('click', closeSea);
overlay.addEventListener('click', (e) => { if (e.target === overlay) closeSea(); });
// ── Deck draw ──────────────────────────────────────────────────────────────
const SEA_DECK_URL = overlay.dataset.seaDeckUrl;
const SPREAD_ORDER = {
'waite-smith': ['.sea-pos-cover', '.sea-pos-cross', '.sea-pos-crown', '.sea-pos-root', '.sea-pos-future', '.sea-pos-past'],
'escape-velocity': ['.sea-pos-cover', '.sea-pos-cross', '.sea-pos-root', '.sea-pos-past', '.sea-pos-crown', '.sea-pos-future'],
};
let levityPile = [], gravityPile = [];
let _filled = 0;
let _activeStack = null;
const spreadSel = overlay.querySelector('#id_sea_spread');
const lockBtn = overlay.querySelector('#id_sea_lock_hand');
const delBtn = overlay.querySelector('#id_sea_del');
function _spreadKey() {
return spreadSel ? spreadSel.value : 'waite-smith';
}
function _nextPosSelector() {
const order = SPREAD_ORDER[_spreadKey()] || SPREAD_ORDER['waite-smith'];
return order[_filled] || null;
}
function _hideOk() {
if (_activeStack) {
const ok = _activeStack.querySelector('.sea-stack-ok');
if (ok) ok.style.display = 'none';
_activeStack.classList.remove('sea-deck-stack--active');
_activeStack = null;
}
}
function _showOk(stack) {
_hideOk();
_activeStack = stack;
stack.classList.add('sea-deck-stack--active');
const ok = stack.querySelector('.sea-stack-ok');
if (ok) ok.style.display = '';
}
function _reset() {
_filled = 0;
_hideOk();
overlay.querySelectorAll('.sea-card-slot').forEach(s => {
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;
delete s.dataset.posKey;
});
if (lockBtn) lockBtn.disabled = true;
if (window.SeaDeal) SeaDeal.resetHand();
_fetchDeck();
}
function _fetchDeck() {
fetch(SEA_DECK_URL, { credentials: 'same-origin' })
.then(r => r.json())
.then(data => { levityPile = data.levity || []; gravityPile = data.gravity || []; })
.catch(() => {});
}
overlay.querySelectorAll('.sea-deck-stack').forEach(stack => {
stack.addEventListener('click', e => {
e.stopPropagation();
_activeStack === stack ? _hideOk() : _showOk(stack);
});
const ok = stack.querySelector('.sea-stack-ok');
if (ok) {
ok.style.display = 'none';
ok.addEventListener('click', e => {
e.stopPropagation();
const isLevity = stack.classList.contains('sea-deck-stack--levity');
const pile = isLevity ? levityPile : gravityPile;
const card = pile.length ? pile.shift() : null;
const pos = _nextPosSelector();
if (card && pos) {
_filled++;
if (lockBtn) lockBtn.disabled = (_filled < 6);
if (window.SeaDeal) SeaDeal.openStage(card, pos, isLevity);
}
_hideOk();
});
}
});
overlay.addEventListener('click', _hideOk);
if (delBtn) delBtn.addEventListener('click', _reset);
_fetchDeck();
if (window.SeaDeal) SeaDeal.reinit();
})(); })();
</script> </script>

View File

@@ -113,5 +113,6 @@
<script src="{% static 'apps/epic/gatekeeper.js' %}"></script> <script src="{% static 'apps/epic/gatekeeper.js' %}"></script>
<script src="{% static 'apps/epic/role-select.js' %}"></script> <script src="{% static 'apps/epic/role-select.js' %}"></script>
<script src="{% static 'apps/epic/sig-select.js' %}"></script> <script src="{% static 'apps/epic/sig-select.js' %}"></script>
<script src="{% static 'apps/epic/sea.js' %}"></script>
<script src="{% static 'apps/epic/tray.js' %}"></script> <script src="{% static 'apps/epic/tray.js' %}"></script>
{% endblock scripts %} {% endblock scripts %}