GAME KIT: DON|DOFF equip system — portal tooltips, kit bag sync, btn-disabled fix
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed

- DON/DOFF buttons on left edge of game kit applet portal tooltip (mirroring FLIP/FYI)
- equip-trinket/unequip-trinket/equip-deck/unequip-deck views + URLs
- Portal stays open after DON/DOFF; buttons swap state in-place (_setEquipState)
- _syncTokenButtons: updates all .tt DON/DOFF buttons after equip state change
- _syncKitBagDialog (DOFF): replaces card with grayed placeholder icon in-place
- _refreshKitDialog (DON): re-fetches kit content so newly-equipped card appears immediately
- kit-content-refreshed event: game-kit.js re-attaches card listeners after re-fetch
- Bounding box expanded 24px left so buttons at portal edge don't trigger close
- mini-portal pinned with right (not left) so text width changes grow/shrink leftward
- btn-disabled moved dead last in .btn block — wins by source order, no !important needed
- Kit bag panel: trinket + token sections always render (placeholder when empty)
- Backstage Pass in GameKitEquipTest setUp (is_staff, natural unequipped state)
- Portal padding 0.75rem / 1.5rem; tt-description/shoptalk smaller; tt-expiry --priRd
- Wallet tokens CSS hover rule for .tt removed (portal-only now)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-04-16 00:14:47 -04:00
parent d3e4638233
commit db9ac9cb24
13 changed files with 509 additions and 108 deletions

View File

@@ -74,6 +74,11 @@
}); });
} }
// gameboard.js re-fetches dialog content after DON and fires this event.
dialog.addEventListener('kit-content-refreshed', function () {
attachCardListeners();
});
function attachCardListeners() { function attachCardListeners() {
dialog.querySelectorAll('.token[data-token-id]').forEach(function (card) { dialog.querySelectorAll('.token[data-token-id]').forEach(function (card) {
card.addEventListener('click', function () { card.addEventListener('click', function () {

View File

@@ -14,7 +14,6 @@ function initGameKitTooltips() {
portal.style.display = 'none'; portal.style.display = 'none';
miniPortal.style.display = 'none'; miniPortal.style.display = 'none';
let equippedId = gameKit.dataset.equippedId || '';
let activeToken = null; let activeToken = null;
let equipping = false; let equipping = false;
@@ -32,7 +31,16 @@ function initGameKitTooltips() {
document.addEventListener('mousemove', (e) => { document.addEventListener('mousemove', (e) => {
if (portal.classList.contains('active') && activeToken) { if (portal.classList.contains('active') && activeToken) {
const rects = [activeToken.getBoundingClientRect(), portal.getBoundingClientRect()]; const tokenRect = activeToken.getBoundingClientRect();
const portalRect = portal.getBoundingClientRect();
// Expand left to cover button overflow outside portal edge
const expandedPortalRect = {
left: portalRect.left - 24,
top: portalRect.top,
right: portalRect.right,
bottom: portalRect.bottom,
};
const rects = [tokenRect, expandedPortalRect];
if (miniPortal.classList.contains('active')) rects.push(miniPortal.getBoundingClientRect()); if (miniPortal.classList.contains('active')) rects.push(miniPortal.getBoundingClientRect());
const left = Math.min(...rects.map(r => r.left)); const left = Math.min(...rects.map(r => r.left));
const top = Math.min(...rects.map(r => r.top)); const top = Math.min(...rects.map(r => r.top));
@@ -50,67 +58,155 @@ function initGameKitTooltips() {
} }
}); });
// buildMiniContent takes the full element so it can inspect data-deck-id vs data-token-id. // buildMiniContent — text-only status; DON/DOFF buttons live in the main portal.
function buildMiniContent(token) { function buildMiniContent(token) {
const deckId = token.dataset.deckId; const deckId = token.dataset.deckId;
const tokenId = token.dataset.tokenId; const tokenId = token.dataset.tokenId;
const equippedId = gameKit.dataset.equippedId || '';
if (deckId) {
const equippedDeckId = gameKit.dataset.equippedDeckId || ''; const equippedDeckId = gameKit.dataset.equippedDeckId || '';
if (equippedDeckId && deckId === equippedDeckId) { if (deckId) {
miniPortal.textContent = 'Equipped'; miniPortal.textContent = (equippedDeckId && deckId === equippedDeckId) ? 'Equipped' : 'Not Equipped';
} else if (tokenId) {
miniPortal.textContent = (equippedId && tokenId === equippedId) ? 'Equipped' : 'Not Equipped';
}
}
// Update DON/DOFF button pair to reflect new equipped state.
function _setEquipState(donBtn, doffBtn, isEquipped) {
if (isEquipped) {
donBtn.classList.add('btn-disabled'); donBtn.textContent = '×';
doffBtn.classList.remove('btn-disabled'); doffBtn.textContent = 'DOFF';
} else { } else {
const btn = document.createElement('button'); donBtn.classList.remove('btn-disabled'); donBtn.textContent = 'DON';
btn.className = 'equip-deck-btn'; doffBtn.classList.add('btn-disabled'); doffBtn.textContent = '×';
btn.textContent = 'Equip Deck?'; }
btn.addEventListener('click', (e) => { }
// Sync all tokens' .tt DON/DOFF buttons after an equip state change.
function _syncTokenButtons(kind, newEquippedId) {
const selector = kind === 'deck' ? '[data-deck-id]' : '[data-token-id]';
gameKit.querySelectorAll('.token' + selector).forEach(tokenEl => {
const id = kind === 'deck' ? tokenEl.dataset.deckId : tokenEl.dataset.tokenId;
const tt = tokenEl.querySelector('.tt');
if (!tt) return;
const don = tt.querySelector('.btn-equip');
const doff = tt.querySelector('.btn-unequip');
if (!don || !doff) return;
_setEquipState(don, doff, id === newEquippedId);
});
}
// If the kit bag dialog is open, re-fetch its content (used after DON so the
// newly-equipped card appears without the user having to close+reopen).
function _refreshKitDialog() {
const dialog = document.getElementById('id_kit_bag_dialog');
const kitBtn = document.getElementById('id_kit_btn');
if (!dialog || !dialog.hasAttribute('open') || !kitBtn || !kitBtn.dataset.kitUrl) return;
fetch(kitBtn.dataset.kitUrl, {
headers: { 'X-Requested-With': 'XMLHttpRequest' },
})
.then(r => r.text())
.then(html => {
dialog.innerHTML = html;
dialog.dispatchEvent(new CustomEvent('kit-content-refreshed'));
});
}
// If the kit bag dialog is open, replace the matching card with a placeholder.
function _syncKitBagDialog(kind, id) {
const dialog = document.getElementById('id_kit_bag_dialog');
if (!dialog || !dialog.hasAttribute('open')) return;
const selector = kind === 'deck'
? `.kit-bag-deck[data-deck-id="${id}"]`
: `.token[data-token-id="${id}"]`;
const card = dialog.querySelector(selector);
if (!card) return;
const placeholder = document.createElement('div');
placeholder.className = 'kit-bag-placeholder';
const icon = card.querySelector('i');
if (icon) placeholder.innerHTML = icon.outerHTML;
card.parentNode.insertBefore(placeholder, card);
card.remove();
}
function wireDonDoff(token) {
const donBtn = portal.querySelector('.btn-equip');
const doffBtn = portal.querySelector('.btn-unequip');
if (!donBtn || !doffBtn) return;
donBtn.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
if (donBtn.classList.contains('btn-disabled') || equipping) return;
equipping = true; equipping = true;
const tokenId = donBtn.dataset.tokenId;
const deckId = donBtn.dataset.deckId;
if (tokenId) {
gameKit.dataset.equippedId = tokenId;
fetch(`/gameboard/equip-trinket/${tokenId}/`, {
method: 'POST',
headers: {'X-CSRFToken': getCsrfToken()},
}).then(r => {
equipping = false;
if (r.ok) {
_setEquipState(donBtn, doffBtn, true);
_syncTokenButtons('trinket', tokenId);
buildMiniContent(token);
_refreshKitDialog();
}
});
} else if (deckId) {
gameKit.dataset.equippedDeckId = deckId; gameKit.dataset.equippedDeckId = deckId;
fetch(`/gameboard/equip-deck/${deckId}/`, { fetch(`/gameboard/equip-deck/${deckId}/`, {
method: 'POST', method: 'POST',
headers: {'X-CSRFToken': getCsrfToken()}, headers: {'X-CSRFToken': getCsrfToken()},
}).then(r => { }).then(r => {
if (r.ok && equipping) {
equipping = false;
closePortals();
} else {
equipping = false; equipping = false;
if (r.ok) {
_setEquipState(donBtn, doffBtn, true);
_syncTokenButtons('deck', deckId);
buildMiniContent(token);
_refreshKitDialog();
} }
}); });
});
miniPortal.innerHTML = '';
miniPortal.appendChild(btn);
} }
} else if (tokenId) { });
if (equippedId && tokenId === equippedId) {
miniPortal.textContent = 'Equipped'; doffBtn.addEventListener('click', (e) => {
} else {
const btn = document.createElement('button');
btn.className = 'equip-trinket-btn';
btn.dataset.tokenId = tokenId;
btn.textContent = 'Equip Trinket?';
btn.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
if (doffBtn.classList.contains('btn-disabled') || equipping) return;
equipping = true; equipping = true;
equippedId = tokenId; const tokenId = doffBtn.dataset.tokenId;
gameKit.dataset.equippedId = equippedId; const deckId = doffBtn.dataset.deckId;
fetch(`/gameboard/equip-trinket/${tokenId}/`, { if (tokenId) {
gameKit.dataset.equippedId = '';
fetch(`/gameboard/unequip-trinket/${tokenId}/`, {
method: 'POST', method: 'POST',
headers: {'X-CSRFToken': getCsrfToken()}, headers: {'X-CSRFToken': getCsrfToken()},
}).then(r => { }).then(r => {
if (r.ok && equipping) {
equipping = false;
closePortals();
} else {
equipping = false; equipping = false;
if (r.ok) {
_setEquipState(donBtn, doffBtn, false);
_syncTokenButtons('trinket', '');
buildMiniContent(token);
_syncKitBagDialog('token', tokenId);
} }
}); });
} else if (deckId) {
gameKit.dataset.equippedDeckId = '';
fetch(`/gameboard/unequip-deck/${deckId}/`, {
method: 'POST',
headers: {'X-CSRFToken': getCsrfToken()},
}).then(r => {
equipping = false;
if (r.ok) {
_setEquipState(donBtn, doffBtn, false);
_syncTokenButtons('deck', '');
buildMiniContent(token);
_syncKitBagDialog('deck', deckId);
}
}); });
miniPortal.innerHTML = '';
miniPortal.appendChild(btn);
}
} }
});
} }
function showPortals(token) { function showPortals(token) {
@@ -129,6 +225,7 @@ function initGameKitTooltips() {
miniPortal.classList.add('active'); miniPortal.classList.add('active');
miniPortal.style.display = 'block'; miniPortal.style.display = 'block';
miniHeight = miniPortal.offsetHeight + 4; miniHeight = miniPortal.offsetHeight + 4;
wireDonDoff(token);
} else { } else {
miniPortal.classList.remove('active'); miniPortal.classList.remove('active');
miniPortal.style.display = 'none'; miniPortal.style.display = 'none';
@@ -154,7 +251,10 @@ function initGameKitTooltips() {
if (isEquippable) { if (isEquippable) {
const mainRect = portal.getBoundingClientRect(); const mainRect = portal.getBoundingClientRect();
miniPortal.style.left = (mainRect.right - miniPortal.offsetWidth) + 'px'; // Pin the right edge of the mini-portal to the right edge of the main portal.
// Using `right` (not `left`) means text width changes grow/shrink leftward.
miniPortal.style.left = '';
miniPortal.style.right = Math.round(window.innerWidth - mainRect.right) + 'px';
miniPortal.style.top = (mainRect.bottom + 4) + 'px'; miniPortal.style.top = (mainRect.bottom + 4) + 'px';
} }
} }

View File

@@ -8,6 +8,8 @@ urlpatterns = [
path('toggle-applets', views.toggle_game_applets, name='toggle_game_applets'), path('toggle-applets', views.toggle_game_applets, name='toggle_game_applets'),
path('equip-trinket/<int:token_id>/', views.equip_trinket, name='equip_trinket'), path('equip-trinket/<int:token_id>/', views.equip_trinket, name='equip_trinket'),
path('equip-deck/<int:deck_id>/', views.equip_deck, name='equip_deck'), path('equip-deck/<int:deck_id>/', views.equip_deck, name='equip_deck'),
path('unequip-trinket/<int:token_id>/', views.unequip_trinket, name='unequip_trinket'),
path('unequip-deck/<int:deck_id>/', views.unequip_deck, name='unequip_deck'),
path('game-kit/', views.game_kit, name='game_kit'), path('game-kit/', views.game_kit, name='game_kit'),
path('game-kit/toggle-sections', views.toggle_game_kit_sections, name='toggle_game_kit_sections'), path('game-kit/toggle-sections', views.toggle_game_kit_sections, name='toggle_game_kit_sections'),
path('game-kit/deck/<int:deck_id>/', views.tarot_fan, name='tarot_fan'), path('game-kit/deck/<int:deck_id>/', views.tarot_fan, name='tarot_fan'),

View File

@@ -30,8 +30,8 @@ def gameboard(request):
"pass_token": pass_token, "pass_token": pass_token,
"coin": coin, "coin": coin,
"carte": carte, "carte": carte,
"equipped_trinket_id": str(request.user.equipped_trinket_id or ""), "equipped_trinket_id": request.user.equipped_trinket_id,
"equipped_deck_id": str(request.user.equipped_deck_id or ""), "equipped_deck_id": request.user.equipped_deck_id,
"deck_variants": list(request.user.unlocked_decks.all()), "deck_variants": list(request.user.unlocked_decks.all()),
"free_tokens": free_tokens, "free_tokens": free_tokens,
"free_count": len(free_tokens), "free_count": len(free_tokens),
@@ -60,8 +60,8 @@ def toggle_game_applets(request):
"pass_token": request.user.tokens.filter(token_type=Token.PASS).first() if request.user.is_staff else None, "pass_token": request.user.tokens.filter(token_type=Token.PASS).first() if request.user.is_staff else None,
"coin": request.user.tokens.filter(token_type=Token.COIN).first(), "coin": request.user.tokens.filter(token_type=Token.COIN).first(),
"carte": request.user.tokens.filter(token_type=Token.CARTE).first(), "carte": request.user.tokens.filter(token_type=Token.CARTE).first(),
"equipped_trinket_id": str(request.user.equipped_trinket_id or ""), "equipped_trinket_id": request.user.equipped_trinket_id,
"equipped_deck_id": str(request.user.equipped_deck_id or ""), "equipped_deck_id": request.user.equipped_deck_id,
"deck_variants": list(request.user.unlocked_decks.all()), "deck_variants": list(request.user.unlocked_decks.all()),
"free_tokens": list(request.user.tokens.filter( "free_tokens": list(request.user.tokens.filter(
token_type=Token.FREE, expires_at__gt=timezone.now() token_type=Token.FREE, expires_at__gt=timezone.now()
@@ -102,6 +102,28 @@ def equip_deck(request, deck_id):
return HttpResponse(status=405) return HttpResponse(status=405)
@login_required(login_url="/")
def unequip_trinket(request, token_id):
token = get_object_or_404(Token, pk=token_id, user=request.user)
if request.method == "POST":
if request.user.equipped_trinket_id == token.pk:
request.user.equipped_trinket = None
request.user.save(update_fields=["equipped_trinket"])
return HttpResponse(status=204)
return HttpResponse(status=405)
@login_required(login_url="/")
def unequip_deck(request, deck_id):
get_object_or_404(DeckVariant, pk=deck_id)
if request.method == "POST":
if request.user.equipped_deck_id == deck_id:
request.user.equipped_deck = None
request.user.save(update_fields=["equipped_deck"])
return HttpResponse(status=204)
return HttpResponse(status=405)
def _game_kit_context(user): def _game_kit_context(user):
coin = user.tokens.filter(token_type=Token.COIN).first() coin = user.tokens.filter(token_type=Token.COIN).first()
pass_token = user.tokens.filter(token_type=Token.PASS).first() if user.is_staff else None pass_token = user.tokens.filter(token_type=Token.PASS).first() if user.is_staff else None

View File

@@ -28,4 +28,5 @@ def tooltip(data):
'keywords_rev':extras.get('keywords_rev'), 'keywords_rev':extras.get('keywords_rev'),
'cautions': extras.get('cautions'), 'cautions': extras.get('cautions'),
'nav': extras.get('nav'), 'nav': extras.get('nav'),
'equippable': data.get('equippable'),
} }

View File

@@ -4,6 +4,8 @@ from selenium.webdriver.common.by import By
from .base import FunctionalTest from .base import FunctionalTest
from apps.applets.models import Applet from apps.applets.models import Applet
from apps.epic.models import DeckVariant, Room
from apps.lyric.models import Token, User
class GameboardNavigationTest(FunctionalTest): class GameboardNavigationTest(FunctionalTest):
@@ -156,3 +158,139 @@ class GameboardAppletMenuTest(FunctionalTest):
) )
# 7. Assert no full page reload occurred # 7. Assert no full page reload occurred
self.assertTrue(self.browser.execute_script("return window.__no_reload_marker === true")) self.assertTrue(self.browser.execute_script("return window.__no_reload_marker === true"))
class GameKitEquipTest(FunctionalTest):
"""DON|DOFF equip buttons in the game kit applet portal tooltip."""
def setUp(self):
super().setUp()
self.earthman, _ = DeckVariant.objects.get_or_create(
slug="earthman",
defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True},
)
Applet.objects.get_or_create(slug="game-kit", defaults={"name": "Game Kit", "context": "gameboard"})
self.create_pre_authenticated_session("gamer@equip.io")
self.gamer = User.objects.get(email="gamer@equip.io")
# Promote to staff so the pass_token appears in the game kit applet.
self.gamer.is_staff = True
self.gamer.save(update_fields=["is_staff"])
self.gamer.unlocked_decks.add(self.earthman)
self.coin = self.gamer.tokens.filter(token_type=Token.COIN).first()
# Create a PASS token manually — starts unequipped (coin is auto-equipped).
self.pass_token = Token.objects.create(user=self.gamer, token_type=Token.PASS)
self.browser.set_window_size(1200, 900)
self.browser.get(self.live_server_url + "/gameboard/")
def _hover_game_kit_token(self, token_el):
"""Hover token, wait for portal, return portal element."""
ActionChains(self.browser).move_to_element(token_el).perform()
self.wait_for(
lambda: self.assertTrue(
self.browser.find_element(By.ID, "id_tooltip_portal").is_displayed()
)
)
return self.browser.find_element(By.ID, "id_tooltip_portal")
def test_unequipped_token_shows_don_active_doff_disabled(self):
"""Backstage Pass — naturally unequipped (coin is auto-equipped): DON active, DOFF is ×."""
pass_el = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_kit_pass")
)
portal = self._hover_game_kit_token(pass_el)
don = portal.find_element(By.CSS_SELECTOR, ".btn-equip")
doff = portal.find_element(By.CSS_SELECTOR, ".btn-unequip")
self.assertNotIn("btn-disabled", don.get_attribute("class"))
self.assertIn("btn-disabled", doff.get_attribute("class"))
self.assertEqual(doff.text, "×")
def test_equipped_token_shows_doff_active_don_disabled(self):
"""Auto-equipped coin: DOFF active, DON is ×; mini-portal says Equipped."""
coin = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_kit_coin_on_a_string")
)
portal = self._hover_game_kit_token(coin)
don = portal.find_element(By.CSS_SELECTOR, ".btn-equip")
doff = portal.find_element(By.CSS_SELECTOR, ".btn-unequip")
self.assertIn("btn-disabled", don.get_attribute("class"))
self.assertEqual(don.text, "×")
self.assertNotIn("btn-disabled", doff.get_attribute("class"))
mini = self.browser.find_element(By.ID, "id_mini_tooltip_portal")
self.assertEqual(mini.text, "Equipped")
self.assertEqual(len(mini.find_elements(By.CSS_SELECTOR, "button")), 0)
def test_doff_then_don_roundtrip(self):
"""Full roundtrip: DOFF unequips (portal stays open, buttons swap, mini updates);
DON re-equips (buttons swap back, mini updates back, DB confirms)."""
coin = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_kit_coin_on_a_string")
)
portal = self._hover_game_kit_token(coin)
# — DOFF —
portal.find_element(By.CSS_SELECTOR, ".btn-unequip").click()
self.wait_for(
lambda: self.assertIn(
"btn-disabled",
portal.find_element(By.CSS_SELECTOR, ".btn-unequip").get_attribute("class"),
)
)
self.assertEqual(portal.find_element(By.CSS_SELECTOR, ".btn-unequip").text, "×")
self.assertNotIn(
"btn-disabled",
portal.find_element(By.CSS_SELECTOR, ".btn-equip").get_attribute("class"),
)
self.assertEqual(
self.browser.find_element(By.ID, "id_mini_tooltip_portal").text, "Not Equipped"
)
self.gamer.refresh_from_db()
self.assertIsNone(self.gamer.equipped_trinket)
# — DON —
portal.find_element(By.CSS_SELECTOR, ".btn-equip").click()
self.wait_for(
lambda: self.assertIn(
"btn-disabled",
portal.find_element(By.CSS_SELECTOR, ".btn-equip").get_attribute("class"),
)
)
self.assertEqual(portal.find_element(By.CSS_SELECTOR, ".btn-equip").text, "×")
self.assertNotIn(
"btn-disabled",
portal.find_element(By.CSS_SELECTOR, ".btn-unequip").get_attribute("class"),
)
self.assertEqual(
self.browser.find_element(By.ID, "id_mini_tooltip_portal").text, "Equipped"
)
self.gamer.refresh_from_db()
self.assertEqual(self.gamer.equipped_trinket, self.coin)
def test_doff_updates_open_kit_bag_dialog(self):
"""DOFF from game kit applet replaces trinket card in currently-open kit bag dialog."""
self.gamer.equipped_trinket = self.coin
self.gamer.save(update_fields=["equipped_trinket"])
self.browser.refresh()
# Open kit bag dialog
self.browser.find_element(By.ID, "id_kit_btn").click()
self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR,
f'#id_kit_bag_dialog [data-token-id="{self.coin.id}"]',
)
)
# While dialog is open, hover coin and DOFF from game kit
coin = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_kit_coin_on_a_string")
)
portal = self._hover_game_kit_token(coin)
portal.find_element(By.CSS_SELECTOR, ".btn-unequip").click()
# Dialog updates: trinket card replaced with placeholder
self.wait_for(
lambda: self.assertEqual(
len(self.browser.find_elements(
By.CSS_SELECTOR,
f'#id_kit_bag_dialog [data-token-id="{self.coin.id}"]',
)), 0
)
)
self.browser.find_element(By.CSS_SELECTOR, "#id_kit_bag_dialog .kit-bag-placeholder")

View File

@@ -286,44 +286,6 @@
font-size: 0.75rem; // 0.63rem × 1.2 font-size: 0.75rem; // 0.63rem × 1.2
} }
&.btn-disabled {
cursor: default !important;
pointer-events: none;
font-size: 1.2rem;
padding-bottom: 0.1rem;
color: rgba(var(--secUser), 0.25) !important;
background-color: rgba(var(--priUser), 1) !important;
border-color: rgba(var(--secUser), 0.25) !important;
box-shadow:
0.1rem 0.1rem 0.12rem rgba(var(--priUser), 0.5),
0.12rem 0.12rem 0.25rem rgba(0, 0, 0, 0.25),
0.25rem 0.25rem 0.25rem rgba(var(--secUser), 0.12)
;
&:hover {
text-shadow:
0.1rem 0.1rem 0.1rem rgba(0, 0, 0, 0.25),
0 0 1rem rgba(var(--priUser), 1)
;
box-shadow:
0.12rem 0.12rem 0.5rem rgba(0, 0, 0, 0.25),
0 0 0.5rem rgba(var(--priUser), 0.12)
;
}
&:active {
text-shadow:
-0.1rem -0.1rem 0.25rem rgba(0, 0, 0, 0.25),
0 0 0.12rem rgba(var(--priUser), 1)
;
box-shadow:
-0.1rem -0.1rem 0.12rem rgba(var(--priUser), 0.75),
-0.1rem -0.1rem 0.12rem rgba(0, 0, 0, 0.25),
0 0 0.5rem rgba(var(--secUser), 0.12)
;
}
}
&.btn-nav-left { &.btn-nav-left {
color: rgba(var(--priFs), 1); color: rgba(var(--priFs), 1);
border-color: rgba(var(--priFs), 1); border-color: rgba(var(--priFs), 1);
@@ -394,6 +356,76 @@
} }
} }
&.btn-equip {
color: rgba(var(--priTk), 1);
border-color: rgba(var(--priTk), 1);
background-color: rgba(var(--terTk), 1);
box-shadow:
0.1rem 0.1rem 0.12rem rgba(var(--terTk), 0.25),
0.12rem 0.12rem 0.25rem rgba(0, 0, 0, 0.25),
0.25rem 0.25rem 0.25rem rgba(var(--terTk), 0.12)
;
&:hover {
text-shadow:
0.1rem 0.1rem 0.1rem rgba(0, 0, 0, 0.25),
0 0 1rem rgba(var(--priTk), 1)
;
box-shadow:
0.12rem 0.12rem 0.5rem rgba(0, 0, 0, 0.25),
0 0 0.5rem rgba(var(--priTk), 0.12)
;
}
&:active {
border: 0.18rem solid rgba(var(--priTk), 1);
text-shadow:
-0.1rem -0.1rem 0.25rem rgba(0, 0, 0, 0.25),
0 0 0.12rem rgba(var(--priTk), 1)
;
box-shadow:
-0.1rem -0.1rem 0.12rem rgba(var(--terTk), 0.25),
-0.1rem -0.1rem 0.12rem rgba(0, 0, 0, 0.25),
0 0 0.5rem rgba(var(--priTk), 0.12)
;
}
}
&.btn-unequip {
color: rgba(var(--priMe), 1);
border-color: rgba(var(--priMe), 1);
background-color: rgba(var(--terMe), 1);
box-shadow:
0.1rem 0.1rem 0.12rem rgba(var(--terMe), 0.25),
0.12rem 0.12rem 0.25rem rgba(0, 0, 0, 0.25),
0.25rem 0.25rem 0.25rem rgba(var(--terMe), 0.12)
;
&:hover {
text-shadow:
0.1rem 0.1rem 0.1rem rgba(0, 0, 0, 0.25),
0 0 1rem rgba(var(--priMe), 1)
;
box-shadow:
0.12rem 0.12rem 0.5rem rgba(0, 0, 0, 0.25),
0 0 0.5rem rgba(var(--priMe), 0.12)
;
}
&:active {
border: 0.18rem solid rgba(var(--priMe), 1);
text-shadow:
-0.1rem -0.1rem 0.25rem rgba(0, 0, 0, 0.25),
0 0 0.12rem rgba(var(--priMe), 1)
;
box-shadow:
-0.1rem -0.1rem 0.12rem rgba(var(--terMe), 0.25),
-0.1rem -0.1rem 0.12rem rgba(0, 0, 0, 0.25),
0 0 0.5rem rgba(var(--priMe), 0.12)
;
}
}
&.btn-reverse { &.btn-reverse {
color: rgba(var(--priCy), 1); color: rgba(var(--priCy), 1);
border-color: rgba(var(--priCy), 1); border-color: rgba(var(--priCy), 1);
@@ -463,4 +495,43 @@
; ;
} }
} }
// Dead last — wins over all color modifiers by source order.
&.btn-disabled {
cursor: default !important;
pointer-events: none;
font-size: 1.2rem;
padding-bottom: 0.1rem;
color: rgba(var(--secUser), 0.25) !important;
background-color: rgba(var(--priUser), 1) !important;
border-color: rgba(var(--secUser), 0.25) !important;
box-shadow:
0.1rem 0.1rem 0.12rem rgba(var(--priUser), 0.5),
0.12rem 0.12rem 0.25rem rgba(0, 0, 0, 0.25),
0.25rem 0.25rem 0.25rem rgba(var(--secUser), 0.12)
;
&:hover {
text-shadow:
0.1rem 0.1rem 0.1rem rgba(0, 0, 0, 0.25),
0 0 1rem rgba(var(--priUser), 1)
;
box-shadow:
0.12rem 0.12rem 0.5rem rgba(0, 0, 0, 0.25),
0 0 0.5rem rgba(var(--priUser), 0.12)
;
}
&:active {
text-shadow:
-0.1rem -0.1rem 0.25rem rgba(0, 0, 0, 0.25),
0 0 0.12rem rgba(var(--priUser), 1)
;
box-shadow:
-0.1rem -0.1rem 0.12rem rgba(var(--priUser), 0.75),
-0.1rem -0.1rem 0.12rem rgba(0, 0, 0, 0.25),
0 0 0.5rem rgba(var(--secUser), 0.12)
;
}
}
} }

View File

@@ -112,6 +112,19 @@
.token-tooltip, .token-tooltip,
.tt { .tt {
z-index: 9999; z-index: 9999;
// Buttons positioned on left edge of the fixed inline tooltip
.tt-equip-btns {
position: absolute;
left: -1rem;
top: 0;
display: flex;
flex-direction: column;
gap: 1.25rem;
z-index: 1;
.btn { margin: 0; }
}
} }
.kit-bag-deck { .kit-bag-deck {

View File

@@ -122,6 +122,25 @@ body.page-gameboard {
position: fixed; position: fixed;
z-index: 9999; z-index: 9999;
padding: 0.75rem 1.5rem;
.tt-title { font-size: 1rem; }
.tt-description { padding: 0.125rem; font-size: 0.75rem; }
.tt-shoptalk { font-size: 0.75rem; opacity: 0.75; }
.tt-expiry { font-size: 1rem; color: rgba(var(--priRd), 1); }
.tt-equip-btns {
position: absolute;
left: -1rem;
top: -1rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
z-index: 1;
.btn { margin: 0; }
}
&.active { display: block; } &.active { display: block; }
} }

View File

@@ -67,9 +67,8 @@
transform: translateX(-50%); transform: translateX(-50%);
} }
&:hover .token-tooltip, &:hover .token-tooltip {
&:hover .tt { display: block; // legacy fallback; .tt is JS-portal-only (no CSS hover)
display: block;
} }
} }

View File

@@ -3,11 +3,14 @@
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 'game_kit' %}">Game Kit</a></h2> <h2><a href="{% url 'game_kit' %}">Game Kit</a></h2>
<div id="id_game_kit" data-equipped-id="{{ equipped_trinket_id }}" data-equipped-deck-id="{{ equipped_deck_id }}"> <div id="id_game_kit" data-equipped-id="{{ equipped_trinket_id|default:'' }}" data-equipped-deck-id="{{ equipped_deck_id|default:'' }}">
{% if pass_token %} {% if pass_token %}
<div id="id_kit_pass" class="token" data-token-id="{{ pass_token.pk }}"> <div id="id_kit_pass" class="token" data-token-id="{{ pass_token.pk }}">
<i class="fa-solid fa-clipboard"></i> <i class="fa-solid fa-clipboard"></i>
<div class="tt"> <div class="tt">
<div class="tt-equip-btns">
{% if pass_token.pk == equipped_trinket_id %}<button class="btn btn-equip btn-disabled" data-token-id="{{ pass_token.pk }}">×</button><button class="btn btn-unequip" data-token-id="{{ pass_token.pk }}">DOFF</button>{% else %}<button class="btn btn-equip" data-token-id="{{ pass_token.pk }}">DON</button><button class="btn btn-unequip btn-disabled" data-token-id="{{ pass_token.pk }}">×</button>{% endif %}
</div>
<h4 class="tt-title">{{ pass_token.tooltip_name }}</h4> <h4 class="tt-title">{{ pass_token.tooltip_name }}</h4>
<p class="tt-description">{{ pass_token.tooltip_description }}</p> <p class="tt-description">{{ pass_token.tooltip_description }}</p>
{% if pass_token.tooltip_shoptalk %} {% if pass_token.tooltip_shoptalk %}
@@ -21,6 +24,9 @@
<div id="id_kit_carte_blanche" class="token" data-token-id="{{ carte.pk }}"> <div id="id_kit_carte_blanche" class="token" data-token-id="{{ carte.pk }}">
<i class="fa-solid fa-money-check"></i> <i class="fa-solid fa-money-check"></i>
<div class="tt"> <div class="tt">
<div class="tt-equip-btns">
{% if carte.pk == equipped_trinket_id %}<button class="btn btn-equip btn-disabled" data-token-id="{{ carte.pk }}">×</button><button class="btn btn-unequip" data-token-id="{{ carte.pk }}">DOFF</button>{% else %}<button class="btn btn-equip" data-token-id="{{ carte.pk }}">DON</button><button class="btn btn-unequip btn-disabled" data-token-id="{{ carte.pk }}">×</button>{% endif %}
</div>
<h4 class="tt-title">{{ carte.tooltip_name }}</h4> <h4 class="tt-title">{{ carte.tooltip_name }}</h4>
<p class="tt-description">{{ carte.tooltip_description }}</p> <p class="tt-description">{{ carte.tooltip_description }}</p>
{% if carte.tooltip_shoptalk %} {% if carte.tooltip_shoptalk %}
@@ -34,6 +40,9 @@
<div id="id_kit_coin_on_a_string" class="token" data-token-id="{{ coin.pk }}"> <div id="id_kit_coin_on_a_string" class="token" data-token-id="{{ coin.pk }}">
<i class="fa-solid fa-medal"></i> <i class="fa-solid fa-medal"></i>
<div class="tt"> <div class="tt">
<div class="tt-equip-btns">
{% if coin.pk == equipped_trinket_id %}<button class="btn btn-equip btn-disabled" data-token-id="{{ coin.pk }}">×</button><button class="btn btn-unequip" data-token-id="{{ coin.pk }}">DOFF</button>{% else %}<button class="btn btn-equip" data-token-id="{{ coin.pk }}">DON</button><button class="btn btn-unequip btn-disabled" data-token-id="{{ coin.pk }}">×</button>{% endif %}
</div>
<h4 class="tt-title">{{ coin.tooltip_name }}</h4> <h4 class="tt-title">{{ coin.tooltip_name }}</h4>
<p class="tt-description">{{ coin.tooltip_description }}</p> <p class="tt-description">{{ coin.tooltip_description }}</p>
{% if coin.tooltip_shoptalk %} {% if coin.tooltip_shoptalk %}
@@ -62,6 +71,9 @@
<div id="id_kit_{{ deck.short_key }}_deck" class="token deck-variant" data-deck-id="{{ deck.pk }}"> <div id="id_kit_{{ deck.short_key }}_deck" class="token deck-variant" data-deck-id="{{ deck.pk }}">
<i class="fa-regular fa-id-badge"></i> <i class="fa-regular fa-id-badge"></i>
<div class="tt"> <div class="tt">
<div class="tt-equip-btns">
{% if deck.pk == equipped_deck_id %}<button class="btn btn-equip btn-disabled" data-deck-id="{{ deck.pk }}">×</button><button class="btn btn-unequip" data-deck-id="{{ deck.pk }}">DOFF</button>{% else %}<button class="btn btn-equip" data-deck-id="{{ deck.pk }}">DON</button><button class="btn btn-unequip btn-disabled" data-deck-id="{{ deck.pk }}">×</button>{% endif %}
</div>
<h4 class="tt-title">{{ deck.name }}</h4> <h4 class="tt-title">{{ deck.name }}</h4>
<p class="tt-description">{{ deck.card_count }} cards</p> <p class="tt-description">{{ deck.card_count }} cards</p>
</div> </div>

View File

@@ -1,5 +1,17 @@
<div class="tt"> <div class="tt">
{% if equippable %}
<div class="tt-equip-btns">
{% if equippable.is_equipped %}
<button class="btn btn-equip btn-disabled"{% if equippable.token_id %} data-token-id="{{ equippable.token_id }}"{% endif %}{% if equippable.deck_id %} data-deck-id="{{ equippable.deck_id }}"{% endif %}>×</button>
<button class="btn btn-unequip"{% if equippable.token_id %} data-token-id="{{ equippable.token_id }}"{% endif %}{% if equippable.deck_id %} data-deck-id="{{ equippable.deck_id }}"{% endif %}>DOFF</button>
{% else %}
<button class="btn btn-equip"{% if equippable.token_id %} data-token-id="{{ equippable.token_id }}"{% endif %}{% if equippable.deck_id %} data-deck-id="{{ equippable.deck_id }}"{% endif %}>DON</button>
<button class="btn btn-unequip btn-disabled"{% if equippable.token_id %} data-token-id="{{ equippable.token_id }}"{% endif %}{% if equippable.deck_id %} data-deck-id="{{ equippable.deck_id }}"{% endif %}>×</button>
{% endif %}
</div>
{% endif %}
<h4 class="tt-title">{{ title }}</h4> <h4 class="tt-title">{{ title }}</h4>
{% if type_label %}<p class="tt-type">{{ type_label }}</p>{% endif %} {% if type_label %}<p class="tt-type">{{ type_label }}</p>{% endif %}

View File

@@ -25,10 +25,10 @@
</div> </div>
</div> </div>
{% if equipped_trinket %}
<div class="kit-bag-section"> <div class="kit-bag-section">
<span class="kit-bag-label">Trinket</span> <span class="kit-bag-label">Trinket</span>
<div class="kit-bag-row"> <div class="kit-bag-row">
{% if equipped_trinket %}
{% with token=equipped_trinket %} {% with token=equipped_trinket %}
<div <div
class="token" class="token"
@@ -56,11 +56,14 @@
</div> </div>
</div> </div>
{% endwith %} {% endwith %}
</div> {% else %}
<div class="kit-bag-placeholder">
<i class="fa-solid fa-medal"></i>
</div> </div>
{% endif %} {% endif %}
</div>
</div>
{% if free_token or tithe_token %}
<div class="kit-bag-section kit-bag-section--tokens"> <div class="kit-bag-section kit-bag-section--tokens">
<span class="kit-bag-label">Tokens</span> <span class="kit-bag-label">Tokens</span>
<div class="kit-bag-row kit-bag-row--scroll"> <div class="kit-bag-row kit-bag-row--scroll">
@@ -97,6 +100,10 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
</div> {% if not free_token and not tithe_token %}
<div class="kit-bag-placeholder">
<i class="fa-solid fa-coins"></i>
</div> </div>
{% endif %} {% endif %}
</div>
</div>