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