feat: My Sea applet dynamic population + lay/leave POSITION_LABELS swap fix + My Sign applet stat-block + Brief-fied sign-gate + --duoUser olive on all four personal-data surfaces. Six visual+structural items batched across the dashboard/billboard/gameboard.
(1) **My Sea applet dynamic population.** Applet at `_applet-my-sea.html` was referencing an undefined `latest_draw_cards` template var — fell through to "No draws yet" even when the user had an active draw. New helpers in `apps/gameboard/models.py`: `DRAW_ORDER` + `POSITION_LABELS` constants (Python mirrors of the JS dicts in `my_sea.html:274-293`) + `latest_draw_slots(user)` builder that pairs each spread position w. its drawn card + display label + polarity. Wired through `gameboard()` + `toggle_game_applets()` views as `my_sea_slots`. Applet now renders all spread slots in DRAW_ORDER: filled = `.my-sea-slot--filled.my-sea-slot--{gravity,levity}` w. corner-tl + face (name + arcana) + corner-br (mirror) markup (same shape language as my_sign.html `.sig-stage-card`), empty = `.my-sea-slot--empty` w. `0.15rem dashed rgba(var(--terUser), 1)` border (matches the picker's `.sea-card-slot` style exactly so the applet reads as a true scaled-down twin). Container queries (`container-type: size` on `.my-sea-scroll`) lift `--slot-w` to fill the applet's vertical aperture (`min(100cqi, calc((100cqh - 1rem) * 5 / 8))` carves the label row). Position labels pulled tight against the slot's bottom border (`margin-top: -0.15rem` crosses the border line) + vertically stretched (`transform: scaleY(1.4)` mirroring `.sea-pos-label` in `_card-deck.scss:1671-1684`) — empty-slot labels keep the same `--secUser` ink as filled-slot labels for title cohesion across the row. Horizontal-scroll on multi-card spreads via mousewheel — `bindMySeaWheel()` in `gameboard.js` translates vertical wheel events to `scrollLeft += deltaY` (lifted verbatim from `bindPaletteWheel` in `dashboard.js:7-14`).
(2) **lay/leave POSITION_LABELS swap fix.** User caught in the Escape Velocity picker that LEFT slot read "Lay" + BOTTOM slot read "Leave" — opposite of traditional Celtic Cross semantics (LEFT = Behind/past, BOTTOM = Beneath/root). Root cause: POSITION_LABELS for both Waite-Smith + Escape Velocity had `lay`/`leave` slug→label assignments inverted vs the CSS grid's spatial mapping (`_card-deck.scss:1276-1279` puts slug `lay` at BOTTOM, slug `leave` at LEFT). Fix in 5 places: `my_sea.html:287,292` JS POSITION_LABELS (WS: lay→"Beneath", leave→"Behind"; EV: lay→"Lay", leave→"Leave"), `gameboard/models.py:44-47` Python mirror, `test_game_my_sea.py:618-619` FT label-assertion table, `_sea_overlay.html:28,53` annotated comments (`sea-pos-leave` → "Behind (past) — CC pos 6 / EV pos 4"; `sea-pos-lay` → "Beneath (root) — CC pos 4 / EV pos 3"). Slug-to-CSS mapping, DRAW_ORDER, + DB persistence unchanged → no migrations, no data invalidation. **Crucial for Voronoi mapping correctness** per user spec.
(3) **My Sign applet — stage-card layout + stat-block beside.** Applet card markup upgraded to mirror my_sign.html `.sig-stage-card`: corner-tl + face (name + arcana centred) + corner-br (mirror, rotated 180°). Sized to fill applet height via container queries (`--applet-card-w: min(48cqi, 62.5cqh)` — 48cqi caps the card at half the row to leave room for the stat-block). Sibling `.my-sign-applet-stat-block` partial added — emanation/reversal face label + keyword list (from `card.keywords_upright` / `keywords_reversed` keyed off `significator_reversed`), no SPIN/FYI buttons (applet is read-only). Styling cribbed from `.sig-stat-block` in `_card-deck.scss:595-607` — priUser-translucent bg + terUser border + matching `--applet-card-w` sizing.
(4) **My Sea sign-gate refactored to Brief banner.** Was an inline `.my-sea-sign-gate` div w. its own SCSS — broke from the project's `Brief.showBanner` portal pattern. Refactored to a shared `_my_sea_sign_gate_brief.html` partial that fires `Brief.showBanner` w. title="Sign required" + line_text="Look!—pick your sign before drawing the Sea." + post_url=`/billboard/my-sign/`. Brief portals to the page-level h2 anchor via `note.js`'s `_alignToH2` (gaussian-glass `.note-banner` shell, FYI button → my-sign picker, NVM dismisses). Modifier class `.my-sea-sign-gate-brief` added post-render for FT selector disambiguation. note.js load hoisted to gameboard.html `{% block scripts %}` + the top of `my_sea.html {% block content %}` (single load per page — note.js declares `const Brief = ...` at global scope, second load = SyntaxError). All `.my-sea-sign-gate{,--applet,__line,__actions,__back,__fyi}` SCSS deleted. FTs (`test_no_sig_renders_lookline_gate_on_standalone_page` + 5 siblings) + ITs (`test_my_sea_applet_fires_sign_gate_brief_for_user_without_sig` etc.) updated to assert `.note-banner.my-sea-sign-gate-brief` + the JS-rendered FYI/NVM buttons inside the Brief shell.
(5) **Levity card text invisibility fix.** My-sea applet levity slots (--secUser bg) rendered their corner-rank + suit-icon invisible because `.fan-card-corner` carries a global `color: rgba(var(--secUser), 0.75)` rule at `_card-deck.scss:312-319` (specificity 0,1,0) that out-specifics the slot's inherited `color: --priUser`. Same trap as the `.fan-card-name { color: --quiUser }` global. Fix at `_gameboard.scss` inside the levity rule: explicit `.fan-card-corner { color: rgba(var(--priUser), 1) }` + `.fan-card-name { color: rgba(var(--priUser), 1) }` + `.fan-card-arcana { color: rgba(var(--priUser), 0.7) }` overrides at (1,3,1) specificity — beats the globals without `!important`. **Trap captured in memory** — pattern repeats across game-kit, my-sign, my-sea so worth pinning.
(6) **--duoUser olive on all five personal-data surfaces.** Per user spec, the four "personal" applets (My Sign on billboard, My Sea on gameboard, My Sky on dashboard) + the standalone Dashsky page + the standalone My Sign page got `background-color: rgba(var(--duoUser), 1)` so they read as a unified olive-bg group across navigation surfaces. For Dashsky specifically, the form column also got the override (`.sky-page .sky-form-col { background: --duoUser }`) — the base `.sky-form-col { background: --priUser }` (`_sky.scss:137`, shared w. the in-room CAST SKY modal) was leaving the dashsky form column purple inside the otherwise-olive page. Scoped to `.sky-page` so the in-room modal's purple form-col stays intact (sits over --secUser room bg, needs that contrast). One detour caught: tried `body.page-sky { background-color: --duoUser }` to fill the gap below .sky-page's content-sized aperture but it bled to navbar + footer (which sit outside .container) — reverted.
**TDD coverage**: 3 new ITs in `apps/gameboard/tests/integrated/test_views.py` — `test_my_sea_applet_renders_drawn_cards_in_draw_order` (SAO 1-of-3 fills `lay` slot, cover/crown render as empty placeholders), `test_my_sea_applet_labels_match_locked_spread` (SAO labels exactly Situation/Action/Outcome), `test_my_sea_applet_waite_smith_labels_post_fix` (regression pin for the WS Cover/Cross/Crown/**Beneath**/Before/**Behind** sequence post-swap-fix). Existing my-sea applet ITs updated to match the new selector vocabulary (`.my-sea-slot--filled` instead of `.my-sea-card`, Brief script substring instead of `.my-sea-sign-gate--applet`). 6 my-sea FTs updated to the Brief-banner contract. 1214/1214 IT/UT green.
**.gitignore**: temporary entry for `src/apps/epic/static/apps/epic/images/cards-faces/minchiate-fiorentine/` until images get renamed — flagged for removal once the rename lands. (Per user's wget download of the Minchiate faces into the gameboard cards/ tree this session.)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,6 +1,9 @@
|
|||||||
# Created by https://www.toptal.com/developers/gitignore/api/django
|
# Created by https://www.toptal.com/developers/gitignore/api/django
|
||||||
# Edit at https://www.toptal.com/developers/gitignore?templates=django
|
# Edit at https://www.toptal.com/developers/gitignore?templates=django
|
||||||
|
|
||||||
|
### Remove this once images renamed ###
|
||||||
|
src/apps/epic/static/apps/epic/images/cards-faces/minchiate-fiorentine/
|
||||||
|
|
||||||
### Claude ###
|
### Claude ###
|
||||||
.claude
|
.claude
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,86 @@ HAND_SIZE_BY_SPREAD = {
|
|||||||
"escape-velocity": 6,
|
"escape-velocity": 6,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Position order per spread — mirrors `DRAW_ORDER` in my_sea.html JS
|
||||||
|
# (lines 274-281). The applet renders slots in this order left-to-right
|
||||||
|
# so drawn cards appear in chronological draw sequence regardless of
|
||||||
|
# their spread-grid placement.
|
||||||
|
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"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Per-spread human-readable labels for each position slug. Mirrors
|
||||||
|
# `POSITION_LABELS` in my_sea.html JS (lines 282-293). Escape Velocity
|
||||||
|
# remaps the diagonal positions per the user-locked spec 2026-05-19
|
||||||
|
# (Beneath→Lay, Before→Loom, Behind→Leave) — Crown/Cover/Cross keep
|
||||||
|
# their Waite-Smith names.
|
||||||
|
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": "Behind", "cover": "Cover",
|
||||||
|
"cross": "Cross", "loom": "Before", "lay": "Beneath"},
|
||||||
|
"escape-velocity": {"crown": "Crown", "leave": "Leave", "cover": "Cover",
|
||||||
|
"cross": "Cross", "loom": "Loom", "lay": "Lay"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def latest_draw_slots(user):
|
||||||
|
"""Build the slot list for the My Sea applet — pairs each draw-order
|
||||||
|
position w. its drawn card (if any) + display label + polarity.
|
||||||
|
|
||||||
|
Drives `_applet-my-sea.html`: the applet renders ALL positions in
|
||||||
|
spread DRAW_ORDER, filled slots showing the drawn card, empty slots
|
||||||
|
rendering as a labelled placeholder w. --duoUser bg + --terUser
|
||||||
|
dotted border (user spec 2026-05-22). Spread is locked at first
|
||||||
|
card draw + survives DEL'd-but-not-expired rows... but the applet
|
||||||
|
only populates while `hand` is non-empty: a DEL'd row reads as
|
||||||
|
"No draws yet" since the user has explicitly cleared their picks.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[dict] — one entry per spread position in draw order:
|
||||||
|
{
|
||||||
|
"position": "lay", # position slug
|
||||||
|
"label": "Situation", # spread-specific human label
|
||||||
|
"card": <TarotCard>, # None if not yet drawn
|
||||||
|
"reversed": bool, # False if not yet drawn
|
||||||
|
"polarity": "gravity", # "" if not yet drawn
|
||||||
|
}
|
||||||
|
Empty list = no active draw OR active draw w. empty hand.
|
||||||
|
"""
|
||||||
|
draw = active_draw_for(user)
|
||||||
|
if draw is None or not draw.hand:
|
||||||
|
return []
|
||||||
|
from apps.epic.models import TarotCard
|
||||||
|
order = DRAW_ORDER.get(draw.spread, ())
|
||||||
|
labels = POSITION_LABELS.get(draw.spread, {})
|
||||||
|
by_position = {entry["position"]: entry for entry in draw.hand}
|
||||||
|
card_ids = {
|
||||||
|
entry["card_id"] for entry in draw.hand
|
||||||
|
if entry.get("card_id")
|
||||||
|
}
|
||||||
|
cards_by_id = (
|
||||||
|
{c.id: c for c in TarotCard.objects.filter(id__in=card_ids)}
|
||||||
|
if card_ids else {}
|
||||||
|
)
|
||||||
|
slots = []
|
||||||
|
for pos in order:
|
||||||
|
entry = by_position.get(pos)
|
||||||
|
slots.append({
|
||||||
|
"position": pos,
|
||||||
|
"label": labels.get(pos, ""),
|
||||||
|
"card": cards_by_id.get(entry["card_id"]) if entry else None,
|
||||||
|
"reversed": entry.get("reversed", False) if entry else False,
|
||||||
|
"polarity": entry.get("polarity", "") if entry else "",
|
||||||
|
})
|
||||||
|
return slots
|
||||||
|
|
||||||
|
|
||||||
class MySeaDraw(models.Model):
|
class MySeaDraw(models.Model):
|
||||||
"""Persisted Celtic-Cross-style tarot draw for the solo-user My Sea
|
"""Persisted Celtic-Cross-style tarot draw for the solo-user My Sea
|
||||||
|
|||||||
@@ -306,4 +306,20 @@ function initGameKitTooltips() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mousewheel → horizontal scroll on the My Sea applet (mirrors the
|
||||||
|
// Palette applet's `bindPaletteWheel` in `apps/dashboard/dashboard.js`).
|
||||||
|
// Vertical wheel events translate to horizontal scroll so multi-card
|
||||||
|
// spreads (6-slot Celtic Cross / Escape Velocity) can be panned through
|
||||||
|
// without touchpad gestures or a horizontal scrollbar drag.
|
||||||
|
function bindMySeaWheel() {
|
||||||
|
document.querySelectorAll('.my-sea-scroll').forEach(function (el) {
|
||||||
|
el.addEventListener('wheel', function (e) {
|
||||||
|
if (e.deltaY === 0) return;
|
||||||
|
e.preventDefault();
|
||||||
|
el.scrollLeft += e.deltaY;
|
||||||
|
}, { passive: false });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', initGameKitTooltips);
|
document.addEventListener('DOMContentLoaded', initGameKitTooltips);
|
||||||
|
document.addEventListener('DOMContentLoaded', bindMySeaWheel);
|
||||||
|
|||||||
@@ -42,23 +42,32 @@ class GameboardViewTest(TestCase):
|
|||||||
# flow lands in later sprints. Seeded via migration 0008.
|
# flow lands in later sprints. Seeded via migration 0008.
|
||||||
[_] = self.parsed.cssselect("#id_applet_my_sea")
|
[_] = self.parsed.cssselect("#id_applet_my_sea")
|
||||||
|
|
||||||
def test_my_sea_applet_renders_sign_gate_for_user_without_sig(self):
|
def test_my_sea_applet_fires_sign_gate_brief_for_user_without_sig(self):
|
||||||
# Sprint 4b — user with no significator sees the Look!-formatted
|
# Sprint 4b (refactored 2026-05-22) — user with no significator
|
||||||
# gate (mirror of the standalone page), not the draw UX.
|
# gets a Look!-formatted Brief banner (`Brief.showBanner` script
|
||||||
[_gate] = self.parsed.cssselect(
|
# fired in the applet template) AND the applet body falls through
|
||||||
"#id_applet_my_sea .my-sea-sign-gate--applet"
|
# to the empty-state "No draws yet" (no sig → no draws is the only
|
||||||
)
|
# possible state). The Brief itself renders client-side via JS so
|
||||||
# Draw-state nodes are suppressed while the gate is up.
|
# we assert the script content + the FYI url, not the DOM banner.
|
||||||
|
html = self.parsed.text_content() if False else \
|
||||||
|
lxml.html.tostring(self.parsed, encoding="unicode")
|
||||||
|
self.assertIn("Look!", html)
|
||||||
|
self.assertIn("pick your sign before drawing the Sea", html)
|
||||||
|
self.assertIn("Brief.showBanner", html)
|
||||||
|
# FYI url baked into the Brief script's `post_url`
|
||||||
|
self.assertIn("/billboard/my-sign/", html)
|
||||||
|
# Old inline gate markup is gone
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
len(self.parsed.cssselect("#id_applet_my_sea .my-sea-empty")), 0,
|
len(self.parsed.cssselect(".my-sea-sign-gate")), 0,
|
||||||
)
|
)
|
||||||
|
# Card cells suppressed (no active draw possible without sig)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
len(self.parsed.cssselect("#id_applet_my_sea .my-sea-card")), 0,
|
len(self.parsed.cssselect("#id_applet_my_sea .my-sea-slot--filled")), 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_my_sea_applet_renders_empty_state_for_user_with_sig_no_draws(self):
|
def test_my_sea_applet_renders_empty_state_for_user_with_sig_no_draws(self):
|
||||||
# Sig set + no saved draws → the scroll container hosts a single
|
# Sig set + no saved draws → the scroll container hosts a single
|
||||||
# placeholder line ("No draws yet."), no card cells, no gate.
|
# placeholder line ("No draws yet."), no card cells, no gate Brief.
|
||||||
from apps.epic.models import personal_sig_cards
|
from apps.epic.models import personal_sig_cards
|
||||||
sig_pile = personal_sig_cards(self.user)
|
sig_pile = personal_sig_cards(self.user)
|
||||||
self.user.significator = sig_pile[0]
|
self.user.significator = sig_pile[0]
|
||||||
@@ -68,16 +77,123 @@ class GameboardViewTest(TestCase):
|
|||||||
[empty] = parsed.cssselect("#id_applet_my_sea .my-sea-empty")
|
[empty] = parsed.cssselect("#id_applet_my_sea .my-sea-empty")
|
||||||
self.assertIn("No draws yet", empty.text_content())
|
self.assertIn("No draws yet", empty.text_content())
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
len(parsed.cssselect("#id_applet_my_sea .my-sea-card")), 0,
|
len(parsed.cssselect("#id_applet_my_sea .my-sea-slot--filled")), 0,
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
len(parsed.cssselect("#id_applet_my_sea .my-sea-sign-gate--applet")),
|
|
||||||
0,
|
|
||||||
)
|
)
|
||||||
|
# No sign-gate Brief script fires when the user already has a sig
|
||||||
|
html = lxml.html.tostring(parsed, encoding="unicode")
|
||||||
|
self.assertNotIn("pick your sign before drawing the Sea", html)
|
||||||
|
|
||||||
def test_my_sea_applet_header_links_to_my_sea_page(self):
|
def test_my_sea_applet_header_links_to_my_sea_page(self):
|
||||||
[link] = self.parsed.cssselect("#id_applet_my_sea h2 a")
|
[link] = self.parsed.cssselect("#id_applet_my_sea h2 a")
|
||||||
self.assertEqual(link.get("href"), reverse("my_sea"))
|
self.assertEqual(link.get("href"), reverse("my_sea"))
|
||||||
|
|
||||||
|
def test_my_sea_applet_renders_drawn_cards_in_draw_order(self):
|
||||||
|
"""User w. a partial SAO draw — applet renders 3 slots in DRAW_
|
||||||
|
ORDER (lay, cover, crown → labelled Beneath/Cover/Crown? no —
|
||||||
|
SAO labels are Situation/Action/Outcome). Drawn slot 1 (`lay`)
|
||||||
|
carries the card; the un-drawn `cover` + `crown` slots are
|
||||||
|
empty placeholders w. their per-spread labels."""
|
||||||
|
from apps.epic.models import personal_sig_cards, TarotCard
|
||||||
|
from apps.gameboard.models import MySeaDraw
|
||||||
|
sig_pile = personal_sig_cards(self.user)
|
||||||
|
self.user.significator = sig_pile[0]
|
||||||
|
self.user.save()
|
||||||
|
card = TarotCard.objects.first()
|
||||||
|
MySeaDraw.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
spread="situation-action-outcome",
|
||||||
|
hand=[
|
||||||
|
{"position": "lay", "card_id": card.id,
|
||||||
|
"reversed": False, "polarity": "gravity"},
|
||||||
|
],
|
||||||
|
significator_id=self.user.significator_id,
|
||||||
|
)
|
||||||
|
response = self.client.get("/gameboard/")
|
||||||
|
parsed = lxml.html.fromstring(response.content)
|
||||||
|
# All 3 SAO positions render in DRAW_ORDER (lay, cover, crown).
|
||||||
|
wraps = parsed.cssselect("#id_applet_my_sea .my-sea-slot-wrap")
|
||||||
|
self.assertEqual(len(wraps), 3,
|
||||||
|
"SAO has 3 positions — applet should render 3 slot wraps")
|
||||||
|
# Position 1 (`lay`) is filled w. the drawn card.
|
||||||
|
filled = parsed.cssselect("#id_applet_my_sea .my-sea-slot--filled")
|
||||||
|
self.assertEqual(len(filled), 1)
|
||||||
|
self.assertEqual(
|
||||||
|
filled[0].get("data-position"), "lay",
|
||||||
|
"First drawn card should land in the `lay` slug slot",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
filled[0].get("data-card-id"), str(card.id),
|
||||||
|
)
|
||||||
|
# Positions 2 + 3 (cover, crown) are empty placeholders.
|
||||||
|
empties = parsed.cssselect("#id_applet_my_sea .my-sea-slot--empty")
|
||||||
|
self.assertEqual(len(empties), 2)
|
||||||
|
empty_positions = {e.get("data-position") for e in empties}
|
||||||
|
self.assertEqual(empty_positions, {"cover", "crown"})
|
||||||
|
|
||||||
|
def test_my_sea_applet_labels_match_locked_spread(self):
|
||||||
|
"""SAO label per spec: lay='Situation', cover='Action',
|
||||||
|
crown='Outcome'. Empty slots still carry their label so the
|
||||||
|
user can see which position is yet to draw."""
|
||||||
|
from apps.epic.models import personal_sig_cards, TarotCard
|
||||||
|
from apps.gameboard.models import MySeaDraw
|
||||||
|
sig_pile = personal_sig_cards(self.user)
|
||||||
|
self.user.significator = sig_pile[0]
|
||||||
|
self.user.save()
|
||||||
|
card = TarotCard.objects.first()
|
||||||
|
MySeaDraw.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
spread="situation-action-outcome",
|
||||||
|
hand=[
|
||||||
|
{"position": "lay", "card_id": card.id,
|
||||||
|
"reversed": False, "polarity": "gravity"},
|
||||||
|
],
|
||||||
|
significator_id=self.user.significator_id,
|
||||||
|
)
|
||||||
|
response = self.client.get("/gameboard/")
|
||||||
|
parsed = lxml.html.fromstring(response.content)
|
||||||
|
labels = parsed.cssselect(
|
||||||
|
"#id_applet_my_sea .my-sea-slot-wrap .my-sea-slot-label"
|
||||||
|
)
|
||||||
|
# Labels in DOM order (== DRAW_ORDER): Situation, Action, Outcome
|
||||||
|
self.assertEqual(
|
||||||
|
[l.text_content().strip() for l in labels],
|
||||||
|
["Situation", "Action", "Outcome"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_my_sea_applet_waite_smith_labels_post_fix(self):
|
||||||
|
"""Regression pin for the 2026-05-22 POSITION_LABELS swap fix:
|
||||||
|
WS `leave` slot (LEFT) is "Behind", `lay` slot (BOTTOM) is
|
||||||
|
"Beneath" — was inverted prior to the fix. Voronoi mapping
|
||||||
|
depends on this being right."""
|
||||||
|
from apps.epic.models import personal_sig_cards, TarotCard
|
||||||
|
from apps.gameboard.models import MySeaDraw
|
||||||
|
sig_pile = personal_sig_cards(self.user)
|
||||||
|
self.user.significator = sig_pile[0]
|
||||||
|
self.user.save()
|
||||||
|
card = TarotCard.objects.first()
|
||||||
|
# Single-card WS draw — populates `cover` (DRAW_ORDER pos 1).
|
||||||
|
MySeaDraw.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
spread="waite-smith",
|
||||||
|
hand=[
|
||||||
|
{"position": "cover", "card_id": card.id,
|
||||||
|
"reversed": False, "polarity": "gravity"},
|
||||||
|
],
|
||||||
|
significator_id=self.user.significator_id,
|
||||||
|
)
|
||||||
|
response = self.client.get("/gameboard/")
|
||||||
|
parsed = lxml.html.fromstring(response.content)
|
||||||
|
wraps = parsed.cssselect("#id_applet_my_sea .my-sea-slot-wrap")
|
||||||
|
# WS DRAW_ORDER = [cover, cross, crown, lay, loom, leave]
|
||||||
|
# Labels post-fix: Cover Cross Crown Beneath Before Behind
|
||||||
|
labels = [
|
||||||
|
w.cssselect(".my-sea-slot-label")[0].text_content().strip()
|
||||||
|
for w in wraps
|
||||||
|
]
|
||||||
|
self.assertEqual(
|
||||||
|
labels,
|
||||||
|
["Cover", "Cross", "Crown", "Beneath", "Before", "Behind"],
|
||||||
|
)
|
||||||
|
|
||||||
def test_gameboard_shows_game_kit(self):
|
def test_gameboard_shows_game_kit(self):
|
||||||
[_] = self.parsed.cssselect("#id_game_kit")
|
[_] = self.parsed.cssselect("#id_game_kit")
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from django.views.decorators.http import require_POST
|
|||||||
|
|
||||||
from apps.applets.utils import applet_context, apply_applet_toggle
|
from apps.applets.utils import applet_context, apply_applet_toggle
|
||||||
from .models import (
|
from .models import (
|
||||||
HAND_SIZE_BY_SPREAD, MySeaDraw, active_draw_for,
|
HAND_SIZE_BY_SPREAD, MySeaDraw, active_draw_for, latest_draw_slots,
|
||||||
_select_my_sea_token, debit_my_sea_token,
|
_select_my_sea_token, debit_my_sea_token,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -59,6 +59,7 @@ def gameboard(request):
|
|||||||
"applets": applet_context(request.user, "gameboard"),
|
"applets": applet_context(request.user, "gameboard"),
|
||||||
"page_class": "page-gameboard",
|
"page_class": "page-gameboard",
|
||||||
"my_games": annotate_latest_event(rooms_for_user(request.user)),
|
"my_games": annotate_latest_event(rooms_for_user(request.user)),
|
||||||
|
"my_sea_slots": latest_draw_slots(request.user),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -82,6 +83,7 @@ def toggle_game_applets(request):
|
|||||||
"free_tokens": free_tokens,
|
"free_tokens": free_tokens,
|
||||||
"free_count": len(free_tokens),
|
"free_count": len(free_tokens),
|
||||||
"my_games": annotate_latest_event(rooms_for_user(request.user)),
|
"my_games": annotate_latest_event(rooms_for_user(request.user)),
|
||||||
|
"my_sea_slots": latest_draw_slots(request.user),
|
||||||
})
|
})
|
||||||
return redirect("gameboard")
|
return redirect("gameboard")
|
||||||
|
|
||||||
|
|||||||
@@ -62,35 +62,37 @@ class MySeaSignGateTest(FunctionalTest):
|
|||||||
# ── Test 1 ───────────────────────────────────────────────────────────────
|
# ── Test 1 ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def test_no_sig_renders_lookline_gate_on_standalone_page(self):
|
def test_no_sig_renders_lookline_gate_on_standalone_page(self):
|
||||||
"""User without significator → /gameboard/my-sea/ shows the Look!-
|
"""User without significator → /gameboard/my-sea/ fires the Look!-
|
||||||
formatted Brief-style line w. the gate copy + FYI + NVM buttons."""
|
formatted sign-gate Brief banner w. FYI + NVM controls (refactored
|
||||||
|
2026-05-22 from inline `.my-sea-sign-gate` div to a `.note-banner`
|
||||||
|
portaled via `Brief.showBanner`)."""
|
||||||
self.create_pre_authenticated_session(self.email)
|
self.create_pre_authenticated_session(self.email)
|
||||||
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
|
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
|
||||||
gate = self.wait_for(
|
banner = self.wait_for(
|
||||||
lambda: self.browser.find_element(
|
lambda: self.browser.find_element(
|
||||||
By.CSS_SELECTOR, ".my-sea-sign-gate"
|
By.CSS_SELECTOR, ".note-banner.my-sea-sign-gate-brief"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
text = gate.text
|
text = banner.text
|
||||||
self.assertIn("Look!", text)
|
self.assertIn("Look!", text)
|
||||||
self.assertIn("pick your sign", text.lower())
|
self.assertIn("pick your sign", text.lower())
|
||||||
self.assertIn("drawing the Sea", text)
|
self.assertIn("drawing the Sea", text)
|
||||||
# FYI + NVM action buttons (class .my-sea-sign-gate__back retained
|
# FYI + NVM action buttons live inside the Brief shell (built-in).
|
||||||
# post-relabel; the BACK→NVM swap was label-only).
|
fyi = banner.find_element(By.CSS_SELECTOR, ".note-banner__fyi")
|
||||||
fyi = gate.find_element(By.CSS_SELECTOR, ".my-sea-sign-gate__fyi")
|
|
||||||
self.assertTrue(fyi.is_displayed())
|
self.assertTrue(fyi.is_displayed())
|
||||||
nvm = gate.find_element(By.CSS_SELECTOR, ".my-sea-sign-gate__back")
|
nvm = banner.find_element(By.CSS_SELECTOR, ".note-banner__nvm")
|
||||||
self.assertTrue(nvm.is_displayed())
|
self.assertTrue(nvm.is_displayed())
|
||||||
|
|
||||||
# ── Test 2 ───────────────────────────────────────────────────────────────
|
# ── Test 2 ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def test_gate_fyi_links_to_my_sign_picker(self):
|
def test_gate_fyi_links_to_my_sign_picker(self):
|
||||||
"""FYI button is an `<a href>` pointing at /billboard/my-sign/."""
|
"""FYI button on the Brief is an `<a href>` pointing at /billboard/
|
||||||
|
my-sign/. Carries the standard `.note-banner__fyi` class."""
|
||||||
self.create_pre_authenticated_session(self.email)
|
self.create_pre_authenticated_session(self.email)
|
||||||
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
|
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
|
||||||
fyi = self.wait_for(
|
fyi = self.wait_for(
|
||||||
lambda: self.browser.find_element(
|
lambda: self.browser.find_element(
|
||||||
By.CSS_SELECTOR, ".my-sea-sign-gate__fyi"
|
By.CSS_SELECTOR, ".my-sea-sign-gate-brief .note-banner__fyi"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
href = fyi.get_attribute("href") or ""
|
href = fyi.get_attribute("href") or ""
|
||||||
@@ -101,27 +103,32 @@ class MySeaSignGateTest(FunctionalTest):
|
|||||||
|
|
||||||
# ── Test 3 ───────────────────────────────────────────────────────────────
|
# ── Test 3 ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def test_gate_back_links_to_gameboard(self):
|
def test_gate_nvm_dismisses_brief(self):
|
||||||
"""NVM button is an `<a href>` pointing at /gameboard/. CSS class
|
"""NVM button on the Brief dismisses the banner (built into
|
||||||
`.my-sea-sign-gate__back` retained post BACK→NVM label swap."""
|
`Brief.showBanner`'s nvm handler — `banner.remove()`)."""
|
||||||
self.create_pre_authenticated_session(self.email)
|
self.create_pre_authenticated_session(self.email)
|
||||||
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
|
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
|
||||||
nvm = self.wait_for(
|
nvm = self.wait_for(
|
||||||
lambda: self.browser.find_element(
|
lambda: self.browser.find_element(
|
||||||
By.CSS_SELECTOR, ".my-sea-sign-gate__back"
|
By.CSS_SELECTOR, ".my-sea-sign-gate-brief .note-banner__nvm"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
href = nvm.get_attribute("href") or ""
|
nvm.click()
|
||||||
self.assertTrue(
|
self.wait_for(
|
||||||
href.endswith("/gameboard/"),
|
lambda: self.assertEqual(
|
||||||
f"NVM should link to /gameboard/, got {href!r}",
|
len(self.browser.find_elements(
|
||||||
|
By.CSS_SELECTOR, ".my-sea-sign-gate-brief"
|
||||||
|
)),
|
||||||
|
0,
|
||||||
|
"NVM should remove the gate Brief from the DOM",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── Test 4 ───────────────────────────────────────────────────────────────
|
# ── Test 4 ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def test_with_sig_skips_gate_and_renders_draw_shell(self):
|
def test_with_sig_skips_gate_and_renders_draw_shell(self):
|
||||||
"""User w. saved significator → no .my-sea-sign-gate on the page;
|
"""User w. saved significator → no sign-gate Brief on the page;
|
||||||
draw shell renders normally (Sprint 3 placeholder)."""
|
draw shell renders normally."""
|
||||||
_assign_sig(self.gamer, self.target_card)
|
_assign_sig(self.gamer, self.target_card)
|
||||||
self.create_pre_authenticated_session(self.email)
|
self.create_pre_authenticated_session(self.email)
|
||||||
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
|
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
|
||||||
@@ -129,34 +136,35 @@ class MySeaSignGateTest(FunctionalTest):
|
|||||||
lambda: self.browser.find_element(By.CSS_SELECTOR, ".my-sea-page")
|
lambda: self.browser.find_element(By.CSS_SELECTOR, ".my-sea-page")
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
len(self.browser.find_elements(By.CSS_SELECTOR, ".my-sea-sign-gate")),
|
len(self.browser.find_elements(
|
||||||
|
By.CSS_SELECTOR, ".my-sea-sign-gate-brief")),
|
||||||
0,
|
0,
|
||||||
"Gate should not render when user has a saved significator",
|
"Gate Brief should not render when user has a saved significator",
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── Test 5 ───────────────────────────────────────────────────────────────
|
# ── Test 5 ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def test_no_sig_applet_mirrors_gate_with_fyi_link(self):
|
def test_no_sig_applet_mirrors_gate_with_fyi_link(self):
|
||||||
"""On /gameboard/, the My Sea applet's empty state shows the same
|
"""On /gameboard/, the My Sea applet fires the same sign-gate
|
||||||
Look!-formatted gate w. FYI link to /billboard/my-sign/ when the
|
Brief w. FYI link to /billboard/my-sign/ when the user has no
|
||||||
user has no significator. Provides a consistent UX across surfaces."""
|
significator. Provides a consistent UX across surfaces."""
|
||||||
self.create_pre_authenticated_session(self.email)
|
self.create_pre_authenticated_session(self.email)
|
||||||
self.browser.get(self.live_server_url + "/gameboard/")
|
self.browser.get(self.live_server_url + "/gameboard/")
|
||||||
applet_gate = self.wait_for(
|
banner = self.wait_for(
|
||||||
lambda: self.browser.find_element(
|
lambda: self.browser.find_element(
|
||||||
By.CSS_SELECTOR, "#id_applet_my_sea .my-sea-sign-gate"
|
By.CSS_SELECTOR, ".note-banner.my-sea-sign-gate-brief"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self.assertIn("Look!", applet_gate.text)
|
self.assertIn("Look!", banner.text)
|
||||||
fyi = applet_gate.find_element(By.CSS_SELECTOR, ".my-sea-sign-gate__fyi")
|
fyi = banner.find_element(By.CSS_SELECTOR, ".note-banner__fyi")
|
||||||
href = fyi.get_attribute("href") or ""
|
href = fyi.get_attribute("href") or ""
|
||||||
self.assertTrue(href.endswith("/billboard/my-sign/"))
|
self.assertTrue(href.endswith("/billboard/my-sign/"))
|
||||||
|
|
||||||
# ── Test 6 ───────────────────────────────────────────────────────────────
|
# ── Test 6 ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def test_with_sig_applet_renders_default_empty_state(self):
|
def test_with_sig_applet_renders_default_empty_state(self):
|
||||||
"""Applet w. saved sig → no gate, empty-state placeholder (until
|
"""Applet w. saved sig → no gate Brief, empty-state placeholder
|
||||||
Sprint 7 wires up the latest-draw rendering)."""
|
("No draws yet.") inside the applet body."""
|
||||||
_assign_sig(self.gamer, self.target_card)
|
_assign_sig(self.gamer, self.target_card)
|
||||||
self.create_pre_authenticated_session(self.email)
|
self.create_pre_authenticated_session(self.email)
|
||||||
self.browser.get(self.live_server_url + "/gameboard/")
|
self.browser.get(self.live_server_url + "/gameboard/")
|
||||||
@@ -165,7 +173,7 @@ class MySeaSignGateTest(FunctionalTest):
|
|||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
len(self.browser.find_elements(
|
len(self.browser.find_elements(
|
||||||
By.CSS_SELECTOR, "#id_applet_my_sea .my-sea-sign-gate"
|
By.CSS_SELECTOR, ".my-sea-sign-gate-brief"
|
||||||
)),
|
)),
|
||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
@@ -615,8 +623,8 @@ class MySeaSpreadFormTest(FunctionalTest):
|
|||||||
"past-present-future": {"leave": "Past", "cover": "Present", "loom": "Future"},
|
"past-present-future": {"leave": "Past", "cover": "Present", "loom": "Future"},
|
||||||
"mind-body-spirit": {"crown": "Mind", "lay": "Body", "loom": "Spirit"},
|
"mind-body-spirit": {"crown": "Mind", "lay": "Body", "loom": "Spirit"},
|
||||||
"desire-obstacle-solution": {"loom": "Desire", "cross": "Obstacle","crown":"Solution"},
|
"desire-obstacle-solution": {"loom": "Desire", "cross": "Obstacle","crown":"Solution"},
|
||||||
"waite-smith": {"crown": "Crown", "leave": "Beneath", "cover": "Cover",
|
"waite-smith": {"crown": "Crown", "leave": "Behind", "cover": "Cover",
|
||||||
"cross": "Cross", "loom": "Before", "lay": "Behind"},
|
"cross": "Cross", "loom": "Before", "lay": "Beneath"},
|
||||||
}
|
}
|
||||||
picker = self._enter_picker_phase()
|
picker = self._enter_picker_phase()
|
||||||
combo = picker.find_element(By.CSS_SELECTOR, "[data-combobox]")
|
combo = picker.find_element(By.CSS_SELECTOR, "[data-combobox]")
|
||||||
|
|||||||
@@ -433,73 +433,165 @@ body.page-billposts {
|
|||||||
// `.applet-list-page .applet-scroll`). Old `.scroll-list` styling removed.
|
// `.applet-list-page .applet-scroll`). Old `.scroll-list` styling removed.
|
||||||
|
|
||||||
// ── My Sign applet (billboard) ────────────────────────────────────────────
|
// ── My Sign applet (billboard) ────────────────────────────────────────────
|
||||||
// Saved-sig preview thumbnail. Same 5:8 card shell + corner/name layout as
|
// Saved-sig preview — mirrors the `.sig-stage-card` layout (corner top-
|
||||||
// `.sig-stage-card` (see `_card-deck.scss`) but scaled down to fit the 4×6
|
// left + face w. name + arcana + mirror corner bottom-right) but sized
|
||||||
// applet aperture. Without these rules the markup collapses bg-less to the
|
// to fill the applet's vertical aperture rather than a fixed 5rem.
|
||||||
// applet's top-left corner. `--applet-card-w` drives all child font sizing
|
// Container queries on `.my-sign-applet-body` lift `--applet-card-w` to
|
||||||
// off a single knob (same calc-fractions as `.sig-stage-card` w. --sig-card-w).
|
// `min(100cqi, 62.5cqh)` — the card grows to fill whichever axis is
|
||||||
|
// constraining (62.5cqh = `100cqh * 5/8` keeps the 5:8 aspect inside
|
||||||
|
// the container height). All child font sizes calc off --applet-card-w
|
||||||
|
// so the typography scales w. the card without per-applet tuning.
|
||||||
#id_applet_my_sign {
|
#id_applet_my_sign {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
// Anchor for #id_applet_sky_delete_btn's absolute centering.
|
||||||
|
position: relative;
|
||||||
|
background-color: rgba(var(--duoUser), 1) !important;
|
||||||
|
|
||||||
|
.my-sign-applet-empty {
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
flex-shrink: 0;
|
||||||
|
background-color: rgba(var(--priUser), 1);
|
||||||
|
box-shadow: rgba(0, 0, 0, 1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
.my-sign-applet-body {
|
.my-sign-applet-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
padding: 0.3rem;
|
||||||
|
container-type: size;
|
||||||
}
|
}
|
||||||
|
|
||||||
.my-sign-applet-card {
|
.my-sign-applet-card {
|
||||||
--applet-card-w: 5rem;
|
// Width-cap shrinks by half the row (card + gap + stat-block) so the
|
||||||
|
// pair centres without horizontal overflow. `1.0cqi` keeps the
|
||||||
|
// gap/padding allowance — anything left after the stat-block grows
|
||||||
|
// to fill, capped by the card's natural 5:8 aspect.
|
||||||
|
--applet-card-w: min(48cqi, 62.5cqh);
|
||||||
width: var(--applet-card-w);
|
width: var(--applet-card-w);
|
||||||
aspect-ratio: 5 / 8;
|
aspect-ratio: 5 / 8;
|
||||||
border-radius: 0.4rem;
|
border-radius: 0.4rem;
|
||||||
background: rgba(var(--priUser), 1);
|
background: rgba(var(--priUser), 1);
|
||||||
border: 0.12rem solid rgba(var(--secUser), 0.6);
|
border: 0.12rem solid rgba(var(--secUser), 0.6);
|
||||||
color: rgba(var(--secUser), 1);
|
color: rgba(var(--secUser), 1);
|
||||||
padding: 0.25rem;
|
padding: 0.35rem;
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: transform 0.4s ease;
|
transition: transform 0.4s ease;
|
||||||
|
|
||||||
.fan-card-corner--tl {
|
// Top-left + bottom-right corners — rank + suit icon stacked.
|
||||||
|
// br is rotated 180° (mirror) so the card reads as "completed"
|
||||||
|
// from both edges, matching the stage card pattern.
|
||||||
|
.fan-card-corner--tl,
|
||||||
|
.fan-card-corner--br {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
line-height: 1.1;
|
line-height: 1.05;
|
||||||
gap: 0.05rem;
|
gap: 0.05rem;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0.2rem;
|
|
||||||
left: 0.2rem;
|
|
||||||
|
|
||||||
.fan-corner-rank {
|
.fan-corner-rank {
|
||||||
font-size: calc(var(--applet-card-w) * 0.18);
|
font-size: calc(var(--applet-card-w) * 0.16);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
i { font-size: calc(var(--applet-card-w) * 0.14); }
|
i { font-size: calc(var(--applet-card-w) * 0.13); }
|
||||||
|
}
|
||||||
|
.fan-card-corner--tl { top: 0.25rem; left: 0.3rem; }
|
||||||
|
.fan-card-corner--br {
|
||||||
|
bottom: 0.25rem; right: 0.3rem;
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Card face — name + arcana stacked, centred in the remaining
|
||||||
|
// vertical space between the two corners. `flex: 1` lets the
|
||||||
|
// face absorb whatever's left after the absolute-positioned
|
||||||
|
// corners.
|
||||||
|
.fan-card-face {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.2rem;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0 0.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fan-card-name {
|
.fan-card-name {
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
text-align: center;
|
font-size: calc(var(--applet-card-w) * 0.11);
|
||||||
font-size: calc(var(--applet-card-w) * 0.13);
|
font-weight: 700;
|
||||||
font-weight: 600;
|
line-height: 1.15;
|
||||||
text-wrap: balance;
|
text-wrap: balance;
|
||||||
padding: 0 0.15rem;
|
|
||||||
color: rgba(var(--quiUser), 1);
|
color: rgba(var(--quiUser), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fan-card-arcana {
|
||||||
|
margin: 0;
|
||||||
|
font-size: calc(var(--applet-card-w) * 0.075);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
&.stage-card--reversed { transform: rotate(180deg); }
|
&.stage-card--reversed { transform: rotate(180deg); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stat block — mirrors the stage card's footprint (same 5:8 aspect +
|
||||||
|
// height) so the pair reads as a balanced 2-tile composition centred
|
||||||
|
// in the applet aperture. Styling cribbed from `.sig-stat-block` in
|
||||||
|
// `_card-deck.scss:595-607` (priUser-translucent bg + terUser border)
|
||||||
|
// minus the SPIN/FYI button apparatus — applet is read-only, no
|
||||||
|
// interaction needed. `--applet-card-w` is reused as the sizing knob
|
||||||
|
// so stat-face-label + stat-keywords typography scales w. the card.
|
||||||
|
.my-sign-applet-stat-block {
|
||||||
|
--applet-card-w: min(48cqi, 62.5cqh);
|
||||||
|
width: var(--applet-card-w);
|
||||||
|
aspect-ratio: 5 / 8;
|
||||||
|
align-self: center;
|
||||||
|
background: rgba(var(--priUser), 0.8);
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
border: 0.1rem solid rgba(var(--terUser), 0.15);
|
||||||
|
padding: calc(var(--applet-card-w) * 0.08);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.stat-face-label {
|
||||||
|
font-size: calc(var(--applet-card-w) * 0.08);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.09em;
|
||||||
|
opacity: 0.7;
|
||||||
|
color: rgba(var(--terUser), 1);
|
||||||
|
margin: 0 0 calc(var(--applet-card-w) * 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-keywords {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
li {
|
||||||
|
font-size: calc(var(--applet-card-w) * 0.1);
|
||||||
|
padding: calc(var(--applet-card-w) * 0.04) 0;
|
||||||
|
color: rgba(var(--quiUser), 1);
|
||||||
|
border-bottom: 0.05rem solid rgba(var(--terUser), 0.18);
|
||||||
|
&:last-child { border-bottom: none; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.my-sign-applet-empty {
|
.my-sign-applet-empty {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -60,6 +60,8 @@
|
|||||||
.stat-face--upright { display: block; }
|
.stat-face--upright { display: block; }
|
||||||
|
|
||||||
&.is-reversed {
|
&.is-reversed {
|
||||||
|
opacity: 1;
|
||||||
|
|
||||||
.stat-face--upright { display: none; }
|
.stat-face--upright { display: none; }
|
||||||
.stat-face--reversed { display: block; }
|
.stat-face--reversed { display: block; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,48 +162,13 @@ body.page-gameboard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── My Sea sign-gate ────────────────────────────────────────────────────────
|
// ─── My Sea sign-gate ────────────────────────────────────────────────────────
|
||||||
// Sprint 4b of [[project-my-sea-roadmap]]. Renders when User.significator
|
// REMOVED 2026-05-22 — refactored to a Brief banner. The no-sig nudge now
|
||||||
// is None, on both the standalone /gameboard/my-sea/ page AND the
|
// fires via `Brief.showBanner` from `_my_sea_sign_gate_brief.html`, which
|
||||||
// /gameboard/ My Sea applet. Look!-formatted Brief-style line w. FYI
|
// portals a `.note-banner.my-sea-sign-gate-brief` to the page h2 (gaussian-
|
||||||
// (→ /billboard/my-sign/) + BACK (→ /gameboard/) action buttons. Inline
|
// glass shell, FYI → /billboard/my-sign/, NVM dismisses). All the inline
|
||||||
// content (not portaled like .note-banner) — it IS the page content
|
// `.my-sea-sign-gate{,--applet,__line,__actions,__back,__fyi}` styling
|
||||||
// until a sig is picked, not a transient nudge.
|
// dropped — `.note-banner` rules in `_note.scss:11` cover positioning,
|
||||||
.my-sea-sign-gate {
|
// shell, + button placement DRYly.
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 1rem;
|
|
||||||
padding: 1.5rem;
|
|
||||||
color: rgba(var(--terUser), 1);
|
|
||||||
|
|
||||||
.my-sea-sign-gate__line {
|
|
||||||
text-align: center;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
line-height: 1.4;
|
|
||||||
margin: 0;
|
|
||||||
// --terUser ink mirrors the gate's accent + signals "do this
|
|
||||||
// first" visually distinct from the body's standard --secUser.
|
|
||||||
color: rgba(var(--terUser), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.my-sea-sign-gate__actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
gap: 0.5rem;
|
|
||||||
|
|
||||||
.my-sea-sign-gate__line {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── My Sea DRAW SEA landing ─────────────────────────────────────────────────
|
// ─── My Sea DRAW SEA landing ─────────────────────────────────────────────────
|
||||||
// Sprint 5 iter 1 of [[project-my-sea-roadmap]]. When a user has a saved
|
// Sprint 5 iter 1 of [[project-my-sea-roadmap]]. When a user has a saved
|
||||||
@@ -596,3 +561,214 @@ body.page-gameboard {
|
|||||||
// above the anchor button w. Gaussian glass + no backdrop). The picker IIFE
|
// above the anchor button w. Gaussian glass + no backdrop). The picker IIFE
|
||||||
// invokes it via `window.showGuard(delBtn, "Are you sure?", confirmFn,
|
// invokes it via `window.showGuard(delBtn, "Are you sure?", confirmFn,
|
||||||
// null, {yesLabel: "DEL"})`. No my-sea-specific SCSS needed.
|
// null, {yesLabel: "DEL"})`. No my-sea-specific SCSS needed.
|
||||||
|
|
||||||
|
|
||||||
|
// ── My Sea applet (billboard-style gameboard applet) ─────────────────────────
|
||||||
|
// The applet at `_applet-my-sea.html` lists the active draw's slots in
|
||||||
|
// DRAW_ORDER — drawn cards filled + empty slots placeholder'd, each
|
||||||
|
// w. a label caption tucked tight against the slot's bottom edge.
|
||||||
|
// Horizontal-scroll mirrors the Palettes applet (`.palette` in
|
||||||
|
// `_palette-picker.scss:1`): row of fixed-size items + `overflow-x:
|
||||||
|
// auto`, so 6-card spreads scroll while 3-card spreads fit. Slots use
|
||||||
|
// the same `.sig-stage-card` layout language as the my_sign.html stage
|
||||||
|
// card (corner-tl + face w. name + corner-br) at applet scale —
|
||||||
|
// container queries on `.my-sea-scroll` lift `--slot-w` to fill the
|
||||||
|
// scroll's vertical aperture (minus label) so cards span the whole
|
||||||
|
// applet height per user spec 2026-05-22.
|
||||||
|
#id_applet_my_sea {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
// Anchor for #id_applet_sky_delete_btn's absolute centering.
|
||||||
|
position: relative;
|
||||||
|
background-color: rgba(var(--duoUser), 1) !important;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
flex-shrink: 0;
|
||||||
|
background-color: rgba(var(--priUser), 1);
|
||||||
|
box-shadow: rgba(0, 0, 0, 1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.my-sea-scroll {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.25rem 0.5rem 0.5rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
scroll-snap-type: x mandatory;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
container-type: size;
|
||||||
|
}
|
||||||
|
|
||||||
|
.my-sea-slot-wrap {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
scroll-snap-align: start;
|
||||||
|
height: 100%;
|
||||||
|
// No gap — the label sits directly against the slot's bottom
|
||||||
|
// border per user spec ("tighter [...] practically overlapping").
|
||||||
|
// Slight negative margin pulls the label baseline up into the
|
||||||
|
// slot's border line so the two visually merge.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slot shell — 5:8 card, sized to fill the wrap's height minus the
|
||||||
|
// label row. `--slot-w` resolves via container queries: 100cqi-cap
|
||||||
|
// when the scroll is wide-but-shallow, 100cqh*5/8 = 62.5cqh - tiny-
|
||||||
|
// label-reservation otherwise. The `- 1rem` carves out the label
|
||||||
|
// row + tight gap so the card doesn't overshoot the applet floor.
|
||||||
|
.my-sea-slot {
|
||||||
|
--slot-w: min(100cqi, calc((100cqh - 1rem) * 5 / 8));
|
||||||
|
width: var(--slot-w);
|
||||||
|
aspect-ratio: 5 / 8;
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
border: 0.12rem solid rgba(var(--secUser), 0.6);
|
||||||
|
padding: 0.35rem;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
|
||||||
|
.fan-card-corner--tl,
|
||||||
|
.fan-card-corner--br {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
line-height: 1.05;
|
||||||
|
gap: 0.05rem;
|
||||||
|
position: absolute;
|
||||||
|
|
||||||
|
.fan-corner-rank {
|
||||||
|
font-size: calc(var(--slot-w) * 0.16);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
i { font-size: calc(var(--slot-w) * 0.13); }
|
||||||
|
}
|
||||||
|
.fan-card-corner--tl { top: 0.25rem; left: 0.3rem; }
|
||||||
|
.fan-card-corner--br {
|
||||||
|
bottom: 0.25rem; right: 0.3rem;
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fan-card-face {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.2rem;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fan-card-name {
|
||||||
|
margin: 0;
|
||||||
|
font-size: calc(var(--slot-w) * 0.105);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.15;
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fan-card-arcana {
|
||||||
|
margin: 0;
|
||||||
|
font-size: calc(var(--slot-w) * 0.07);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filled slot polarity — mirrors `.sea-card-slot--gravity` / `--levity`
|
||||||
|
// in `_card-deck.scss:1332-1341`. Gravity = priUser bg + quiUser text;
|
||||||
|
// levity = inverted (secUser bg + priUser text). Explicit
|
||||||
|
// `.fan-card-name { color: ... }` override is required: the global
|
||||||
|
// `.fan-card-name` rule in `_card-deck.scss:569-570` hardcodes
|
||||||
|
// --quiUser, which is invisible on the levity --secUser bg (both
|
||||||
|
// light variants). Setting it back to --priUser here restores
|
||||||
|
// contrast. Corner-rank + arcana inherit from the slot's `color`
|
||||||
|
// (no global override) so they follow polarity automatically.
|
||||||
|
.my-sea-slot--filled.my-sea-slot--gravity {
|
||||||
|
background: rgba(var(--priUser), 1);
|
||||||
|
color: rgba(var(--quiUser), 1);
|
||||||
|
border-color: rgba(var(--secUser), 0.6);
|
||||||
|
}
|
||||||
|
.my-sea-slot--filled.my-sea-slot--levity {
|
||||||
|
background: rgba(var(--secUser), 1);
|
||||||
|
color: rgba(var(--priUser), 1);
|
||||||
|
border-color: rgba(var(--priUser), 1);
|
||||||
|
// `.fan-card-corner` carries a global `color: rgba(var(--secUser),
|
||||||
|
// 0.75)` rule in `_card-deck.scss:312-319` that out-specifics the
|
||||||
|
// slot's inherited color (specificity 0,1,0 wins over inheritance).
|
||||||
|
// On the levity --secUser bg this paints the corner-rank + suit-
|
||||||
|
// icon in the same color as the background → invisible. Same trap
|
||||||
|
// bit my-sign + game-kit earlier — fix is an explicit override at
|
||||||
|
// matching/higher specificity inside the polarity rule.
|
||||||
|
// `.fan-card-name` has its own `color: --quiUser` global rule
|
||||||
|
// (`_card-deck.scss:569-570`); `.fan-card-arcana` inherits but pin
|
||||||
|
// explicitly so a future global tweak can't silently re-break it.
|
||||||
|
.fan-card-corner { color: rgba(var(--priUser), 1); }
|
||||||
|
.fan-card-name { color: rgba(var(--priUser), 1); }
|
||||||
|
.fan-card-arcana { color: rgba(var(--priUser), 0.7); }
|
||||||
|
}
|
||||||
|
.my-sea-slot--filled.my-sea-slot--reversed { transform: rotate(180deg); }
|
||||||
|
|
||||||
|
// Empty slot — matches the my_sea.html picker's empty `.sea-card-
|
||||||
|
// slot` style (`_card-deck.scss:1299-1303`): 0.15rem DASHED border in
|
||||||
|
// --terUser at full opacity, --duoUser fill. Same width + dash
|
||||||
|
// frequency as the picker so the applet reads as a true "miniature"
|
||||||
|
// of the picker rather than a cousin w. different dotting cadence.
|
||||||
|
// `!important` on the three border properties: the base `.my-sea-
|
||||||
|
// slot` border shorthand sits at the same (1,1,0) specificity, so
|
||||||
|
// belt-and-suspenders the override.
|
||||||
|
.my-sea-slot--empty {
|
||||||
|
background-color: rgba(var(--duoUser), 1);
|
||||||
|
border-style: dashed !important;
|
||||||
|
border-color: rgba(var(--terUser), 1) !important;
|
||||||
|
border-width: 0.15rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Label — pulled tight against the slot's bottom border + vertically
|
||||||
|
// stretched via `scaleY(1.2)` to match the my_sea.html picker's
|
||||||
|
// `.sea-pos-label` typography (re-appropriated from `.sea-stack-name`
|
||||||
|
// in `_card-deck.scss:1671-1684`). Negative margin-top crosses the
|
||||||
|
// 0.12rem border so the label's top edge overlaps the bottom edge of
|
||||||
|
// the slot, per the user-locked spec ("practically overlapping").
|
||||||
|
.my-sea-slot-label {
|
||||||
|
margin-top: -0.15rem;
|
||||||
|
padding: 0 0.2rem;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: rgba(var(--secUser), 0.85);
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
line-height: 1.1;
|
||||||
|
transform: scaleY(1.4);
|
||||||
|
transform-origin: top center;
|
||||||
|
}
|
||||||
|
// `.my-sea-slot-label--empty` intentionally has NO per-state recolor
|
||||||
|
// — the empty-state label keeps the same `--secUser` ink as the
|
||||||
|
// filled-slot label per user spec 2026-05-22 (pins position identity
|
||||||
|
// Cover/Cross/etc. across the row regardless of fill state).
|
||||||
|
// Previously dimmed to --terUser to echo the dashed border tone —
|
||||||
|
// but that broke title cohesion when most slots were empty.
|
||||||
|
|
||||||
|
// No-draws empty state — centred italic, mirrors the Brief / applet-
|
||||||
|
// list-entry--empty pattern in `_billboard.scss:29-38`.
|
||||||
|
.my-sea-empty {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
font-style: italic;
|
||||||
|
opacity: 0.6;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -906,8 +906,13 @@ body[class*="-light"] #id_sky_tooltip_2 {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
// Anchor for #id_applet_sky_delete_btn's absolute centering.
|
// Anchor for #id_applet_sky_delete_btn's absolute centering.
|
||||||
position: relative;
|
position: relative;
|
||||||
|
background-color: rgba(var(--duoUser), 1) !important;
|
||||||
|
|
||||||
h2 { flex-shrink: 0; }
|
h2 {
|
||||||
|
flex-shrink: 0;
|
||||||
|
background-color: rgba(var(--priUser), 1);
|
||||||
|
box-shadow: rgba(0, 0, 0, 1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
.sky-svg {
|
.sky-svg {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -953,8 +958,20 @@ body[class*="-light"] #id_sky_tooltip_2 {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
// --duoUser aperture bg — matches the My Sky / My Sea / My Sign
|
||||||
|
// applet shells (`_sky.scss:909`, `_gameboard.scss:583`,
|
||||||
|
// `_billboard.scss:449`) so all four personal-data surfaces read
|
||||||
|
// as a unified olive-bg group across the dashboard/billboard/
|
||||||
|
// gameboard navigation.
|
||||||
|
background-color: rgba(var(--duoUser), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Note: tried `body.page-sky { background-color: --duoUser }` to fill
|
||||||
|
// any gap below the .sky-page aperture but it bled to navbar + footer
|
||||||
|
// (which sit OUTSIDE .container) — reverted 2026-05-22. The real fix
|
||||||
|
// is the scoped `.sky-page .sky-form-col` rule below, which puts the
|
||||||
|
// olive on the form column where the purple was leaking through.
|
||||||
|
|
||||||
// DEL btn pinned at the wheel center — appears wherever a wheel is shown
|
// DEL btn pinned at the wheel center — appears wherever a wheel is shown
|
||||||
// (Dashsky form#id_sky_delete_form, CAST SKY overlay #id_sky_delete_btn,
|
// (Dashsky form#id_sky_delete_form, CAST SKY overlay #id_sky_delete_btn,
|
||||||
// My Sky applet #id_applet_sky_delete_btn). Anchored to .sky-wheel-col /
|
// My Sky applet #id_applet_sky_delete_btn). Anchored to .sky-wheel-col /
|
||||||
@@ -1021,6 +1038,16 @@ body[class*="-light"] #id_sky_tooltip_2 {
|
|||||||
overflow-y: visible;
|
overflow-y: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Override the base `.sky-form-col { background: --priUser }` (line 137,
|
||||||
|
// shared w. the in-room CAST SKY modal) so the dashsky form column sits
|
||||||
|
// on the same --duoUser olive as the rest of the .sky-page aperture.
|
||||||
|
// Scoped to `.sky-page` so the in-room modal's purple form-col stays
|
||||||
|
// intact (it sits over the room's --secUser bg + needs the contrast).
|
||||||
|
.sky-page .sky-form-col {
|
||||||
|
background: rgba(var(--duoUser), 1);
|
||||||
|
border-right-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
// The (max-width:600px) block (written for the in-room CAST SKY modal where
|
// The (max-width:600px) block (written for the in-room CAST SKY modal where
|
||||||
// form-col is flex-row) sets align-self:flex-end on the btn — that's "right"
|
// form-col is flex-row) sets align-self:flex-end on the btn — that's "right"
|
||||||
// once we flip to flex-column. Reset.
|
// once we flip to flex-column. Reset.
|
||||||
|
|||||||
@@ -6,13 +6,48 @@
|
|||||||
<div class="my-sign-applet-body">
|
<div class="my-sign-applet-body">
|
||||||
{% if request.user.significator %}
|
{% if request.user.significator %}
|
||||||
{% with card=request.user.significator %}
|
{% with card=request.user.significator %}
|
||||||
|
{# Mirrors the my_sign.html `.sig-stage-card` layout — corner #}
|
||||||
|
{# top-left, full name in the face, polarity-reversed mirror #}
|
||||||
|
{# at the bottom (pre-rotated). Sized to fill the applet's #}
|
||||||
|
{# vertical aperture via container queries in `_billboard.scss`. #}
|
||||||
<div class="my-sign-applet-card{% if request.user.significator_reversed %} stage-card--reversed{% endif %}"
|
<div class="my-sign-applet-card{% if request.user.significator_reversed %} stage-card--reversed{% endif %}"
|
||||||
data-card-id="{{ card.id }}">
|
data-card-id="{{ card.id }}">
|
||||||
<div class="fan-card-corner fan-card-corner--tl">
|
<div class="fan-card-corner fan-card-corner--tl">
|
||||||
<span class="fan-corner-rank">{{ card.corner_rank }}</span>
|
<span class="fan-corner-rank">{{ card.corner_rank }}</span>
|
||||||
{% if card.suit_icon %}<i class="fa-solid {{ card.suit_icon }}"></i>{% endif %}
|
{% if card.suit_icon %}<i class="fa-solid {{ card.suit_icon }}"></i>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<p class="fan-card-name">{{ card.name_title }}</p>
|
<div class="fan-card-face">
|
||||||
|
<p class="fan-card-name">{{ card.name_title }}</p>
|
||||||
|
<p class="fan-card-arcana">{{ card.get_arcana_display }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="fan-card-corner fan-card-corner--br">
|
||||||
|
<span class="fan-corner-rank">{{ card.corner_rank }}</span>
|
||||||
|
{% if card.suit_icon %}<i class="fa-solid {{ card.suit_icon }}"></i>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{# Stat block — same shape as my_sign.html's `.sig-stat-block` #}
|
||||||
|
{# (Emanation/Reversal face label + keyword list) but no SPIN #}
|
||||||
|
{# or FYI buttons since the applet is a read-only preview. The #}
|
||||||
|
{# face shown is keyed off significator_reversed: True → #}
|
||||||
|
{# reversal keywords (labelled "Reversal"), False → upright #}
|
||||||
|
{# (labelled "Emanation"). Mirrors the FYI panel populated by #}
|
||||||
|
{# `StageCard.populateKeywords` in my_sign.html's JS init. #}
|
||||||
|
<div class="my-sign-applet-stat-block">
|
||||||
|
{% if request.user.significator_reversed %}
|
||||||
|
<p class="stat-face-label">Reversal</p>
|
||||||
|
<ul class="stat-keywords">
|
||||||
|
{% for kw in card.keywords_reversed %}
|
||||||
|
<li>{{ kw }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p class="stat-face-label">Emanation</p>
|
||||||
|
<ul class="stat-keywords">
|
||||||
|
{% for kw in card.keywords_upright %}
|
||||||
|
<li>{{ kw }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
@@ -3,26 +3,56 @@
|
|||||||
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
|
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
|
||||||
>
|
>
|
||||||
<h2><a href="{% url 'my_sea' %}">My Sea</a></h2>
|
<h2><a href="{% url 'my_sea' %}">My Sea</a></h2>
|
||||||
<div class="my-sea-scroll">
|
{# `my_sea_slots` (built by `latest_draw_slots()` in `gameboard.models`) #}
|
||||||
{% if not request.user.significator_id %}
|
{# carries one entry per spread position in DRAW_ORDER — filled slots #}
|
||||||
{# Sprint 4b applet-gate mirror — same Look!-formatted nudge as #}
|
{# render the drawn card, empty slots render as labelled placeholders. #}
|
||||||
{# the standalone page so the UX is consistent across surfaces. #}
|
{# Spread lock-in: the row is created at first card draw, so the moment #}
|
||||||
<div class="my-sea-sign-gate my-sea-sign-gate--applet">
|
{# 1+ cards exist all the spread's positions show in the applet. The #}
|
||||||
<p class="my-sea-sign-gate__line">
|
{# scroll container handles overflow (mirrors the Palettes applet). #}
|
||||||
Look!—pick your sign before drawing the Sea.
|
{% if not request.user.significator_id %}
|
||||||
</p>
|
{# Sprint 4b applet-gate — DRYly rendered as a project-wide Brief #}
|
||||||
<a class="btn btn-info my-sea-sign-gate__fyi"
|
{# banner (`note-banner` Gaussian-glass shell, portaled to the #}
|
||||||
href="{% url 'billboard:my_sign' %}">FYI</a>
|
{# page h2). Inline body falls through to the empty-state "No #}
|
||||||
</div>
|
{# draws yet" since no sig → no draws is the only possible state.#}
|
||||||
{% elif latest_draw_cards %}
|
{% include "apps/gameboard/_partials/_my_sea_sign_gate_brief.html" %}
|
||||||
{% for card in latest_draw_cards %}
|
<p class="my-sea-empty">No draws yet.</p>
|
||||||
<div class="my-sea-card" data-position="{{ card.position }}">
|
{% elif my_sea_slots %}
|
||||||
<span class="fan-corner-rank">{{ card.corner_rank }}</span>
|
<div class="my-sea-scroll">
|
||||||
{% if card.suit_icon %}<i class="fa-solid {{ card.suit_icon }}"></i>{% endif %}
|
{% for slot in my_sea_slots %}
|
||||||
|
{% if slot.card %}
|
||||||
|
<div class="my-sea-slot-wrap">
|
||||||
|
{# Mirrors the my_sign.html `.sig-stage-card` layout — #}
|
||||||
|
{# corner top-left, face w. name + arcana, mirror corner #}
|
||||||
|
{# bottom-right. Sized to fill the applet height via #}
|
||||||
|
{# container queries in `_gameboard.scss`. #}
|
||||||
|
<div class="my-sea-slot my-sea-slot--filled my-sea-slot--{{ slot.polarity }}{% if slot.reversed %} my-sea-slot--reversed{% endif %}"
|
||||||
|
data-position="{{ slot.position }}"
|
||||||
|
data-card-id="{{ slot.card.id }}">
|
||||||
|
<div class="fan-card-corner fan-card-corner--tl">
|
||||||
|
<span class="fan-corner-rank">{{ slot.card.corner_rank }}</span>
|
||||||
|
{% if slot.card.suit_icon %}<i class="fa-solid {{ slot.card.suit_icon }}"></i>{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="fan-card-face">
|
||||||
|
<p class="fan-card-name">{{ slot.card.name_title }}</p>
|
||||||
|
<p class="fan-card-arcana">{{ slot.card.get_arcana_display }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="fan-card-corner fan-card-corner--br">
|
||||||
|
<span class="fan-corner-rank">{{ slot.card.corner_rank }}</span>
|
||||||
|
{% if slot.card.suit_icon %}<i class="fa-solid {{ slot.card.suit_icon }}"></i>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="my-sea-slot-label">{{ slot.label }}</span>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% else %}
|
||||||
{% else %}
|
<div class="my-sea-slot-wrap">
|
||||||
<p class="my-sea-empty">No draws yet.</p>
|
<div class="my-sea-slot my-sea-slot--empty"
|
||||||
{% endif %}
|
data-position="{{ slot.position }}"></div>
|
||||||
</div>
|
<span class="my-sea-slot-label my-sea-slot-label--empty">{{ slot.label }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="my-sea-empty">No draws yet.</p>
|
||||||
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{# Sign-gate Brief — fired when the user has no `significator` set on the #}
|
||||||
|
{# My Sea page OR the My Sea applet. Replaces the prior inline `.my-sea- #}
|
||||||
|
{# sign-gate` markup w. a DRY call into the project-wide `Brief.showBanner` #}
|
||||||
|
{# pattern (gaussian-glass `.note-banner` shell, portaled to the h2 anchor #}
|
||||||
|
{# by `note.js`'s `_alignToH2`). FYI → /billboard/my-sign/ (pick a sign); #}
|
||||||
|
{# NVM dismisses (built into Brief). `.my-sea-sign-gate-brief` modifier #}
|
||||||
|
{# class is added post-render so FT selectors can disambiguate this banner #}
|
||||||
|
{# from any other Brief on the same page. #}
|
||||||
|
{# #}
|
||||||
|
{# Host pages MUST load `apps/dashboard/note.js` themselves (the partial #}
|
||||||
|
{# can't safely re-load it — `note.js` declares `const Brief = ...` at #}
|
||||||
|
{# global scope, so a second load would SyntaxError on re-declaration). #}
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
if (!window.Brief || !Brief.showBanner) return;
|
||||||
|
Brief.showBanner({
|
||||||
|
title: 'Sign required',
|
||||||
|
line_text: 'Look!—pick your sign before drawing the Sea.',
|
||||||
|
post_url: '{% url "billboard:my_sign" %}',
|
||||||
|
created_at: '',
|
||||||
|
kind: 'NUDGE',
|
||||||
|
});
|
||||||
|
var banner = document.querySelector('.note-banner');
|
||||||
|
if (banner) banner.classList.add('my-sea-sign-gate-brief');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
<div class="sea-crucifix-cell sea-pos-crown">
|
<div class="sea-crucifix-cell sea-pos-crown">
|
||||||
<div class="sea-card-slot sea-card-slot--empty"></div>
|
<div class="sea-card-slot sea-card-slot--empty"></div>
|
||||||
</div>
|
</div>
|
||||||
{# Beneath (past) — CC pos 4 / EV pos 3 #}
|
{# Behind (past) — CC pos 6 / EV pos 4 #}
|
||||||
<div class="sea-crucifix-cell sea-pos-leave">
|
<div class="sea-crucifix-cell sea-pos-leave">
|
||||||
<div class="sea-card-slot sea-card-slot--empty"></div>
|
<div class="sea-card-slot sea-card-slot--empty"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
<div class="sea-crucifix-cell sea-pos-loom">
|
<div class="sea-crucifix-cell sea-pos-loom">
|
||||||
<div class="sea-card-slot sea-card-slot--empty"></div>
|
<div class="sea-card-slot sea-card-slot--empty"></div>
|
||||||
</div>
|
</div>
|
||||||
{# Behind (root) — CC pos 6 / EV pos 4 #}
|
{# Beneath (root) — CC pos 4 / EV pos 3 #}
|
||||||
<div class="sea-crucifix-cell sea-pos-lay">
|
<div class="sea-crucifix-cell sea-pos-lay">
|
||||||
<div class="sea-card-slot sea-card-slot--empty"></div>
|
<div class="sea-card-slot sea-card-slot--empty"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,5 +14,10 @@
|
|||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
|
{# note.js exposes window.Brief — used by the My Sea applet's sign-gate #}
|
||||||
|
{# Brief partial (`_my_sea_sign_gate_brief.html`) when the user has no #}
|
||||||
|
{# saved significator. Page-level load so the partial doesn't risk a #}
|
||||||
|
{# re-declaration SyntaxError (note.js uses `const Brief = ...`). #}
|
||||||
|
<script src="{% static 'apps/dashboard/note.js' %}"></script>
|
||||||
<script src="{% static 'apps/gameboard/gameboard.js' %}"></script>
|
<script src="{% static 'apps/gameboard/gameboard.js' %}"></script>
|
||||||
{% endblock scripts %}
|
{% endblock scripts %}
|
||||||
|
|||||||
@@ -5,26 +5,23 @@
|
|||||||
{% block header_text %}<span>Game</span><span>Sea</span>{% endblock header_text %}
|
{% block header_text %}<span>Game</span><span>Sea</span>{% endblock header_text %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{# note.js exposes window.Brief — needed by BOTH the no-sig branch's #}
|
||||||
|
{# sign-gate Brief partial AND the with-sig branch's Default-deck + #}
|
||||||
|
{# Free-draw-locked Briefs. Hoisted out of the branches so it loads #}
|
||||||
|
{# exactly once regardless of which path the user is on (note.js #}
|
||||||
|
{# declares `const Brief = ...` at global scope — a second load would #}
|
||||||
|
{# SyntaxError on re-declaration). #}
|
||||||
|
<script src="{% static 'apps/dashboard/note.js' %}"></script>
|
||||||
<div class="my-sea-page"
|
<div class="my-sea-page"
|
||||||
data-phase="{% if show_picker %}picker{% else %}landing{% endif %}"
|
data-phase="{% if show_picker %}picker{% else %}landing{% endif %}"
|
||||||
data-polarity="{% if significator_reversed %}levity{% else %}gravity{% endif %}">
|
data-polarity="{% if significator_reversed %}levity{% else %}gravity{% endif %}">
|
||||||
{% if not user_has_sig %}
|
{% if not user_has_sig %}
|
||||||
{# Sprint 4b sign-gate. The draw UX is gated behind a saved #}
|
{# Sprint 4b sign-gate — DRYly Brief-banner'd (was inline div #}
|
||||||
{# significator — render a Look!-formatted Brief-style line w. #}
|
{# .my-sea-sign-gate, refactored 2026-05-22 to use the project- #}
|
||||||
{# FYI (→ /billboard/my-sign/) + NVM (→ /gameboard/) until the #}
|
{# wide `.note-banner` portal via `Brief.showBanner`). NVM lives #}
|
||||||
{# user picks a sign. Inline (not portaled like .note-banner) #}
|
{# inside the Brief shell; FYI links to /billboard/my-sign/. #}
|
||||||
{# because the gate IS the page content, not a transient nudge. #}
|
{# Page body stays empty — the Brief is the entire no-sig UX. #}
|
||||||
<div class="my-sea-sign-gate">
|
{% include "apps/gameboard/_partials/_my_sea_sign_gate_brief.html" %}
|
||||||
<p class="my-sea-sign-gate__line">
|
|
||||||
Look!—pick your sign before drawing the Sea.
|
|
||||||
</p>
|
|
||||||
<div class="my-sea-sign-gate__actions">
|
|
||||||
<a class="btn btn-cancel my-sea-sign-gate__back"
|
|
||||||
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 %}
|
{% else %}
|
||||||
{% if not show_picker %}
|
{% if not show_picker %}
|
||||||
{# Sprint 5 iter 1 — DRAW SEA landing UX. DRY table hex from #}
|
{# Sprint 5 iter 1 — DRAW SEA landing UX. DRY table hex from #}
|
||||||
@@ -284,12 +281,20 @@
|
|||||||
'situation-action-outcome': { lay: 'Situation', cover: 'Action', crown: 'Outcome' },
|
'situation-action-outcome': { lay: 'Situation', cover: 'Action', crown: 'Outcome' },
|
||||||
'mind-body-spirit': { crown: 'Mind', lay: 'Body', loom: 'Spirit' },
|
'mind-body-spirit': { crown: 'Mind', lay: 'Body', loom: 'Spirit' },
|
||||||
'desire-obstacle-solution': { loom: 'Desire', cross: 'Obstacle',crown: 'Solution' },
|
'desire-obstacle-solution': { loom: 'Desire', cross: 'Obstacle',crown: 'Solution' },
|
||||||
'waite-smith': { crown: 'Crown', leave: 'Beneath', cover: 'Cover', cross: 'Cross', loom: 'Before', lay: 'Behind' },
|
// CSS grid (`_card-deck.scss:1276-1279`) places sea-pos-
|
||||||
|
// leave at LEFT + sea-pos-lay at BOTTOM. Traditional
|
||||||
|
// Celtic Cross has "Behind" (past) at LEFT + "Beneath"
|
||||||
|
// (root) at BOTTOM, so labels here pair lay→"Beneath"
|
||||||
|
// + leave→"Behind". Fix landed 2026-05-22: prior assign-
|
||||||
|
// ment had Beneath at LEFT + Behind at BOTTOM (inverted
|
||||||
|
// vs tradition + vs the grid's own commented intent in
|
||||||
|
// `_sea_overlay.html`).
|
||||||
|
'waite-smith': { crown: 'Crown', leave: 'Behind', cover: 'Cover', cross: 'Cross', loom: 'Before', lay: 'Beneath' },
|
||||||
// Escape Velocity remaps the diagonal positions per the
|
// Escape Velocity remaps the diagonal positions per the
|
||||||
// user-locked spec (2026-05-19): Beneath→Lay, Before→
|
// user-locked spec (2026-05-19): Beneath→Lay, Before→
|
||||||
// Loom, Behind→Leave. Crown/Cover/Cross keep the WS
|
// Loom, Behind→Leave. Crown/Cover/Cross keep the WS
|
||||||
// names.
|
// names. Carries the same LEFT/BOTTOM correction as WS.
|
||||||
'escape-velocity': { crown: 'Crown', leave: 'Lay', cover: 'Cover', cross: 'Cross', loom: 'Loom', lay: 'Leave' },
|
'escape-velocity': { crown: 'Crown', leave: 'Leave', cover: 'Cover', cross: 'Cross', loom: 'Loom', lay: 'Lay' },
|
||||||
};
|
};
|
||||||
var hidden = document.getElementById('id_sea_spread');
|
var hidden = document.getElementById('id_sea_spread');
|
||||||
var cross = document.querySelector('.my-sea-cross');
|
var cross = document.querySelector('.my-sea-cross');
|
||||||
@@ -934,8 +939,8 @@
|
|||||||
{# /billboard/my-sign/'s no-equipped-deck path. Same copy, #}
|
{# /billboard/my-sign/'s no-equipped-deck path. Same copy, #}
|
||||||
{# same FYI (→ /gameboard/) + NVM (dismiss + proceed) actions.#}
|
{# same FYI (→ /gameboard/) + NVM (dismiss + proceed) actions.#}
|
||||||
{# Tagged w. .my-sea-intro-banner so FTs disambiguate from #}
|
{# Tagged w. .my-sea-intro-banner so FTs disambiguate from #}
|
||||||
{# any other Briefs on the page. #}
|
{# any other Briefs on the page. note.js itself is hoisted #}
|
||||||
<script src="{% static 'apps/dashboard/note.js' %}"></script>
|
{# to the top of {% block content %} (single load per page). #}
|
||||||
{% if active_draw %}
|
{% if active_draw %}
|
||||||
{# Iter 4b — saved-draw Brief. Standard portaled banner via #}
|
{# Iter 4b — saved-draw Brief. Standard portaled banner via #}
|
||||||
{# Brief.showBanner (Gaussian-glass bg, atop-h2 positioning); #}
|
{# Brief.showBanner (Gaussian-glass bg, atop-h2 positioning); #}
|
||||||
|
|||||||
Reference in New Issue
Block a user