diff --git a/src/apps/gameboard/tests/integrated/test_views.py b/src/apps/gameboard/tests/integrated/test_views.py
index f90b5e6..da04e6f 100644
--- a/src/apps/gameboard/tests/integrated/test_views.py
+++ b/src/apps/gameboard/tests/integrated/test_views.py
@@ -695,9 +695,27 @@ class MySeaSpreadFormTemplateTest(TestCase):
'data-value="situation-action-outcome" aria-selected="true"', html,
)
- def test_cross_carries_initial_three_card_spread_shape(self):
+ def test_cross_carries_initial_data_spread_sao(self):
+ # `.my-sea-cross[data-spread]` is the per-spread visibility key;
+ # default-spread context value renders into the attribute. SCSS
+ # rules in _gameboard.scss hide the inactive positions per spread.
response = self.client.get(reverse("my_sea"))
- self.assertContains(response, 'data-spread-shape="three-card"')
+ self.assertContains(response, 'data-spread="situation-action-outcome"')
+
+ def test_template_renders_sao_position_labels_on_default(self):
+ # Server-renders the SAO position labels into the empty drop-zone
+ # `.sea-pos-label` spans so the page is correct before JS boots.
+ # JS swaps labels on spread change.
+ response = self.client.get(reverse("my_sea"))
+ html = response.content.decode()
+ self.assertIn('data-position="lay">Situation', html)
+ self.assertIn('data-position="cover">Action', html)
+ self.assertIn('data-position="crown">Outcome', html)
+ # Inactive-for-SAO positions render their span but w. empty
+ # textContent (JS fills them on spread switch).
+ self.assertIn('data-position="leave">', html)
+ self.assertIn('data-position="loom">', html)
+ self.assertIn('data-position="cross">', html)
def test_form_col_renders_decks_lock_hand_del_and_reversal_hint(self):
response = self.client.get(reverse("my_sea"))
diff --git a/src/functional_tests/test_game_my_sea.py b/src/functional_tests/test_game_my_sea.py
index 0a176a1..d569cd3 100644
--- a/src/functional_tests/test_game_my_sea.py
+++ b/src/functional_tests/test_game_my_sea.py
@@ -399,24 +399,24 @@ class MySeaPickerPhaseTest(FunctionalTest):
# ── Test 3 ───────────────────────────────────────────────────────────────
- 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."""
+ def test_picker_renders_sao_default_position_subset(self):
+ """Default spread = Situation/Action/Outcome (SAO) → only lay
+ (Situation) + cover (Action) + crown (Outcome) visible from the
+ 6 surrounding positions; leave / loom / cross hidden. All 6
+ cells render in DOM so spread-switching never re-mutates the
+ cross structure — per-spread visibility lives in SCSS via
+ `.my-sea-cross[data-spread="..."]` rules."""
picker = self._enter_picker_phase()
- 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)
+ visible = {".sea-pos-lay", ".sea-pos-cover", ".sea-pos-crown"}
+ hidden = {".sea-pos-leave", ".sea-pos-loom", ".sea-pos-cross"}
+ for pos in visible | hidden:
+ with self.subTest(position=pos):
+ elements = picker.find_elements(By.CSS_SELECTOR, pos)
+ self.assertEqual(len(elements), 1, f"{pos} should render in DOM")
+ expected_visible = pos in visible
self.assertEqual(
- 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",
+ elements[0].is_displayed(), expected_visible,
+ f"{pos} visibility wrong for SAO default; expected {expected_visible}",
)
@@ -495,16 +495,13 @@ class MySeaSpreadFormTest(FunctionalTest):
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)."""
+ the hidden `` initial value + on
+ `.my-sea-cross[data-spread]`."""
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']"
)
@@ -512,52 +509,132 @@ class MySeaSpreadFormTest(FunctionalTest):
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")
+ self.assertEqual(
+ cross.get_attribute("data-spread"), "situation-action-outcome",
+ )
# ── 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`."""
+ def test_picking_spread_swaps_data_spread_and_position_visibility(self):
+ """Each spread reveals its own position subset (user-locked
+ 2026-05-19):
+ PPF → leave + cover + loom visible
+ SAO → lay + cover + crown
+ MBS → crown + lay + loom
+ DOS → loom + cross + cover
+ CC variants → all 6 surrounding positions.
+ `.my-sea-cross[data-spread]` swaps on combobox change; SCSS
+ rules toggle the inactive positions to `display: none`."""
+ ALL_POSITIONS = {"crown", "leave", "cover", "cross", "loom", "lay"}
+ SPREAD_POSITIONS = {
+ "past-present-future": {"leave", "cover", "loom"},
+ "situation-action-outcome": {"lay", "cover", "crown"},
+ "mind-body-spirit": {"crown", "lay", "loom"},
+ "desire-obstacle-solution": {"loom", "cross", "cover"},
+ "waite-smith": ALL_POSITIONS,
+ "escape-velocity": ALL_POSITIONS,
+ }
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"
+ cross = picker.find_element(By.CSS_SELECTOR, ".my-sea-cross")
+ combo = picker.find_element(By.CSS_SELECTOR, "[data-combobox]")
+
+ def _pick(value):
+ # Combobox click outside an open dropdown opens it; click on
+ # an option inside selects + closes. Re-opening for each pick
+ # keeps the test deterministic.
+ if combo.get_attribute("aria-expanded") != "true":
+ combo.click()
+ opt = picker.find_element(
+ By.CSS_SELECTOR,
+ f".sea-select-list [role='option'][data-value='{value}']",
)
- )
- 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",
+ opt.click()
+
+ for value, expected_visible in SPREAD_POSITIONS.items():
+ with self.subTest(spread=value):
+ _pick(value)
+ self.wait_for(
+ lambda v=value: self.assertEqual(
+ cross.get_attribute("data-spread"), v
+ )
)
- # 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']",
+ for pos in ALL_POSITIONS:
+ element = picker.find_element(
+ By.CSS_SELECTOR, f".sea-pos-{pos}"
+ )
+ should_show = pos in expected_visible
+ self.assertEqual(
+ element.is_displayed(), should_show,
+ f"spread={value} pos={pos}: expected is_displayed={should_show}",
+ )
+
+ # ── Test 4 ───────────────────────────────────────────────────────────────
+
+ def test_per_spread_position_labels_render_and_update(self):
+ """Each visible empty slot carries a `.sea-pos-label` caption
+ whose text matches the spread's per-position label map (e.g.
+ SAO default: lay='Situation', cover='Action', crown='Outcome').
+ JS updates labels on spread change. Reappropriates the
+ GRAVITY/LEVITY (`.sea-stack-name`) caption styling."""
+ SPREAD_LABELS = {
+ "situation-action-outcome": {"lay": "Situation", "cover": "Action", "crown": "Outcome"},
+ "past-present-future": {"leave": "Past", "cover": "Present", "loom": "Future"},
+ "mind-body-spirit": {"crown": "Mind", "lay": "Body", "loom": "Spirit"},
+ "desire-obstacle-solution": {"loom": "Desire", "cross": "Obstacle","cover":"Solution"},
+ "waite-smith": {"crown": "Crown", "leave": "Beneath", "cover": "Cover",
+ "cross": "Cross", "loom": "Before", "lay": "Behind"},
+ }
+ picker = self._enter_picker_phase()
+ combo = picker.find_element(By.CSS_SELECTOR, "[data-combobox]")
+
+ def _pick(value):
+ if combo.get_attribute("aria-expanded") != "true":
+ combo.click()
+ picker.find_element(
+ By.CSS_SELECTOR,
+ f".sea-select-list [role='option'][data-value='{value}']",
+ ).click()
+
+ # SAO default — assert labels via the server-rendered initial state.
+ for pos, expected_label in SPREAD_LABELS["situation-action-outcome"].items():
+ with self.subTest(spread="situation-action-outcome", position=pos):
+ label_el = picker.find_element(
+ By.CSS_SELECTOR,
+ f".sea-pos-label[data-position='{pos}']",
+ )
+ self.assertEqual(
+ label_el.get_attribute("textContent").strip(),
+ expected_label,
+ )
+
+ # Switch to each other spread + verify the labels update.
+ for spread, position_to_label in SPREAD_LABELS.items():
+ if spread == "situation-action-outcome":
+ continue
+ _pick(spread)
+ for pos, expected_label in position_to_label.items():
+ with self.subTest(spread=spread, position=pos):
+ label_el = self.wait_for(
+ lambda p=pos, lbl=expected_label: self._wait_label(p, lbl, picker)
+ )
+ self.assertEqual(
+ label_el.get_attribute("textContent").strip(),
+ expected_label,
+ )
+
+ def _wait_label(self, position, expected_label, picker):
+ el = picker.find_element(
+ By.CSS_SELECTOR, f".sea-pos-label[data-position='{position}']"
)
- mbs.click()
- self.wait_for(
- lambda: self.assertEqual(
- cross.get_attribute("data-spread-shape"), "three-card"
+ if el.get_attribute("textContent").strip() != expected_label:
+ raise AssertionError(
+ f"label@{position}: got "
+ f"{el.get_attribute('textContent')!r}, want {expected_label!r}"
)
- )
+ return el
# ── Test 4 ───────────────────────────────────────────────────────────────
diff --git a/src/static_src/scss/_gameboard.scss b/src/static_src/scss/_gameboard.scss
index 9e28e47..49c14a0 100644
--- a/src/static_src/scss/_gameboard.scss
+++ b/src/static_src/scss/_gameboard.scss
@@ -285,25 +285,64 @@ body.page-gameboard {
gap: 1rem;
}
-// .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;
+// .my-sea-cross renders all 6 surrounding positions (crown/leave/lay/
+// loom + cover/cross overlaid on core) unconditionally. The SPREAD
+// dropdown sets `data-spread=""` on this element; per-spread
+// rules below hide the positions each spread doesn't use. Inherits
+// the 3×3 `grid-template-areas` from _card-deck.scss line 1189-1200
+// so visible cells land in their canonical positions; hidden cells
+// just leave their grid slots empty.
+//
+// Per-spread position subsets — user-locked 2026-05-19:
+// PPF: leave (1) cover (2) loom (3) — horizontal middle row
+// SAO: lay (1) cover (2) crown (3) — vertical center column
+// MBS: crown (1) lay (2) loom (3) — T-shape (crown + lay vertical, loom right)
+// DOS: loom (1) cross (2) cover (3) — sig-anchored cluster + loom
+// CC variants: all 6 positions (Waite-Smith / Escape Velocity differ in DRAW ORDER only,
+// not in position visibility).
+.my-sea-cross[data-spread="past-present-future"] {
.sea-pos-crown,
- .sea-pos-lay,
+ .sea-pos-cross,
+ .sea-pos-lay { display: none; }
+}
+
+.my-sea-cross[data-spread="situation-action-outcome"] {
+ .sea-pos-leave,
+ .sea-pos-loom,
.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.
+.my-sea-cross[data-spread="mind-body-spirit"] {
+ .sea-pos-leave,
+ .sea-pos-cover,
+ .sea-pos-cross { display: none; }
+}
+
+.my-sea-cross[data-spread="desire-obstacle-solution"] {
+ .sea-pos-leave,
+ .sea-pos-crown,
+ .sea-pos-lay { display: none; }
+}
+
+// Celtic Cross variants (waite-smith / escape-velocity) — all positions
+// visible by default. No `display: none` overrides needed.
+
+// Position-name caption inside each empty `.sea-card-slot--empty` —
+// re-appropriates the GRAVITY/LEVITY `.sea-stack-name` typographic
+// look (_card-deck.scss line 1557): small uppercase letter-spaced w.
+// a subtle scaleY stretch, --terUser ink at 0.6 opacity. No polarity
+// coloring — these are spread-position labels, not deck identifiers.
+.sea-pos-label {
+ font-size: 0.65rem;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ font-weight: 600;
+ opacity: 0.6;
+ transform: scaleY(1.2);
+ color: rgba(var(--terUser), 1);
+ text-align: center;
+ pointer-events: none;
}
// Section dividers inside the SPREAD combobox — labels "3-card spreads"
diff --git a/src/templates/apps/gameboard/my_sea.html b/src/templates/apps/gameboard/my_sea.html
index c99616e..4696b39 100644
--- a/src/templates/apps/gameboard/my_sea.html
+++ b/src/templates/apps/gameboard/my_sea.html
@@ -66,21 +66,27 @@
- {# 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`. #}
+ {# Picker phase — per-spread flexible layout. Sig pins .sea- #}
+ {# pos-core; the 6 surrounding positions all render in DOM #}
+ {# so the SPREAD dropdown can swap `.my-sea-cross[data- #}
+ {# spread]` between the 4 three-card variants (each w. its #}
+ {# own 3-position subset + draw order) + the 2 six-card #}
+ {# Celtic Cross variants (all 6 surrounding positions). #}
+ {# Each empty slot carries a `.sea-pos-label` caption (re- #}
+ {# appropriated from the GRAVITY/LEVITY .sea-stack-name look) #}
+ {# that JS updates per spread. #}