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 () { (function () {
'use strict'; 'use strict';
var _state = 'closed'; // 'closed' | 'open' | 'previewing'
var _selectedPalette = null; var _selectedPalette = null;
var _activeItem = null; var _activeItem = null;
var _originalPalette = null;
var _dismissTimer = null;
// ── helpers ────────────────────────────────────────────────────────────── // ── helpers ──────────────────────────────────────────────────────────────
@@ -15,6 +16,22 @@
return Array.from(el.classList).find(function (c) { return c.startsWith('palette-'); }) || ''; 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() { function _getCsrf() {
var m = document.cookie.match(/csrftoken=([^;]+)/); var m = document.cookie.match(/csrftoken=([^;]+)/);
return m ? m[1] : ''; return m ? m[1] : '';
@@ -23,7 +40,6 @@
// ── modal lifecycle ─────────────────────────────────────────────────────── // ── modal lifecycle ───────────────────────────────────────────────────────
function _openModal() { function _openModal() {
// Clone from <template> if the modal is not in the DOM yet.
var existing = _activeModal(); var existing = _activeModal();
if (!existing) { if (!existing) {
var tpl = _activeItem.querySelector('.note-palette-modal-tpl'); var tpl = _activeItem.querySelector('.note-palette-modal-tpl');
@@ -32,15 +48,37 @@
_activeItem.appendChild(clone); _activeItem.appendChild(clone);
_wireModal(); _wireModal();
} }
_state = 'open'; _activeItem.classList.add('note-item--active');
var confirmEl = _activeModal().querySelector('.note-palette-confirm'); var confirmEl = _activeModal().querySelector('.note-palette-confirm');
if (confirmEl) confirmEl.hidden = true; if (confirmEl) confirmEl.hidden = true;
} }
function _closeModal() { function _closeModal() {
_state = 'closed'; clearTimeout(_dismissTimer);
var modal = _activeModal(); _dismissTimer = null;
var modal = _activeModal();
if (modal) modal.remove(); 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. // Wire event listeners onto the freshly-cloned modal DOM.
@@ -48,28 +86,30 @@
var modal = _activeModal(); var modal = _activeModal();
if (!modal) return; 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) { modal.querySelectorAll('.note-swatch-body').forEach(function (body) {
body.addEventListener('click', function (e) { body.addEventListener('click', function (e) {
e.stopPropagation(); e.stopPropagation();
_selectedPalette = _paletteClass(body.parentElement); // Clear any in-progress preview first
_state = 'previewing'; if (_selectedPalette) _revertPreview();
modal.remove();
}); _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'); var confirmEl = modal.querySelector('.note-palette-confirm');
if (confirmEl) confirmEl.hidden = false; 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) { modal.querySelectorAll('.note-palette-confirm .btn.btn-confirm').forEach(function (btn) {
btn.addEventListener('click', function (e) { btn.addEventListener('click', function (e) {
e.stopPropagation(); 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) { modal.querySelectorAll('.note-palette-confirm .btn.btn-cancel').forEach(function (btn) {
btn.addEventListener('click', function (e) { btn.addEventListener('click', function (e) {
e.stopPropagation(); 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(); }); modal.addEventListener('click', function (e) { e.stopPropagation(); });
} }
@@ -94,6 +134,8 @@
function _doSetPalette() { function _doSetPalette() {
var url = _activeItem.dataset.setPaletteUrl; var url = _activeItem.dataset.setPaletteUrl;
var palette = _selectedPalette; var palette = _selectedPalette;
var item = _activeItem;
// Body already shows new palette from preview — keep it by not reverting.
fetch(url, { fetch(url, {
method: 'POST', method: 'POST',
credentials: 'same-origin', credentials: 'same-origin',
@@ -106,7 +148,7 @@
.then(function (r) { return r.json(); }) .then(function (r) { return r.json(); })
.then(function () { .then(function () {
_closeModal(); _closeModal();
var imageBox = _activeItem.querySelector('.note-item__image-box'); var imageBox = item.querySelector('.note-item__image-box');
if (imageBox) { if (imageBox) {
var swatch = document.createElement('div'); var swatch = document.createElement('div');
swatch.className = 'note-item__palette ' + palette; 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 () { document.body.addEventListener('click', function () {
if (_state === 'previewing') { if (_selectedPalette) _revertPreview();
_openModal(); _closeModal();
}
}); });
} }

View File

@@ -259,6 +259,17 @@ class NoteSetPaletteViewTest(TestCase):
) )
self.assertEqual(response.status_code, 404) 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): class SaveScrollPositionTest(TestCase):
def setUp(self): def setUp(self):

View File

@@ -110,14 +110,20 @@ _NOTE_META = {
@login_required(login_url="/") @login_required(login_url="/")
def note_set_palette(request, slug): def note_set_palette(request, slug):
from django.http import Http404 from django.http import Http404
from apps.dashboard.views import _unlocked_palettes_for_user
try: try:
note = Note.objects.get(user=request.user, slug=slug) note = Note.objects.get(user=request.user, slug=slug)
except Note.DoesNotExist: except Note.DoesNotExist:
raise Http404 raise Http404
if request.method == "POST": if request.method == "POST":
body = json.loads(request.body) body = json.loads(request.body)
note.palette = body.get("palette", "") palette = body.get("palette", "")
note.palette = palette
note.save(update_fields=["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}) return JsonResponse({"ok": True})

View File

@@ -156,7 +156,7 @@ class StargazerNoteFromDashboardTest(FunctionalTest):
# FYI navigates to Note page # FYI navigates to Note page
fyi.click() fyi.click()
self.wait_for( 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 # Note page: one Stargazer item
@@ -172,62 +172,106 @@ class StargazerNoteFromDashboardTest(FunctionalTest):
# ── T2b ────────────────────────────────────────────────────────────────── # ── T2b ──────────────────────────────────────────────────────────────────
def test_note_page_palette_modal_flow(self): def _open_modal_and_click_bardo(self):
"""Note page palette modal: image-box opens modal, swatch preview, """Helper: navigate to /billboard/my-notes/, open modal, click bardo swatch body.
body-click restores modal, OK raises confirm submenu, confirm sets palette.""" Returns (modal, confirm_menu) after the confirm bar is visible."""
Note.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/my-notes/") self.browser.get(self.live_server_url + "/billboard/my-notes/")
image_box = self.wait_for( image_box = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".note-item__image-box") lambda: self.browser.find_element(By.CSS_SELECTOR, ".note-item__image-box")
) )
# Clicking ? opens palette modal
image_box.click() image_box.click()
modal = self.wait_for( modal = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".note-palette-modal") 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") bardo_body = modal.find_element(By.CSS_SELECTOR, ".palette-bardo .note-swatch-body")
self.browser.execute_script( self.browser.execute_script(
"arguments[0].dispatchEvent(new MouseEvent('click', {bubbles: true}))", "arguments[0].dispatchEvent(new MouseEvent('click', {bubbles: true}))",
bardo_body, 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( confirm_menu = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".note-palette-confirm") 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 def test_note_page_swatch_previews_palette_sitewide_and_ok_persists(self):
confirm_menu.find_element(By.CSS_SELECTOR, ".btn.btn-confirm").click() """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.wait_for(lambda: self.assertFalse(
self.browser.find_elements(By.CSS_SELECTOR, ".note-palette-modal") self.browser.find_elements(By.CSS_SELECTOR, ".note-palette-modal")
)) ))
item = self.browser.find_element(By.CSS_SELECTOR, ".note-item") item = self.browser.find_element(By.CSS_SELECTOR, ".note-item")
self.assertTrue( self.assertTrue(item.find_elements(By.CSS_SELECTOR, ".note-item__palette.palette-bardo"))
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 ────────────────────────────────────────────────────────────────── # ── T2c ──────────────────────────────────────────────────────────────────

View File

@@ -88,9 +88,9 @@ describe('Note.showBanner', () => {
// ── T7 ── NVM button ────────────────────────────────────────────────────── // ── 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); 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 ──────────────────────────────────────────────────────── // ── T8 ── FYI link ────────────────────────────────────────────────────────
@@ -106,7 +106,7 @@ describe('Note.showBanner', () => {
it('T9: clicking the NVM button removes the banner from the DOM', () => { it('T9: clicking the NVM button removes the banner from the DOM', () => {
Note.showBanner(SAMPLE_NOTE); 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(); expect(document.querySelector('.note-banner')).toBeNull();
}); });

View File

@@ -52,55 +52,80 @@
list-style: none; list-style: none;
padding: 0; padding: 0;
margin: 0; margin: 0;
display: flex; display: grid;
flex-wrap: wrap; grid-template-columns: repeat(2, 1fr);
gap: 1rem; gap: 1rem;
@media (min-width: 900px) { grid-template-columns: repeat(3, 1fr); }
@media (min-width: 1200px) { grid-template-columns: repeat(4, 1fr); }
} }
.note-item { .note-item {
position: relative; position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: row;
gap: 0.4rem; align-items: flex-start;
gap: 0.75rem;
padding: 0.75rem; padding: 0.75rem;
background: rgba(var(--priUser), 0.06); background-color: rgba(var(--tooltip-bg), 0.75);
border: 1px solid rgba(var(--priUser), 0.2); backdrop-filter: blur(6px);
border-radius: 4px; border: 0.1rem solid rgba(var(--secUser), 0.4);
width: 14rem; 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 { .note-item__title {
margin: 0; margin: 0;
font-weight: bold; font-weight: bold;
font-size: 1rem;
transition: color 0.15s;
} }
.note-item__description { .note-item__description {
margin: 0; margin: 0.25rem 0 0;
font-size: 0.85rem; font-size: 0.8rem;
opacity: 0.75; 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 { .note-item__image-box {
width: 5rem; width: 3rem;
height: 5rem; height: 3rem;
flex-shrink: 0;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: rgba(var(--priUser), 0.12); background: rgba(var(--priUser), 0.12);
border: 1px dashed rgba(var(--priUser), 0.4); border: 1px dashed rgba(var(--priUser), 0.7);
border-radius: 2px; border-radius: 0.25rem;
cursor: pointer; cursor: pointer;
font-size: 1.5rem; font-size: 1.2rem;
opacity: 0.6; opacity: 0.6;
&:hover { opacity: 1; } &:hover { opacity: 1; }
} }
// Unlocked palette swatch inside a note item // Unlocked palette swatch (right side, same footprint as image-box)
.note-item__palette { .note-item__palette {
width: 5rem; width: 3rem;
height: 5rem; height: 3rem;
flex-shrink: 0;
border-radius: 2px; border-radius: 2px;
border: 2px solid rgba(var(--priUser), 0.4); border: 2px solid rgba(var(--priUser), 0.4);
} }
@@ -112,12 +137,13 @@
top: 0; top: 0;
left: 0; left: 0;
z-index: 200; z-index: 200;
background: var(--bg, #1a1a1a); background-color: rgba(var(--tooltip-bg), 0.92);
border: 1px solid rgba(var(--priUser), 0.4); backdrop-filter: blur(8px);
border-radius: 4px; border: 0.1rem solid rgba(var(--secUser), 0.4);
border-radius: 0.5rem;
padding: 0.75rem; padding: 0.75rem;
gap: 0.5rem; gap: 0.5rem;
min-width: 12rem; min-width: 10rem;
&:not([hidden]) { display: flex; flex-direction: column; } &:not([hidden]) { display: flex; flex-direction: column; }
@@ -130,12 +156,34 @@
.note-swatch-body { .note-swatch-body {
width: 2.5rem; width: 2.5rem;
height: 2.5rem; height: 2.5rem;
border-radius: 2px; border-radius: 0.25rem;
cursor: pointer; 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; 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; background: #1a1a2e;
} }
// ── Confirm submenu ──────────────────────────────────────────────────────── // ── Confirm submenu — floats below modal, out of its flow ─────────────────
.note-palette-confirm { .note-palette-confirm {
border-top: 1px solid rgba(var(--priUser), 0.2); position: absolute;
padding-top: 0.5rem; 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; gap: 0.4rem;
&:not([hidden]) { display: flex; flex-direction: column; } &:not([hidden]) { display: flex; flex-direction: row; align-items: center; gap: 0.5rem; }
p { p {
flex: 1;
margin: 0; margin: 0;
font-size: 0.85rem; font-size: 0.85rem;
} }

View File

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