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),
})