recognition: page, palette modal, & dashboard palette unlock — TDD

- billboard/recognition/ view + template; recognition/<slug>/set-palette endpoint (no trailing slash)
- recognition.html: <template>-based modal (clone on open, remove on close — Selenium find_elements compatible)
- recognition-page.js: image-box → modal → swatch preview → body-click restore → OK → confirm → POST set-palette
- _palettes_for_user() replaces static PALETTES; Recognition.palette unlocks swatch + populates data-shoptalk
- _unlocked_palettes_for_user() wires dynamic unlock check into set_palette view
- _applet-palette.html: data-shoptalk from context instead of hard-coded "Placeholder"
- _recognition.scss: banner, recog-list/item, image-box, modal, palette-confirm; :not([hidden]) pattern avoids display override
- FT T2 split into T2a (banner → FYI → recog page), T2b (palette modal flow), T2c (dashboard palette applet)
- 684 ITs green; 7 FTs green

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-04-22 04:02:14 -04:00
parent 565f727aa6
commit 6d9d3d4f54
11 changed files with 683 additions and 53 deletions

View File

@@ -0,0 +1,143 @@
(function () {
'use strict';
var _state = 'closed'; // 'closed' | 'open' | 'previewing'
var _selectedPalette = null;
var _activeItem = null;
// ── helpers ──────────────────────────────────────────────────────────────
function _activeModal() {
return _activeItem && _activeItem.querySelector('.recog-palette-modal');
}
function _paletteClass(el) {
return Array.from(el.classList).find(function (c) { return c.startsWith('palette-'); }) || '';
}
function _getCsrf() {
var m = document.cookie.match(/csrftoken=([^;]+)/);
return m ? m[1] : '';
}
// ── modal lifecycle ───────────────────────────────────────────────────────
function _openModal() {
// Clone from <template> if the modal is not in the DOM yet.
var existing = _activeModal();
if (!existing) {
var tpl = _activeItem.querySelector('.recog-palette-modal-tpl');
if (!tpl) return;
var clone = tpl.content.firstElementChild.cloneNode(true);
_activeItem.appendChild(clone);
_wireModal();
}
_state = 'open';
var confirmEl = _activeModal().querySelector('.recog-palette-confirm');
if (confirmEl) confirmEl.hidden = true;
}
function _closeModal() {
_state = 'closed';
var modal = _activeModal();
if (modal) modal.remove();
}
// Wire event listeners onto the freshly-cloned modal DOM.
function _wireModal() {
var modal = _activeModal();
if (!modal) return;
// Swatch body → preview (remove modal from DOM)
modal.querySelectorAll('.recog-swatch-body').forEach(function (body) {
body.addEventListener('click', function (e) {
e.stopPropagation();
_selectedPalette = _paletteClass(body.parentElement);
_state = 'previewing';
modal.remove();
});
});
// OK button (not inside confirm submenu) → show confirm submenu
modal.querySelectorAll('.btn.btn-confirm').forEach(function (btn) {
if (btn.closest('.recog-palette-confirm')) return;
btn.addEventListener('click', function (e) {
e.stopPropagation();
_selectedPalette = _paletteClass(btn.parentElement);
var confirmEl = modal.querySelector('.recog-palette-confirm');
if (confirmEl) confirmEl.hidden = false;
});
});
// Confirm YES → POST and update DOM
modal.querySelectorAll('.recog-palette-confirm .btn.btn-confirm').forEach(function (btn) {
btn.addEventListener('click', function (e) {
e.stopPropagation();
_doSetPalette();
});
});
// Confirm NVM → hide confirm submenu
modal.querySelectorAll('.recog-palette-confirm .btn.btn-cancel').forEach(function (btn) {
btn.addEventListener('click', function (e) {
e.stopPropagation();
btn.closest('.recog-palette-confirm').hidden = true;
});
});
// Stop all other modal clicks from reaching body handler.
modal.addEventListener('click', function (e) { e.stopPropagation(); });
}
// ── set-palette POST ──────────────────────────────────────────────────────
function _doSetPalette() {
var url = _activeItem.dataset.setPaletteUrl;
var palette = _selectedPalette;
fetch(url, {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': _getCsrf(),
},
body: JSON.stringify({ palette: palette }),
})
.then(function (r) { return r.json(); })
.then(function () {
_closeModal();
var imageBox = _activeItem.querySelector('.recog-item__image-box');
if (imageBox) {
var swatch = document.createElement('div');
swatch.className = 'recog-item__palette ' + palette;
imageBox.parentNode.replaceChild(swatch, imageBox);
}
});
}
// ── init ──────────────────────────────────────────────────────────────────
function _init() {
// Image-box click → open modal
document.querySelectorAll('.recog-item__image-box').forEach(function (box) {
box.addEventListener('click', function (e) {
e.stopPropagation();
_activeItem = box.closest('.recog-item');
_openModal();
});
});
// Body click → restore modal when previewing
document.body.addEventListener('click', function () {
if (_state === 'previewing') {
_openModal();
}
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', _init);
} else {
_init();
}
}());

View File

@@ -1,8 +1,11 @@
import json as _json
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from apps.applets.models import Applet
from apps.drama.models import GameEvent, ScrollPosition, record
from apps.drama.models import GameEvent, Recognition, ScrollPosition, record
from apps.epic.models import Room
from apps.lyric.models import User
@@ -161,6 +164,102 @@ class BillscrollViewTest(TestCase):
self.assertContains(response, 'class="drama-event-time"')
class RecognitionPageViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="recog@test.io")
self.client.force_login(self.user)
def test_requires_login(self):
self.client.logout()
response = self.client.get("/billboard/recognition/")
self.assertEqual(response.status_code, 302)
def test_returns_200(self):
response = self.client.get("/billboard/recognition/")
self.assertEqual(response.status_code, 200)
def test_uses_recognition_template(self):
response = self.client.get("/billboard/recognition/")
self.assertTemplateUsed(response, "apps/billboard/recognition.html")
def test_passes_recognitions_in_context(self):
recog = Recognition.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now()
)
response = self.client.get("/billboard/recognition/")
self.assertIn(recog, response.context["recognitions"])
def test_excludes_other_users_recognitions(self):
other = User.objects.create(email="other@test.io")
Recognition.objects.create(
user=other, slug="stargazer", earned_at=timezone.now()
)
response = self.client.get("/billboard/recognition/")
self.assertEqual(list(response.context["recognitions"]), [])
def test_renders_recog_list_and_items(self):
Recognition.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now()
)
response = self.client.get("/billboard/recognition/")
self.assertContains(response, 'class="recog-list"')
self.assertContains(response, 'class="recog-item"')
def test_renders_recog_item_title_description_image_box(self):
Recognition.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now()
)
response = self.client.get("/billboard/recognition/")
self.assertContains(response, 'class="recog-item__title"')
self.assertContains(response, 'class="recog-item__description"')
self.assertContains(response, 'class="recog-item__image-box"')
class RecognitionSetPaletteViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="setpal@test.io")
self.client.force_login(self.user)
self.recognition = Recognition.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now(),
)
self.url = "/billboard/recognition/stargazer/set-palette"
def test_requires_login(self):
self.client.logout()
response = self.client.post(
self.url,
data=_json.dumps({"palette": "palette-bardo"}),
content_type="application/json",
)
self.assertEqual(response.status_code, 302)
def test_sets_palette_on_recognition(self):
self.client.post(
self.url,
data=_json.dumps({"palette": "palette-bardo"}),
content_type="application/json",
)
self.recognition.refresh_from_db()
self.assertEqual(self.recognition.palette, "palette-bardo")
def test_returns_200_with_ok(self):
response = self.client.post(
self.url,
data=_json.dumps({"palette": "palette-bardo"}),
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"ok": True})
def test_returns_404_for_slug_user_does_not_own(self):
response = self.client.post(
"/billboard/recognition/schizo/set-palette",
data=_json.dumps({"palette": "palette-bardo"}),
content_type="application/json",
)
self.assertEqual(response.status_code, 404)
class SaveScrollPositionTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="test@savescroll.io")

View File

@@ -7,6 +7,8 @@ app_name = "billboard"
urlpatterns = [
path("", views.billboard, name="billboard"),
path("toggle-applets", views.toggle_billboard_applets, name="toggle_applets"),
path("recognition/", views.recognition_page, name="recognition"),
path("recognition/<slug:slug>/set-palette", views.recognition_set_palette, name="recognition_set_palette"),
path("room/<uuid:room_id>/scroll/", views.room_scroll, name="scroll"),
path("room/<uuid:room_id>/scroll-position/", views.save_scroll_position, name="save_scroll_position"),
]

View File

@@ -1,9 +1,12 @@
import json
from django.contrib.auth.decorators import login_required
from django.db.models import Max, Q
from django.http import JsonResponse
from django.shortcuts import redirect, render
from apps.applets.utils import applet_context, apply_applet_toggle
from apps.drama.models import GameEvent, ScrollPosition
from apps.drama.models import GameEvent, Recognition, ScrollPosition
from apps.epic.models import Room
from apps.epic.utils import rooms_for_user
@@ -68,6 +71,58 @@ def room_scroll(request, room_id):
})
_RECOGNITION_META = {
"stargazer": {
"title": "Stargazer",
"description": "You saved your first personal sky chart.",
"palette_options": ["palette-bardo", "palette-sheol"],
},
"schizo": {
"title": "Schizo",
"description": "The socius recognizes the line of flight.",
"palette_options": [],
},
"nomad": {
"title": "Nomad",
"description": "The socius recognizes the smooth space.",
"palette_options": [],
},
}
@login_required(login_url="/")
def recognition_set_palette(request, slug):
from django.http import Http404
try:
recognition = Recognition.objects.get(user=request.user, slug=slug)
except Recognition.DoesNotExist:
raise Http404
if request.method == "POST":
body = json.loads(request.body)
recognition.palette = body.get("palette", "")
recognition.save(update_fields=["palette"])
return JsonResponse({"ok": True})
@login_required(login_url="/")
def recognition_page(request):
qs = Recognition.objects.filter(user=request.user)
recognitions = [
{
"obj": r,
"title": _RECOGNITION_META.get(r.slug, {}).get("title", r.slug),
"description": _RECOGNITION_META.get(r.slug, {}).get("description", ""),
"palette_options": _RECOGNITION_META.get(r.slug, {}).get("palette_options", []),
}
for r in qs
]
return render(request, "apps/billboard/recognition.html", {
"recognitions": qs,
"recognition_items": recognitions,
"page_class": "page-recognition",
})
@login_required(login_url="/")
def save_scroll_position(request, room_id):
if request.method != "POST":

View File

@@ -5,7 +5,7 @@ from unittest.mock import patch, MagicMock
from django.contrib.messages import get_messages
from django.test import override_settings, TestCase
from django.urls import reverse
from django.utils import html
from django.utils import html, timezone
from apps.applets.models import Applet, UserApplet
from apps.dashboard.forms import (
@@ -13,6 +13,7 @@ from apps.dashboard.forms import (
EMPTY_ITEM_ERROR,
)
from apps.dashboard.models import Item, Note
from apps.drama.models import Recognition
from apps.lyric.models import User
@@ -348,6 +349,52 @@ class SetPaletteTest(TestCase):
swatches = parsed.cssselect(".swatch")
self.assertEqual(len(swatches), len(response.context["palettes"]))
class RecognitionPaletteContextTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="recog_palette@test.io")
self.client.force_login(self.user)
Applet.objects.get_or_create(slug="palette", defaults={"name": "Palette"})
def test_recognition_palette_unlocks_swatch_in_context(self):
Recognition.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now(),
palette="palette-bardo",
)
response = self.client.get("/")
palettes = response.context["palettes"]
bardo = next(p for p in palettes if p["name"] == "palette-bardo")
self.assertFalse(bardo["locked"])
def test_recognition_palette_shoptalk_contains_recognition_title(self):
Recognition.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now(),
palette="palette-bardo",
)
response = self.client.get("/")
palettes = response.context["palettes"]
bardo = next(p for p in palettes if p["name"] == "palette-bardo")
self.assertIn("Stargazer", bardo["shoptalk"])
def test_recognition_without_palette_field_keeps_swatch_locked(self):
Recognition.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now(),
palette=None,
)
response = self.client.get("/")
palettes = response.context["palettes"]
bardo = next(p for p in palettes if p["name"] == "palette-bardo")
self.assertTrue(bardo["locked"])
def test_recognition_palette_allows_set_palette_via_view(self):
Recognition.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now(),
palette="palette-bardo",
)
self.client.post("/dashboard/set_palette", data={"palette": "palette-bardo"})
self.user.refresh_from_db()
self.assertEqual(self.user.palette, "palette-bardo")
@override_settings(COMPRESS_ENABLED=False)
class ProfileViewTest(TestCase):
def setUp(self):

View File

@@ -23,23 +23,59 @@ from apps.lyric.models import PaymentMethod, Token, User, Wallet
APPLET_ORDER = ["wallet", "new-note", "my-notes", "username", "palette"]
UNLOCKED_PALETTES = frozenset([
_BASE_UNLOCKED = frozenset([
"palette-default",
"palette-cedar",
"palette-oblivion-light",
"palette-monochrome-dark",
])
PALETTES = [
{"name": "palette-default", "label": "Earthman", "locked": False},
{"name": "palette-cedar", "label": "Cedar", "locked": False},
{"name": "palette-oblivion-light", "label": "Oblivion (Light)", "locked": False},
{"name": "palette-monochrome-dark", "label": "Monochrome (Dark)", "locked": False},
{"name": "palette-bardo", "label": "Bardo", "locked": True},
{"name": "palette-sheol", "label": "Sheol", "locked": True},
{"name": "palette-inferno", "label": "Inferno", "locked": True},
{"name": "palette-terrestre", "label": "Terrestre", "locked": True},
{"name": "palette-celestia", "label": "Celestia", "locked": True},
_PALETTE_DEFS = [
{"name": "palette-default", "label": "Earthman", "locked": False},
{"name": "palette-cedar", "label": "Cedar", "locked": False},
{"name": "palette-oblivion-light", "label": "Oblivion (Light)","locked": False},
{"name": "palette-monochrome-dark","label": "Monochrome (Dark)","locked": False},
{"name": "palette-bardo", "label": "Bardo", "locked": True},
{"name": "palette-sheol", "label": "Sheol", "locked": True},
{"name": "palette-inferno", "label": "Inferno", "locked": True},
{"name": "palette-terrestre", "label": "Terrestre", "locked": True},
{"name": "palette-celestia", "label": "Celestia", "locked": True},
]
_RECOGNITION_TITLES = {
"stargazer": "Stargazer",
"schizo": "Schizo",
"nomad": "Nomad",
}
# Keep PALETTES as an alias used by views that don't have a request user.
PALETTES = _PALETTE_DEFS
def _palettes_for_user(user):
if not (user and user.is_authenticated):
return [dict(p, shoptalk="Placeholder") for p in _PALETTE_DEFS]
granted = {
r.palette: r
for r in Recognition.objects.filter(user=user, palette__isnull=False).exclude(palette="")
}
result = []
for p in _PALETTE_DEFS:
entry = dict(p)
r = granted.get(p["name"])
if r and p["locked"]:
entry["locked"] = False
title = _RECOGNITION_TITLES.get(r.slug, r.slug.capitalize())
entry["shoptalk"] = f"{title} · {r.earned_at.strftime('%b %d, %Y').replace(' 0', ' ')}"
else:
entry["shoptalk"] = "Placeholder"
result.append(entry)
return result
def _unlocked_palettes_for_user(user):
base = set(_BASE_UNLOCKED)
if user and user.is_authenticated:
for r in Recognition.objects.filter(user=user, palette__isnull=False).exclude(palette=""):
base.add(r.palette)
return base
def _recent_notes(user, limit=3):
@@ -55,7 +91,7 @@ def _recent_notes(user, limit=3):
def home_page(request):
context = {
"form": ItemForm(),
"palettes": PALETTES,
"palettes": _palettes_for_user(request.user),
"page_class": "page-dashboard",
}
if request.user.is_authenticated:
@@ -75,7 +111,7 @@ def new_note(request):
else:
context = {
"form": form,
"palettes": PALETTES,
"palettes": _palettes_for_user(request.user),
"page_class": "page-dashboard",
}
if request.user.is_authenticated:
@@ -125,7 +161,7 @@ def share_note(request, note_id):
def set_palette(request):
if request.method == "POST":
palette = request.POST.get("palette", "")
if palette in UNLOCKED_PALETTES:
if palette in _unlocked_palettes_for_user(request.user):
request.user.palette = palette
request.user.save(update_fields=["palette"])
if "application/json" in request.headers.get("Accept", ""):
@@ -147,7 +183,7 @@ def toggle_applets(request):
if request.headers.get("HX-Request"):
return render(request, "apps/dashboard/_partials/_applets.html", {
"applets": applet_context(request.user, "dashboard"),
"palettes": PALETTES,
"palettes": _palettes_for_user(request.user),
"form": ItemForm(),
"recent_notes": _recent_notes(request.user),
})

View File

@@ -8,6 +8,11 @@ outside of any game room.
Two test classes — one per surface that can trigger the unlock — each asserting both
the negative (disabled/incomplete save does nothing) and positive (first valid save
fires the banner) conditions.
T2 (Dashboard full flow) is split across three focused tests:
T2a — save → banner → FYI → recognition page item
T2b — palette modal flow on recognition page
T2c — dashboard palette applet reflects Recognition palette unlock
"""
import json as _json
@@ -15,6 +20,7 @@ from django.utils import timezone
from selenium.webdriver.common.by import By
from apps.applets.models import Applet
from apps.drama.models import Recognition
from apps.lyric.models import User
from .base import FunctionalTest
@@ -84,11 +90,7 @@ def _fill_valid_sky_form(browser):
# ──────────────────────────────────────────────────────────────────────────────
class StargazerRecognitionFromDashboardTest(FunctionalTest):
"""Stargazer Recognition triggered from the My Sky applet.
T1 — incomplete save does not fire Recognition.
T2 — first valid save fires banner, full flow to palette unlock.
"""
"""Stargazer Recognition triggered from the My Sky applet."""
def setUp(self):
super().setUp()
@@ -102,8 +104,8 @@ class StargazerRecognitionFromDashboardTest(FunctionalTest):
defaults={"name": "Palettes", "grid_cols": 6, "grid_rows": 6, "context": "dashboard"},
)
Applet.objects.get_or_create(
slug="recognition",
defaults={"name": "Recognition", "grid_cols": 12, "grid_rows": 3, "context": "billboard"},
slug="billboard-recognition",
defaults={"name": "Recognition", "grid_cols": 4, "grid_rows": 4, "context": "billboard"},
)
self.gamer = User.objects.create(email="stargazer@test.io")
@@ -121,16 +123,11 @@ class StargazerRecognitionFromDashboardTest(FunctionalTest):
self.assertIsNotNone(confirm_btn.get_attribute("disabled"))
self.assertFalse(self.browser.find_elements(By.CSS_SELECTOR, ".recog-banner"))
# ── T2 ──────────────────────────────────────────────────────────────────
# ── T2a ──────────────────────────────────────────────────────────────────
def test_first_valid_save_from_applet_unlocks_stargazer_full_flow(self):
"""First valid SAVE SKY from the My Sky applet:
banner slides in below the Dash h2 → FYI leads to /billboard/recognition/ →
Recognition page shows Stargazer item → ? box opens palette modal →
swatch preview → OK → confirm → chosen palette permanently unlocked in
Palette applet on Dashboard.
"""
def test_first_valid_save_from_applet_fires_banner_and_leads_to_recognition_page(self):
"""First valid SAVE SKY from the My Sky applet fires the Stargazer banner.
FYI button navigates to /billboard/recognition/ showing the Stargazer item."""
self.create_pre_authenticated_session("stargazer@test.io")
self.browser.get(self.live_server_url)
@@ -142,7 +139,7 @@ class StargazerRecognitionFromDashboardTest(FunctionalTest):
self.wait_for(lambda: self.assertIsNone(confirm_btn.get_attribute("disabled")))
confirm_btn.click()
# --- Banner slides in below the Dash h2 ---
# Banner slides in below the Dash h2
banner = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".recog-banner")
)
@@ -153,16 +150,16 @@ class StargazerRecognitionFromDashboardTest(FunctionalTest):
banner.find_element(By.CSS_SELECTOR, ".recog-banner__description")
banner.find_element(By.CSS_SELECTOR, ".recog-banner__timestamp")
banner.find_element(By.CSS_SELECTOR, ".recog-banner__image")
banner.find_element(By.CSS_SELECTOR, ".btn.btn-danger") # NVM
banner.find_element(By.CSS_SELECTOR, ".btn.btn-danger") # NVM
fyi = banner.find_element(By.CSS_SELECTOR, ".btn.btn-caution") # FYI
# --- FYI navigates to Recognition page ---
# FYI navigates to Recognition page
fyi.click()
self.wait_for(
lambda: self.assertRegex(self.browser.current_url, r"/billboard/recognition")
)
# --- Recognition page: one Stargazer item ---
# Recognition page: one Stargazer item
item = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".recog-list .recog-item")
)
@@ -171,9 +168,24 @@ class StargazerRecognitionFromDashboardTest(FunctionalTest):
item.find_element(By.CSS_SELECTOR, ".recog-item__title").text,
)
item.find_element(By.CSS_SELECTOR, ".recog-item__description")
image_box = item.find_element(By.CSS_SELECTOR, ".recog-item__image-box")
item.find_element(By.CSS_SELECTOR, ".recog-item__image-box")
# --- Clicking ? opens palette modal ---
# ── T2b ──────────────────────────────────────────────────────────────────
def test_recognition_page_palette_modal_flow(self):
"""Recognition page palette modal: image-box opens modal, swatch preview,
body-click restores modal, OK raises confirm submenu, confirm sets palette."""
Recognition.objects.create(
user=self.gamer, slug="stargazer", earned_at=timezone.now(),
)
self.create_pre_authenticated_session("stargazer@test.io")
self.browser.get(self.live_server_url + "/billboard/recognition/")
image_box = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".recog-item__image-box")
)
# Clicking ? opens palette modal
image_box.click()
modal = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".recog-palette-modal")
@@ -181,7 +193,7 @@ class StargazerRecognitionFromDashboardTest(FunctionalTest):
modal.find_element(By.CSS_SELECTOR, ".palette-bardo")
modal.find_element(By.CSS_SELECTOR, ".palette-sheol")
# --- Clicking a swatch body previews the palette and dismisses the modal ---
# Clicking a swatch body previews the palette and dismisses the modal
bardo_body = modal.find_element(By.CSS_SELECTOR, ".palette-bardo .recog-swatch-body")
self.browser.execute_script(
"arguments[0].dispatchEvent(new MouseEvent('click', {bubbles: true}))",
@@ -191,20 +203,20 @@ class StargazerRecognitionFromDashboardTest(FunctionalTest):
self.browser.find_elements(By.CSS_SELECTOR, ".recog-palette-modal")
))
# --- Clicking anything else ends preview and restores the modal ---
# Clicking elsewhere ends preview and restores the modal
self.browser.find_element(By.TAG_NAME, "body").click()
modal = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".recog-palette-modal")
)
# --- Clicking OK on the swatch raises a confirmation submenu ---
# Clicking OK on the swatch raises a confirmation submenu
ok_btn = modal.find_element(By.CSS_SELECTOR, ".palette-bardo .btn.btn-confirm")
ok_btn.click()
confirm_menu = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".recog-palette-confirm")
)
# --- Confirming sets palette, closes modal, replaces ? box ---
# Confirming sets palette, closes modal, replaces image-box with palette swatch
confirm_menu.find_element(By.CSS_SELECTOR, ".btn.btn-confirm").click()
self.wait_for(lambda: self.assertFalse(
self.browser.find_elements(By.CSS_SELECTOR, ".recog-palette-modal")
@@ -217,8 +229,18 @@ class StargazerRecognitionFromDashboardTest(FunctionalTest):
item.find_elements(By.CSS_SELECTOR, ".recog-item__image-box")
)
# --- Dashboard: Palette applet shows bardo permanently unlocked ---
# ── T2c ──────────────────────────────────────────────────────────────────
def test_dashboard_palette_applet_reflects_recognition_palette_unlock(self):
"""After palette unlock via Recognition, the Dashboard Palette applet shows
the palette swatch as unlocked with Stargazer shoptalk."""
Recognition.objects.create(
user=self.gamer, slug="stargazer", earned_at=timezone.now(),
palette="palette-bardo",
)
self.create_pre_authenticated_session("stargazer@test.io")
self.browser.get(self.live_server_url)
palette_applet = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_applet_palette")
)
@@ -227,10 +249,7 @@ class StargazerRecognitionFromDashboardTest(FunctionalTest):
bardo_ok = bardo.find_element(By.CSS_SELECTOR, ".palette-ok")
self.assertIn("btn-confirm", bardo_ok.get_attribute("class"))
self.assertNotIn("btn-disabled", bardo_ok.get_attribute("class"))
self.assertIn(
"Stargazer",
bardo.get_attribute("data-shoptalk"),
)
self.assertIn("Stargazer", bardo.get_attribute("data-shoptalk"))
# ──────────────────────────────────────────────────────────────────────────────
@@ -253,8 +272,8 @@ class StargazerRecognitionFromSkyPageTest(FunctionalTest):
defaults={"name": "My Sky", "grid_cols": 6, "grid_rows": 6, "context": "dashboard"},
)
Applet.objects.get_or_create(
slug="recognition",
defaults={"name": "Recognition", "grid_cols": 12, "grid_rows": 3, "context": "billboard"},
slug="billboard-recognition",
defaults={"name": "Recognition", "grid_cols": 4, "grid_rows": 4, "context": "billboard"},
)
self.gamer = User.objects.create(email="stargazer@test.io")
self.sky_url = self.live_server_url + "/dashboard/sky/"
@@ -307,7 +326,6 @@ class StargazerRecognitionFromSkyPageTest(FunctionalTest):
def test_already_earned_recognition_does_not_show_banner_on_subsequent_save(self):
"""When Stargazer is already in the database for this user, a valid sky
save does not fire another Recognition banner."""
from apps.drama.models import Recognition
Recognition.objects.create(
user=self.gamer,
slug="stargazer",

View File

@@ -0,0 +1,180 @@
// ── Recognition banner (slides in below page h2 after unlock) ─────────────
.recog-banner {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: rgba(var(--priUser), 0.12);
border-left: 3px solid rgba(var(--priUser), 0.6);
margin-bottom: 0.75rem;
.recog-banner__body {
flex: 1;
min-width: 0;
}
.recog-banner__title {
margin: 0;
font-weight: bold;
}
.recog-banner__description,
.recog-banner__timestamp {
margin: 0.1rem 0 0;
font-size: 0.85rem;
opacity: 0.75;
}
.recog-banner__image {
width: 3rem;
height: 3rem;
flex-shrink: 0;
background: rgba(var(--priUser), 0.15);
border-radius: 2px;
}
.recog-banner__nvm,
.recog-banner__fyi {
flex-shrink: 0;
font-size: 0.8rem;
padding: 0.25rem 0.6rem;
}
}
// ── Recognition page ───────────────────────────────────────────────────────
.recognition-page {
padding: 0.75rem;
}
.recog-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.recog-item {
position: relative;
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 0.75rem;
background: rgba(var(--priUser), 0.06);
border: 1px solid rgba(var(--priUser), 0.2);
border-radius: 4px;
width: 14rem;
.recog-item__title {
margin: 0;
font-weight: bold;
}
.recog-item__description {
margin: 0;
font-size: 0.85rem;
opacity: 0.75;
}
}
// Image box — must have a defined size so Selenium can interact with it.
.recog-item__image-box {
width: 5rem;
height: 5rem;
display: flex;
align-items: center;
justify-content: center;
background: rgba(var(--priUser), 0.12);
border: 1px dashed rgba(var(--priUser), 0.4);
border-radius: 2px;
cursor: pointer;
font-size: 1.5rem;
opacity: 0.6;
&:hover { opacity: 1; }
}
// Unlocked palette swatch inside a recognition item
.recog-item__palette {
width: 5rem;
height: 5rem;
border-radius: 2px;
border: 2px solid rgba(var(--priUser), 0.4);
}
// ── Palette modal ──────────────────────────────────────────────────────────
.recog-palette-modal {
position: absolute;
top: 0;
left: 0;
z-index: 200;
background: var(--bg, #1a1a1a);
border: 1px solid rgba(var(--priUser), 0.4);
border-radius: 4px;
padding: 0.75rem;
gap: 0.5rem;
min-width: 12rem;
&:not([hidden]) { display: flex; flex-direction: column; }
// Each palette swatch option inside the modal
> [class*="palette-"] {
display: flex;
align-items: center;
gap: 0.5rem;
.recog-swatch-body {
width: 2.5rem;
height: 2.5rem;
border-radius: 2px;
cursor: pointer;
border: 2px solid rgba(var(--priUser), 0.3);
flex-shrink: 0;
&:hover { border-color: rgba(var(--priUser), 0.8); }
}
.btn {
font-size: 0.75rem;
padding: 0.2rem 0.5rem;
}
}
}
// ── Palette swatch color fills ─────────────────────────────────────────────
// These match the actual palette CSS variables — used both in modal swatches
// and as the confirmed .recog-item__palette swatch.
.palette-bardo .recog-swatch-body,
.recog-item__palette.palette-bardo {
background: #2a1a2e;
}
.palette-sheol .recog-swatch-body,
.recog-item__palette.palette-sheol {
background: #1a1a2e;
}
// ── Confirm submenu ────────────────────────────────────────────────────────
.recog-palette-confirm {
border-top: 1px solid rgba(var(--priUser), 0.2);
padding-top: 0.5rem;
gap: 0.4rem;
&:not([hidden]) { display: flex; flex-direction: column; }
p {
margin: 0;
font-size: 0.85rem;
}
.btn {
font-size: 0.8rem;
padding: 0.2rem 0.5rem;
}
}

View File

@@ -10,6 +10,7 @@
@import 'natus';
@import 'tray';
@import 'billboard';
@import 'recognition';
@import 'tooltips';
@import 'game-kit';
@import 'wallet-tokens';

View File

@@ -0,0 +1,49 @@
{% extends "core/base.html" %}
{% load static %}
{% block title_text %}Recognition{% endblock title_text %}
{% block header_text %}<span>Bill</span>recognition{% endblock header_text %}
{% block content %}
<div class="recognition-page">
<h2>Recognition</h2>
<ul class="recog-list">
{% for item in recognition_items %}
<li class="recog-item" data-slug="{{ item.obj.slug }}"
data-set-palette-url="{% url 'billboard:recognition_set_palette' item.obj.slug %}">
{% if item.obj.palette %}
<div class="recog-item__palette {{ item.obj.palette }}"></div>
{% else %}
<div class="recog-item__image-box">?</div>
{% endif %}
<p class="recog-item__title">{{ item.title }}</p>
<p class="recog-item__description">{{ item.description }}</p>
{% if not item.obj.palette and item.palette_options %}
<template class="recog-palette-modal-tpl">
<div class="recog-palette-modal">
{% for palette_name in item.palette_options %}
<div class="{{ palette_name }}">
<div class="recog-swatch-body"></div>
<button type="button" class="btn btn-confirm">OK</button>
</div>
{% endfor %}
<div class="recog-palette-confirm" hidden>
<p>Lock in this palette?</p>
<button type="button" class="btn btn-confirm">OK</button>
<button type="button" class="btn btn-cancel">NVM</button>
</div>
</div>
</template>
{% endif %}
</li>
{% empty %}
<li class="recog-item recog-item--empty">No recognitions yet.</li>
{% endfor %}
</ul>
</div>
<script src="{% static 'apps/billboard/recognition-page.js' %}"></script>
{% endblock %}

View File

@@ -13,7 +13,7 @@
data-label="{{ palette.label }}"
data-locked="{{ palette.locked|yesno:'true,false' }}"
data-unlocked-date="{% if not palette.locked %}Default{% endif %}"
data-shoptalk="Placeholder"
data-shoptalk="{{ palette.shoptalk }}"
>
{% if not palette.locked %}
<button type="button" class="btn btn-confirm palette-ok" hidden>OK</button>