Compare commits

..

9 Commits

Author SHA1 Message Date
Disco DeDisco
31ed2bda0e Billnote palette swatch: DRY w. .swatch via palette-swatch-bg mixin; fixes --quaUser→--quiUser drift
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed
Note swatches (right-side thumbnail + modal preview tiles) were inlining their own linear-gradient that ended on --quaUser instead of --quiUser, so the 4th band rendered the wrong palette colour (e.g., Maryland's red showed up on the Baltimorean Note where the My Palettes applet's matching tile shows none). Extracted the canonical gradient into a @mixin palette-swatch-bg in _palette-picker.scss and @include'd it from .swatch, .note-item__palette + .note-swatch-body so all three share one source of truth.

Code architected by Disco DeDisco <discodedisco@outlook.com>

Git commit message Co-Authored-By:

Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 22:09:01 -04:00
Disco DeDisco
b6e93b9d64 My Sea iter-4a follow-up batch: modal port + draw polish + label positioning + Major Arcana fix — TDD
User-driven bug-squash + UX-polish cycle on top of iter 4a (ca2a62f). All 14 fixes ship behind the same iter-4a banner since they close the substage's UX gaps without expanding scope to iter 4b's persistence layer.

SeaDeal modal port — extracted apps/gameboard/_partials/_sea_stage.html shared by gameroom + my-sea; aliased .my-sea-picker w. id=id_sea_overlay so SeaDeal.init() finds it; FLIP click → SeaDeal.openStage delegation instead of bare _fillSlot. Fixes the user-reported 'thumbnail disappears' bug — slot was landing at opacity 0 (.--filled w.o .--visible) because SeaDeal's _hideStage (which adds --visible on modal dismiss) was never running. 3 new FTs cover the modal flow.

Spread lock + DEL reshuffle — _lockSpread/_unlockSpread toggle .sea-select--locked class on the combobox; first deposit locks, _resetHand unlocks. _reshuffleDeck Fisher-Yates over combined piles + re-rolls 25% reversal axis on DEL so successive DELs don't re-deal the same hand. Verified Claudezilla: 3 DEL cycles produced distinct lay cards (150 → 114 → 155).

Cover/cross empty slots — subtle dotted outline (transparent bg + 0.25 alpha border) w. --duoUser mask reveal on hover/touch. Per the user spec; rule lives in _card-deck.scss (shared between gameroom + my-sea). Plus matching label-opacity (0.25 idle → 0.6 hover) via CSS :hover ancestor propagation.

DOS spec — Solution moved from cover → crown per user correction. DRAW_ORDER ['loom', 'cross', 'crown']; POSITION_LABELS {loom: Desire, cross: Obstacle, crown: Solution}; SCSS hide list flipped from [leave, crown, lay] → [leave, cover, lay]; FT/IT assertions updated.

SAO → DOS soft-reload bug — Firefox autofill on hidden input restored the previous-session DOS value, tripping combobox.js's change-event guard. Fix: autocomplete=off + force-sync hidden.value from server-rendered aria-selected option in init. Captured as feedback_firefox_autofill_hidden_inputs (generalizable trap).

.sea-pos-label outside .sea-card-slot — moved label to be a sibling of the slot in the cell, so SeaDeal innerHTML clobber on draw doesn't erase it. Per-position absolute positioning touching slot borders: crown/cover above (translate -50%, 0.1rem, scaleY 1.2); lay/cross below (translate -50%, -0.1rem, scaleY 1.2); leave left, CCW (writing-mode vertical-rl + rotate 180deg + scaleX 1.2); loom right, CW (writing-mode vertical-rl + scaleX 1.2). scaleX for rotated labels (not scaleY) — perpendicular to text-flow is the visible-width direction after rotation. .my-sea-cross gap bumped to 1.75rem for label clearance.

Escape Velocity label swaps — POSITION_LABELS for escape-velocity: {crown: Crown, leave: Lay, cover: Cover, cross: Cross, loom: Loom, lay: Leave}. Replaces the Waite-Smith Behind/Beneath/Before per user spec.

SPREAD dropdown portal — .my-sea-form-col .sea-form-main { overflow: visible } + .sea-select-list { z-index: 1000 } so the dropdown extends past the form-main scroll area + sits above the picker stacking ints. Gameroom .sea-form-main still scrolls (only my-sea opts out).

Major Arcana polarity-split rendering — added 9 missing _card_dict keys to my-sea's _my_sea_deck_data to match gameroom epic.views.sea_deck's contract: levity_emanation, gravity_emanation, levity_reversal, gravity_reversal, italic_word, keywords_upright, keywords_reversed, energies, operations. Without these StageCard.populateCard falls through to plain name_title for trumps 19-21 + cards 48-49. Iter 4b cleanup candidate: extract apps.epic.utils.card_dict() to DRY the now-identical helpers.

Tests deferred — user explicitly belayed FT runs during the bug-fix substage. Iter 4b will re-establish a green sweep before its commit lands.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 22:02:27 -04:00
Disco DeDisco
ca2a62fd84 My Sea client-side card draw + DEL + LOCK HAND visual lock — Sprint 5 iter 4a of My Sea roadmap — TDD
Two-step deposit flow lifted from gameroom sea.js's `_fillSlot`: click a polarity stack → FLIP btn appears on the active stack → click FLIP → top card pops from that polarity's pile and deposits into `DRAW_ORDER[currentSpread][_filled]`. Per-spread hand-size completion (3 for any three-card spread, 6 for Celtic Cross variants) flips LOCK HAND from disabled to enabled. DEL fully resets — every filled slot reverts to `.sea-card-slot--empty` w. its `.sea-pos-label` re-rendered, piles re-clone from the immutable server payload, LOCK HAND re-disables. Switching spreads mid-draw triggers the same reset (position-subset + draw-order both change). LOCK HAND click visually locks the picker (`.my-sea-picker--locked` + `.btn-disabled` on stacks/DEL/itself) — server persistence defers to iter 4b.

**Card source**: new `_my_sea_deck_data(user)` helper in `apps/gameboard/views.py` mirrors the gameroom `epic.views.sea_deck` JSON contract — same `_card_dict` shape (id/name/arcana/suit/number/corner_rank/suit_icon/name_group/name_title/qualifiers/reversed). Differences from the room version per spec lock:

- No `room` context; excludes only the **current user's significator** (no other seated gamers).
- Backup-deck fallthrough: `user.equipped_deck or DeckVariant.filter(slug='earthman').first()` — mirrors `personal_sig_cards`, keeps the no-deck-equipped path working.
- Reversal probability hardcoded at 0.25 per the iter 3 spec lock. (Future per-user config will share a helper w. the gameroom's `stack_reversal_probability`.)

Deck data flows in via `{{ sea_deck_data|json_script:"id_my_sea_deck" }}` — Django's built-in script-tag JSON embedder. JS reads `id_my_sea_deck`'s textContent on init + maintains `_levityPile` / `_gravityPile` working copies that shift one card per deposit. DEL re-clones from the immutable initial payload rather than re-fetching (server is stateless wrt this client-side dealing — same shuffle survives DEL).

`.my-sea-picker--locked` SCSS: `.sea-deck-stack.btn-disabled` gets `pointer-events: none; opacity: 0.5` per [[feedback_btn_disabled_pointer_events]] convention. Hand state freezes; only iter 4b's LOCK HAND POST can mutate the persisted state from there.

**FTs** (9 in new `MySeaCardDrawTest`, using a `_draw_one(picker, polarity)` helper that clicks the stack + waits for the FLIP btn to surface + clicks FLIP):
- deck JSON embedded w. two polarity halves, disjoint card ids;
- user significator excluded from both halves;
- first LEVITY draw lands in SAO's first slot (`.sea-pos-lay`) w. `.sea-card-slot--filled.sea-card-slot--levity` + corner_rank inside;
- second draw (GRAVITY) lands in SAO's second slot (`.sea-pos-cover`) w. polarity reflected;
- 3 draws complete the SAO hand → LOCK HAND `disabled` attribute drops;
- DEL resets every filled slot, LOCK HAND re-disables;
- LOCK HAND click adds `.my-sea-picker--locked` + `.btn-disabled` on the stacks;
- switching to MBS mid-draw wipes the in-progress hand.

**ITs** (6 in new `MySeaDeckDataViewTest`):
- context `sea_deck_data` has `levity` + `gravity` keys, both lists;
- user significator absent from both halves;
- halves are disjoint sets of card ids;
- card dicts carry `id` / `corner_rank` / `suit_icon` / `reversed` (bool); shape matches gameroom contract;
- template embeds via `<script id="id_my_sea_deck" type="application/json">`;
- no-equipped-deck users get the Earthman backup pile (not empty).

Tests: 41/41 FT green across test_bill_my_sign + test_game_my_sea; 1055/1055 IT/UT green in 53s.

**Deferred to iter 4b** (server persistence):
- `MySeaDraw` model (FK to user, spread name, JSON field for hand layout, created_at);
- LOCK HAND POST endpoint → commits the hand to the DB;
- 1/24h FREE DRAW quota check + the eventual FREE DRAW → DRAW SEA btn-label swap;
- Sig stage card full populate (name/qualifier/keywords/FYI/SPIN/FLIP) — currently corner rank + suit icon only.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 20:02:20 -04:00
Disco DeDisco
f154d660bd My Sea per-spread positions + draw-order JS config + position labels — Sprint 5 iter 3 follow-up — TDD
User-locked spec 2026-05-19: each three-card spread uses a DIFFERENT 3-position subset of the 6 surrounding positions, in its own draw order. Replaces the iter-3 binary `data-spread-shape="three-card|six-card"` model w. per-spread `data-spread="<value>"`. Closes iter 3 cleanly + scaffolds the draw-order data iter 4 will consume.

Position subsets (per spread):
  PPF → leave (1) · cover (2) · loom  (3)
  SAO → lay   (1) · cover (2) · crown (3)
  MBS → crown (1) · lay   (2) · loom  (3)
  DOS → loom  (1) · cross (2) · cover (3)
  Waite-Smith     → all 6 surrounding (cover · cross · crown · lay · loom · leave)
  Escape Velocity → all 6 surrounding (cover · cross · lay · leave · crown · loom)

All 6 cells continue to render in DOM unconditionally — `.my-sea-cross[data-spread="<value>"]` SCSS rules hide inactive positions per spread via `display: none`. Cover/cross live nested inside `.sea-pos-core` so their absolute-overlay positioning rules from `_card-deck.scss:1310-1331` carry over for free.

**Position labels** (re-appropriated `.sea-stack-name` typography per user) — `.sea-pos-label` inside each empty `.sea-card-slot--empty` carries the per-spread caption. Server-renders SAO's labels by default (lay=Situation, cover=Action, crown=Outcome); JS swaps labels via `POSITION_LABELS[spread]` lookup on combobox change. Inactive-for-spread positions render their span w. empty `textContent` so JS only has to set text, never toggle visibility. Celtic Cross variants share the gameroom's existing position vocabulary (Crown/Beneath/Cover/Cross/Before/Behind).

**DRAW_ORDER JS const** baked into the inline picker IIFE — array of position names per spread, ready for iter 4's deck-click-deposit logic to consume. Exposed via `window._mySeaDrawOrder` so iter-4 click handlers can `window._mySeaDrawOrder[currentSpread][nextSlotIdx]` to resolve the target position. No click handlers wired yet — iter 4 territory.

**Selenium trap caught**: the combobox click-twice-on-the-toggle bug — re-clicking the combobox while `aria-expanded='true'` closes the dropdown (combobox.js's toggle behavior). Test 3's spread-cycling iterates through 6 spreads, each needs the dropdown OPEN before clicking a new option; added a `_pick(value)` helper that checks `aria-expanded` first.

Files:
- `templates/apps/gameboard/my_sea.html` — `.my-sea-cross[data-spread]` w. server-rendered default; each empty slot wraps a `<span class="sea-pos-label" data-position="<name>">` (SAO labels seeded inline, others empty initially); inline IIFE adds `DRAW_ORDER` + `POSITION_LABELS` consts + `syncLabels()` that swaps captions on `change`.
- `static_src/scss/_gameboard.scss` — drops the `data-spread-shape="three-card"|"six-card"` rules; adds 4 per-spread visibility rules (PPF/SAO/MBS/DOS). Celtic Cross variants inherit the gameroom's full 3×3 grid w. no overrides. `.sea-pos-label` style mirrors `.sea-stack-name` from _card-deck.scss line 1557 (small-uppercase-letter-spaced-scaleY) sans the polarity color — these aren't deck identifiers, just spread-position captions.
- `apps/gameboard/tests/integrated/test_views.py` — IT `test_cross_carries_initial_three_card_spread_shape` renamed + retargeted to `data-spread="situation-action-outcome"`; new IT `test_template_renders_sao_position_labels_on_default` pins the seeded SAO labels + empty spans for inactive positions.
- `functional_tests/test_game_my_sea.py` — iter-2's `test_picker_hides_six_card_only_positions_by_default` renamed to `test_picker_renders_sao_default_position_subset` w. SAO-specific visibility expectations (lay/cover/crown visible; leave/loom/cross hidden). iter-3's `test_picking_celtic_cross_reveals_six_card_positions` rewritten + expanded to `test_picking_spread_swaps_data_spread_and_position_visibility` — cycles through all 6 spreads, asserts `data-spread` attribute + per-position `is_displayed()` for each. New `test_per_spread_position_labels_render_and_update` cycles through 5 spreads (SAO default + 4 switches) asserting captions match the spec.

Tests: 33/33 FT green across test_bill_my_sign + test_game_my_sea; 1049/1049 IT/UT green in 52s.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 19:38:53 -04:00
Disco DeDisco
fd5db951a7 My Sea form col + SPREAD dropdown w. 3-card/6-card section dividers — Sprint 5 iter 3 of My Sea roadmap — TDD
Picker phase form col: SPREAD combobox w. 6 spread options under 2 horizontal section dividers ("3-card spreads" / "6-card spreads"), reversal-% caption, GRAVITY + LEVITY deck swatches, LOCK HAND + DEL btns. Default = Situation, Action, Outcome (a 3-card spread). Selecting a 6-card spread (Celtic Cross Waite-Smith or Escape Velocity) swaps `.my-sea-cross[data-spread-shape]` from `three-card` to `six-card` — revealing the crown / lay / cross cells that the default 3-card variants hide.

Naming correction (user-locked): the spread itself is a "three-card spread" not a "three-card cross" — "cross" stays scoped to the Celtic Cross variants (6-card spreads). CSS class `.my-sea-cross` carries grid-container semantics regardless of which spread shape is active; the spread-vs-cross distinction lives at the spread-name layer only.

- **View** (gameboard/views.py): `my_sea` adds `default_spread = "situation-action-outcome"` + `reversals_pct = 25` context keys.
- **Template** (my_sea.html): renders all 7 cross cells (crown/leave/core+cover+cross/loom/lay) unconditionally + adds `data-spread-shape="three-card"` to `.my-sea-cross`. Form col DRY-reuses gameroom `_sea_overlay.html`'s `.sea-form-col` shape — `.sea-form-main` w. `.sea-field` (SPREAD label + reversal hint + custom combobox) + `.sea-stacks` (GRAVITY + LEVITY swatches) + `.sea-form-actions` (LOCK HAND + DEL). 6 options + 2 dividers in the combobox `<ul>`; dividers are `role="presentation"` so `combobox.js` skips them naturally. Inline IIFE listens for the hidden `<input id="id_sea_spread">`'s `change` event + sets `.my-sea-cross`'s `data-spread-shape` based on whether the value is in `['waite-smith', 'escape-velocity']`. No new combobox.js wiring — the existing module's `change`-bubbling contract feeds straight in.
- **SCSS** (_gameboard.scss):
  - `.my-sea-cross[data-spread-shape="three-card"]` — single-row `"leave core loom"` grid + `display: none` on crown/lay/cross.
  - `.my-sea-cross[data-spread-shape="six-card"]` — inherits the gameroom `.sea-cross`'s 3×3 grid + reveals all cells.
  - `.sea-select-divider` — section header style mirrors `.kit-bag-label`'s small-uppercase-underlined-letter-spaced --quaUser/0.75 treatment but HORIZONTAL (kit-bag uses `writing-mode: vertical-rl`; dropdown menus are flat). `pointer-events: none` belt-and-braces against accidental click/hover.
  - `.my-sea-form-col` — width-constrains the form col so the picker's cross + form sit side-by-side.

**Iter-2 contract updated** (cells in DOM, hidden via CSS for 3-card default):

- FT `test_picker_does_not_render_forsaken_positions` → renamed to `test_picker_hides_six_card_only_positions_by_default` — asserts the 3 cells are in the DOM but `is_displayed() == False` so iter-3's spread switch can reveal them via CSS without re-rendering.
- IT `test_picker_does_not_render_forsaken_positions` → renamed to `test_picker_renders_six_card_only_positions_for_spread_switch` — assertContains the classes (server now renders them unconditionally).

**Tests**:

- 4 FTs in new `MySeaSpreadFormTest`: combobox renders 6 options + 2 dividers w. correct labels, default is Situation/Action/Outcome (hidden input value + visible current-label span + cross's data-spread-shape), picking Celtic Cross flips data-spread-shape to six-card + reveals crown/lay/cross, form col carries DECKS swatches + LOCK HAND + DEL + reversal-% caption. Combobox `<li>` options are inside `aria-expanded='false'` listbox → use `get_attribute("textContent")` not `.text` (which returns "" for Selenium-hidden elements).
- 7 ITs in new `MySeaSpreadFormTemplateTest`: default_spread + reversals_pct context keys, all 6 options + both labels render, 2 dividers render w. expected text, default option carries aria-selected="true", cross's initial data-spread-shape="three-card", form col DECKS + buttons + reversal hint render.

Tests: 32/32 FT green across test_bill_my_sign + test_game_my_sea; 1048/1048 IT/UT green in 52s.

Card-draw mechanics (clicking a deck swatch deposits a card into the next empty slot; LOCK HAND commits the draw) defer to iter 4 — this iter ships the spread-selection + layout-shape switch UI; the buttons are stubs (LOCK HAND starts disabled, DEL is a placeholder).

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 17:23:25 -04:00
Disco DeDisco
f5fc1e15f8 My Sea picker phase: three-card cross (sig + cover/leave/loom) — Sprint 5 iter 2 of My Sea roadmap — TDD
After the FREE DRAW click on iter 1's landing swaps `data-phase` to `picker`, the picker now renders a stripped Celtic Cross: user's saved significator pinned in `.sea-pos-core`, three drawn-card drop zones around it — cover (overlaid on sig), leave (left of core), loom (right of core). Crown / lay / cross from the gameroom's 6-position spread are deliberately forsaken (user-locked spec).

DRY w. the gameroom sea-overlay: reuses `.sea-cards-col` + `.sea-cross` + `.sea-crucifix-cell` + `.sea-pos-*` + `.sea-card-slot--empty` + `.sea-sig-card` classes & their _card-deck.scss styling (1181-1331). Only divergence from the room: a `.my-sea-cross` modifier in `_gameboard.scss` overrides `grid-template-areas` from the room's `". crown . / leave core loom / . lay ."` 3×3 to a single-row `"leave core loom"` — drops the crown + lay rows since those positions are forsaken. Cover stays nested inside `.sea-pos-core` so the absolute-overlay rules from _card-deck.scss line 1310-1331 carry over for free.

Picker bg = `rgba(var(--duoUser), 1)` on `.my-sea-page[data-phase="picker"]` — parallels `.my-sign-page[data-phase="picker"]` from _card-deck.scss line 704, so the landing→picker swap reads as a continuous surface (hex face → felt) like on /billboard/my-sign/.

The sig card renders w. `data-card-id="{{ significator.id }}"` + `.fan-corner-rank` + `.fa-solid {suit-icon}` (mirrors the gameroom's `.sea-sig-card` minimal markup at `_sea_overlay.html` line 33-39). Full card-face / FYI / SPIN wiring deferred — iter 3 lands the form col + interactive draw flow.

View context: `my_sea` now passes `significator` (FK pass-through) + `significator_reversed` so the template can render the corner rank + suit icon at render time without re-fetching.

- 3 FTs in new `MySeaPickerPhaseTest`: sig card w. `data-card-id` matching `user.significator.id` in `.sea-pos-core`; cover/leave/loom empty drop zones render; crown/lay/cross absent. Shared `_enter_picker_phase()` helper polls for `data-phase='picker'` after the ~800ms seat-1C animation delay.
- 4 ITs in new `MySeaPickerPhaseTemplateTest`: server-render contract for sig in core + cover/leave/loom classes + forsaken-positions-absent + picker entirely absent when user has no sig (4b gate precedence).

Tests: 28/28 FT green across test_bill_my_sign + test_game_my_sea (~219s); 1041/1041 IT/UT green (53s).

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 16:06:14 -04:00
Disco DeDisco
285597b467 My Sea FREE DRAW + seat-1C seated transition — Sprint 5 iter 1 follow-up — TDD
Iter-1 follow-up after the user re-spec'd the FREE DRAW click behavior:

- **Btn label** DRAW SEA → FREE DRAW (the 1/24h free-quota draw). Element ID `id_draw_sea_btn` retained — describes intent, not label, so a future sprint can conditionally swap the label back to DRAW SEA once the daily free has been used (at which point the btn calls the room gatekeeper partial for token-deposit per [[project-my-sea-roadmap]] Sprint 6).
- **Chair seat markup** — each `.table-seat` now renders w. `.fa-chair` + `.seat-position-label` (1C-6C) + `.position-status-icon.fa-solid.fa-ban` mirroring the room's hex grammar from `_table_positions.html`. **`.seat-position-label`** (not `.seat-role-label`) because my-sea is the solo flow — no roles — and the existing room class carries role-grammar semantics that don't apply here. New class gets its own grid placement in `_gameboard.scss` (col 2 / row 1 default, col 1 for left-side seats 3/4/5 per the room's flip rule).
- **FREE DRAW click flow** — (1) seat 1C immediately gains `.seated` class & its `.fa-ban` icon swaps to `.fa-circle-check`; (2) after 800ms (so the user sees the seat animation against `_room.scss`'s 0.6s `color`/`filter` transition on `.fa-chair`), `data-phase` swaps to `picker` & landing hides. Seat 1C-only because my-sea is single-user-per-page until friend-invite lands — the user always occupies the lowest-numeral seat.
- **`.table-seat.seated` SCSS** in `_gameboard.scss` — `--terUser` chair color + `drop-shadow(--ninUser)` glow. Mirrors `_room.scss:626` `.table-seat.active .fa-chair` styling but uses a stable `.seated` class (semantically distinct: `.active` = current turn in a multi-user room, `.seated` = draw-locked occupant in the solo flow). Status icon green via the existing `_room.scss:616` `.position-status-icon.fa-circle-check` rule — no new color rule needed.
- **FT/IT updates** — renamed `test_landing_renders_hex_with_draw_sea_btn` → `…_free_draw_btn` w. "FREE DRAW" label assertions; T2 extended to assert `.position-status-icon.fa-ban` on each seat at render time; T3 (formerly `…_transitions_to_picker_phase`) rewritten as `test_free_draw_click_seats_user_in_1C_then_swaps_phase` to pin the full click contract: 1C goes `.seated` + `.fa-circle-check`, seats 2-6 unchanged, picker phase swap after the delay. New IT `test_landing_renders_position_status_ban_icon_on_each_seat` asserts initial ban + `.seat-position-label` counts.

**Substring trap caught** (worth a sticky note): bare class-name substrings (`fa-ban`, `position-status-icon`) appear ALSO in the inline JS handler's `classList.remove(…)` / `querySelector(…)` arg strings → `html.count("fa-ban") = 7`, not 6. Tightened IT assertions to match the full class attribute (`class="position-status-icon fa-solid fa-ban"`) — never count bare class names in `assertContains` / `html.count` when the same class is JS-manipulated client-side.

Tests: 25/25 FT green across test_bill_my_sign + test_game_my_sea (165s); 1037/1037 IT/UT green (49s).

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 15:48:07 -04:00
Disco DeDisco
de48ae226d My Sea DRAW SEA landing — Sprint 5 iter 1 of My Sea roadmap — TDD
DRAW SEA landing UX on /gameboard/my-sea/ for users past the [[sprint-my-sea-sign-gate-may19]] gate. DRY table hex (reused from the room shell + my-sign Sprint 4a iter 3) w. 6 chair seats labeled 1C-6C (placeholder for friend-invite per the My Sea roadmap "Six chairs retained even in solo" anchor) + central DRAW SEA `.btn-primary` mirroring SCAN SIGN on /billboard/my-sign/. Click swaps `.my-sea-page[data-phase]` from `landing` to `picker`; the picker UX itself (three-card cross w. cover/leave/loom + form col / spread dropdown / decks / LOCK HAND / DEL) lands in iters 2 + 3.

The 'C' suffix on the chair labels = "Chair" (user-locked); no role semantics (this is a solo draw, not a 6-player role assignment). `.table-seat` CSS class + `data-slot` attribute preserved so the room's existing `[data-slot="N"]` positioning rules (`_room.scss` L583-588) carry over for free — no SCSS fork; just a new `.seat-label` span inside each seat.

The 'Default deck warning' Brief banner from /billboard/my-sign/ fires verbatim when `user.equipped_deck` is None (the user is headed for a draw against the Earthman [Shabby Cardstock] backup unless they equip one first). Tagged `.my-sea-intro-banner` so FTs disambiguate from other Briefs. Same FYI (→ /gameboard/) / NVM (dismiss) action grammar.

Bundled: BACK→NVM label swap (user-edited mid-sprint) in the existing sign-gate. CSS class `.my-sea-sign-gate__back` retained — the swap was label-only — so existing FTs targeting the class still pass; docstrings + comments updated for accuracy.

Files:
- `apps/gameboard/views.py` — `my_sea` view adds 2 context keys: `no_equipped_deck` (bool) + `show_backup_intro_banner` (= user_has_sig AND no_equipped_deck). The sig-gate path still wins precedence.
- `templates/apps/gameboard/my_sea.html` — `.my-sea-page[data-phase="landing"]`; new `.my-sea-landing` block w. room-shell hex + `#id_draw_sea_btn` + 6 `.table-seat[data-slot="N"]` w. `<span class="seat-label">NC</span>`; new `.my-sea-picker` placeholder (`display:none` til DRAW SEA click); inline `<script>` for the click→data-phase swap + scaleTable re-fire on next tick (mirrors my-sign's iter-3 RAF dispatch); copies the my-sign Brief banner script block verbatim w. `.my-sea-intro-banner` post-render tag.
- `static_src/scss/_gameboard.scss` — new `.my-sea-page` + `.my-sea-landing` + `.my-sea-picker` rule blocks mirroring `.my-sign-page` / `.my-sign-landing` from `_card-deck.scss`. `.seat-label` styled in `--terUser` to match the chair iconography.
- `apps/gameboard/tests/integrated/test_views.py` — `+7 ITs` in new `MySeaDrawSeaLandingViewTest` pinning the context keys + presence/absence of `#id_draw_sea_btn` + 6 `data-slot=N` w. `NC` labels + Sprint 4b gate's precedence over the new landing.
- `functional_tests/test_game_my_sea.py` — `+5 FTs` in new `MySeaDrawSeaLandingTest` for the visible UX (hex + DRAW SEA, 6 seats labeled 1C-6C, click→picker phase swap, Brief banner on no-deck, no banner when deck equipped). Uses new `_assign_sig` helper from [[sprint-sig-page-helper-may19c]] for the user-w-sig precondition.

Tests: 25/25 FTs green across test_bill_my_sign + test_game_my_sea in 219s; 1036/1036 IT/UT green in 50s (+7 from baseline).

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 15:15:37 -04:00
Disco DeDisco
4d1c74a2af FT helper: sig_page.py — _seed_earthman_sig_pile + _assign_sig — Sprint 4c of My Sea roadmap — TDD
Pure test-infra refactor. Sprint 5+ (sea-select / DRAW SEA / latest-draw rendering) all need a "user has sig" precondition without walking the picker — extracting it once now prevents Sprint 5's setUps from copy-pasting the inline assignment dance again.

Two helpers in new `src/functional_tests/sig_page.py` (mirrors the `room_page.py` / `post_page.py` / `my_posts_page.py` convention — underscored to signal "test infrastructure, not API surface", public within `functional_tests/`):

- `_seed_earthman_sig_pile()` — re-seeds Earthman DeckVariant + the 16 MIDDLE court cards (Maid/Jack/Queen/King × BRANDS/CROWNS/BLADES/GRAILS) that `personal_sig_cards(user)` returns. Hoisted verbatim from the duplicate definitions in `test_bill_my_sign.py` + `test_game_my_sea.py` introduced in [[sprint_serialized_rollback_ft_fix_may19]]. Major 0/1 are deliberately NOT seeded — `_filter_major_unlocks` in `personal_sig_cards()` strips them for users w.o the matching Note unlocks, which is the default state in solo FTs.
- `_assign_sig(user, card=None, reversed_flag=False)` — sets `user.significator` + `significator_reversed` directly, bypassing the picker UI. Returns the assigned card so downstream assertions can use it. `card=None` defaults to `personal_sig_cards(user)[0]` (the same target the picker happy-path FT uses).

Call sites updated:
- `test_bill_my_sign.py` — drops the local `_seed_earthman_sig_pile` definition (32 lines); imports from `sig_page`. `MySignClearTest.setUp` now uses `_assign_sig(self.gamer)` instead of the 4-line manual sig-assignment block.
- `test_game_my_sea.py` — drops the local `_seed_earthman_sig_pile` definition (22 lines); imports from `sig_page`. `MySeaSignGateTest`'s two "user w. sig" tests (#4 + #6) swap their 2-line `self.gamer.significator = ... ; .save(...)` blocks for `_assign_sig(self.gamer, self.target_card)`.

Diff stat: +69 lines (new helper module), -59 lines (duplicate code removed). Net +10 LOC but the duplication trap is closed — single source of truth for sig-state FT setup.

20/20 FT green across both files in 174s post-refactor.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 14:22:49 -04:00
12 changed files with 2249 additions and 163 deletions

View File

@@ -512,3 +512,288 @@ class MySeaViewTest(TestCase):
response = self.client.get(reverse("my_sea"))
self.assertIn("page-gameboard", response.content.decode())
self.assertIn("page-my-sea", response.content.decode())
class MySeaDrawSeaLandingViewTest(TestCase):
"""Sprint 5 iter 1 — view context for the DRAW SEA landing UX. Pins
`no_equipped_deck` + `show_backup_intro_banner` context keys + the
presence of the new landing template elements when user passes the
Sprint 4b sign-gate."""
def setUp(self):
from apps.epic.models import personal_sig_cards
self.user = User.objects.create(email="draw@test.io")
self.client.force_login(self.user)
# Assign a sig so the view's landing branch (not the gate) renders.
self.user.significator = personal_sig_cards(self.user)[0]
self.user.save(update_fields=["significator"])
def test_context_no_equipped_deck_false_when_user_has_deck(self):
# post_save auto-equips Earthman; `no_equipped_deck` should be False.
response = self.client.get(reverse("my_sea"))
self.assertFalse(response.context["no_equipped_deck"])
def test_context_no_equipped_deck_true_when_user_cleared_deck(self):
self.user.equipped_deck = None
self.user.save(update_fields=["equipped_deck"])
response = self.client.get(reverse("my_sea"))
self.assertTrue(response.context["no_equipped_deck"])
def test_context_show_backup_intro_banner_when_no_deck_and_has_sig(self):
# Brief banner fires when user has a sig AND no deck — they're on the
# landing UX (gate passed) but headed for the backup-deck draw path.
self.user.equipped_deck = None
self.user.save(update_fields=["equipped_deck"])
response = self.client.get(reverse("my_sea"))
self.assertTrue(response.context["show_backup_intro_banner"])
def test_context_show_backup_intro_banner_false_when_deck_equipped(self):
response = self.client.get(reverse("my_sea"))
self.assertFalse(response.context["show_backup_intro_banner"])
def test_landing_renders_free_draw_btn_when_sig_set(self):
# Element ID `id_draw_sea_btn` describes intent (draw entry point);
# visible label is "FREE DRAW" for the daily-free quota draw.
response = self.client.get(reverse("my_sea"))
self.assertContains(response, 'id="id_draw_sea_btn"')
self.assertContains(response, "FREE")
self.assertContains(response, "DRAW")
def test_landing_renders_six_chair_seats_with_C_suffix(self):
response = self.client.get(reverse("my_sea"))
html = response.content.decode()
for n in range(1, 7):
with self.subTest(slot=n):
self.assertIn(f'data-slot="{n}"', html)
self.assertIn(f"{n}C", html)
def test_landing_renders_position_status_ban_icon_on_each_seat(self):
# Each chair seat starts empty (red `.fa-ban` status icon). The
# FREE DRAW click handler swaps seat 1C's icon to .fa-circle-check
# client-side; this IT only pins the initial render state. Class
# substrings ("position-status-icon", "fa-ban") ALSO appear in the
# inline JS handler (classList.remove arg, querySelector arg) — so
# counts are asserted on the full class-attribute string only.
response = self.client.get(reverse("my_sea"))
html = response.content.decode()
self.assertEqual(html.count('class="position-status-icon fa-solid fa-ban"'), 6)
self.assertEqual(html.count('class="seat-position-label"'), 6)
def test_landing_not_rendered_when_user_has_no_sig(self):
# Sprint 4b gate still wins precedence — FREE DRAW must not render
# when significator is None.
self.user.significator = None
self.user.save(update_fields=["significator"])
response = self.client.get(reverse("my_sea"))
self.assertNotContains(response, 'id="id_draw_sea_btn"')
class MySeaPickerPhaseTemplateTest(TestCase):
"""Sprint 5 iter 2 — picker-phase template render contract: the
three-card cross (sig in core + cover/leave/loom drop zones) is
server-rendered (hidden until JS swaps data-phase after FREE DRAW).
Crown / lay / cross from the gameroom's 6-position Celtic Cross are
deliberately forsaken in the solo flow."""
def setUp(self):
from apps.epic.models import personal_sig_cards
self.user = User.objects.create(email="picker@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_picker_renders_significator_in_core_cell(self):
response = self.client.get(reverse("my_sea"))
html = response.content.decode()
# Sig card carries the user's significator id so iter 3's draw
# flow can target it for SPIN / FLIP / FYI without re-fetching.
self.assertIn('sea-pos-core', html)
self.assertIn('sea-sig-card', html)
self.assertIn(f'data-card-id="{self.target.id}"', html)
def test_picker_renders_cover_leave_loom_positions(self):
response = self.client.get(reverse("my_sea"))
self.assertContains(response, "sea-pos-cover")
self.assertContains(response, "sea-pos-leave")
self.assertContains(response, "sea-pos-loom")
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.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.
self.user.significator = None
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_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="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</span>', html)
self.assertIn('data-position="cover">Action</span>', html)
self.assertIn('data-position="crown">Outcome</span>', html)
# Inactive-for-SAO positions render their span but w. empty
# textContent (JS fills them on spread switch).
self.assertIn('data-position="leave"></span>', html)
self.assertIn('data-position="loom"></span>', html)
self.assertIn('data-position="cross"></span>', html)
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)
class MySeaDeckDataViewTest(TestCase):
"""Sprint 5 iter 4a — view-level deck-data contract. `my_sea` view
embeds a shuffled deck (levity + gravity halves, current user's
significator excluded, reversal pre-rolled at ~25%) as JSON via
the `sea_deck_data` context key + `{{ ...|json_script }}` filter
in the template."""
def setUp(self):
from apps.epic.models import personal_sig_cards
self.user = User.objects.create(email="deck@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_sea_deck_data_has_two_polarity_halves(self):
response = self.client.get(reverse("my_sea"))
deck = response.context["sea_deck_data"]
self.assertIn("levity", deck)
self.assertIn("gravity", deck)
self.assertIsInstance(deck["levity"], list)
self.assertIsInstance(deck["gravity"], list)
def test_deck_data_excludes_user_significator(self):
response = self.client.get(reverse("my_sea"))
deck = response.context["sea_deck_data"]
all_ids = (
{c["id"] for c in deck["levity"]}
| {c["id"] for c in deck["gravity"]}
)
self.assertNotIn(self.target.id, all_ids)
def test_deck_data_halves_are_disjoint(self):
response = self.client.get(reverse("my_sea"))
deck = response.context["sea_deck_data"]
levity_ids = {c["id"] for c in deck["levity"]}
gravity_ids = {c["id"] for c in deck["gravity"]}
self.assertEqual(levity_ids & gravity_ids, set())
def test_deck_data_cards_carry_corner_rank_suit_icon_and_reversed(self):
# Card dict shape mirrors the gameroom `sea_deck` endpoint so
# iter 4b's persistence/render path can reuse the JSON contract.
response = self.client.get(reverse("my_sea"))
deck = response.context["sea_deck_data"]
any_card = (deck["levity"] + deck["gravity"])[0]
for key in ("id", "corner_rank", "suit_icon", "reversed"):
with self.subTest(key=key):
self.assertIn(key, any_card)
self.assertIsInstance(any_card["reversed"], bool)
def test_template_embeds_deck_as_json_script(self):
# Embed mechanism: `{{ sea_deck_data|json_script:"id_my_sea_deck" }}`
# gives a `<script type="application/json" id="id_my_sea_deck">`.
response = self.client.get(reverse("my_sea"))
self.assertContains(
response,
'<script id="id_my_sea_deck" type="application/json">',
)
def test_deck_data_empty_when_user_has_no_equipped_deck(self):
# Backup-deck branch: per [[sprint-my-sign-picker-may18h]] follow-
# up, no-deck users still proceed via Earthman. So deck_data falls
# back to Earthman, NOT empty. (Earthman seed is migration-loaded
# in this TestCase context.)
self.user.equipped_deck = None
self.user.save(update_fields=["equipped_deck"])
response = self.client.get(reverse("my_sea"))
deck = response.context["sea_deck_data"]
self.assertGreater(len(deck["levity"]) + len(deck["gravity"]), 0)

View File

@@ -170,20 +170,109 @@ def toggle_game_kit_sections(request):
def my_sea(request):
"""Shell view for the My Sea standalone page.
Sprint 3 scaffolding + Sprint 4b sign-gate. The gate fires when the
user has no saved significator — a Look!-formatted Brief-style line
nudges them to /billboard/my-sign/ (FYI) or back to /gameboard/
(BACK) before the draw UX can be reached. With a sig set, the draw
shell renders normally (gatekeeper / sig-select / sea-select land
in Sprints 5-9).
Branches three ways:
1. No sig → Look!-formatted gate w. FYI/NVM (Sprint 4b).
2. Sig + equipped deck → DRAW SEA landing (Sprint 5 iter 1) — hex w.
6 chair seats labeled 1C-6C + central DRAW SEA btn. Click swaps
data-phase to picker (the picker UX itself lands in iter 2).
3. Sig + no equipped deck → same landing PLUS a 'Default deck warning'
Brief banner identical to the one on /billboard/my-sign/ (the user
is headed for a draw against the Earthman [Shabby Cardstock]
backup deck unless they equip one first).
"""
user_has_sig = request.user.significator_id is not None
no_equipped_deck = request.user.equipped_deck_id is None
return render(request, "apps/gameboard/my_sea.html", {
"user_has_sig": user_has_sig,
"no_equipped_deck": no_equipped_deck,
"show_backup_intro_banner": user_has_sig and no_equipped_deck,
# Sprint 5 iter 2 — significator pinned in `.sea-pos-core` on the
# picker phase. Template guards on `user_has_sig` so a None pass-
# through is safe; we pass the FK directly so `.corner_rank` +
# `.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,
# Sprint 5 iter 4a — shuffled deck (levity + gravity halves, sig
# excluded) for the client-side card-draw mechanic. Embedded in
# the template via `{{ sea_deck_data|json_script:"..." }}`; JS
# reads on init + maintains the in-progress hand state client-
# side. Persistence (LOCK HAND → POST) lands in iter 4b.
"sea_deck_data": _my_sea_deck_data(request.user) if user_has_sig else {"levity": [], "gravity": []},
"page_class": "page-gameboard page-my-sea",
})
def _my_sea_deck_data(user):
"""Build the shuffled deck (levity + gravity halves) for the my-sea
picker's card-draw mechanic. Mirrors the gameroom `epic.views.sea_
deck` endpoint's card_dict shape so iter 4b's render/persist path
can reuse the same JSON contract.
Differences from the room version:
- No `room` context — exclude only the current user's significator
(no other seated gamers to worry about).
- Backup-deck fallthrough: if the user's `equipped_deck` is None,
fall back to Earthman (mirrors `personal_sig_cards`).
- Reversal probability hardcoded to 0.25 per the iter 3 spec lock;
future per-user config rides on the shared `stack_reversal_
probability` helper.
"""
import random
from apps.epic.models import DeckVariant, TarotCard
deck = user.equipped_deck or DeckVariant.objects.filter(slug="earthman").first()
if not deck:
return {"levity": [], "gravity": []}
available = list(TarotCard.objects.filter(deck_variant=deck))
if user.significator_id:
available = [c for c in available if c.id != user.significator_id]
random.shuffle(available)
mid = len(available) // 2
reversal_prob = 0.25
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_qualifier": c.reversal_qualifier,
# Polarity-split full-title overrides — required for Major
# Arcana (Earthman trumps 19-21 + cards 48-49) to render
# their per-polarity emanation/reversal names on the stage
# card. Without these StageCard.populateCard falls back to
# the plain `name_title` w. no qualifier. Mirrors the
# gameroom `epic.views.sea_deck` JSON shape exactly.
"levity_emanation": c.levity_emanation,
"gravity_emanation": c.gravity_emanation,
"levity_reversal": c.levity_reversal,
"gravity_reversal": c.gravity_reversal,
"italic_word": c.italic_word,
"keywords_upright": c.keywords_upright,
"keywords_reversed": c.keywords_reversed,
"energies": c.energies,
"operations": c.operations,
"reversed": random.random() < reversal_prob,
}
return {
"levity": [_card_dict(c) for c in available[:mid]],
"gravity": [_card_dict(c) for c in available[mid:]],
}
@login_required(login_url="/")
def tarot_fan(request, deck_id):
from apps.epic.models import TarotCard

View File

@@ -0,0 +1,69 @@
"""Sig-state FT helpers — sprint 4c of [[project-my-sea-roadmap]]. Two
public helpers:
- `_seed_earthman_sig_pile()` — restore the Earthman DeckVariant + 16
MIDDLE court cards that `personal_sig_cards(user)` returns. The
migration seed gets flushed by TransactionTestCase between tests
(see [[feedback_transactiontestcase_flush]]); each setUp that touches
the picker or its dependents must call this before creating a User.
- `_assign_sig(user, card=None, reversed_flag=False)` — set
`User.significator` (+ `significator_reversed`) directly, bypassing
the picker UI. Returns the assigned card so downstream assertions can
use it. Use this in any FT that needs a "user has a sig" precondition
without walking the SCAN SIGN → click thumb → OK → SAVE SIGN flow.
Naming follows the room_page.py / post_page.py / my_posts_page.py
convention: underscored to signal "test infrastructure, not API
surface"; public within `functional_tests/`.
Consumers:
test_bill_my_sign — both helpers
test_game_my_sea — both helpers (gate / no-gate branches)
"""
from apps.epic.models import DeckVariant, TarotCard, personal_sig_cards
def _seed_earthman_sig_pile():
"""Re-seed Earthman DeckVariant + the 16 MIDDLE court cards that
`personal_sig_cards(user)` returns. Idempotent — `get_or_create` on
deck + each card slug.
The 16 cards are Maid/Jack/Queen/King × BRANDS/CROWNS/BLADES/GRAILS.
Major 0/1 (Nomad/Schizo) are *not* seeded here — `_filter_major_unlocks`
in `personal_sig_cards()` strips them for users without the matching
Note unlocks, which is the default state in solo FTs. If a future FT
needs the Major seed (Note-unlocked path), it should seed those rows
separately or extend this helper w. a flag."""
earthman, _ = DeckVariant.objects.get_or_create(
slug="earthman",
defaults={"name": "Earthman", "card_count": 106, "is_default": True},
)
_NAME = {11: "Maid", 12: "Jack", 13: "Queen", 14: "King"}
for suit in ("BRANDS", "CROWNS", "BLADES", "GRAILS"):
for number in (11, 12, 13, 14):
TarotCard.objects.get_or_create(
deck_variant=earthman,
slug=f"{_NAME[number].lower()}-of-{suit.lower()}-em",
defaults={"arcana": "MIDDLE", "suit": suit, "number": number,
"name": f"{_NAME[number]} of {suit.capitalize()}",
"levity_qualifier": "Elevated",
"gravity_qualifier": "Graven"},
)
return earthman
def _assign_sig(user, card=None, reversed_flag=False):
"""Assign `user.significator` (and optionally `significator_reversed`)
directly, bypassing the picker UI. Returns the assigned card.
If `card` is None, defaults to the first card in
`personal_sig_cards(user)` — the same card the picker happy-path FT
targets. Caller is responsible for ensuring the sig pile is seeded
(call `_seed_earthman_sig_pile()` before User.create if needed)."""
if card is None:
card = personal_sig_cards(user)[0]
user.significator = card
user.significator_reversed = reversed_flag
user.save(update_fields=["significator", "significator_reversed"])
return card

View File

@@ -10,8 +10,9 @@ is branded "Sign" / "Game Sign".
from selenium.webdriver.common.by import By
from .base import FunctionalTest
from .sig_page import _assign_sig, _seed_earthman_sig_pile
from apps.applets.models import Applet
from apps.epic.models import DeckVariant, TarotCard, personal_sig_cards
from apps.epic.models import personal_sig_cards
from apps.lyric.models import User
@@ -23,29 +24,6 @@ def _seed_my_sign_applet():
)
def _seed_earthman_sig_pile():
"""Re-seed Earthman DeckVariant + the 16 MIDDLE court cards that
personal_sig_cards() returns. TransactionTestCase flushes wipe the
migration seed between tests, so each setUp must restore them.
See [[feedback_transactiontestcase_flush]]."""
earthman, _ = DeckVariant.objects.get_or_create(
slug="earthman",
defaults={"name": "Earthman", "card_count": 106, "is_default": True},
)
_NAME = {11: "Maid", 12: "Jack", 13: "Queen", 14: "King"}
for suit in ("BRANDS", "CROWNS", "BLADES", "GRAILS"):
for number in (11, 12, 13, 14):
TarotCard.objects.get_or_create(
deck_variant=earthman,
slug=f"{_NAME[number].lower()}-of-{suit.lower()}-em",
defaults={"arcana": "MIDDLE", "suit": suit, "number": number,
"name": f"{_NAME[number]} of {suit.capitalize()}",
"levity_qualifier": "Elevated",
"gravity_qualifier": "Graven"},
)
return earthman
class MySignPickerTest(FunctionalTest):
"""Happy-path picker: a user with the Earthman deck equipped lands at
/billboard/my-sign/, picks a card, clicks SAVE SIGN, and sees the sig
@@ -469,13 +447,8 @@ class MySignClearTest(FunctionalTest):
_seed_my_sign_applet()
self.email = "clear@test.io"
self.gamer = User.objects.create(email=self.email)
sig_pile = personal_sig_cards(self.gamer)
self.target_card = sig_pile[0] if sig_pile else None
self.assertIsNotNone(self.target_card)
# Pre-save a sig so the CLEAR affordance is visible on landing.
self.gamer.significator = self.target_card
self.gamer.significator_reversed = False
self.gamer.save(update_fields=["significator", "significator_reversed"])
# Pre-save a sig so the DEL affordance is visible on landing.
self.target_card = _assign_sig(self.gamer)
# ── Test 1 ───────────────────────────────────────────────────────────────

View File

@@ -2,15 +2,16 @@
Sprint 4b of [[project-my-sea-roadmap]]. The /gameboard/my-sea/ page is
gated behind sig selection — when `user.significator` is None, render a
Look!-formatted Brief-style line w. FYI (→ /billboard/my-sign/) + BACK
Look!-formatted Brief-style line w. FYI (→ /billboard/my-sign/) + NVM
(→ /gameboard/) instead of the draw UX. The My Sea applet on /gameboard/
mirrors the gate hint in its empty-state slot.
"""
from selenium.webdriver.common.by import By
from .base import FunctionalTest
from .sig_page import _assign_sig, _seed_earthman_sig_pile
from apps.applets.models import Applet
from apps.epic.models import DeckVariant, TarotCard, personal_sig_cards
from apps.epic.models import personal_sig_cards
from apps.lyric.models import User
@@ -32,33 +33,10 @@ def _seed_gameboard_applets():
)
def _seed_earthman_sig_pile():
"""Re-seed Earthman DeckVariant + the 16 MIDDLE court cards that
personal_sig_cards() returns. TransactionTestCase flushes wipe the
migration seed between tests, so each setUp must restore them.
See [[feedback_transactiontestcase_flush]]."""
earthman, _ = DeckVariant.objects.get_or_create(
slug="earthman",
defaults={"name": "Earthman", "card_count": 106, "is_default": True},
)
_NAME = {11: "Maid", 12: "Jack", 13: "Queen", 14: "King"}
for suit in ("BRANDS", "CROWNS", "BLADES", "GRAILS"):
for number in (11, 12, 13, 14):
TarotCard.objects.get_or_create(
deck_variant=earthman,
slug=f"{_NAME[number].lower()}-of-{suit.lower()}-em",
defaults={"arcana": "MIDDLE", "suit": suit, "number": number,
"name": f"{_NAME[number]} of {suit.capitalize()}",
"levity_qualifier": "Elevated",
"gravity_qualifier": "Graven"},
)
return earthman
class MySeaSignGateTest(FunctionalTest):
"""Sign-gate UX on the standalone /gameboard/my-sea/ page + the
/gameboard/ My Sea applet. User without a saved sig sees a Look!-
formatted nudge w. FYI to the picker + BACK to the gameboard."""
formatted nudge w. FYI to the picker + NVM to the gameboard."""
def setUp(self):
super().setUp()
@@ -77,7 +55,7 @@ class MySeaSignGateTest(FunctionalTest):
def test_no_sig_renders_lookline_gate_on_standalone_page(self):
"""User without significator → /gameboard/my-sea/ shows the Look!-
formatted Brief-style line w. the gate copy + FYI + BACK buttons."""
formatted Brief-style line w. the gate copy + FYI + NVM buttons."""
self.create_pre_authenticated_session(self.email)
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
gate = self.wait_for(
@@ -89,11 +67,12 @@ class MySeaSignGateTest(FunctionalTest):
self.assertIn("Look!", text)
self.assertIn("pick your sign", text.lower())
self.assertIn("drawing the Sea", text)
# FYI + BACK action buttons
# FYI + NVM action buttons (class .my-sea-sign-gate__back retained
# post-relabel; the BACK→NVM swap was label-only).
fyi = gate.find_element(By.CSS_SELECTOR, ".my-sea-sign-gate__fyi")
self.assertTrue(fyi.is_displayed())
back = gate.find_element(By.CSS_SELECTOR, ".my-sea-sign-gate__back")
self.assertTrue(back.is_displayed())
nvm = gate.find_element(By.CSS_SELECTOR, ".my-sea-sign-gate__back")
self.assertTrue(nvm.is_displayed())
# ── Test 2 ───────────────────────────────────────────────────────────────
@@ -115,18 +94,19 @@ class MySeaSignGateTest(FunctionalTest):
# ── Test 3 ───────────────────────────────────────────────────────────────
def test_gate_back_links_to_gameboard(self):
"""BACK button is an `<a href>` pointing at /gameboard/."""
"""NVM button is an `<a href>` pointing at /gameboard/. CSS class
`.my-sea-sign-gate__back` retained post BACK→NVM label swap."""
self.create_pre_authenticated_session(self.email)
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
back = self.wait_for(
nvm = self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, ".my-sea-sign-gate__back"
)
)
href = back.get_attribute("href") or ""
href = nvm.get_attribute("href") or ""
self.assertTrue(
href.endswith("/gameboard/"),
f"BACK should link to /gameboard/, got {href!r}",
f"NVM should link to /gameboard/, got {href!r}",
)
# ── Test 4 ───────────────────────────────────────────────────────────────
@@ -134,8 +114,7 @@ class MySeaSignGateTest(FunctionalTest):
def test_with_sig_skips_gate_and_renders_draw_shell(self):
"""User w. saved significator → no .my-sea-sign-gate on the page;
draw shell renders normally (Sprint 3 placeholder)."""
self.gamer.significator = self.target_card
self.gamer.save(update_fields=["significator"])
_assign_sig(self.gamer, self.target_card)
self.create_pre_authenticated_session(self.email)
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
self.wait_for(
@@ -170,8 +149,7 @@ class MySeaSignGateTest(FunctionalTest):
def test_with_sig_applet_renders_default_empty_state(self):
"""Applet w. saved sig → no gate, empty-state placeholder (until
Sprint 7 wires up the latest-draw rendering)."""
self.gamer.significator = self.target_card
self.gamer.save(update_fields=["significator"])
_assign_sig(self.gamer, self.target_card)
self.create_pre_authenticated_session(self.email)
self.browser.get(self.live_server_url + "/gameboard/")
self.wait_for(
@@ -183,3 +161,837 @@ class MySeaSignGateTest(FunctionalTest):
)),
0,
)
class MySeaDrawSeaLandingTest(FunctionalTest):
"""Sprint 5 iter 1 — FREE DRAW landing on /gameboard/my-sea/ for a
user w. a saved sig (past the [[sprint-my-sea-sign-gate-may19]] gate).
Landing renders a DRY table hex (parameterized from the room) w. 6
chair seats labeled 1C-6C (placeholders for the eventual friend-
invite feature per [[project-my-sea-roadmap]] architectural anchor
"Six chairs retained even in solo") + a central FREE DRAW `.btn-
primary` mirroring SCAN SIGN on /billboard/my-sign/. Each chair seat
renders w. a red `.fa-ban` status icon (empty slot).
Click flow: FREE DRAW → seat 1C transitions to `.seated` state
(chair `--terUser` + drop-shadow glow + `.fa-ban` swap to `.fa-
circle-check` green) → after a brief delay so the user sees the
animation, `data-phase` swaps to `picker` (picker content lands in
iter 2). The 'C' = "Chair" (user-locked vocabulary); no role
semantics in this solo flow.
"FREE DRAW" is the label for the 1/24h free quota draw — a future
sprint will conditionally swap the label to "DRAW SEA" once the
free has been used, w. the DRAW SEA btn calling the room
gatekeeper partial for token-deposit.
The same Brief "Default deck warning" copy from my-sign fires when
the user has no equipped deck."""
def setUp(self):
super().setUp()
_seed_earthman_sig_pile()
_seed_gameboard_applets()
self.email = "draw@test.io"
self.gamer = User.objects.create(email=self.email)
# Assign a sig so the page passes the Sprint 4b gate + lands on
# the new DRAW SEA UX rather than the Look!-line gate.
self.target_card = _assign_sig(self.gamer)
# ── Test 1 ───────────────────────────────────────────────────────────────
def test_landing_renders_hex_with_free_draw_btn(self):
"""User w. sig → /gameboard/my-sea/ shows the DRY table hex (re-
used from my-sign / the room shell) w. a central FREE DRAW btn.
Element ID `id_draw_sea_btn` describes intent (the draw entry
point) — a future sprint will conditionally swap the label to
DRAW SEA once the daily free has been used."""
self.create_pre_authenticated_session(self.email)
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
# data-phase=landing on the page wrapper
page = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".my-sea-page[data-phase='landing']")
)
# Hex shell present
page.find_element(By.CSS_SELECTOR, ".room-shell .table-hex")
# FREE DRAW btn in hex center
btn = page.find_element(By.CSS_SELECTOR, "#id_draw_sea_btn")
self.assertTrue(btn.is_displayed())
self.assertIn("FREE", btn.text.upper())
self.assertIn("DRAW", btn.text.upper())
self.assertIn("btn-primary", btn.get_attribute("class"))
# ── Test 2 ───────────────────────────────────────────────────────────────
def test_landing_renders_six_chair_seats_labeled_1C_to_6C(self):
"""All 6 chair positions render w. labels 1C-6C (placeholder for
friend-invite). CSS class `.table-seat` is preserved so the SCSS
positioning rules (data-slot=N) carry over from the room shell.
Each seat starts w. a red `.fa-ban` status icon (empty)."""
self.create_pre_authenticated_session(self.email)
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
seats = self.wait_for(
lambda: self._six_seats()
)
self.assertEqual(len(seats), 6)
for n, seat in enumerate(seats, start=1):
with self.subTest(slot=n):
label = "".join(seat.text.upper().split())
self.assertIn(f"{n}C", label)
# Each seat carries the red ban status icon initially.
seat.find_element(
By.CSS_SELECTOR, ".position-status-icon.fa-ban"
)
def _six_seats(self):
seats = self.browser.find_elements(
By.CSS_SELECTOR, ".my-sea-page[data-phase='landing'] .table-seat"
)
if len(seats) != 6:
raise AssertionError(f"expected 6 seats, got {len(seats)}")
return seats
# ── Test 3 ───────────────────────────────────────────────────────────────
def test_free_draw_click_seats_user_in_1C_then_swaps_phase(self):
"""Click FREE DRAW → seat 1C transitions to `.seated` w. fa-ban
swapped for fa-circle-check (visible to the user during the
~800ms animation delay); other seats remain empty; then the
page's data-phase swaps to 'picker' so iter 2's content can
take over. Single-user instance for now → user always gets the
lowest-numeral seat (1C)."""
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()
# Seat 1C goes seated + icon swaps. Other seats unchanged.
seat1 = self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, ".table-seat[data-slot='1'].seated"
)
)
seat1.find_element(By.CSS_SELECTOR, ".position-status-icon.fa-circle-check")
# Seats 2-6 retain the .fa-ban icon (still empty).
for n in range(2, 7):
with self.subTest(slot=n):
other = self.browser.find_element(
By.CSS_SELECTOR, f".table-seat[data-slot='{n}']"
)
self.assertNotIn("seated", other.get_attribute("class"))
other.find_element(By.CSS_SELECTOR, ".position-status-icon.fa-ban")
# After the seat animation, data-phase swaps to picker + landing hides.
self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, ".my-sea-page[data-phase='picker']"
)
)
landing = self.browser.find_element(By.CSS_SELECTOR, ".my-sea-landing")
self.assertFalse(landing.is_displayed())
# ── Test 4 ───────────────────────────────────────────────────────────────
def test_brief_banner_renders_when_no_deck_equipped(self):
"""No equipped deck → the same 'Default deck warning' Brief
banner from my-sign fires (lifted verbatim). Tagged w. a my-sea-
specific class so FTs can disambiguate from any other Briefs."""
self.gamer.equipped_deck = None
self.gamer.save(update_fields=["equipped_deck"])
self.create_pre_authenticated_session(self.email)
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
banner = self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, ".my-sea-intro-banner"
)
)
self.assertIn("Default deck warning", banner.text)
self.assertIn("no deck is equipped", banner.text)
self.assertIn("Shabby Cardstock", banner.text)
# ── Test 5 ───────────────────────────────────────────────────────────────
def test_no_brief_banner_when_deck_equipped(self):
"""User w. an equipped deck → no Default-deck-warning Brief on
landing. Auto-equip via the User post_save signal handles this
for fresh users; assertion guards against accidental render of
the banner when the condition shouldn't fire."""
self.create_pre_authenticated_session(self.email)
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_draw_sea_btn")
)
self.assertEqual(
len(self.browser.find_elements(By.CSS_SELECTOR, ".my-sea-intro-banner")),
0,
)
class MySeaPickerPhaseTest(FunctionalTest):
"""Sprint 5 iter 2 — picker phase content on /gameboard/my-sea/ after
FREE DRAW click swaps `data-phase` to `picker`. Three-card spread:
user's saved significator pinned in the center (`.sea-pos-core`) +
three drawn-card positions surrounding it — cover (overlaid on sig),
leave (left of center), loom (right of center). Crown / lay / cross
from the gameroom's 6-position Celtic Cross are deliberately omitted
(user-locked spec). Empty drop-zones are visible — actual card-draw
wiring lands in iter 3 alongside the form col (spread dropdown /
decks / LOCK HAND / DEL)."""
def setUp(self):
super().setUp()
_seed_earthman_sig_pile()
_seed_gameboard_applets()
self.email = "picker@test.io"
self.gamer = User.objects.create(email=self.email)
self.target_card = _assign_sig(self.gamer)
def _enter_picker_phase(self):
"""Common nav: load /gameboard/my-sea/, click FREE DRAW, wait for
the page wrapper's data-phase to swap to `picker` (which happens
~800ms after click per the seat-1C animation delay in the inline
JS)."""
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_picker_renders_significator_card_in_core_cell(self):
"""User's saved significator pins the `.sea-pos-core` cell — the
center of the three-card cross. Card data attribute reflects the
actual TarotCard.id so future iters can wire FYI / SPIN onto it."""
self._enter_picker_phase()
core = self.browser.find_element(
By.CSS_SELECTOR, ".my-sea-picker .sea-pos-core .sea-sig-card"
)
self.assertEqual(
core.get_attribute("data-card-id"), str(self.target_card.id)
)
# ── Test 2 ───────────────────────────────────────────────────────────────
def test_picker_renders_cover_leave_loom_positions(self):
"""The three drawn-card positions (cover/leave/loom) render as
empty `.sea-card-slot--empty` drop zones. Cover is overlaid on
the sig card via `.sea-pos-core > .sea-pos-cover` nesting; leave
+ loom sit in their own grid cells flanking core."""
picker = self._enter_picker_phase()
# Cover lives nested inside .sea-pos-core (overlaid on sig)
picker.find_element(
By.CSS_SELECTOR, ".sea-pos-core .sea-pos-cover .sea-card-slot--empty"
)
picker.find_element(
By.CSS_SELECTOR, ".sea-pos-leave .sea-card-slot--empty"
)
picker.find_element(
By.CSS_SELECTOR, ".sea-pos-loom .sea-card-slot--empty"
)
# ── Test 3 ───────────────────────────────────────────────────────────────
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()
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(
elements[0].is_displayed(), expected_visible,
f"{pos} visibility wrong for SAO default; expected {expected_visible}",
)
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 <li>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 `<input id="id_sea_spread">` 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 = picker.find_element(
By.CSS_SELECTOR, ".sea-select-list [role='option'][aria-selected='true']"
)
self.assertEqual(
selected.get_attribute("textContent").strip(),
"Situation, Action, Outcome",
)
current = picker.find_element(By.CSS_SELECTOR, ".sea-select-current")
self.assertEqual(current.text.strip(), "Situation, Action, Outcome")
cross = picker.find_element(By.CSS_SELECTOR, ".my-sea-cross")
self.assertEqual(
cross.get_attribute("data-spread"), "situation-action-outcome",
)
# ── Test 3 ───────────────────────────────────────────────────────────────
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", "crown"},
"waite-smith": ALL_POSITIONS,
"escape-velocity": ALL_POSITIONS,
}
picker = self._enter_picker_phase()
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}']",
)
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
)
)
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","crown":"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}']"
)
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
class MySeaCardDrawTest(FunctionalTest):
"""Sprint 5 iter 4a — client-side card-draw mechanics on the picker
phase. Server embeds the deck (gravity + levity halves, user's sig
excluded) as JSON; clicking GRAVITY/LEVITY swatch shows FLIP; FLIP
deposits the next card into the next DRAW_ORDER slot for the active
spread. DEL fully resets the in-progress hand. LOCK HAND enables
when the hand is complete + click locks down further interaction.
Switching spreads also resets the hand (the position-subset changes).
Server-side persistence (committing the locked hand to a MySeaDraw
model) defers to iter 4b."""
def setUp(self):
super().setUp()
_seed_earthman_sig_pile()
_seed_gameboard_applets()
self.email = "draw@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']"
)
)
def _draw_open_modal(self, picker, polarity):
"""Click a polarity swatch + the FLIP btn that appears → opens
the SeaDeal stage modal. Returns the stage element so callers
can assert on it before dismissing."""
stack = picker.find_element(
By.CSS_SELECTOR, f".sea-deck-stack--{polarity}"
)
stack.click()
flip = self.wait_for(
lambda: stack.find_element(By.CSS_SELECTOR, ".sea-stack-ok")
)
self.wait_for(lambda: self.assertTrue(flip.is_displayed()))
flip.click()
# SeaDeal.openStage shows #id_sea_stage. Wait for the modal.
return self.wait_for(
lambda: self._stage_visible()
)
def _stage_visible(self):
stage = self.browser.find_element(By.CSS_SELECTOR, "#id_sea_stage")
if not stage.is_displayed():
raise AssertionError("sea-stage not visible after FLIP click")
return stage
def _dismiss_modal(self):
"""Click the stage backdrop → SeaDeal._hideStage → modal hides +
slot gains `.--visible` (thumbnail fades in).
Uses `execute_script` to dispatch the click rather than a native
Selenium `.click()` — `.sea-stage-content` overlays the backdrop
visually (centered card + stat block), so Selenium reports
ElementClickInterceptedException for a direct click. This is
the documented Selenium-limitation exception per the TDD skill;
the actual backdrop-click → close behaviour is Jasmine-tested
in [[SeaDealSpec.js]] / "Backdrop click closes the stage"."""
self.browser.execute_script(
"document.querySelector('#id_sea_stage .sea-stage-backdrop').click();"
)
self.wait_for(
lambda: self.assertFalse(
self.browser.find_element(By.CSS_SELECTOR, "#id_sea_stage").is_displayed()
)
)
def _draw_one(self, picker, polarity):
"""Full single-draw cycle: open modal + dismiss it. Used by FTs
that need to deposit multiple cards in sequence (the stage
backdrop blocks subsequent deck-stack clicks)."""
self._draw_open_modal(picker, polarity)
self._dismiss_modal()
# ── Test 1 ───────────────────────────────────────────────────────────────
def test_deck_data_embedded_with_two_polarity_halves(self):
"""Server-side renders the shuffled deck (levity + gravity
halves, sig excluded) inside `<script type="application/json"
id="id_my_sea_deck">`. Client-side JS reads on init."""
import json as _json
picker = self._enter_picker_phase()
data_el = picker.find_element(By.CSS_SELECTOR, "#id_my_sea_deck")
deck = _json.loads(data_el.get_attribute("textContent"))
self.assertIn("levity", deck)
self.assertIn("gravity", deck)
self.assertIsInstance(deck["levity"], list)
self.assertIsInstance(deck["gravity"], list)
# Both halves should be non-empty (16 court cards in the seed,
# minus 1 sig → 15 cards split ~7/8).
self.assertGreater(len(deck["levity"]) + len(deck["gravity"]), 0)
# No card appears in both halves.
levity_ids = {c["id"] for c in deck["levity"]}
gravity_ids = {c["id"] for c in deck["gravity"]}
self.assertEqual(levity_ids & gravity_ids, set())
# ── Test 2 ───────────────────────────────────────────────────────────────
def test_user_significator_excluded_from_drawn_deck(self):
"""The user's significator (pinned in `.sea-pos-core`) must NOT
appear in the gravity or levity deck halves — would otherwise
let the same card show up twice in the layout."""
import json as _json
picker = self._enter_picker_phase()
data_el = picker.find_element(By.CSS_SELECTOR, "#id_my_sea_deck")
deck = _json.loads(data_el.get_attribute("textContent"))
all_ids = {c["id"] for c in deck["levity"]} | {c["id"] for c in deck["gravity"]}
self.assertNotIn(self.target_card.id, all_ids)
# ── Test 3 ───────────────────────────────────────────────────────────────
def test_levity_click_then_flip_deposits_card_into_first_sao_slot(self):
"""Default spread = SAO; first slot = `.sea-pos-lay` per the
DRAW_ORDER spec. Clicking LEVITY → FLIP → the first drawn card
lands in lay's `.sea-card-slot` w. `--filled` + `--levity`
classes + corner_rank text content from the deck card."""
picker = self._enter_picker_phase()
self._draw_one(picker, "levity")
slot = self.wait_for(
lambda: picker.find_element(
By.CSS_SELECTOR,
".sea-pos-lay .sea-card-slot.sea-card-slot--filled",
)
)
self.assertIn("sea-card-slot--levity", slot.get_attribute("class"))
# Card has a corner-rank rendered inside.
slot.find_element(By.CSS_SELECTOR, ".fan-corner-rank")
# Slot has a data-card-id attribute set to the deposited card's id.
self.assertTrue(slot.get_attribute("data-card-id"))
# ── Test 4 ───────────────────────────────────────────────────────────────
def test_two_draws_fill_first_two_slots_in_draw_order(self):
"""SAO draw order = lay → cover → crown. Second draw lands in
`.sea-pos-cover` regardless of polarity. Polarity of each
slot reflects which swatch was clicked."""
picker = self._enter_picker_phase()
self._draw_one(picker, "levity")
self._draw_one(picker, "gravity")
# First slot (lay) — levity
lay = picker.find_element(
By.CSS_SELECTOR, ".sea-pos-lay .sea-card-slot.sea-card-slot--filled"
)
self.assertIn("sea-card-slot--levity", lay.get_attribute("class"))
# Second slot (cover) — gravity
cover = picker.find_element(
By.CSS_SELECTOR, ".sea-pos-cover .sea-card-slot.sea-card-slot--filled"
)
self.assertIn("sea-card-slot--gravity", cover.get_attribute("class"))
# ── Test 5 ───────────────────────────────────────────────────────────────
def test_lock_hand_enables_when_sao_hand_is_complete(self):
"""LOCK HAND starts disabled; flips to enabled once all 3 SAO
positions are drawn (hand-size = 3 for any three-card spread)."""
picker = self._enter_picker_phase()
lock = picker.find_element(By.CSS_SELECTOR, "#id_sea_lock_hand")
self.assertEqual(lock.get_attribute("disabled"), "true")
self._draw_one(picker, "levity")
self._draw_one(picker, "levity")
# Two draws — still disabled.
self.assertEqual(lock.get_attribute("disabled"), "true")
self._draw_one(picker, "gravity")
# Third draw completes the SAO hand — LOCK HAND enables.
self.wait_for(
lambda: self.assertIsNone(lock.get_attribute("disabled"))
)
# ── Test 6 ───────────────────────────────────────────────────────────────
def test_del_click_resets_hand_and_disables_lock_hand(self):
"""DEL fully resets — every filled slot returns to `--empty`,
labels re-render, _filled counter zeros, LOCK HAND disables."""
picker = self._enter_picker_phase()
self._draw_one(picker, "levity")
self._draw_one(picker, "gravity")
self.assertEqual(
len(picker.find_elements(
By.CSS_SELECTOR, ".sea-card-slot.sea-card-slot--filled"
)),
2,
)
delbtn = picker.find_element(By.CSS_SELECTOR, "#id_sea_del")
delbtn.click()
self.wait_for(
lambda: self.assertEqual(
len(picker.find_elements(
By.CSS_SELECTOR, ".sea-card-slot.sea-card-slot--filled"
)),
0,
)
)
lock = picker.find_element(By.CSS_SELECTOR, "#id_sea_lock_hand")
self.assertEqual(lock.get_attribute("disabled"), "true")
# ── Test 7 ───────────────────────────────────────────────────────────────
def test_lock_hand_click_disables_further_interaction(self):
"""After LOCK HAND fires, deck swatches + DEL btn + LOCK HAND
itself all carry the `.btn-disabled` class so the hand can't
be mutated further. Persistence (POST to a server endpoint)
defers to iter 4b — this test pins only the visual lock."""
picker = self._enter_picker_phase()
self._draw_one(picker, "levity")
self._draw_one(picker, "levity")
self._draw_one(picker, "gravity")
lock = picker.find_element(By.CSS_SELECTOR, "#id_sea_lock_hand")
self.wait_for(
lambda: self.assertIsNone(lock.get_attribute("disabled"))
)
lock.click()
# Picker carries a .my-sea-picker--locked class after LOCK HAND.
self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, ".my-sea-picker.my-sea-picker--locked"
)
)
# Swatches no longer respond — clicking them does nothing.
gravity_stack = picker.find_element(
By.CSS_SELECTOR, ".sea-deck-stack--gravity"
)
self.assertIn("btn-disabled", gravity_stack.get_attribute("class"))
# ── Test 8 ───────────────────────────────────────────────────────────────
def test_switching_spread_resets_in_progress_hand(self):
"""Picking a different spread on the combobox mid-draw resets
the hand — different spreads use different position subsets +
different hand-sizes, so an in-progress hand can't carry over."""
picker = self._enter_picker_phase()
self._draw_one(picker, "levity")
self.assertEqual(
len(picker.find_elements(
By.CSS_SELECTOR, ".sea-card-slot.sea-card-slot--filled"
)),
1,
)
combo = picker.find_element(By.CSS_SELECTOR, "[data-combobox]")
combo.click()
picker.find_element(
By.CSS_SELECTOR,
".sea-select-list [role='option'][data-value='mind-body-spirit']",
).click()
self.wait_for(
lambda: self.assertEqual(
len(picker.find_elements(
By.CSS_SELECTOR, ".sea-card-slot.sea-card-slot--filled"
)),
0,
)
)
# ── 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())
# ── Test (modal bug fix) ────────────────────────────────────────────────
def test_flip_click_opens_portaled_stage_modal(self):
"""Bug fix (2026-05-19): the user-reported missing modal. After
clicking the deck stack + the FLIP btn that appears, SeaDeal.
openStage should fire — showing `#id_sea_stage` (position-fixed
full-viewport portal) above everything else. Before the fix the
slot got filled directly at opacity 0 → 'thumbnail summarily
disappears'. Now: modal opens; slot stays at `--filled` but
`--visible` is NOT added yet (waits for backdrop dismiss)."""
picker = self._enter_picker_phase()
stage = self._draw_open_modal(picker, "levity")
# Stage card carries the drawn card's data — non-empty corner rank.
rank = stage.find_element(
By.CSS_SELECTOR, ".sea-stage-card .fan-card-corner--tl .fan-corner-rank"
)
self.assertTrue(rank.text.strip(), "stage card should display the drawn card's corner rank")
# Slot in the cross is in `.--filled` state but the thumbnail is
# invisible until the modal dismisses (the bug we're guarding).
slot = picker.find_element(
By.CSS_SELECTOR, ".sea-pos-lay .sea-card-slot.sea-card-slot--filled"
)
self.assertNotIn(
"sea-card-slot--visible", slot.get_attribute("class"),
"slot should still be in pre-reveal opacity-0 state while modal is open",
)
# ── Test (modal bug fix, dismiss reveal) ───────────────────────────────
def test_backdrop_click_dismisses_modal_and_reveals_thumbnail(self):
"""Bug fix part 2: clicking the `.sea-stage-backdrop` closes the
modal AND adds `.sea-card-slot--visible` to the deposited slot,
making the thumbnail fade in. Confirms the user-reported 'card
appears where the slot was' behavior post-dismiss."""
picker = self._enter_picker_phase()
self._draw_open_modal(picker, "levity")
self._dismiss_modal()
slot = picker.find_element(
By.CSS_SELECTOR, ".sea-pos-lay .sea-card-slot.sea-card-slot--filled"
)
self.assertIn(
"sea-card-slot--visible", slot.get_attribute("class"),
"post-dismiss, the slot should fade in via `.--visible`",
)
# ── Test (modal bug fix, stat block populates) ─────────────────────────
def test_modal_stage_renders_stat_block_dom_contract(self):
"""SeaDeal._populate populates the stat-block keyword `<ul>`s
via `#id_sea_stat_upright` / `#id_sea_stat_reversed`. The DOM
contract — these IDs exist inside the stage — is what this FT
pins; the actual stat content (keyword text, qualifier render)
is exercised by [[SeaDealSpec.js]]. Earthman seed cards in the
iter-4a FT pile carry empty keyword arrays so we can't assert
text content here without enriching the seed."""
picker = self._enter_picker_phase()
self._draw_open_modal(picker, "levity")
# Stat-block UL elements exist inside the visible stage.
upright = self.browser.find_element(By.CSS_SELECTOR, "#id_sea_stat_upright")
reversed_ul = self.browser.find_element(By.CSS_SELECTOR, "#id_sea_stat_reversed")
self.assertIsNotNone(upright)
self.assertIsNotNone(reversed_ul)
# The sea stat block is inside the visible stage modal.
stat_block = self.browser.find_element(
By.CSS_SELECTOR, "#id_sea_stage .sea-stat-block"
)
self.assertIsNotNone(stat_block)

View File

@@ -1324,9 +1324,30 @@ $sea-card-h: 6.5rem;
.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
// Empty Cover/Cross slots — subtle dotted outline (no fill) so the
// underlying Sig card shows through. Hovering/touching reveals the
// full --duoUser mask, opaquing the slot + obscuring the Sig behind.
// Border + label dim to 0.25 alpha default; bounce to full on hover.
// The filled-slot hover behavior (opacity 0.3/0.15 → 1) at lines 1300-
// 1301 is untouched — this only restyles the EMPTY state.
.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--empty {
background-color: transparent;
border-color: rgba(var(--terUser), 0.25);
box-shadow: none;
pointer-events: auto;
transition: background-color 0.15s ease, border-color 0.15s ease;
.sea-pos-label { opacity: 0.25; }
}
.sea-pos-cover .sea-card-slot--empty:hover,
.sea-pos-cross .sea-card-slot--empty:hover {
background-color: rgba(var(--duoUser), 1);
border-color: rgba(var(--terUser), 1);
.sea-pos-label { opacity: 0.6; }
}
.sea-pos-cross .sea-card-slot { transform: rotate(90deg); }

View File

@@ -194,7 +194,7 @@ body.page-gameboard {
align-items: center;
}
// Applet variant — denser layout, omits BACK (the user is already on
// Applet variant — denser layout, omits NVM (the user is already on
// the gameboard). Smaller line + just the FYI action surviving.
&.my-sea-sign-gate--applet {
padding: 0.5rem;
@@ -205,3 +205,301 @@ body.page-gameboard {
}
}
}
// ─── My Sea DRAW SEA landing ─────────────────────────────────────────────────
// Sprint 5 iter 1 of [[project-my-sea-roadmap]]. When a user has a saved
// significator (gate passed), /gameboard/my-sea/ renders this landing
// screen: DRY table hex w. 6 chair seats labeled 1C-6C + central DRAW
// SEA btn. Mirrors my-sign's `.my-sign-page` + `.my-sign-landing`
// structure — same room-shell chain so room.js's scaleTable() can size
// the hex; same flex setup so the container chain propagates real
// height down for the scale calc.
.my-sea-page {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
position: relative;
}
.my-sea-landing {
flex: 1;
min-height: 0;
display: flex;
// FREE DRAW btn — centered in the hex, mirrors SCAN SIGN's 2-line
// font sizing so "FREE/DRAW" sits cleanly inside the 4rem circle.
#id_draw_sea_btn {
font-size: 0.75rem;
line-height: 1.1;
white-space: normal;
}
// Chair-position labels (1C-6C). Mirrors the room's `.seat-role-
// label` grid placement (col 2, row 1 by default; flips to col 1
// for left-side seats 3/4/5 so the label sits closest to the hex)
// but uses a role-free class name — my-sea is the solo draw flow,
// no role-pick phase, so the room's role-grammar doesn't apply.
.table-seat .seat-position-label {
grid-column: 2;
grid-row: 1;
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.05em;
color: rgba(var(--secUser), 1);
}
.table-seat[data-slot="3"] .seat-position-label,
.table-seat[data-slot="4"] .seat-position-label,
.table-seat[data-slot="5"] .seat-position-label {
grid-column: 1;
}
// Seated chair (post-FREE DRAW). Visual transition mirrors
// `.table-seat.active .fa-chair` from _room.scss line 626 —
// --terUser color + --ninUser drop-shadow glow — but uses a stable
// `.seated` class (semantically distinct from `.active`: active =
// current turn in a multi-user room; seated = draw-locked occupant
// in this solo-flow). _room.scss line 596 makes the colour change
// a 0.6s ease transition so the chair animates rather than snaps.
// Status icon (.position-status-icon) colour swap fa-ban red →
// fa-circle-check green is handled by _room.scss lines 615-616.
.table-seat.seated .fa-chair {
color: rgba(var(--terUser), 1);
filter: drop-shadow(0 0 4px rgba(var(--ninUser), 1));
}
}
// Picker phase bg — `--duoUser` matches the table hex's interior so
// the landing→picker swap reads as a continuous surface (parallels
// `.my-sign-page[data-phase="picker"]` in _card-deck.scss line 704).
.my-sea-page[data-phase="picker"] {
background: rgba(var(--duoUser), 1);
}
.my-sea-picker {
flex: 1;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
}
// .my-sea-cross renders all 6 surrounding positions (crown/leave/lay/
// loom + cover/cross overlaid on core) unconditionally. The SPREAD
// dropdown sets `data-spread="<name>"` 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) crown (3) — loom right · cross overlay · crown above
// CC variants: all 6 positions (Waite-Smith / Escape Velocity differ in DRAW ORDER only,
// not in position visibility).
// Bump grid gap on my-sea (gameroom .sea-cross stays at 0.5rem since
// gameroom slots have no per-position labels). The vertical leave/loom
// labels need ~1.5rem of horizontal clearance from adjacent cells, and
// the horizontal crown/cover/lay/cross labels need ~1rem of vertical
// clearance so they don't overlap into the next row.
.my-sea-cross {
gap: 1rem !important;
}
.my-sea-cross[data-spread="past-present-future"] {
.sea-pos-crown,
.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="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-cover,
.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 — 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.
//
// Labels live OUTSIDE the .sea-card-slot (sibling, inside the crucifix
// cell or the cover/cross wrapper) so they survive SeaDeal._fillSlot's
// `slot.innerHTML = …` clobber on draw. Each label is absolute-
// positioned to nearly touch the slot's nearest border per the user-
// locked spec:
// crown / cover — above top border
// lay / cross — below bottom border
// leave — left of left border, rotated 90° CCW
// loom — right of right border, rotated 90° CW
.sea-pos-label {
font-size: 0.65rem;
letter-spacing: 0.08em;
text-transform: uppercase;
font-weight: 600;
opacity: 1;
color: rgba(var(--seciUser), 1);
text-align: center;
pointer-events: none;
white-space: nowrap;
position: absolute;
z-index: 2;
}
// Cells need `position: relative` so absolute label children anchor
// to them. `.sea-pos-core` already has `position: relative` per the
// existing rule in _card-deck.scss line 1311; the other crucifix
// cells need it added.
.my-sea-cross .sea-crucifix-cell { position: relative; }
// Above top border — overlaps slot's top edge by 0.1rem (per the
// `.sea-stack-name` "tuck under" treatment in _card-deck.scss:1564).
.sea-pos-crown > .sea-pos-label,
.sea-pos-cover > .sea-pos-label {
bottom: 100%;
left: 50%;
transform: translate(-50%, 0.1rem) scaleY(1.2);
}
// Cover + cross labels dim w. their slots — they sit on top of the
// sig card so a vivid label would compete w. the sig at idle. Default
// 0.25 opacity matches the slot's faint dotted-outline at idle; the
// parent's :hover state (propagated up when the inside `.sea-card-
// slot:hover` fires per CSS hover-ancestor rules) boosts to the
// `.sea-pos-label` baseline 0.6, matching the slot's `--duoUser` mask
// reveal.
.sea-pos-cover > .sea-pos-label,
.sea-pos-cross > .sea-pos-label {
opacity: 0.5;
transition: opacity 0.15s ease;
}
.sea-pos-cover:hover > .sea-pos-label,
.sea-pos-cross:hover > .sea-pos-label {
opacity: 1;
}
// Below bottom border — same `0.1rem` overlap but downward.
.sea-pos-lay > .sea-pos-label,
.sea-pos-cross > .sea-pos-label {
top: 100%;
left: 50%;
transform: translate(-50%, -0.1rem) scaleY(1.2);
}
// Left of left border, rotated 90° CCW — text reads bottom-to-top.
// `writing-mode: vertical-rl` puts text top-to-bottom (CW); a 180°
// rotation flips it to read bottom-to-top (CCW), satisfying the user-
// locked "Leave: counterclockwise" spec.
//
// `scaleX(1.2)` (instead of the horizontal labels' scaleY) widens the
// character column (perpendicular to text-flow) — for vertical-rl
// labels, that's the visible "width" the user noticed had been lost
// at this angle. Without it, the rotated labels look squat.
.sea-pos-leave > .sea-pos-label {
right: 100%;
top: 50%;
writing-mode: vertical-rl;
transform: translate(0.1rem, -50%) rotate(180deg) scaleX(1.2);
}
// Right of right border, rotated 90° CW — text reads top-to-bottom.
// Native `writing-mode: vertical-rl` direction; no extra rotation.
.sea-pos-loom > .sea-pos-label {
left: 100%;
top: 50%;
writing-mode: vertical-rl;
transform: translate(-0.1rem, -50%) scaleX(1.2);
}
// 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;
// Portal the SPREAD dropdown out of `.sea-form-main`'s overflow
// clip — by default the gameroom's `.sea-form-main { overflow-y:
// auto }` (from _card-deck.scss:1424) keeps the modal contents
// scrollable, but for my-sea's much shorter form the dropdown gets
// clipped instead of overlaying the LOCK HAND / DEL btns below.
// Setting overflow visible here lets the absolute-positioned
// `.sea-select-list` extend past the form area + sit "above
// everything else" via its existing z-index: 100.
.sea-form-main {
overflow: visible;
}
// Bump the dropdown z-index well above the picker's stacking ints
// (cover z:3, cross z:4, modal stage z:9999 only opens on draw
// anyway). 1000 sits above any in-page layer the user might be
// interacting w. when they open the SPREAD picker.
.sea-select-list {
z-index: 1000;
}
}
// LOCK HAND post-commit visual-lock: dim everything that mutates the
// hand. `.btn-disabled` is the project's existing soft-disabled
// treatment per [[feedback_btn_disabled_pointer_events]] — pointer-
// events:none + opacity reduction. The deck stacks aren't buttons
// themselves so we apply the class manually + the rule below ensures
// they stop responding to clicks.
.my-sea-picker--locked {
.sea-deck-stack.btn-disabled {
pointer-events: none;
opacity: 0.5;
cursor: default;
}
}
// SPREAD combobox lock — applied after the first deposit so the user
// can't switch spread mid-draw + scramble the in-progress hand's
// position-to-card mapping. DEL releases the lock by removing this
// class. Same `pointer-events: none` treatment as `.btn-disabled` per
// [[feedback_btn_disabled_pointer_events]].
.sea-select.sea-select--locked {
pointer-events: none;
opacity: 0.5;
cursor: default;
}

View File

@@ -233,25 +233,14 @@
}
}
// Confirmed palette swatch — right-side thumbnail, same gradient as .note-swatch-body.
// palette-* class is on the element so CSS vars cascade from that palette automatically.
// Confirmed palette swatch — right-side thumbnail, shares the canonical
// gradient w. .swatch via palette-swatch-bg mixin (single source of truth).
.note-item__palette {
width: 3rem;
height: 3rem;
flex-shrink: 0;
border-radius: 0.25rem;
border: 0.15rem solid rgba(var(--secUser), 0.5);
background: linear-gradient(
to bottom,
rgba(var(--secUser), 1) 0%,
rgba(var(--secUser), 1) 30%,
rgba(var(--priUser), 1) 30%,
rgba(var(--priUser), 1) 70%,
rgba(var(--terUser), 1) 70%,
rgba(var(--terUser), 1) 85%,
rgba(var(--quaUser), 1) 85%,
rgba(var(--quaUser), 1) 100%
);
@include palette-swatch-bg;
}
// ── Palette modal ──────────────────────────────────────────────────────────
@@ -282,20 +271,7 @@
height: 3.25rem;
border-radius: 0.25rem;
cursor: pointer;
// Gradient uses vars scoped to the parent palette-* class,
// so each swatch shows its own palette's colours (same as .swatch).
background: linear-gradient(
to bottom,
rgba(var(--secUser), 1) 0%,
rgba(var(--secUser), 1) 30%,
rgba(var(--priUser), 1) 30%,
rgba(var(--priUser), 1) 70%,
rgba(var(--terUser), 1) 70%,
rgba(var(--terUser), 1) 85%,
rgba(var(--quaUser), 1) 85%,
rgba(var(--quaUser), 1) 100%
);
border: 0.15rem solid rgba(var(--secUser), 0.5);
@include palette-swatch-bg;
flex-shrink: 0;
transition: border-color 0.12s, box-shadow 0.12s;

View File

@@ -18,13 +18,11 @@
scroll-snap-align: start;
}
.swatch {
position: relative;
width: 7rem;
height: 7rem;
aspect-ratio: 1;
border-radius: 0.5rem;
cursor: pointer;
// Canonical palette swatch gradient. Single source of truth for every place
// a palette is rendered as a 4-band swatch (applet thumb, billnote thumb,
// billnote modal options). The palette-* class on the element supplies the
// --priUser/--secUser/--terUser/--quiUser vars; this mixin paints them.
@mixin palette-swatch-bg {
background: linear-gradient(
to bottom,
rgba(var(--secUser), 1) 0%,
@@ -37,6 +35,16 @@
rgba(var(--quiUser), 1) 100%
);
border: 0.15rem solid rgba(var(--secUser), 0.5);
}
.swatch {
position: relative;
width: 7rem;
height: 7rem;
aspect-ratio: 1;
border-radius: 0.5rem;
cursor: pointer;
@include palette-swatch-bg;
&.active {
border: 0.2rem solid rgba(var(--ninUser), 1);

View File

@@ -139,50 +139,9 @@
</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">
<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>
<p class="fan-card-name"></p>
<p class="sig-qualifier-below"></p>
</div>
<p class="fan-card-arcana"></p>
<div class="fan-card-face-reversal">
{# Default DOM order — matches non-major arcana layout. stage-card.js #}
{# swaps the class names on these <p>s for Major arcana so each #}
{# element's class still matches its semantic content. #}
<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 spin-btn" type="button">SPIN</button>
<button class="btn btn-info 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>
{% include "apps/gameboard/_partials/_sig_fyi_panel.html" with panel_id="id_sea_fyi_panel" panel_extra_attrs='style="display:none"' %}
</div>
</div>
</div>
{# Extracted to a shared partial so the my-sea picker (Sprint 5 iter 4-bugs) #}
{# reuses the same DOM contract that SeaDeal binds to. #}
{% include "apps/gameboard/_partials/_sea_stage.html" %}
</div>{# /.sea-overlay #}

View File

@@ -0,0 +1,52 @@
{# Sea stage — full-viewport portaled modal (`position: fixed; inset: 0` #}
{# per _card-deck.scss:1615) that opens above the picker / overlay when #}
{# `SeaDeal.openStage(card, posSelector, isLevity)` fires. Hosts the #}
{# full card face + stat block + SPIN / FYI controls; click backdrop to #}
{# dismiss + reveal the deposited card thumbnail in its slot. #}
{# #}
{# Shared by the gameroom SEA SELECT phase and the my-sea picker — same #}
{# HTML, same SeaDeal module bindings; only the parent overlay differs. #}
<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">
<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>
<p class="fan-card-name"></p>
<p class="sig-qualifier-below"></p>
</div>
<p class="fan-card-arcana"></p>
<div class="fan-card-face-reversal">
{# Default DOM order — matches non-major arcana layout. stage-card.js #}
{# swaps the class names on these <p>s for Major arcana so each #}
{# element's class still matches its semantic content. #}
<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 spin-btn" type="button">SPIN</button>
<button class="btn btn-info 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>
{% include "apps/gameboard/_partials/_sig_fyi_panel.html" with panel_id="id_sea_fyi_panel" panel_extra_attrs='style="display:none"' %}
</div>
</div>
</div>

View File

@@ -5,11 +5,11 @@
{% block header_text %}<span>Game</span><span>Sea</span>{% endblock header_text %}
{% block content %}
<div class="my-sea-page">
<div class="my-sea-page" data-phase="landing">
{% if not user_has_sig %}
{# Sprint 4b sign-gate. The draw UX is gated behind a saved #}
{# significator — render a Look!-formatted Brief-style line w. #}
{# FYI (→ /billboard/my-sign/) + BACK (→ /gameboard/) until the #}
{# FYI (→ /billboard/my-sign/) + NVM (→ /gameboard/) until the #}
{# user picks a sign. Inline (not portaled like .note-banner) #}
{# because the gate IS the page content, not a transient nudge. #}
<div class="my-sea-sign-gate">
@@ -18,15 +18,559 @@
</p>
<div class="my-sea-sign-gate__actions">
<a class="btn btn-cancel my-sea-sign-gate__back"
href="{% url 'gameboard' %}">BACK</a>
href="{% url 'gameboard' %}">NVM</a>
<a class="btn btn-info my-sea-sign-gate__fyi"
href="{% url 'billboard:my_sign' %}">FYI</a>
</div>
</div>
{% else %}
{# Sprint 3 shell — gatekeeper / sig-select / sea-select phases #}
{# will land here in later sprints of the My Sea roadmap. #}
<p class="my-sea-page__empty">No draws yet&mdash;the depths remain unfathomable.</p>
{# Sprint 5 iter 1 — DRAW SEA landing UX. DRY table hex from #}
{# the room shell (.room-shell > .room-table > … > .table-hex) #}
{# w. 6 chair seats labeled 1C-6C as placeholders for the #}
{# friend-invite feature per the My Sea roadmap architectural #}
{# anchor "Six chairs retained even in solo". DRAW SEA btn #}
{# mirrors SCAN SIGN on /billboard/my-sign/. #}
<div class="my-sea-landing">
<div class="room-shell">
<div id="id_game_table" class="room-table">
<div class="room-table-scene">
<div class="table-hex-border">
<div class="table-hex">
<div class="table-center">
{# Sprint 5 iter 1 — FREE DRAW = the 1/24hr free-quota draw. #}
{# Future sprint will conditionally swap this for a DRAW SEA #}
{# .btn-primary that calls the gatekeeper partial once the #}
{# free daily has been used; until then the btn renders FREE #}
{# DRAW. ID retained as `id_draw_sea_btn` (intent: the draw #}
{# entry point) so the swap is label-only when iter 6+ lands. #}
<button id="id_draw_sea_btn" type="button" class="btn btn-primary">FREE<br>DRAW</button>
</div>
</div>
</div>
{% for n in "123456" %}
{# Chair-position labels (1C-6C). No roles in #}
{# my-sea (this is the solo draw flow); using #}
{# `.seat-position-label` instead of the room's #}
{# `.seat-role-label` to keep the no-role #}
{# semantics clean. `.position-status-icon` + #}
{# `.fa-ban` are unchanged — already role- #}
{# agnostic in _room.scss. #}
<div class="table-seat" data-slot="{{ n }}">
<i class="fa-solid fa-chair"></i>
<span class="seat-position-label">{{ n }}C</span>
<i class="position-status-icon fa-solid fa-ban"></i>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
{# 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. #}
{# #}
{# `id="id_sea_overlay"` aliases the picker to what SeaDeal #}
{# binds to (the gameroom uses the same ID on a different #}
{# page — no DOM collision since my-sea + gameroom never co- #}
{# exist in one DOM). FLIP click delegates to SeaDeal. #}
{# openStage(), which fills the slot AND opens the portaled #}
{# stage modal w. SPIN / FYI controls. #}
<div class="my-sea-picker" id="id_sea_overlay" style="display:none">
<div class="sea-cards-col">
<div class="sea-cross my-sea-cross" data-spread="{{ default_spread }}">
{# `.sea-pos-label` lives OUTSIDE the slot so SeaDeal._fillSlot's #}
{# `slot.innerHTML = …` (which writes the drawn card's corner- #}
{# rank + suit-icon) doesn't clobber it. Labels persist as #}
{# adjacent siblings + are positioned via absolute SCSS to #}
{# touch the slot's nearest edge. #}
<div class="sea-crucifix-cell sea-pos-crown">
<span class="sea-pos-label" data-position="crown">Outcome</span>
<div class="sea-card-slot sea-card-slot--empty"></div>
</div>
<div class="sea-crucifix-cell sea-pos-leave">
<span class="sea-pos-label" data-position="leave"></span>
<div class="sea-card-slot sea-card-slot--empty"></div>
</div>
<div class="sea-crucifix-cell sea-pos-core">
<div class="sig-stage-card sea-sig-card"
data-card-id="{{ significator.id }}">
<span class="fan-corner-rank">{{ significator.corner_rank }}</span>
{% if significator.suit_icon %}<i class="fa-solid {{ significator.suit_icon }}"></i>{% endif %}
</div>
<div class="sea-pos-cover">
<span class="sea-pos-label" data-position="cover">Action</span>
<div class="sea-card-slot sea-card-slot--empty"></div>
</div>
<div class="sea-pos-cross">
<span class="sea-pos-label" data-position="cross"></span>
<div class="sea-card-slot sea-card-slot--empty sea-card-slot--crossing"></div>
</div>
</div>
<div class="sea-crucifix-cell sea-pos-loom">
<span class="sea-pos-label" data-position="loom"></span>
<div class="sea-card-slot sea-card-slot--empty"></div>
</div>
<div class="sea-crucifix-cell sea-pos-lay">
<span class="sea-pos-label" data-position="lay">Situation</span>
<div class="sea-card-slot sea-card-slot--empty"></div>
</div>
</div>
</div>
{# 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). #}
<div class="sea-form-col my-sea-form-col">
<div class="sea-form-main">
<div class="sea-field">
<label for="id_sea_spread" id="id_sea_spread_label">Spread</label>
<p class="sea-reversal-hint">{{ reversals_pct|default:25 }}% reversals</p>
{# autocomplete="off" opts the hidden input out of #}
{# Firefox's form-history autofill, which otherwise #}
{# restores the LAST value on soft reload (F5). #}
{# Without this, combobox.js's `select(i)` short- #}
{# circuits its change-event dispatch when the #}
{# user re-picks the value Firefox already restored #}
{# → my-sea's sync() never fires → data-spread on #}
{# .my-sea-cross stays stuck on SAO default. #}
<input type="hidden" id="id_sea_spread" name="spread"
value="{{ default_spread }}" autocomplete="off">
<div class="sea-select"
data-combobox
data-combobox-target="id_sea_spread"
role="combobox"
aria-expanded="false"
aria-haspopup="listbox"
aria-labelledby="id_sea_spread_label"
tabindex="0">
<span class="sea-select-current">Situation, Action, Outcome</span>
<span class="sea-select-arrow" aria-hidden="true"></span>
<ul class="sea-select-list" role="listbox">
<li role="presentation" class="sea-select-divider">3-card spreads</li>
<li role="option" data-value="past-present-future" aria-selected="false">Past, Present, Future</li>
<li role="option" data-value="situation-action-outcome" aria-selected="true">Situation, Action, Outcome</li>
<li role="option" data-value="mind-body-spirit" aria-selected="false">Mind, Body, Spirit</li>
<li role="option" data-value="desire-obstacle-solution" aria-selected="false">Desire, Obstacle, Solution</li>
<li role="presentation" class="sea-select-divider">6-card spreads</li>
<li role="option" data-value="waite-smith" aria-selected="false">Celtic Cross, Waite-Smith</li>
<li role="option" data-value="escape-velocity" aria-selected="false">Celtic Cross, Escape Velocity</li>
</ul>
</div>
</div>
<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 class="sea-form-actions">
<button type="button" id="id_sea_lock_hand" class="btn btn-primary" disabled>
LOCK HAND
</button>
<button type="button" id="id_sea_del" class="btn btn-danger">
DEL
</button>
</div>
</div>
{# Sea stage — portaled modal that opens on FLIP click via #}
{# SeaDeal.openStage. `position:fixed; inset:0` covers the #}
{# viewport; click backdrop to dismiss + reveal the slot #}
{# thumbnail. #}
{% include "apps/gameboard/_partials/_sea_stage.html" %}
</div>
{# Sprint 5 iter 4a — shuffled deck (levity + gravity halves, #}
{# sig excluded) embedded as JSON; JS reads on init and #}
{# pops from the relevant pile on each deposit. #}
{{ sea_deck_data|json_script:"id_my_sea_deck" }}
{# StageCard + SeaDeal — both bind to `#id_sea_overlay` (the #}
{# my-sea-picker) + `#id_sea_stage` (the stage partial) on #}
{# DOMContentLoaded; openStage() runs on FLIP click below. #}
<script src="{% static 'apps/epic/stage-card.js' %}"></script>
<script src="{% static 'apps/epic/sea.js' %}"></script>
<script src="{% static 'apps/epic/combobox.js' %}"></script>
<script>
(function () {
// Per-spread draw order + position labels — locked in spec
// (user 2026-05-19). Each three-card spread uses a DIFFERENT
// 3-position subset of the 6 surrounding positions, in a
// specific order. The Celtic Cross variants share position
// labels (Crown/Beneath/Cover/Cross/Before/Behind — gameroom
// vocabulary) but differ in draw order.
var DRAW_ORDER = {
'past-present-future': ['leave', 'cover', 'loom'],
'situation-action-outcome': ['lay', 'cover', 'crown'],
'mind-body-spirit': ['crown', 'lay', 'loom'],
'desire-obstacle-solution': ['loom', 'cross', 'crown'],
'waite-smith': ['cover', 'cross', 'crown', 'lay', 'loom', 'leave'],
'escape-velocity': ['cover', 'cross', 'lay', 'leave', 'crown', 'loom'],
};
var POSITION_LABELS = {
'past-present-future': { leave: 'Past', cover: 'Present', loom: 'Future' },
'situation-action-outcome': { lay: 'Situation', cover: 'Action', crown: 'Outcome' },
'mind-body-spirit': { crown: 'Mind', lay: 'Body', loom: 'Spirit' },
'desire-obstacle-solution': { loom: 'Desire', cross: 'Obstacle',crown: 'Solution' },
'waite-smith': { crown: 'Crown', leave: 'Beneath', cover: 'Cover', cross: 'Cross', loom: 'Before', lay: 'Behind' },
// Escape Velocity remaps the diagonal positions per the
// user-locked spec (2026-05-19): Beneath→Lay, Before→
// Loom, Behind→Leave. Crown/Cover/Cross keep the WS
// names.
'escape-velocity': { crown: 'Crown', leave: 'Lay', cover: 'Cover', cross: 'Cross', loom: 'Loom', lay: 'Leave' },
};
var hidden = document.getElementById('id_sea_spread');
var cross = document.querySelector('.my-sea-cross');
var picker = document.querySelector('.my-sea-picker');
var lockBtn= document.getElementById('id_sea_lock_hand');
var delBtn = document.getElementById('id_sea_del');
var deckEl = document.getElementById('id_my_sea_deck');
var seaSelect = document.querySelector('[data-combobox][data-combobox-target="id_sea_spread"]');
if (!hidden || !cross || !picker) return;
// ── Deck state ──────────────────────────────────────────
// `_deckData` is the immutable initial payload from the
// server; `_levityPile` + `_gravityPile` are working
// copies that pop one card per deposit. DEL re-clones
// from `_deckData` rather than re-fetching.
var _deckData = { levity: [], gravity: [] };
try { _deckData = JSON.parse((deckEl && deckEl.textContent) || '{}'); } catch (e) {}
var _levityPile = (_deckData.levity || []).slice();
var _gravityPile = (_deckData.gravity || []).slice();
var _filled = 0;
var _activeStack = null;
var _locked = false;
function _currentOrder() {
return DRAW_ORDER[hidden.value] || [];
}
function _hideOk() {
if (_activeStack) {
var 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');
var ok = stack.querySelector('.sea-stack-ok');
if (ok) ok.style.display = '';
}
function _fillSlot(positionName, card, isLevity) {
// Lifted from gameroom sea.js's `_fillSlot`: strip
// .--empty + the position label, layer .--filled +
// polarity classes, set corner-rank + suit-icon.
var cell = cross.querySelector('.sea-pos-' + positionName);
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');
if (card.reversed) slot.classList.add('sea-card-slot--reversed');
slot.dataset.cardId = String(card.id);
slot.dataset.posKey = positionName;
slot.innerHTML =
'<span class="fan-corner-rank">' + (card.corner_rank || '') + '</span>' +
(card.suit_icon ? '<i class="fa-solid ' + card.suit_icon + '"></i>' : '');
}
function _emptySlot(cell) {
// DEL restores each filled slot to its initial empty
// state. `.sea-pos-label` is now a SIBLING of the slot
// (outside it) so SeaDeal's innerHTML clobber on draw
// doesn't touch it — we don't need to re-render the
// label here, just wipe slot contents + classes.
var slot = cell.querySelector('.sea-card-slot');
if (!slot) return;
slot.className = slot.className
.split(' ')
.filter(function (c) {
return !/^sea-card-slot--(filled|visible|focused|levity|gravity|reversed|rank-long)$/.test(c);
})
.join(' ');
slot.classList.add('sea-card-slot--empty');
delete slot.dataset.cardId;
delete slot.dataset.posKey;
slot.innerHTML = '';
}
function _lockSpread() {
// Lock the SPREAD combobox after the first deposit —
// switching spread mid-draw would scramble the in-
// progress hand. Unlocks on DEL.
if (seaSelect) {
seaSelect.classList.add('sea-select--locked');
seaSelect.setAttribute('aria-disabled', 'true');
}
}
function _unlockSpread() {
if (seaSelect) {
seaSelect.classList.remove('sea-select--locked');
seaSelect.removeAttribute('aria-disabled');
}
}
function _reshuffleDeck() {
// Fisher-Yates re-shuffle on DEL — re-distributes cards
// across both polarity halves + re-rolls the 25% reversal
// axis per card. Page-load shuffle already came from the
// server (`_my_sea_deck_data`); subsequent shuffles run
// client-side so DEL → fresh-hand doesn't require a
// network round-trip.
var all = (_deckData.levity || []).concat(_deckData.gravity || []);
for (var i = all.length - 1; i > 0; i--) {
var j = Math.floor(Math.random() * (i + 1));
var tmp = all[i]; all[i] = all[j]; all[j] = tmp;
}
// Clone each card (don't mutate the immutable server payload
// — DEL can fire many times; we don't want successive shuffles
// to fold previous reversal flips into the next round).
all = all.map(function (c) {
var clone = {};
for (var k in c) if (Object.prototype.hasOwnProperty.call(c, k)) clone[k] = c[k];
clone.reversed = Math.random() < 0.25;
return clone;
});
var mid = Math.floor(all.length / 2);
_levityPile = all.slice(0, mid);
_gravityPile = all.slice(mid);
}
function _resetHand() {
_filled = 0;
_hideOk();
_unlockSpread();
cross.querySelectorAll(
'.sea-crucifix-cell.sea-pos-crown, ' +
'.sea-crucifix-cell.sea-pos-leave, ' +
'.sea-crucifix-cell.sea-pos-loom, ' +
'.sea-crucifix-cell.sea-pos-lay, ' +
'.sea-pos-cover, .sea-pos-cross'
).forEach(_emptySlot);
_reshuffleDeck();
if (lockBtn) lockBtn.disabled = true;
// Wipe SeaDeal's stage state too — closes a lingering
// modal + clears its `_seaHand` map so previously-
// drawn cards can't reopen via slot-tap focus.
if (window.SeaDeal && window.SeaDeal.resetHand) {
SeaDeal.resetHand();
}
}
function _setLocked(on) {
_locked = on;
picker.classList.toggle('my-sea-picker--locked', on);
[picker.querySelector('.sea-deck-stack--levity'),
picker.querySelector('.sea-deck-stack--gravity'),
delBtn, lockBtn].forEach(function (el) {
if (!el) return;
el.classList.toggle('btn-disabled', on);
});
_hideOk();
}
// ── Deck-stack click → show FLIP → click FLIP → deposit ─
picker.querySelectorAll('.sea-deck-stack').forEach(function (stack) {
stack.addEventListener('click', function (e) {
if (_locked) return;
e.stopPropagation();
if (_activeStack === stack) _hideOk();
else _showOk(stack);
});
var ok = stack.querySelector('.sea-stack-ok');
if (ok) {
ok.style.display = 'none';
ok.addEventListener('click', function (e) {
if (_locked) return;
e.stopPropagation();
var isLevity = stack.classList.contains('sea-deck-stack--levity');
var pile = isLevity ? _levityPile : _gravityPile;
var card = pile.length ? pile.shift() : null;
var order = _currentOrder();
var posName = order[_filled];
if (card && posName) {
// Delegate to SeaDeal — it `_fillSlot`s
// (sets corner-rank + suit-icon + polarity
// class on the slot at opacity 0) AND opens
// the portaled stage modal w. SPIN / FYI.
// Click-the-backdrop dismisses + the slot
// fades to `.--visible` revealing the
// thumbnail.
if (window.SeaDeal && window.SeaDeal.openStage) {
SeaDeal.openStage(card, '.sea-pos-' + posName, isLevity);
} else {
// Defensive fallback for environments
// where sea.js failed to load (e.g.
// collectstatic miss). Render the slot
// visibly so the draw isn't lost.
_fillSlot(posName, card, isLevity);
}
_filled++;
// First deposit locks the SPREAD combobox —
// switching mid-draw would scramble the
// in-progress hand's position mapping.
if (_filled === 1) _lockSpread();
if (lockBtn) lockBtn.disabled = (_filled < order.length);
}
_hideOk();
});
}
});
// Click elsewhere inside the picker dismisses the FLIP btn.
picker.addEventListener('click', _hideOk);
if (delBtn) {
delBtn.addEventListener('click', function () {
if (_locked) return;
_resetHand();
});
}
if (lockBtn) {
lockBtn.addEventListener('click', function () {
if (lockBtn.disabled) return;
_setLocked(true);
});
}
function syncLabels(spread) {
var labels = POSITION_LABELS[spread] || {};
cross.querySelectorAll('.sea-pos-label').forEach(function (el) {
var pos = el.dataset.position;
el.textContent = labels[pos] || '';
});
}
function sync() {
cross.setAttribute('data-spread', hidden.value);
syncLabels(hidden.value);
// Spread switch invalidates any in-progress hand —
// position-subset + draw-order both change. Reset.
_resetHand();
}
hidden.addEventListener('change', sync);
// Initial state — labels already server-rendered for the
// default spread; we just zero the hand counter + ensure
// LOCK HAND starts disabled.
_filled = 0;
if (lockBtn) lockBtn.disabled = true;
// Belt-and-braces autofill defense (paired w. autocomplete=
// off on the hidden input above). Firefox occasionally
// restores form-history values on soft reload even on
// hidden inputs; if it does, hidden.value diverges from
// the server-rendered aria-selected option + combobox.js
// short-circuits its change-event dispatch on subsequent
// option picks of the autofilled value. Force-sync from
// the server-rendered aria-selected source-of-truth.
var _initialOpt = document.querySelector(
'.sea-select-list [role="option"][aria-selected="true"]'
);
if (_initialOpt && hidden.value !== _initialOpt.dataset.value) {
hidden.value = _initialOpt.dataset.value;
}
// Exposed for iter 4b / future surfaces.
window._mySeaDrawOrder = DRAW_ORDER;
}());
</script>
<script src="{% static 'apps/epic/room.js' %}"></script>
<script>
(function () {
var page = document.querySelector('.my-sea-page');
if (!page) return;
var landing = page.querySelector('.my-sea-landing');
var picker = page.querySelector('.my-sea-picker');
var drawBtn = document.getElementById('id_draw_sea_btn');
// FREE DRAW click flow:
// 1) seat 1C transitions to .seated (chair --terUser +
// drop-shadow glow + .fa-ban → .fa-circle-check —
// _room.scss line 596 makes the colour change a
// 0.6s ease transition);
// 2) after a brief delay (so the user sees the seat
// animation), data-phase swaps to 'picker' + the
// landing hides. Picker content lands in iter 2.
// The seat-take logic is solo-coded for now: 1C is the
// lowest-numeral chair, and my-sea is 1-user-per-page
// until the friend-invite feature (per [[project-my-
// sea-roadmap]]) — so 1C is always the user's seat.
var SEAT_ANIM_MS = 800;
if (drawBtn) {
drawBtn.addEventListener('click', function () {
var seat1 = page.querySelector('.table-seat[data-slot="1"]');
if (seat1) {
seat1.classList.add('seated');
var statusIcon = seat1.querySelector('.position-status-icon');
if (statusIcon) {
statusIcon.classList.remove('fa-ban');
statusIcon.classList.add('fa-circle-check');
}
}
setTimeout(function () {
page.setAttribute('data-phase', 'picker');
if (landing) landing.style.display = 'none';
if (picker) picker.style.display = '';
}, SEAT_ANIM_MS);
});
}
// Mirror my-sign's scaleTable() init timing fix — the
// .my-sea-page hasn't flushed its flex sizing on
// DOMContentLoaded, so the hex stays unscaled until we
// dispatch a resize once layout settles.
window.requestAnimationFrame(function () {
window.dispatchEvent(new Event('resize'));
});
}());
</script>
{# Brief 'Default deck warning' banner — lifted verbatim from #}
{# /billboard/my-sign/'s no-equipped-deck path. Same copy, #}
{# same FYI (→ /gameboard/) + NVM (dismiss + proceed) actions.#}
{# Tagged w. .my-sea-intro-banner so FTs disambiguate from #}
{# any other Briefs on the page. #}
<script src="{% static 'apps/dashboard/note.js' %}"></script>
{% if show_backup_intro_banner %}
<script>
document.addEventListener('DOMContentLoaded', function () {
if (!window.Brief || !Brief.showBanner) return;
Brief.showBanner({
title: 'Default deck warning',
line_text: 'Look!—no deck is equipped. Navigate to the Game Kit to equip one (FYI) or (NVM) proceed with the Earthman [Shabby Cardstock] deck.',
post_url: '{% url "gameboard" %}',
created_at: '',
kind: 'NUDGE',
});
var banner = document.querySelector('.note-banner');
if (banner) banner.classList.add('my-sea-intro-banner');
});
</script>
{% endif %}
{% endif %}
</div>
{% endblock content %}