diff --git a/src/apps/gameboard/tests/integrated/test_views.py b/src/apps/gameboard/tests/integrated/test_views.py
index 9049699..f90b5e6 100644
--- a/src/apps/gameboard/tests/integrated/test_views.py
+++ b/src/apps/gameboard/tests/integrated/test_views.py
@@ -618,13 +618,17 @@ class MySeaPickerPhaseTemplateTest(TestCase):
self.assertContains(response, "sea-pos-leave")
self.assertContains(response, "sea-pos-loom")
- def test_picker_does_not_render_forsaken_positions(self):
- # Crown / lay / cross are gameroom-only — user-locked spec drops
- # them from the solo three-card spread.
+ def test_picker_renders_six_card_only_positions_for_spread_switch(self):
+ # Crown / lay / cross sit in the DOM unconditionally so iter 3's
+ # SPREAD dropdown can reveal them via CSS attribute swap (data-
+ # spread-shape="six-card") without re-rendering. Default 3-card
+ # spread hides them via `.my-sea-cross[data-spread-shape=
+ # "three-card"]` rules in _gameboard.scss — FT pins the hidden
+ # state visually.
response = self.client.get(reverse("my_sea"))
- self.assertNotContains(response, "sea-pos-crown")
- self.assertNotContains(response, "sea-pos-lay")
- self.assertNotContains(response, "sea-pos-cross")
+ self.assertContains(response, "sea-pos-crown")
+ self.assertContains(response, "sea-pos-lay")
+ self.assertContains(response, "sea-pos-cross")
def test_picker_not_rendered_when_user_has_no_sig(self):
# 4b gate wins; picker has no business rendering without a sig.
@@ -632,3 +636,75 @@ class MySeaPickerPhaseTemplateTest(TestCase):
self.user.save(update_fields=["significator"])
response = self.client.get(reverse("my_sea"))
self.assertNotContains(response, "my-sea-picker")
+
+
+class MySeaSpreadFormTemplateTest(TestCase):
+ """Sprint 5 iter 3 — form col + SPREAD dropdown structure + default-
+ spread context + cross's `data-spread-shape` attribute. Iter 3 spec
+ locks `Situation, Action, Outcome` as the default spread (a 3-card
+ variant); the 6 spreads sit under 2 section dividers (3-card / 6-
+ card)."""
+
+ SPREAD_OPTIONS = [
+ ("past-present-future", "Past, Present, Future"),
+ ("situation-action-outcome", "Situation, Action, Outcome"),
+ ("mind-body-spirit", "Mind, Body, Spirit"),
+ ("desire-obstacle-solution", "Desire, Obstacle, Solution"),
+ ("waite-smith", "Celtic Cross, Waite-Smith"),
+ ("escape-velocity", "Celtic Cross, Escape Velocity"),
+ ]
+
+ def setUp(self):
+ from apps.epic.models import personal_sig_cards
+ self.user = User.objects.create(email="spread@test.io")
+ self.client.force_login(self.user)
+ self.target = personal_sig_cards(self.user)[0]
+ self.user.significator = self.target
+ self.user.save(update_fields=["significator"])
+
+ def test_context_default_spread_is_situation_action_outcome(self):
+ response = self.client.get(reverse("my_sea"))
+ self.assertEqual(
+ response.context["default_spread"], "situation-action-outcome",
+ )
+
+ def test_context_reversals_pct_defaults_to_25(self):
+ response = self.client.get(reverse("my_sea"))
+ self.assertEqual(response.context["reversals_pct"], 25)
+
+ def test_template_renders_all_six_spread_options(self):
+ response = self.client.get(reverse("my_sea"))
+ html = response.content.decode()
+ for value, label in self.SPREAD_OPTIONS:
+ with self.subTest(spread=value):
+ self.assertIn(f'data-value="{value}"', html)
+ self.assertIn(label, html)
+
+ def test_template_renders_three_card_and_six_card_section_dividers(self):
+ response = self.client.get(reverse("my_sea"))
+ html = response.content.decode()
+ self.assertEqual(html.count("sea-select-divider"), 2)
+ self.assertIn("3-card spreads", html)
+ self.assertIn("6-card spreads", html)
+
+ def test_template_marks_situation_action_outcome_aria_selected(self):
+ response = self.client.get(reverse("my_sea"))
+ html = response.content.decode()
+ # The default option carries aria-selected="true"; the others false.
+ self.assertIn(
+ 'data-value="situation-action-outcome" aria-selected="true"', html,
+ )
+
+ def test_cross_carries_initial_three_card_spread_shape(self):
+ response = self.client.get(reverse("my_sea"))
+ self.assertContains(response, 'data-spread-shape="three-card"')
+
+ def test_form_col_renders_decks_lock_hand_del_and_reversal_hint(self):
+ response = self.client.get(reverse("my_sea"))
+ html = response.content.decode()
+ self.assertIn("sea-deck-stack--gravity", html)
+ self.assertIn("sea-deck-stack--levity", html)
+ self.assertIn('id="id_sea_lock_hand"', html)
+ self.assertIn('id="id_sea_del"', html)
+ self.assertIn("sea-reversal-hint", html)
+ self.assertIn("25% reversals", html)
diff --git a/src/apps/gameboard/views.py b/src/apps/gameboard/views.py
index 67d8979..4d9c8d0 100644
--- a/src/apps/gameboard/views.py
+++ b/src/apps/gameboard/views.py
@@ -193,6 +193,11 @@ def my_sea(request):
# `.suit_icon` resolve at render time.
"significator": request.user.significator,
"significator_reversed": request.user.significator_reversed,
+ # Sprint 5 iter 3 — SPREAD dropdown defaults to Situation/Action/
+ # Outcome (a 3-card spread) per user-locked spec; `reversals_pct`
+ # is a placeholder UI value pending the per-user setting.
+ "default_spread": "situation-action-outcome",
+ "reversals_pct": 25,
"page_class": "page-gameboard page-my-sea",
})
diff --git a/src/functional_tests/test_game_my_sea.py b/src/functional_tests/test_game_my_sea.py
index 58d64b3..0a176a1 100644
--- a/src/functional_tests/test_game_my_sea.py
+++ b/src/functional_tests/test_game_my_sea.py
@@ -399,15 +399,191 @@ class MySeaPickerPhaseTest(FunctionalTest):
# ── Test 3 ───────────────────────────────────────────────────────────────
- def test_picker_does_not_render_forsaken_positions(self):
- """Crown / lay / cross from the gameroom's 6-position Celtic
- Cross are forsaken in the solo three-card spread (user-locked
- spec). None of these classes should appear in the picker DOM."""
+ def test_picker_hides_six_card_only_positions_by_default(self):
+ """Crown / lay / cross are 6-card-spread-only positions; they
+ sit in the picker DOM (so iter 3's spread switch can reveal
+ them without re-rendering) but stay `display:none` while the
+ default 3-card spread (`data-spread-shape="three-card"`) is
+ active. Iter 3's SPREAD dropdown switches `data-spread-shape`
+ on change to reveal these positions for Celtic Cross variants."""
picker = self._enter_picker_phase()
- for forsaken in (".sea-pos-crown", ".sea-pos-lay", ".sea-pos-cross"):
- with self.subTest(position=forsaken):
+ for hidden_pos in (".sea-pos-crown", ".sea-pos-lay", ".sea-pos-cross"):
+ with self.subTest(position=hidden_pos):
+ elements = picker.find_elements(By.CSS_SELECTOR, hidden_pos)
self.assertEqual(
- len(picker.find_elements(By.CSS_SELECTOR, forsaken)),
- 0,
- f"forsaken position {forsaken} should not render in my-sea picker",
+ len(elements), 1,
+ f"{hidden_pos} should render in DOM (gets shown on 6-card spread switch)",
)
+ self.assertFalse(
+ elements[0].is_displayed(),
+ f"{hidden_pos} should be hidden in default 3-card spread",
+ )
+
+
+class MySeaSpreadFormTest(FunctionalTest):
+ """Sprint 5 iter 3 — form col on the picker phase: SPREAD dropdown
+ (custom combobox w. 6 options + 2 horizontal section dividers for
+ "3-card spreads" / "6-card spreads"), reversal-rate caption, two
+ DECKS swatches (GRAVITY + LEVITY), LOCK HAND + DEL btns. Selecting
+ a 6-card spread (Celtic Cross variants) swaps `.my-sea-cross[data-
+ spread-shape]` from `three-card` to `six-card`, revealing the
+ crown / lay / cross positions hidden by default.
+
+ Card-draw mechanics — clicking a deck swatch to deposit a card into
+ the next empty slot — defers to iter 4."""
+
+ def setUp(self):
+ super().setUp()
+ _seed_earthman_sig_pile()
+ _seed_gameboard_applets()
+ self.email = "spread@test.io"
+ self.gamer = User.objects.create(email=self.email)
+ self.target_card = _assign_sig(self.gamer)
+
+ def _enter_picker_phase(self):
+ self.create_pre_authenticated_session(self.email)
+ self.browser.get(self.live_server_url + "/gameboard/my-sea/")
+ btn = self.wait_for(
+ lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_draw_sea_btn")
+ )
+ btn.click()
+ return self.wait_for(
+ lambda: self.browser.find_element(
+ By.CSS_SELECTOR, ".my-sea-page[data-phase='picker']"
+ )
+ )
+
+ # ── Test 1 ───────────────────────────────────────────────────────────────
+
+ def test_spread_dropdown_renders_six_options_and_two_dividers(self):
+ """SPREAD combobox has 4 three-card options + 2 six-card
+ options + 2 horizontal section dividers labelled "3-card
+ spreads" / "6-card spreads". Dividers are `role=presentation`
+ + `.sea-select-divider` so combobox.js skips them.
+
+ The dropdown is closed (`aria-expanded='false'`) on initial
+ render so the
s aren't displayed; use textContent rather
+ than `.text` (which returns "" for hidden elements)."""
+ picker = self._enter_picker_phase()
+ options = picker.find_elements(
+ By.CSS_SELECTOR, ".sea-select-list [role='option']"
+ )
+ self.assertEqual(len(options), 6)
+ option_labels = [
+ o.get_attribute("textContent").strip() for o in options
+ ]
+ # Three-card variants — labels per [[project-my-sea-roadmap]]
+ # iter 3 spec lock.
+ self.assertIn("Past, Present, Future", option_labels)
+ self.assertIn("Situation, Action, Outcome", option_labels)
+ self.assertIn("Mind, Body, Spirit", option_labels)
+ self.assertIn("Desire, Obstacle, Solution", option_labels)
+ # Six-card variants
+ self.assertIn("Celtic Cross, Waite-Smith", option_labels)
+ self.assertIn("Celtic Cross, Escape Velocity", option_labels)
+ # Two horizontal dividers
+ dividers = picker.find_elements(By.CSS_SELECTOR, ".sea-select-divider")
+ self.assertEqual(len(dividers), 2)
+ divider_text = "|".join(
+ d.get_attribute("textContent").upper().strip() for d in dividers
+ )
+ self.assertIn("3-CARD SPREADS", divider_text)
+ self.assertIn("6-CARD SPREADS", divider_text)
+
+ # ── Test 2 ───────────────────────────────────────────────────────────────
+
+ def test_default_spread_is_situation_action_outcome(self):
+ """Per the spec, `Situation, Action, Outcome` is the default
+ spread on landing — selected in the combobox + reflected in
+ the hidden `` initial value. The
+ combobox's `.sea-select-current` `` carries the visible
+ label (rendered even when the dropdown is closed)."""
+ picker = self._enter_picker_phase()
+ hidden = picker.find_element(By.CSS_SELECTOR, "#id_sea_spread")
+ self.assertEqual(
+ hidden.get_attribute("value"), "situation-action-outcome",
+ )
+ # Selected is inside the closed dropdown — its `.text` is
+ # "" per Selenium's hidden-text convention; use textContent.
+ selected = picker.find_element(
+ By.CSS_SELECTOR, ".sea-select-list [role='option'][aria-selected='true']"
+ )
+ self.assertEqual(
+ selected.get_attribute("textContent").strip(),
+ "Situation, Action, Outcome",
+ )
+ # The visible current-label span is always rendered.
+ current = picker.find_element(By.CSS_SELECTOR, ".sea-select-current")
+ self.assertEqual(current.text.strip(), "Situation, Action, Outcome")
+ # And the cross is in three-card shape mode by default.
+ cross = picker.find_element(By.CSS_SELECTOR, ".my-sea-cross")
+ self.assertEqual(cross.get_attribute("data-spread-shape"), "three-card")
+
+ # ── Test 3 ───────────────────────────────────────────────────────────────
+
+ def test_picking_celtic_cross_reveals_six_card_positions(self):
+ """Clicking a 6-card spread option swaps `data-spread-shape`
+ to `six-card` on `.my-sea-cross` — crown / lay / cross become
+ visible. Three-card variants flip back to `three-card`."""
+ picker = self._enter_picker_phase()
+ cross = picker.find_element(By.CSS_SELECTOR, ".my-sea-cross")
+ # Open the combobox + click Celtic Cross Waite-Smith
+ combo = picker.find_element(By.CSS_SELECTOR, "[data-combobox]")
+ combo.click()
+ waite_smith = picker.find_element(
+ By.CSS_SELECTOR,
+ ".sea-select-list [role='option'][data-value='waite-smith']",
+ )
+ waite_smith.click()
+ self.wait_for(
+ lambda: self.assertEqual(
+ cross.get_attribute("data-spread-shape"), "six-card"
+ )
+ )
+ for pos in (".sea-pos-crown", ".sea-pos-lay", ".sea-pos-cross"):
+ with self.subTest(position=pos):
+ self.assertTrue(
+ picker.find_element(By.CSS_SELECTOR, pos).is_displayed(),
+ f"{pos} should be visible when 6-card spread selected",
+ )
+ # Switch back to a three-card spread → hidden again.
+ combo.click()
+ mbs = picker.find_element(
+ By.CSS_SELECTOR,
+ ".sea-select-list [role='option'][data-value='mind-body-spirit']",
+ )
+ mbs.click()
+ self.wait_for(
+ lambda: self.assertEqual(
+ cross.get_attribute("data-spread-shape"), "three-card"
+ )
+ )
+
+ # ── Test 4 ───────────────────────────────────────────────────────────────
+
+ def test_form_col_renders_decks_lock_hand_del_and_reversal_pct(self):
+ """Form col carries the DECKS swatches (GRAVITY + LEVITY), the
+ LOCK HAND `.btn-primary`, the DEL `.btn-danger`, and the
+ reversal-percentage caption (default 25%)."""
+ picker = self._enter_picker_phase()
+ # DECKS — two stacks
+ stacks = picker.find_elements(By.CSS_SELECTOR, ".sea-deck-stack")
+ self.assertEqual(len(stacks), 2)
+ names = "|".join(
+ s.find_element(By.CSS_SELECTOR, ".sea-stack-name").text.upper()
+ for s in stacks
+ )
+ self.assertIn("GRAVITY", names)
+ self.assertIn("LEVITY", names)
+ # LOCK HAND + DEL
+ lock = picker.find_element(By.CSS_SELECTOR, "#id_sea_lock_hand")
+ self.assertIn("LOCK", lock.text.upper())
+ self.assertIn("HAND", lock.text.upper())
+ self.assertIn("btn-primary", lock.get_attribute("class"))
+ delbtn = picker.find_element(By.CSS_SELECTOR, "#id_sea_del")
+ self.assertIn("DEL", delbtn.text.upper())
+ self.assertIn("btn-danger", delbtn.get_attribute("class"))
+ # Reversal % caption — default 25
+ hint = picker.find_element(By.CSS_SELECTOR, ".sea-reversal-hint")
+ self.assertIn("25", hint.text)
+ self.assertIn("reversal", hint.text.lower())
diff --git a/src/static_src/scss/_gameboard.scss b/src/static_src/scss/_gameboard.scss
index 0ba362a..9e28e47 100644
--- a/src/static_src/scss/_gameboard.scss
+++ b/src/static_src/scss/_gameboard.scss
@@ -282,13 +282,53 @@ body.page-gameboard {
display: flex;
align-items: center;
justify-content: center;
+ gap: 1rem;
}
-// Three-cell horizontal row (leave | core | loom) — strips the crown
-// + lay rows from `.sea-cross`'s 3×3 grid template, leaving only the
-// middle row. Cover stays nested inside `.sea-pos-core` (absolutely
-// positioned overlay handled by _card-deck.scss line 1310-1331).
-.my-sea-cross {
- grid-template-areas: "leave core loom";
+// .my-sea-cross renders all 6 surrounding positions (crown/lay/cross
+// PLUS leave/loom/cover) so the SPREAD dropdown can toggle 3-card vs
+// 6-card layout via a single data-attribute swap (no DOM mutation).
+// Default `three-card` hides the 3 forsaken positions via `display:
+// none`; `six-card` (Celtic Cross variants) renders all 7 cells.
+// Cover always shows (it's part of both shapes — overlaid on sig).
+.my-sea-cross[data-spread-shape="three-card"] {
+ grid-template-areas:
+ "leave core loom";
grid-template-rows: auto;
+
+ .sea-pos-crown,
+ .sea-pos-lay,
+ .sea-pos-cross { display: none; }
+}
+
+.my-sea-cross[data-spread-shape="six-card"] {
+ // Inherits `.sea-cross`'s 3×3 template from _card-deck.scss line 1189-
+ // 1200; nothing to override layout-wise. All cells visible.
+}
+
+// Section dividers inside the SPREAD combobox — labels "3-card spreads"
+// / "6-card spreads" separating the option groups. Styled to echo the
+// `.kit-bag-label` treatment (small uppercase underlined letter-spaced
+// --quaUser) but horizontal rather than vertical (kit-bag uses writing-
+// mode: vertical-rl; this is a flat dropdown).
+.sea-select-list .sea-select-divider {
+ font-size: 0.55rem;
+ text-transform: uppercase;
+ text-decoration: underline;
+ letter-spacing: 0.12em;
+ color: rgba(var(--quaUser), 0.75);
+ padding: 0.4rem 0.6rem 0.2rem;
+ pointer-events: none; // not selectable; combobox.js skips it
+ // (no role=option), but belt-and-braces
+ // against accidental hover/click styles.
+ list-style: none;
+}
+
+// Form col on my-sea — same DRY treatment as the gameroom sea-overlay
+// `.sea-form-col` (handled in _card-deck.scss) but sits next to the
+// picker's cross on a `--duoUser` page. Just constrain the width so it
+// doesn't fight the cross for horizontal space.
+.my-sea-form-col {
+ flex: 0 0 16rem;
+ max-width: 16rem;
}
diff --git a/src/templates/apps/gameboard/my_sea.html b/src/templates/apps/gameboard/my_sea.html
index 04d268f..c99616e 100644
--- a/src/templates/apps/gameboard/my_sea.html
+++ b/src/templates/apps/gameboard/my_sea.html
@@ -66,16 +66,19 @@
- {# Picker phase — three-card cross w. sig pinned in core + #}
- {# cover (overlaid on sig) + leave (left) + loom (right). #}
- {# Crown / lay / cross from the gameroom's 6-position spread #}
- {# are forsaken in the solo flow per user-locked spec. Hidden #}
- {# until FREE DRAW click swaps data-phase to `picker` (see #}
- {# inline JS below); form col (spread dropdown / decks / #}
- {# LOCK HAND / DEL) lands in iter 3. #}
+ {# Picker phase — flexible spread layout. Sig pins .sea-pos- #}
+ {# core; the 6 surrounding positions (crown/leave/loom/lay/ #}
+ {# cover/cross) all render unconditionally so the SPREAD #}
+ {# dropdown can swap `.my-sea-cross[data-spread-shape]` #}
+ {# between `three-card` (default — hides crown/lay/cross via #}
+ {# SCSS) + `six-card` (Celtic Cross variants — shows all). #}
+ {# Hidden until FREE DRAW click swaps data-phase to `picker`. #}
+
+ {# Form col — SPREAD combobox + DECKS swatches + LOCK #}
+ {# HAND / DEL. DRY w. gameroom `_sea_overlay.html`'s #}
+ {# `.sea-form-col` shape; my-sea-specific differences: #}
+ {# (a) 6 spread options under 2 section dividers, #}
+ {# (b) default = situation-action-outcome (3-card), #}
+ {# (c) no `.sea-modal-header` (the gateway IS the page). #}
+
+
+