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":