User-driven polish on iteration 1: separate hover-preview from click-lock semantics (room sig-select pattern), add NVM to unlock, port the room's `.sig-overlay[data-polarity]` polarity-themed CSS to also target `.my-sign-page[data-polarity]`, bump the stage card width so it occupies a bigger slice of the viewport ; **state machine** (inline JS in my_sign.html): three discrete states — (a) idle: stage frame visible but empty (stage card hidden via display:none, stat block hidden via `.sig-stage--frozen` absence, FLIP btn hidden via CSS); (b) hover: hovered .sig-card populates the stage card (preview); mouseleave clears it; mouseover-mouseout sequence guards against transient gaps when moving between adjacent thumbnails (relatedTarget closest('.sig-card') check); (c) locked: click on any grid card freezes the stage — populates content, adds `.sig-stage--frozen` to .sig-stage (which surfaces .sig-stat-block + .my-sign-flip-btn via CSS), enables SAVE SIGN, reveals NVM. Subsequent hovers ignored while locked. NVM click reverts to idle (clears content, hides stat-block + FLIP, disables SAVE, hides NVM) ; **new template** elements: NVM `<button id="id_nvm_sign_btn" class="btn btn-cancel">` next to SAVE SIGN in the form, hidden by default (style="display:none") + revealed on lock. Stage card re-acquires `style="display:none"` (hidden on load, JS-shown on hover/lock). `sig-stage--frozen` class no longer initial — JS-added on click ; **polarity SCSS port** (_card-deck.scss L820-905): extended `.sig-overlay[data-polarity="levity"]` + `[data-polarity="gravity"]` selector lists to include `.my-sign-page[data-polarity="levity"]` + `[gravity"]`. Rules inside (e.g. `.sig-card { background: rgba(--secUser) }`, `.sig-stage-card .fan-card-name { color: --quiUser }`, stat-face-label colour flips, text-shadow polarity variants) automatically apply on the my-sign page since `data-polarity` lives on the page wrapper (descendants .sig-card + .sig-stage-card both inherit). Moved `data-polarity` from `.my-sign-stage` to `.my-sign-page` in the template + JS so descendant scoping works (was a stage-scoped attr in iteration 1, which couldn't reach the sibling .sig-deck-grid) ; **bigger stage** (_card-deck.scss): `.my-sign-page { --sig-card-w: clamp(140px, 36vw, 220px); }` — scales w. viewport, 140px floor for portrait, 220px ceiling for landscape, ~36vw in between. Stage card + stat block both width-driven by this var so they scale together. The clamp() ceiling matches the room sig-select's typical sized card on a mid-laptop ; **FLIP btn visibility** (_card-deck.scss): `.my-sign-flip-btn { display: none }` at rest; `.my-sign-stage.sig-stage--frozen .my-sign-flip-btn { display: inline-flex }` on lock. The btn's position (absolute, bottom-left of card) was already added in iteration 1 ; **on-load lock-restore**: if `User.significator` is set, the picker auto-locks that card via `_lock(savedCardEl)` so the user sees their persisted choice in the locked-state UI (stat block + FLIP visible) instead of an idle empty frame. Polarity initial value (data-polarity on .my-sign-page) reflects `current_significator_reversed` — False=gravity (default), True=levity ; **regression**: 7 FTs in test_bill_my_sign green in 57s. Visual verify deferred to user — picker should now show: idle empty stage + grid below; hover thumbnail → stage card preview; click → preview persists + stat block + FLIP appear; NVM → back to idle; FLIP click → horizontal-perspective Y-axis rotation w. polarity content swap mid-animation. Polarity-themed colour styles (levity inverted palette / gravity stark contrast / per-polarity text-shadows) now apply on my-sign matching the room sig-select look
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
337 lines
18 KiB
HTML
337 lines
18 KiB
HTML
{% extends "core/base.html" %}
|
|
{% load static %}
|
|
|
|
{% block title_text %}Game Sign{% endblock title_text %}
|
|
{% block header_text %}<span>Game</span><span>Sign</span>{% endblock header_text %}
|
|
|
|
{% block content %}
|
|
{# Solo lift of `_sig_select_overlay.html`. Same card-grid + stage-card #}
|
|
{# choreography as the room sig-select, minus countdown / WebSocket / #}
|
|
{# polarity / multi-user. FLIP btn (.spin-btn) lets the user choose the #}
|
|
{# card's orientation; SAVE SIG persists it on the User model. #}
|
|
{# "Significator" is preserved at the storage layer (User.significator) + #}
|
|
{# game-room context; this billboard surface re-brands to "Sign". #}
|
|
<div class="my-sign-page"
|
|
data-save-url="{% url 'billboard:save_sign' %}"
|
|
{% if current_significator %}data-current-card-id="{{ current_significator.id }}"{% endif %}
|
|
data-current-reversed="{{ current_significator_reversed|yesno:'true,false' }}"
|
|
data-polarity="{% if current_significator_reversed %}levity{% else %}gravity{% endif %}">
|
|
|
|
{# Stage frame always visible; stage card + stat block + FLIP btn #}
|
|
{# gated by hover (preview) + click (lock). `.sig-stage--frozen` is #}
|
|
{# added on click + cleared by NVM. data-polarity moved to the page #}
|
|
{# wrapper so descendant .sig-card / .sig-stage-card both get the #}
|
|
{# polarity-themed CSS rules. #}
|
|
<div class="sig-stage my-sign-stage">
|
|
<div class="sig-stage-card" style="display:none">
|
|
<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">
|
|
<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>
|
|
{# FLIP — bottom-left of the stage card. Mirrors the game-kit fan #}
|
|
{# carousel's btn-reveal pattern (game_kit.html#id_fan_flip). Toggles #}
|
|
{# polarity (data-polarity attr + persisted User.significator_reversed). #}
|
|
{# SPIN in the stat-block toggles orientation (180° rotation) for #}
|
|
{# preview only — not persisted. #}
|
|
<button class="btn btn-reveal my-sign-flip-btn" type="button">FLIP</button>
|
|
<div class="sig-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"></ul>
|
|
</div>
|
|
<div class="stat-face stat-face--reversed">
|
|
<p class="stat-face-label">Reversal</p>
|
|
<ul class="stat-keywords"></ul>
|
|
</div>
|
|
{% include "apps/gameboard/_partials/_sig_fyi_panel.html" with panel_id="id_my_sign_fyi_panel" %}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="sig-deck-grid my-sign-deck-grid">
|
|
{% for card in cards %}
|
|
<div class="sig-card"
|
|
data-card-id="{{ card.id }}"
|
|
data-suit-icon="{{ card.suit_icon }}"
|
|
data-corner-rank="{{ card.corner_rank }}"
|
|
data-name-group="{{ card.name_group }}"
|
|
data-name-title="{{ card.name_title }}"
|
|
data-arcana="{{ card.get_arcana_display }}"
|
|
data-correspondence="{{ card.correspondence|default:'' }}"
|
|
data-keywords-upright="{{ card.keywords_upright|join:',' }}"
|
|
data-keywords-reversed="{{ card.keywords_reversed|join:',' }}"
|
|
data-energies="{{ card.energies_json }}"
|
|
data-operations="{{ card.operations_json }}"
|
|
data-levity-qualifier="{{ card.levity_qualifier }}"
|
|
data-gravity-qualifier="{{ card.gravity_qualifier }}"
|
|
data-reversal-qualifier="{{ card.reversal_qualifier }}"
|
|
data-levity-emanation="{{ card.levity_emanation }}"
|
|
data-gravity-emanation="{{ card.gravity_emanation }}"
|
|
data-levity-reversal="{{ card.levity_reversal }}"
|
|
data-gravity-reversal="{{ card.gravity_reversal }}"
|
|
data-italic-word="{{ card.italic_word }}">
|
|
<div class="fan-card-corner fan-card-corner--tl">
|
|
<span class="fan-corner-rank">{{ card.corner_rank }}</span>
|
|
{% if card.suit_icon %}<i class="fa-solid {{ card.suit_icon }}"></i>{% endif %}
|
|
</div>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
|
|
<form id="id_save_sign_form" method="POST" action="{% url 'billboard:save_sign' %}">
|
|
{% csrf_token %}
|
|
<input type="hidden" name="card_id" id="id_save_sign_card_id" value="{{ current_significator.id|default:'' }}">
|
|
<input type="hidden" name="reversed" id="id_save_sign_reversed" value="{{ current_significator_reversed|yesno:'1,0' }}">
|
|
<button type="submit" id="id_save_sign_btn" class="btn btn-primary"{% if not current_significator %} disabled{% endif %}>SAVE SIGN</button>
|
|
<button type="button" id="id_nvm_sign_btn" class="btn btn-cancel"{% if not current_significator %} style="display:none"{% endif %}>NVM</button>
|
|
</form>
|
|
|
|
{# Picker JS — click .sig-card to pick + populate stage preview via #}
|
|
{# StageCard.populateCard (shared w. room sig-select + sea-select). #}
|
|
{# FLIP toggles `.stage-card--reversed` on the preview AND the #}
|
|
{# hidden `reversed` input that gets POSTed on SAVE SIGN. #}
|
|
<script src="{% static 'apps/epic/stage-card.js' %}"></script>
|
|
<script>
|
|
(function () {
|
|
var grid = document.querySelector('.my-sign-deck-grid');
|
|
if (!grid) return;
|
|
var pageEl = document.querySelector('.my-sign-page');
|
|
var stage = document.querySelector('.my-sign-stage');
|
|
var stageCard = stage.querySelector('.sig-stage-card');
|
|
var statBlock = stage.querySelector('.sig-stat-block');
|
|
var cardIdInput = document.getElementById('id_save_sign_card_id');
|
|
var saveBtn = document.getElementById('id_save_sign_btn');
|
|
var nvmBtn = document.getElementById('id_nvm_sign_btn');
|
|
var spinBtn = stage.querySelector('.spin-btn');
|
|
var flipBtn = stage.querySelector('.my-sign-flip-btn');
|
|
var fyiBtn = stage.querySelector('.fyi-btn');
|
|
var fyiPanel = stage.querySelector('.sig-info');
|
|
var fyiPrev = stage.querySelector('.fyi-prev');
|
|
var fyiNext = stage.querySelector('.fyi-next');
|
|
var revInput = document.getElementById('id_save_sign_reversed');
|
|
var _currentCard = null;
|
|
var _focusedCardEl = null;
|
|
var _locked = false; // true after click; cleared by NVM
|
|
var _fyiData = [];
|
|
var _fyiIdx = 0;
|
|
|
|
// Default polarity = gravity (significator_reversed=False);
|
|
// FLIP toggles to levity (significator_reversed=True). Mirrors the
|
|
// user's "gravity-rightside-up by default" design from the Game
|
|
// Kit fan carousel.
|
|
function _polarity() {
|
|
return revInput.value === '1' ? 'levity' : 'gravity';
|
|
}
|
|
|
|
function _populateStage(cardEl) {
|
|
_focusedCardEl = cardEl;
|
|
_currentCard = StageCard.fromDataset(cardEl);
|
|
StageCard.populateCard(stageCard, _currentCard, _polarity());
|
|
StageCard.populateKeywords(statBlock,
|
|
_currentCard.keywords_upright, _currentCard.keywords_reversed);
|
|
_fyiData = StageCard.buildInfoData(_currentCard);
|
|
_fyiIdx = 0;
|
|
if (fyiPanel) StageCard.renderFyi(fyiPanel, _fyiData, _fyiIdx);
|
|
stageCard.style.display = '';
|
|
}
|
|
|
|
function _clearStage() {
|
|
stageCard.style.display = 'none';
|
|
_focusedCardEl = null;
|
|
_currentCard = null;
|
|
}
|
|
|
|
function _lock(cardEl) {
|
|
_locked = true;
|
|
_populateStage(cardEl);
|
|
grid.querySelectorAll('.sig-card.sig-focused').forEach(function (c) {
|
|
if (c !== cardEl) c.classList.remove('sig-focused');
|
|
});
|
|
cardEl.classList.add('sig-focused');
|
|
cardIdInput.value = cardEl.dataset.cardId;
|
|
saveBtn.removeAttribute('disabled');
|
|
if (nvmBtn) nvmBtn.style.display = '';
|
|
stage.classList.add('sig-stage--frozen');
|
|
statBlock.classList.remove('fyi-open');
|
|
}
|
|
|
|
function _unlock() {
|
|
_locked = false;
|
|
stage.classList.remove('sig-stage--frozen');
|
|
statBlock.classList.remove('fyi-open');
|
|
grid.querySelectorAll('.sig-card.sig-focused').forEach(function (c) {
|
|
c.classList.remove('sig-focused');
|
|
});
|
|
_clearStage();
|
|
cardIdInput.value = '';
|
|
saveBtn.setAttribute('disabled', 'disabled');
|
|
if (nvmBtn) nvmBtn.style.display = 'none';
|
|
}
|
|
|
|
// Horizontal-perspective FLIP animation lifted from
|
|
// apps/gameboard/static/apps/gameboard/game-kit.js `_flipActive`.
|
|
// 500ms Y-axis rotation; mid-animation (offset 0.5) the populator
|
|
// swaps polarity content so the user sees the new face from the
|
|
// start of the second half-rotation. Preserves the SPIN orientation
|
|
// (.stage-card--reversed) through the flip by including the spin
|
|
// rotate(180deg) in both keyframes. data-polarity moved to the page
|
|
// wrapper so descendant .sig-card + .sig-stage-card both pick up
|
|
// the polarity-themed CSS rules ([[feedback-applet-vs-page]] / port
|
|
// of `.sig-overlay[data-polarity]` to `.my-sign-page[data-polarity]`).
|
|
function _flipPolarityAnimated() {
|
|
if (!stageCard || stageCard.dataset.flipping) return;
|
|
stageCard.dataset.flipping = '1';
|
|
var spin = stageCard.classList.contains('stage-card--reversed')
|
|
? ' rotate(180deg)' : '';
|
|
var rest = 'translateX(0px) rotateY(0deg) scale(1)' + spin;
|
|
var mid = 'translateX(0px) rotateY(0deg) scale(1)' + spin
|
|
+ ' rotateY(90deg)';
|
|
stageCard.animate([
|
|
{ transform: rest },
|
|
{ transform: mid, offset: 0.5 },
|
|
{ transform: rest },
|
|
], { duration: 500, easing: 'ease' });
|
|
setTimeout(function () {
|
|
revInput.value = revInput.value === '1' ? '0' : '1';
|
|
pageEl.setAttribute('data-polarity', _polarity());
|
|
if (flipBtn) flipBtn.classList.toggle(
|
|
'is-reversed', revInput.value === '1');
|
|
if (_currentCard && window.StageCard) {
|
|
StageCard.populateCard(stageCard, _currentCard, _polarity());
|
|
}
|
|
}, 250);
|
|
setTimeout(function () { delete stageCard.dataset.flipping; }, 500);
|
|
}
|
|
|
|
// Orientation toggle (preview-only) — rotates the card 180° + swaps
|
|
// the stat-block visible face. Not persisted; SAVE SIGN only stores
|
|
// the card identity + polarity (significator_reversed = polarity bit).
|
|
function _toggleOrientation() {
|
|
var on = !stageCard.classList.contains('stage-card--reversed');
|
|
stageCard.classList.toggle('stage-card--reversed', on);
|
|
statBlock.classList.toggle('is-reversed', on);
|
|
if (spinBtn) spinBtn.classList.toggle('is-reversed', on);
|
|
}
|
|
|
|
// Hover preview — only when stage isn't locked. mouseenter shows
|
|
// the stage card; mouseleave hides it. Click locks the state +
|
|
// surfaces the stat block + FLIP btn (via .sig-stage--frozen).
|
|
grid.addEventListener('mouseover', function (e) {
|
|
if (_locked) return;
|
|
var cardEl = e.target.closest('.sig-card');
|
|
if (!cardEl || !window.StageCard) return;
|
|
_populateStage(cardEl);
|
|
});
|
|
grid.addEventListener('mouseout', function (e) {
|
|
if (_locked) return;
|
|
var cardEl = e.target.closest('.sig-card');
|
|
if (!cardEl) return;
|
|
var nextCard = e.relatedTarget && e.relatedTarget.closest
|
|
&& e.relatedTarget.closest('.sig-card');
|
|
if (nextCard) return; // moving between cards — let mouseover update
|
|
_clearStage();
|
|
});
|
|
grid.addEventListener('click', function (e) {
|
|
var cardEl = e.target.closest('.sig-card');
|
|
if (!cardEl || !window.StageCard) return;
|
|
_lock(cardEl);
|
|
});
|
|
if (nvmBtn) {
|
|
nvmBtn.addEventListener('click', function () { _unlock(); });
|
|
}
|
|
if (flipBtn) {
|
|
flipBtn.addEventListener('click', function () {
|
|
if (!_currentCard) return; // need a selected card to flip
|
|
_flipPolarityAnimated();
|
|
});
|
|
}
|
|
if (spinBtn) {
|
|
spinBtn.addEventListener('click', function () {
|
|
_toggleOrientation();
|
|
});
|
|
}
|
|
if (fyiBtn) {
|
|
fyiBtn.addEventListener('click', function () {
|
|
if (!_currentCard) return; // no card selected yet
|
|
statBlock.classList.toggle('fyi-open');
|
|
});
|
|
}
|
|
if (fyiPanel) {
|
|
fyiPanel.addEventListener('click', function () {
|
|
statBlock.classList.remove('fyi-open');
|
|
});
|
|
}
|
|
if (fyiPrev) {
|
|
fyiPrev.addEventListener('click', function () {
|
|
if (!_fyiData.length) return;
|
|
_fyiIdx = (_fyiIdx - 1 + _fyiData.length) % _fyiData.length;
|
|
StageCard.renderFyi(fyiPanel, _fyiData, _fyiIdx);
|
|
});
|
|
}
|
|
if (fyiNext) {
|
|
fyiNext.addEventListener('click', function () {
|
|
if (!_fyiData.length) return;
|
|
_fyiIdx = (_fyiIdx + 1) % _fyiData.length;
|
|
StageCard.renderFyi(fyiPanel, _fyiData, _fyiIdx);
|
|
});
|
|
}
|
|
|
|
// On-load: if user has a saved sig, lock that card so the stage
|
|
// + stat block + FLIP btn appear w. the persisted choice from
|
|
// the start. Otherwise the stage stays empty until first hover.
|
|
var savedId = pageEl && pageEl.dataset.currentCardId;
|
|
if (savedId) {
|
|
var savedCardEl = grid.querySelector(
|
|
'.sig-card[data-card-id="' + savedId + '"]');
|
|
if (savedCardEl) _lock(savedCardEl);
|
|
}
|
|
}());
|
|
</script>
|
|
|
|
{# No equipped deck + no saved sig — fire a Brief banner via the #}
|
|
{# shared note.js API so positioning + NVM behavior + h2-overlay #}
|
|
{# styling matches every other Brief on the site (per #}
|
|
{# [[sprint-baltimorean-note-unlock-may18]] portrait h2 measurement). #}
|
|
{# FYI links to /gameboard/ (Game Kit equip); NVM dismisses + the #}
|
|
{# picker proceeds against the Earthman [Shabby Paperboard] fallback. #}
|
|
<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',
|
|
});
|
|
// Tag the banner so FTs (and any my-sign-specific styling) can
|
|
// distinguish this intro nudge from other Briefs on the page.
|
|
var banner = document.querySelector('.note-banner');
|
|
if (banner) banner.classList.add('my-sign-intro-banner');
|
|
});
|
|
</script>
|
|
{% endif %}
|
|
</div>
|
|
{% endblock content %}
|