note palette: swatch previews body palette, NVM reverts, OK saves sitewide; note_set_palette also saves user.palette — TDD

- note-page.js: body class swap on swatch click; 10s auto-revert timer; NVM reverts;
  .note-item--active persists border/glow while modal open; .previewing on swatch
- billboard/views.py: note_set_palette also saves user.palette via _unlocked_palettes_for_user
- _note.scss: .note-swatch-body gradient (palette vars cascade from parent palette-* class);
  .previewing state; .note-item--active; note-palette-modal tooltip glass;
  note-palette-confirm floats below modal (position:absolute, out of flow)
- my_notes.html: note-item__body wrapper; image-box right; swatch row OK buttons removed
- FTs: T2a URL fix (/recognition → /my-notes); T2b split into preview+persist & NVM tests;
  NoteSetPaletteViewTest.test_also_saves_user_palette IT

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-04-22 23:54:05 -04:00
parent e8687dc050
commit cd5252c185
7 changed files with 258 additions and 98 deletions

View File

@@ -1,9 +1,10 @@
(function () {
'use strict';
var _state = 'closed'; // 'closed' | 'open' | 'previewing'
var _selectedPalette = null;
var _activeItem = null;
var _originalPalette = null;
var _dismissTimer = null;
// ── helpers ──────────────────────────────────────────────────────────────
@@ -15,6 +16,22 @@
return Array.from(el.classList).find(function (c) { return c.startsWith('palette-'); }) || '';
}
function _currentBodyPalette() {
return Array.from(document.body.classList).find(function (c) { return c.startsWith('palette-'); }) || null;
}
function _swapBodyPalette(paletteName) {
var old = _currentBodyPalette();
if (old) document.body.classList.remove(old);
document.body.classList.add(paletteName);
}
function _revertBodyPalette() {
var current = _currentBodyPalette();
if (current) document.body.classList.remove(current);
if (_originalPalette) document.body.classList.add(_originalPalette);
}
function _getCsrf() {
var m = document.cookie.match(/csrftoken=([^;]+)/);
return m ? m[1] : '';
@@ -23,7 +40,6 @@
// ── 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('.note-palette-modal-tpl');
@@ -32,15 +48,37 @@
_activeItem.appendChild(clone);
_wireModal();
}
_state = 'open';
_activeItem.classList.add('note-item--active');
var confirmEl = _activeModal().querySelector('.note-palette-confirm');
if (confirmEl) confirmEl.hidden = true;
}
function _closeModal() {
_state = 'closed';
var modal = _activeModal();
clearTimeout(_dismissTimer);
_dismissTimer = null;
var modal = _activeModal();
if (modal) modal.remove();
if (_activeItem) _activeItem.classList.remove('note-item--active');
_activeItem = null;
_selectedPalette = null;
_originalPalette = null;
}
function _revertPreview() {
clearTimeout(_dismissTimer);
_dismissTimer = null;
_revertBodyPalette();
// Remove .previewing from any swatch in the active modal
var modal = _activeModal();
if (modal) {
modal.querySelectorAll('.note-swatch-body.previewing').forEach(function (s) {
s.classList.remove('previewing');
});
var confirmEl = modal.querySelector('.note-palette-confirm');
if (confirmEl) confirmEl.hidden = true;
}
_selectedPalette = null;
_originalPalette = null;
}
// Wire event listeners onto the freshly-cloned modal DOM.
@@ -48,28 +86,30 @@
var modal = _activeModal();
if (!modal) return;
// Swatch body → preview (remove modal from DOM)
// Swatch body click → preview palette sitewide + show confirm
modal.querySelectorAll('.note-swatch-body').forEach(function (body) {
body.addEventListener('click', function (e) {
e.stopPropagation();
_selectedPalette = _paletteClass(body.parentElement);
_state = 'previewing';
modal.remove();
});
});
// Clear any in-progress preview first
if (_selectedPalette) _revertPreview();
_selectedPalette = _paletteClass(body.parentElement);
_originalPalette = _currentBodyPalette();
body.classList.add('previewing');
_swapBodyPalette(_selectedPalette);
// OK button (not inside confirm submenu) → show confirm submenu
modal.querySelectorAll('.btn.btn-confirm').forEach(function (btn) {
if (btn.closest('.note-palette-confirm')) return;
btn.addEventListener('click', function (e) {
e.stopPropagation();
_selectedPalette = _paletteClass(btn.parentElement);
var confirmEl = modal.querySelector('.note-palette-confirm');
if (confirmEl) confirmEl.hidden = false;
// Auto-revert after 10s
_dismissTimer = setTimeout(function () {
_revertPreview();
}, 10000);
});
});
// Confirm YES → POST and update DOM
// Confirm OK → commit palette sitewide
modal.querySelectorAll('.note-palette-confirm .btn.btn-confirm').forEach(function (btn) {
btn.addEventListener('click', function (e) {
e.stopPropagation();
@@ -77,15 +117,15 @@
});
});
// Confirm NVM → hide confirm submenu
// Confirm NVM → revert preview, hide confirm
modal.querySelectorAll('.note-palette-confirm .btn.btn-cancel').forEach(function (btn) {
btn.addEventListener('click', function (e) {
e.stopPropagation();
btn.closest('.note-palette-confirm').hidden = true;
_revertPreview();
});
});
// Stop all other modal clicks from reaching body handler.
// Stop modal clicks from reaching the body dismiss handler.
modal.addEventListener('click', function (e) { e.stopPropagation(); });
}
@@ -94,6 +134,8 @@
function _doSetPalette() {
var url = _activeItem.dataset.setPaletteUrl;
var palette = _selectedPalette;
var item = _activeItem;
// Body already shows new palette from preview — keep it by not reverting.
fetch(url, {
method: 'POST',
credentials: 'same-origin',
@@ -106,7 +148,7 @@
.then(function (r) { return r.json(); })
.then(function () {
_closeModal();
var imageBox = _activeItem.querySelector('.note-item__image-box');
var imageBox = item.querySelector('.note-item__image-box');
if (imageBox) {
var swatch = document.createElement('div');
swatch.className = 'note-item__palette ' + palette;
@@ -127,11 +169,10 @@
});
});
// Body click → restore modal when previewing
// Body click → dismiss modal (and revert any preview)
document.body.addEventListener('click', function () {
if (_state === 'previewing') {
_openModal();
}
if (_selectedPalette) _revertPreview();
_closeModal();
});
}

View File

@@ -259,6 +259,17 @@ class NoteSetPaletteViewTest(TestCase):
)
self.assertEqual(response.status_code, 404)
def test_also_saves_user_palette(self):
"""note_set_palette must persist the choice to user.palette so the
palette survives page navigation (sitewide commitment)."""
self.client.post(
self.url,
data=_json.dumps({"palette": "palette-bardo"}),
content_type="application/json",
)
self.user.refresh_from_db()
self.assertEqual(self.user.palette, "palette-bardo")
class SaveScrollPositionTest(TestCase):
def setUp(self):

View File

@@ -110,14 +110,20 @@ _NOTE_META = {
@login_required(login_url="/")
def note_set_palette(request, slug):
from django.http import Http404
from apps.dashboard.views import _unlocked_palettes_for_user
try:
note = Note.objects.get(user=request.user, slug=slug)
except Note.DoesNotExist:
raise Http404
if request.method == "POST":
body = json.loads(request.body)
note.palette = body.get("palette", "")
palette = body.get("palette", "")
note.palette = palette
note.save(update_fields=["palette"])
# Commit as the user's active sitewide palette now that the Note unlocks it.
if palette in _unlocked_palettes_for_user(request.user):
request.user.palette = palette
request.user.save(update_fields=["palette"])
return JsonResponse({"ok": True})

View File

@@ -156,7 +156,7 @@ class StargazerNoteFromDashboardTest(FunctionalTest):
# FYI navigates to Note page
fyi.click()
self.wait_for(
lambda: self.assertRegex(self.browser.current_url, r"/billboard/recognition")
lambda: self.assertRegex(self.browser.current_url, r"/billboard/my-notes")
)
# Note page: one Stargazer item
@@ -172,62 +172,106 @@ class StargazerNoteFromDashboardTest(FunctionalTest):
# ── T2b ──────────────────────────────────────────────────────────────────
def test_note_page_palette_modal_flow(self):
"""Note page palette modal: image-box opens modal, swatch preview,
body-click restores modal, OK raises confirm submenu, confirm sets palette."""
Note.objects.create(
user=self.gamer, slug="stargazer", earned_at=timezone.now(),
)
self.create_pre_authenticated_session("stargazer@test.io")
def _open_modal_and_click_bardo(self):
"""Helper: navigate to /billboard/my-notes/, open modal, click bardo swatch body.
Returns (modal, confirm_menu) after the confirm bar is visible."""
self.browser.get(self.live_server_url + "/billboard/my-notes/")
image_box = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".note-item__image-box")
)
# Clicking ? opens palette modal
image_box.click()
modal = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".note-palette-modal")
)
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
bardo_body = modal.find_element(By.CSS_SELECTOR, ".palette-bardo .note-swatch-body")
self.browser.execute_script(
"arguments[0].dispatchEvent(new MouseEvent('click', {bubbles: true}))",
bardo_body,
)
self.wait_for(lambda: self.assertFalse(
self.browser.find_elements(By.CSS_SELECTOR, ".note-palette-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, ".note-palette-modal")
)
# 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, ".note-palette-confirm")
)
return modal, confirm_menu
# Confirming sets palette, closes modal, replaces image-box with palette swatch
confirm_menu.find_element(By.CSS_SELECTOR, ".btn.btn-confirm").click()
def test_note_page_swatch_previews_palette_sitewide_and_ok_persists(self):
"""Clicking a swatch previews that palette on the whole body.
OK commits it — Note.palette and user.palette both saved — so it
survives navigation to a new page."""
Note.objects.create(
user=self.gamer, slug="stargazer", earned_at=timezone.now(),
)
self.create_pre_authenticated_session("stargazer@test.io")
modal, confirm = self._open_modal_and_click_bardo()
# Swatch click previews bardo on the whole body
self.wait_for(
lambda: self.assertIn(
"palette-bardo",
self.browser.find_element(By.TAG_NAME, "body").get_attribute("class"),
)
)
# Modal still open
self.assertTrue(self.browser.find_elements(By.CSS_SELECTOR, ".note-palette-modal"))
# OK → modal closes, ? box replaced by bardo swatch
confirm.find_element(By.CSS_SELECTOR, ".btn.btn-confirm").click()
self.wait_for(lambda: self.assertFalse(
self.browser.find_elements(By.CSS_SELECTOR, ".note-palette-modal")
))
item = self.browser.find_element(By.CSS_SELECTOR, ".note-item")
self.assertTrue(
item.find_elements(By.CSS_SELECTOR, ".note-item__palette.palette-bardo")
self.assertTrue(item.find_elements(By.CSS_SELECTOR, ".note-item__palette.palette-bardo"))
self.assertFalse(item.find_elements(By.CSS_SELECTOR, ".note-item__image-box"))
# Navigate away — palette persists (user.palette was saved)
self.browser.get(self.live_server_url)
self.wait_for(
lambda: self.assertIn(
"palette-bardo",
self.browser.find_element(By.TAG_NAME, "body").get_attribute("class"),
)
)
self.assertFalse(
item.find_elements(By.CSS_SELECTOR, ".note-item__image-box")
def test_note_swatch_nvm_reverts_body_palette(self):
"""NVM in the confirm bar reverts the sitewide body palette back to
what it was before the swatch was clicked."""
Note.objects.create(
user=self.gamer, slug="stargazer", earned_at=timezone.now(),
)
self.create_pre_authenticated_session("stargazer@test.io")
# Record the original palette before opening the modal
self.browser.get(self.live_server_url + "/billboard/my-notes/")
original_classes = self.browser.find_element(
By.TAG_NAME, "body"
).get_attribute("class")
original_palette = next(
(c for c in original_classes.split() if c.startswith("palette-")), None
)
modal, confirm = self._open_modal_and_click_bardo()
# Bardo is previewed
self.wait_for(
lambda: self.assertIn(
"palette-bardo",
self.browser.find_element(By.TAG_NAME, "body").get_attribute("class"),
)
)
# NVM reverts
confirm.find_element(By.CSS_SELECTOR, ".btn.btn-cancel").click()
self.wait_for(
lambda: self.assertNotIn(
"palette-bardo",
self.browser.find_element(By.TAG_NAME, "body").get_attribute("class"),
)
)
if original_palette:
self.assertIn(
original_palette,
self.browser.find_element(By.TAG_NAME, "body").get_attribute("class"),
)
# ── T2c ──────────────────────────────────────────────────────────────────

View File

@@ -88,9 +88,9 @@ describe('Note.showBanner', () => {
// ── T7 ── NVM button ──────────────────────────────────────────────────────
it('T7: banner has a .btn.btn-danger NVM button', () => {
it('T7: banner has a .btn.btn-cancel NVM button', () => {
Note.showBanner(SAMPLE_NOTE);
expect(document.querySelector('.note-banner .btn.btn-danger')).not.toBeNull();
expect(document.querySelector('.note-banner .btn.btn-cancel')).not.toBeNull();
});
// ── T8 ── FYI link ────────────────────────────────────────────────────────
@@ -106,7 +106,7 @@ describe('Note.showBanner', () => {
it('T9: clicking the NVM button removes the banner from the DOM', () => {
Note.showBanner(SAMPLE_NOTE);
document.querySelector('.note-banner .btn.btn-danger').click();
document.querySelector('.note-banner .btn.btn-cancel').click();
expect(document.querySelector('.note-banner')).toBeNull();
});

View File

@@ -52,55 +52,80 @@
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-wrap: wrap;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
@media (min-width: 900px) { grid-template-columns: repeat(3, 1fr); }
@media (min-width: 1200px) { grid-template-columns: repeat(4, 1fr); }
}
.note-item {
position: relative;
display: flex;
flex-direction: column;
gap: 0.4rem;
flex-direction: row;
align-items: flex-start;
gap: 0.75rem;
padding: 0.75rem;
background: rgba(var(--priUser), 0.06);
border: 1px solid rgba(var(--priUser), 0.2);
border-radius: 4px;
width: 14rem;
background-color: rgba(var(--tooltip-bg), 0.75);
backdrop-filter: blur(6px);
border: 0.1rem solid rgba(var(--secUser), 0.4);
border-radius: 0.5rem;
cursor: help;
transition: border-color 0.15s, box-shadow 0.15s;
&:hover,
&:active,
&.note-item--active {
border-color: rgba(var(--terUser), 1);
opacity: 1;
box-shadow: 0 0 10px rgba(var(--ninUser), 0.35);
.note-item__title { color: rgba(var(--terUser), 1); }
}
.note-item__body {
flex: 1;
min-width: 0;
}
.note-item__title {
margin: 0;
font-weight: bold;
font-size: 1rem;
transition: color 0.15s;
}
.note-item__description {
margin: 0;
font-size: 0.85rem;
margin: 0.25rem 0 0;
font-size: 0.8rem;
opacity: 0.75;
}
}
// Image box — must have a defined size so Selenium can interact with it.
// Image box — right-side; must have a defined size so Selenium can interact with it.
.note-item__image-box {
width: 5rem;
height: 5rem;
width: 3rem;
height: 3rem;
flex-shrink: 0;
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;
border: 1px dashed rgba(var(--priUser), 0.7);
border-radius: 0.25rem;
cursor: pointer;
font-size: 1.5rem;
font-size: 1.2rem;
opacity: 0.6;
&:hover { opacity: 1; }
}
// Unlocked palette swatch inside a note item
// Unlocked palette swatch (right side, same footprint as image-box)
.note-item__palette {
width: 5rem;
height: 5rem;
width: 3rem;
height: 3rem;
flex-shrink: 0;
border-radius: 2px;
border: 2px solid rgba(var(--priUser), 0.4);
}
@@ -112,12 +137,13 @@
top: 0;
left: 0;
z-index: 200;
background: var(--bg, #1a1a1a);
border: 1px solid rgba(var(--priUser), 0.4);
border-radius: 4px;
background-color: rgba(var(--tooltip-bg), 0.92);
backdrop-filter: blur(8px);
border: 0.1rem solid rgba(var(--secUser), 0.4);
border-radius: 0.5rem;
padding: 0.75rem;
gap: 0.5rem;
min-width: 12rem;
min-width: 10rem;
&:not([hidden]) { display: flex; flex-direction: column; }
@@ -130,12 +156,34 @@
.note-swatch-body {
width: 2.5rem;
height: 2.5rem;
border-radius: 2px;
border-radius: 0.25rem;
cursor: pointer;
border: 2px solid rgba(var(--priUser), 0.3);
// Gradient uses vars scoped to the parent palette-* class,
// so each swatch shows its own palette's colours (same as .swatch).
background: linear-gradient(
to bottom,
rgba(var(--secUser), 1) 0%,
rgba(var(--secUser), 1) 30%,
rgba(var(--priUser), 1) 30%,
rgba(var(--priUser), 1) 70%,
rgba(var(--terUser), 1) 70%,
rgba(var(--terUser), 1) 85%,
rgba(var(--quaUser), 1) 85%,
rgba(var(--quaUser), 1) 100%
);
border: 0.15rem solid rgba(var(--secUser), 0.5);
flex-shrink: 0;
transition: border-color 0.12s, box-shadow 0.12s;
&:hover { border-color: rgba(var(--priUser), 0.8); }
&:hover {
border-color: rgba(var(--terUser), 1);
box-shadow: 0 0 6px rgba(var(--ninUser), 0.3);
}
&.previewing {
border: 0.2rem solid rgba(var(--ninUser), 1);
box-shadow: 0 0 0.75rem rgba(var(--ninUser), 0.6);
}
}
}
}
@@ -152,16 +200,25 @@
background: #1a1a2e;
}
// ── Confirm submenu ────────────────────────────────────────────────────────
// ── Confirm submenu — floats below modal, out of its flow ─────────────────
.note-palette-confirm {
border-top: 1px solid rgba(var(--priUser), 0.2);
padding-top: 0.5rem;
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
z-index: 201;
background-color: rgba(var(--tooltip-bg), 0.92);
backdrop-filter: blur(8px);
border: 0.1rem solid rgba(var(--secUser), 0.4);
border-radius: 0.5rem;
padding: 0.5rem 0.75rem;
gap: 0.4rem;
&:not([hidden]) { display: flex; flex-direction: column; }
&:not([hidden]) { display: flex; flex-direction: row; align-items: center; gap: 0.5rem; }
p {
flex: 1;
margin: 0;
font-size: 0.85rem;
}

View File

@@ -12,22 +12,23 @@
<li class="note-item" data-slug="{{ item.obj.slug }}"
data-set-palette-url="{% url 'billboard:note_set_palette' item.obj.slug %}">
<div class="note-item__body">
<p class="note-item__title">{{ item.title }}</p>
<p class="note-item__description">{{ item.description }}</p>
</div>
{% if item.obj.palette %}
<div class="note-item__palette {{ item.obj.palette }}"></div>
{% else %}
<div class="note-item__image-box">?</div>
{% endif %}
<p class="note-item__title">{{ item.title }}</p>
<p class="note-item__description">{{ item.description }}</p>
{% if not item.obj.palette and item.palette_options %}
<template class="note-palette-modal-tpl">
<div class="note-palette-modal">
{% for palette_name in item.palette_options %}
<div class="{{ palette_name }}">
<div class="note-swatch-body"></div>
<button type="button" class="btn btn-confirm">OK</button>
</div>
{% endfor %}
<div class="note-palette-confirm" hidden>