From 122de3bc8093ce69cda670d55f6d85273a352ad4 Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Sat, 18 Apr 2026 02:05:27 -0400 Subject: [PATCH] =?UTF-8?q?PALETTE:=20swatch=20preview=20+=20tooltip=20+?= =?UTF-8?q?=20OK=20commit=20=E2=80=94=20TDD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking a swatch instantly swaps the body palette class for a live preview; OK commits silently (POST, no reload); click-elsewhere or 10 s auto-dismiss reverts. Tooltip portal shows label, shoptalk, lock state. Locked swatches show × (disabled). 20 FTs green. Co-Authored-By: Claude Sonnet 4.6 --- .../static/apps/dashboard/dashboard.js | 128 ++++++- src/functional_tests/test_applet_palette.py | 313 ++++++++++++++++-- src/static_src/scss/_dashboard.scss | 3 +- src/static_src/scss/_palette-picker.scss | 62 +++- .../dashboard/_partials/_applet-palette.html | 25 +- .../apps/dashboard/_partials/_scripts.html | 2 +- src/templates/apps/dashboard/home.html | 1 + 7 files changed, 488 insertions(+), 46 deletions(-) diff --git a/src/apps/dashboard/static/apps/dashboard/dashboard.js b/src/apps/dashboard/static/apps/dashboard/dashboard.js index 8615f8b..741a595 100644 --- a/src/apps/dashboard/static/apps/dashboard/dashboard.js +++ b/src/apps/dashboard/static/apps/dashboard/dashboard.js @@ -1,12 +1,7 @@ -// console.log("apps/scripts/dashboard.js loading"); const initialize = (inputSelector) => { - // console.log("initialize called!"); const textInput = document.querySelector(inputSelector); if (!textInput) return; - textInput.oninput = () => { - // console.log("oninput triggered"); - textInput.classList.remove("is-invalid"); - }; + textInput.oninput = () => textInput.classList.remove("is-invalid"); }; const bindPaletteWheel = () => { @@ -18,6 +13,125 @@ const bindPaletteWheel = () => { }); }; +// ── Palette swatch preview + commit ────────────────────────────────────────── + +const bindPaletteSwatches = () => { + const portal = document.getElementById('id_tooltip_portal'); + let activePreview = null; + let originalPalette = null; + let dismissTimer = null; + + function currentBodyPalette() { + return [...document.body.classList].find(c => c.startsWith('palette-')); + } + + function swapPalette(paletteName) { + const old = currentBodyPalette(); + if (old) document.body.classList.remove(old); + document.body.classList.add(paletteName); + } + + function showTooltip(swatch) { + if (!portal) return; + const label = swatch.dataset.label || ''; + const locked = swatch.dataset.locked === 'true'; + const date = swatch.dataset.unlockedDate || ''; + const shoptalk = swatch.dataset.shoptalk || ''; + const lockIcon = locked ? 'fa-lock' : 'fa-lock-open'; + const lockText = locked ? 'Locked' : `Unlocked — ${date}`.trim(); + + portal.innerHTML = ` +

${label}

+ ${shoptalk ? `

${shoptalk}

` : ''} +

${lockText}

`; + + const rect = swatch.getBoundingClientRect(); + portal.style.display = 'block'; + portal.style.position = 'fixed'; + portal.style.top = `${rect.bottom + 8}px`; + portal.style.left = `${Math.min(rect.left, window.innerWidth - 280)}px`; + portal.style.zIndex = '9999'; + } + + function hideTooltip() { + if (!portal) return; + portal.style.display = 'none'; + portal.innerHTML = ''; + } + + function dismiss() { + if (!activePreview) return; + clearTimeout(dismissTimer); + const paletteName = activePreview.dataset.palette; + activePreview.classList.remove('previewing'); + activePreview.querySelector('.palette-ok').style.display = ''; + document.body.classList.remove(paletteName); + if (originalPalette) document.body.classList.add(originalPalette); + activePreview = null; + originalPalette = null; + hideTooltip(); + } + + async function commitPalette(swatch, paletteName) { + // Silent commit — no animation, wipe already happened on preview + const old = originalPalette; + swatch.classList.remove('previewing'); + swatch.querySelector('.palette-ok').style.display = ''; + hideTooltip(); + activePreview = null; + originalPalette = null; + clearTimeout(dismissTimer); + + // Remove old palette, keep new one (already on body from preview) + if (old && old !== paletteName) { + document.body.classList.remove(old); + } + + // Update active indicator + document.querySelectorAll('.swatch').forEach(sw => { + sw.classList.toggle('active', sw.classList.contains(paletteName)); + }); + + // POST to server + const csrf = document.cookie.match(/csrftoken=([^;]+)/)?.[1] || ''; + await fetch('/dashboard/set_palette', { + method: 'POST', + headers: { 'Accept': 'application/json', 'X-CSRFToken': csrf }, + body: new URLSearchParams({ palette: paletteName }), + }); + } + + document.querySelectorAll('.palette-item .swatch').forEach(swatch => { + swatch.addEventListener('click', async (e) => { + e.stopPropagation(); + if (swatch.classList.contains('previewing')) return; + + dismiss(); // clear any existing preview + + originalPalette = currentBodyPalette(); + activePreview = swatch; + + swatch.classList.add('previewing'); + showTooltip(swatch); + swapPalette(swatch.dataset.palette); + swatch.querySelector('.palette-ok').style.display = 'flex'; + + // Auto-dismiss after 10s + dismissTimer = setTimeout(dismiss, 10000); + }); + + const okBtn = swatch.querySelector('.btn-confirm.palette-ok'); + if (okBtn) { + okBtn.addEventListener('click', async (e) => { + e.stopPropagation(); + await commitPalette(swatch, swatch.dataset.palette); + }); + } + }); + + document.addEventListener('click', () => dismiss()); +}; + const bindPaletteForms = () => { document.querySelectorAll('form[action*="set_palette"]').forEach(form => { form.addEventListener("submit", async (e) => { @@ -29,12 +143,10 @@ const bindPaletteForms = () => { }); if (!resp.ok) return; const { palette } = await resp.json(); - // Swap body palette class [...document.body.classList] .filter(c => c.startsWith("palette-")) .forEach(c => document.body.classList.remove(c)); document.body.classList.add(palette); - // Update active swatch indicator document.querySelectorAll(".swatch").forEach(sw => { sw.classList.toggle("active", sw.classList.contains(palette)); }); diff --git a/src/functional_tests/test_applet_palette.py b/src/functional_tests/test_applet_palette.py index 66e043f..690d382 100644 --- a/src/functional_tests/test_applet_palette.py +++ b/src/functional_tests/test_applet_palette.py @@ -1,3 +1,4 @@ +from selenium.webdriver.common.action_chains import ActionChains from selenium.webdriver.common.by import By from apps.applets.models import Applet @@ -6,31 +7,53 @@ from apps.lyric.models import User from .base import FunctionalTest -class PaletteSwapTest(FunctionalTest): - def test_selecting_palette_updates_body_class_without_page_reload(self): - Applet.objects.get_or_create(slug="palette", defaults={ - "name": "Palette", "context": "dashboard", - }) - user, _ = User.objects.get_or_create(email="swap@test.io") - self.create_pre_authenticated_session("swap@test.io") +def _setup_palette_applet(): + Applet.objects.get_or_create( + slug="palette", + defaults={"name": "Palette", "context": "dashboard"}, + ) + + +class PalettePreviewTest(FunctionalTest): + """Clicking a swatch previews the palette on the whole body.""" + + def setUp(self): + super().setUp() + _setup_palette_applet() + User.objects.get_or_create(email="preview@test.io") + self.create_pre_authenticated_session("preview@test.io") self.browser.get(self.live_server_url) - body = self.browser.find_element(By.TAG_NAME, "body") - self.assertIn("palette-default", body.get_attribute("class")) - - # Mark the window — this survives JS execution but is wiped on a real reload - self.browser.execute_script("window._no_reload_marker = true;") - - # Click OK on a non-active palette - btn = self.wait_for( + def _click_non_active_swatch(self): + swatch = self.wait_for( lambda: self.browser.find_element( By.CSS_SELECTOR, - ".palette-item:has(.swatch:not(.active)) .btn-confirm", + ".palette-item:not(:has(.swatch.active)) .swatch", ) ) - btn.click() + swatch.click() + return swatch - # Body palette class swaps without reload + def test_clicking_swatch_adds_preview_class_to_body(self): + swatch = self._click_non_active_swatch() + palette_name = swatch.get_attribute("data-palette") + self.wait_for( + lambda: self.assertIn( + palette_name, + self.browser.find_element(By.TAG_NAME, "body").get_attribute("class"), + ) + ) + + def test_body_has_only_preview_palette_during_preview(self): + # Original palette class is swapped out — only the preview class is on body + swatch = self._click_non_active_swatch() + palette_name = swatch.get_attribute("data-palette") + self.wait_for( + lambda: self.assertIn( + palette_name, + self.browser.find_element(By.TAG_NAME, "body").get_attribute("class"), + ) + ) self.wait_for( lambda: self.assertNotIn( "palette-default", @@ -38,9 +61,257 @@ class PaletteSwapTest(FunctionalTest): ) ) - # Marker still present — no full page reload occurred - self.assertTrue( - self.browser.execute_script("return window._no_reload_marker === true;") + def test_clicking_elsewhere_reverts_body_to_original_palette(self): + swatch = self._click_non_active_swatch() + palette_name = swatch.get_attribute("data-palette") + self.wait_for( + lambda: self.assertIn( + palette_name, + self.browser.find_element(By.TAG_NAME, "body").get_attribute("class"), + ) + ) + # Click outside the applet + self.browser.find_element(By.CSS_SELECTOR, "h1, h2").click() + self.wait_for( + lambda: self.assertNotIn( + palette_name, + self.browser.find_element(By.TAG_NAME, "body").get_attribute("class"), + ) + ) + # Original palette restored + self.assertIn( + "palette-default", + self.browser.find_element(By.TAG_NAME, "body").get_attribute("class"), + ) + + def test_swatch_gets_previewing_class_on_click(self): + self._click_non_active_swatch() + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".swatch.previewing") + ) + + def test_previewing_class_removed_on_dismiss(self): + self._click_non_active_swatch() + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".swatch.previewing") + ) + self.browser.find_element(By.CSS_SELECTOR, "h1, h2").click() + self.wait_for( + lambda: self.assertEqual( + len(self.browser.find_elements(By.CSS_SELECTOR, ".swatch.previewing")), + 0, + ) + ) + + def test_auto_dismiss_after_ten_seconds(self): + swatch = self._click_non_active_swatch() + palette_name = swatch.get_attribute("data-palette") + self.wait_for( + lambda: self.assertIn( + palette_name, + self.browser.find_element(By.TAG_NAME, "body").get_attribute("class"), + ) + ) + # After 10s the preview should clear automatically + self.wait_for_slow( + lambda: self.assertNotIn( + palette_name, + self.browser.find_element(By.TAG_NAME, "body").get_attribute("class"), + ), + timeout=15, + ) + + +class PaletteOkButtonTest(FunctionalTest): + """OK btn (unlocked) / × btn (locked) appear centered on clicked swatch.""" + + def setUp(self): + super().setUp() + _setup_palette_applet() + User.objects.get_or_create(email="okbtn@test.io") + self.create_pre_authenticated_session("okbtn@test.io") + self.browser.get(self.live_server_url) + + def test_ok_btn_absent_before_click(self): + btns = self.browser.find_elements(By.CSS_SELECTOR, ".swatch .btn-confirm") + self.assertEqual(len([b for b in btns if b.is_displayed()]), 0) + + def test_clicking_unlocked_swatch_shows_ok_btn_inside_swatch(self): + swatch = self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, + ".palette-item:not(:has(.swatch.active)):not(:has(.swatch.locked)) .swatch", + ) + ) + swatch.click() + ok = self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, ".swatch.previewing .btn-confirm" + ) + ) + self.assertTrue(ok.is_displayed()) + + def test_clicking_locked_swatch_shows_disabled_times_btn(self): + locked = self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".swatch.locked") + ) + locked.click() + disabled = self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, ".swatch.previewing .btn-disabled" + ) + ) + self.assertIn("×", disabled.text) + + def test_clicking_ok_commits_palette_and_no_reload(self): + self.browser.execute_script("window._no_reload = true") + swatch = self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, + ".palette-item:not(:has(.swatch.active)):not(:has(.swatch.locked)) .swatch", + ) + ) + palette_name = swatch.get_attribute("data-palette") + swatch.click() + + ok = self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, ".swatch.previewing .btn-confirm" + ) + ) + ok.click() + + # Body ends up with only the new palette (preview cleared, committed) + self.wait_for( + lambda: self.assertNotIn( + "palette-default", + self.browser.find_element(By.TAG_NAME, "body").get_attribute("class"), + ) + ) + self.wait_for( + lambda: self.assertIn( + palette_name, + self.browser.find_element(By.TAG_NAME, "body").get_attribute("class"), + ) + ) + self.assertTrue(self.browser.execute_script("return window._no_reload === true")) + + def test_btn_disappears_on_dismiss(self): + swatch = self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, + ".palette-item:not(:has(.swatch.active)):not(:has(.swatch.locked)) .swatch", + ) + ) + swatch.click() + self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, ".swatch.previewing .btn-confirm" + ) + ) + self.browser.find_element(By.CSS_SELECTOR, "h1, h2").click() + self.wait_for( + lambda: self.assertEqual( + len([b for b in self.browser.find_elements( + By.CSS_SELECTOR, ".swatch .btn-confirm" + ) if b.is_displayed()]), + 0, + ) + ) + + + +class PaletteTooltipTest(FunctionalTest): + """Clicking a swatch shows a tooltip in #id_tooltip_portal.""" + + def setUp(self): + super().setUp() + _setup_palette_applet() + self.user, _ = User.objects.get_or_create(email="palettett@test.io") + self.create_pre_authenticated_session("palettett@test.io") + self.browser.get(self.live_server_url) + + def _click_swatch(self, selector=".swatch"): + swatch = self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, selector) + ) + swatch.click() + return swatch + + def test_clicking_swatch_shows_tooltip_portal(self): + self._click_swatch() + portal = self.wait_for( + lambda: self.browser.find_element(By.ID, "id_tooltip_portal") + ) + self.assertTrue(portal.is_displayed()) + + def test_tooltip_title_is_colored_ter_user(self): + self._click_swatch() + title = self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, "#id_tooltip_portal .tt-title" + ) + ) + ter_user = self.browser.execute_script( + "return getComputedStyle(document.body).getPropertyValue('--terUser').trim()" + ) + r, g, b = [int(x.strip()) for x in ter_user.split(",")] + color = self.browser.execute_script( + "return getComputedStyle(arguments[0]).color", title + ) + self.assertIn(f"rgb({r}, {g}, {b})", color) + + def test_tooltip_shows_shoptalk(self): + self._click_swatch() + self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, "#id_tooltip_portal .tt-shoptalk" + ) + ) + + def test_unlocked_swatch_shows_lock_open_and_unlocked(self): + self._click_swatch( + ".palette-item:not(:has(.swatch.locked)) .swatch" + ) + lock_line = self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, "#id_tooltip_portal .tt-lock" + ) + ) + self.assertIn("Unlocked", lock_line.text) + self.assertTrue(lock_line.find_elements(By.CSS_SELECTOR, ".fa-lock-open")) + + def test_locked_swatch_shows_lock_and_locked(self): + self._click_swatch(".swatch.locked") + lock_line = self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, "#id_tooltip_portal .tt-lock" + ) + ) + self.assertIn("Locked", lock_line.text) + self.assertTrue(lock_line.find_elements(By.CSS_SELECTOR, ".fa-lock")) + + def test_unlocked_tooltip_shows_default_label(self): + self._click_swatch(".palette-item:not(:has(.swatch.locked)) .swatch") + lock_line = self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, "#id_tooltip_portal .tt-lock" + ) + ) + self.assertIn("Default", lock_line.text) + + def test_tooltip_dismisses_on_click_outside(self): + self._click_swatch() + self.wait_for( + lambda: self.assertTrue( + self.browser.find_element(By.ID, "id_tooltip_portal").is_displayed() + ) + ) + self.browser.find_element(By.CSS_SELECTOR, "h1, h2").click() + self.wait_for( + lambda: self.assertFalse( + self.browser.find_element(By.ID, "id_tooltip_portal").is_displayed() + ) ) diff --git a/src/static_src/scss/_dashboard.scss b/src/static_src/scss/_dashboard.scss index d1ce4f2..49d4805 100644 --- a/src/static_src/scss/_dashboard.scss +++ b/src/static_src/scss/_dashboard.scss @@ -66,7 +66,8 @@ body.page-dashboard { .palette-scroll { display: flex; - gap: 3.5rem; + gap: 1rem; + align-items: center; overflow-x: auto; padding: 0.75rem 2rem; height: 100%; diff --git a/src/static_src/scss/_palette-picker.scss b/src/static_src/scss/_palette-picker.scss index a7310ef..abcc532 100644 --- a/src/static_src/scss/_palette-picker.scss +++ b/src/static_src/scss/_palette-picker.scss @@ -7,7 +7,6 @@ -webkit-overflow-scrolling: touch; gap: 0.75rem; padding-bottom: 0.5rem; - } .palette-item { @@ -16,15 +15,16 @@ align-items: center; gap: 0.5rem; flex: 0 0 auto; - height: 100%; scroll-snap-align: start; } .swatch { - flex: 1; - min-height: 0; + position: relative; + width: 7rem; + height: 7rem; aspect-ratio: 1; border-radius: 0.5rem; + cursor: pointer; background: linear-gradient( to bottom, rgba(var(--secUser), 1) 0%, @@ -47,4 +47,56 @@ opacity: 0.5; filter: saturate(0.4); } -} \ No newline at end of file + + &.previewing { + border: 0.2rem solid rgba(var(--ninUser), 1); + box-shadow: 0 0 0.75rem rgba(var(--ninUser), 0.6); + + &.locked { + opacity: 1; + filter: none; + } + } + + // OK / × centred inside swatch — hidden until previewing + .palette-ok { + display: none; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 2; + margin: 0; + padding: 0.2rem 0.5rem; + font-size: 0.75rem; + min-width: 0; + line-height: 1; + } +} + + +// ── Palette tooltip portal ──────────────────────────────────────────────────── + +#id_tooltip_portal { + // Override .tt { display: none } — portal content is shown/hidden by JS + .tt-title, + .tt-shoptalk, + .tt-lock { + display: block; + } + + .tt-title { + color: rgba(var(--terUser), 1); + font-size: 0.95rem; + margin: 0 0 0.3rem; + font-weight: bold; + } + + .tt-lock { + margin: 0.25rem 0 0; + font-size: 0.8rem; + opacity: 0.85; + + i { margin-right: 0.25rem; } + } +} diff --git a/src/templates/apps/dashboard/_partials/_applet-palette.html b/src/templates/apps/dashboard/_partials/_applet-palette.html index e1f0edf..68604e6 100644 --- a/src/templates/apps/dashboard/_partials/_applet-palette.html +++ b/src/templates/apps/dashboard/_partials/_applet-palette.html @@ -7,16 +7,21 @@
{% for palette in palettes %}
-
- {% if not palette.locked %} -
- {% csrf_token %} - -
- {% else %} - × - {% endif %} +
+ {% if not palette.locked %} + + {% else %} + + {% endif %} +
{% endfor %}
- \ No newline at end of file + diff --git a/src/templates/apps/dashboard/_partials/_scripts.html b/src/templates/apps/dashboard/_partials/_scripts.html index de34224..7a00a9f 100644 --- a/src/templates/apps/dashboard/_partials/_scripts.html +++ b/src/templates/apps/dashboard/_partials/_scripts.html @@ -3,7 +3,7 @@ \ No newline at end of file diff --git a/src/templates/apps/dashboard/home.html b/src/templates/apps/dashboard/home.html index f22d2cf..4cf1295 100644 --- a/src/templates/apps/dashboard/home.html +++ b/src/templates/apps/dashboard/home.html @@ -20,5 +20,6 @@ {% include "apps/dashboard/_partials/_applets.html" %} {% include "apps/applets/_partials/_gear.html" with menu_id="id_dash_applet_menu" %} + {% endif %} {% endblock content %}