From 8f8b3f795156598acb46619a3259dd5fedc571ff Mon Sep 17 00:00:00 2001 From: Ulas Date: Sat, 4 Apr 2026 23:53:11 +0200 Subject: [PATCH] feat(ux): microinteraction improvements - Zentralisiere @keyframes check-pop in layout.css (war dupliziert in shopping.css + tasks.css) - Subtask-Checkbox erhaelt check-pop Animation (konsistent mit Haupt-Checkbox) - Quick-Add: Checkmark-Feedback auf +-Button nach erfolgreichem Hinzufuegen (700ms, DOM-API) - Swipe-Affordance Hint: swipe-row--hint via localStorage-Counter (max. 3x, nur Mobile) --- public/pages/shopping.js | 57 +++++++++++++++++++++ public/styles/layout.css | 102 +++++++++++++++++++++++++++++++++++++ public/styles/shopping.css | 6 --- public/styles/tasks.css | 7 +-- 4 files changed, 160 insertions(+), 12 deletions(-) diff --git a/public/pages/shopping.js b/public/pages/shopping.js index da88fad..581532c 100644 --- a/public/pages/shopping.js +++ b/public/pages/shopping.js @@ -175,6 +175,7 @@ function renderListContent(container) { stagger(content.querySelectorAll('.shopping-item')); wireAutocomplete(container); wireQuickAdd(container); + maybeShowSwipeHint(container); } function renderItems() { @@ -307,6 +308,35 @@ function wireAutocomplete(container) { // Quick-Add Form // -------------------------------------------------------- +/** + * Zeigt kurzes Checkmark-Feedback auf dem +-Button (700ms). + * Verwendet DOM-API statt innerHTML um XSS-Risiken zu vermeiden. + * @param {HTMLButtonElement|null} btn + */ +function _flashAddBtn(btn) { + if (!btn) return; + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('width', '16'); + svg.setAttribute('height', '16'); + svg.setAttribute('viewBox', '0 0 24 24'); + svg.setAttribute('fill', 'none'); + svg.setAttribute('stroke', 'currentColor'); + svg.setAttribute('stroke-width', '2.5'); + svg.setAttribute('aria-hidden', 'true'); + const poly = document.createElementNS('http://www.w3.org/2000/svg', 'polyline'); + poly.setAttribute('points', '20 6 9 17 4 12'); + svg.appendChild(poly); + + const saved = [...btn.childNodes]; + btn.classList.add('btn--success'); + btn.replaceChildren(svg); + setTimeout(() => { + btn.classList.remove('btn--success'); + btn.replaceChildren(...saved); + if (window.lucide) window.lucide.createIcons(); + }, 700); +} + function wireQuickAdd(container) { const form = container.querySelector('#quick-add-form'); if (!form) return; @@ -332,6 +362,8 @@ function wireQuickAdd(container) { renderTabs(container); nameInput.value = ''; qtyInput.value = ''; + // Erfolgs-Feedback auf dem +-Button (DOM-API, kein innerHTML) + _flashAddBtn(form.querySelector('.quick-add__btn')); nameInput.focus(); } catch (err) { window.oikos.showToast(err.message, 'danger'); @@ -339,6 +371,30 @@ function wireQuickAdd(container) { }); } +// -------------------------------------------------------- +// Swipe-Affordance Hint (Long Loop) +// Zeigt den Nudge-Hinweis maximal 3x (gespeichert in localStorage). +// -------------------------------------------------------- + +const SWIPE_HINT_KEY = 'oikos:swipeHintSeen'; +const SWIPE_HINT_MAX = 3; + +function maybeShowSwipeHint(container) { + if (window.innerWidth >= 1024) return; // Desktop: Swipe nicht relevant + const count = parseInt(localStorage.getItem(SWIPE_HINT_KEY) ?? '0', 10); + if (count >= SWIPE_HINT_MAX) return; + + const firstRow = container.querySelector('.swipe-row'); + if (!firstRow) return; + + firstRow.classList.add('swipe-row--hint'); + firstRow.addEventListener('animationend', () => { + firstRow.classList.remove('swipe-row--hint'); + }, { once: true }); + + localStorage.setItem(SWIPE_HINT_KEY, String(count + 1)); +} + // -------------------------------------------------------- // Swipe-Gesten // -------------------------------------------------------- @@ -487,6 +543,7 @@ function updateItemsList(container) { if (window.lucide) window.lucide.createIcons(); stagger(listEl.querySelectorAll('.shopping-item')); wireSwipeGestures(container); + maybeShowSwipeHint(container); } // clear-checked Button aktualisieren const checkedCount = state.items.filter((i) => i.is_checked).length; diff --git a/public/styles/layout.css b/public/styles/layout.css index 6cb11ee..6c97a4b 100644 --- a/public/styles/layout.css +++ b/public/styles/layout.css @@ -216,6 +216,11 @@ * Mobile: über der Bottom-Nav. Desktop: unten rechts im Content. * Toolbar-"Neu"-Buttons werden überall versteckt. * -------------------------------------------------------- */ +@keyframes fab-in { + from { transform: scale(0.6) translateY(8px); opacity: 0; } + to { transform: scale(1) translateY(0); opacity: 1; } +} + .page-fab { position: fixed; bottom: calc(var(--nav-bottom-height) + 24px + var(--safe-area-inset-bottom)); @@ -234,6 +239,7 @@ z-index: calc(var(--z-nav) - 1); transition: transform var(--transition-base), background-color var(--transition-fast); -webkit-tap-highlight-color: transparent; + animation: fab-in 0.35s var(--ease-out) backwards; } .page-fab:hover { @@ -882,6 +888,7 @@ transform var(--transition-fast), background-color var(--transition-fast), box-shadow var(--transition-fast); + animation: fab-in 0.35s var(--ease-out) backwards; } .fab:hover { @@ -1042,6 +1049,10 @@ background-color: var(--active-module-accent, var(--color-accent)); } +.toggle input:checked + .toggle__track::after { + transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1); +} + .toggle input:checked + .toggle__track::after { transform: translateX(18px); } @@ -1061,6 +1072,15 @@ .toggle__track::after { transition: none; } + + .fab, + .page-fab { animation: none; } + + .list-stagger > * { animation: none; } + + .swipe-row--hint > :first-child { animation: none; } + + .btn--loading::after { animation: none; } } /* -------------------------------------------------------- @@ -1446,6 +1466,16 @@ } /* ── Submit-Feedback Animationen ── */ + +/* Checkbox-Pop: kanonische Definition - shopping.css + tasks.css verwenden diese */ +@keyframes check-pop { + 0% { transform: scale(1); } + 30% { transform: scale(0.8); } + 60% { transform: scale(1.3); } + 80% { transform: scale(0.95); } + 100% { transform: scale(1); } +} + @keyframes btn-shake { 0%, 100% { transform: translateX(0); } 20% { transform: translateX(-4px); } @@ -1464,6 +1494,39 @@ pointer-events: none; } +/* Button loading state: hides text, shows spinner */ +@keyframes btn-spin { + to { transform: rotate(360deg); } +} + +.btn--loading { + position: relative; + color: transparent !important; + pointer-events: none; +} + +.btn--loading::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 14px; + height: 14px; + margin-top: -7px; + margin-left: -7px; + border: 2px solid rgba(255, 255, 255, 0.35); + border-top-color: #fff; + border-radius: var(--radius-full); + animation: btn-spin 0.55s linear infinite; +} + +/* For non-primary buttons (secondary, ghost) */ +.btn--secondary.btn--loading::after, +.btn--ghost.btn--loading::after { + border-color: var(--color-border); + border-top-color: var(--color-accent); +} + /* -------------------------------------------------------- * Swipe-Wrapper - Gemeinsame Basis (Tasks + Shopping) * Modul-spezifische Styles (.swipe-reveal--edit, .swipe-reveal--delete, @@ -1497,6 +1560,45 @@ transition: opacity 0.05s linear; } +/* -------------------------------------------------------- + * List Stagger + * Füge .list-stagger zum Container hinzu. + * Items erscheinen sequenziell beim ersten Rendern. + * -------------------------------------------------------- */ +@keyframes list-item-in { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } +} + +.list-stagger > * { + animation: list-item-in 0.2s var(--ease-out) both; +} + +.list-stagger > *:nth-child(1) { animation-delay: 0ms; } +.list-stagger > *:nth-child(2) { animation-delay: 35ms; } +.list-stagger > *:nth-child(3) { animation-delay: 70ms; } +.list-stagger > *:nth-child(4) { animation-delay: 105ms; } +.list-stagger > *:nth-child(5) { animation-delay: 140ms; } +.list-stagger > *:nth-child(n+6) { animation-delay: 175ms; } + +/* -------------------------------------------------------- + * Swipe Affordance Hint + * Einmalig per JS: .swipe-row--hint auf das erste Element. + * Gibt nach Seitenladezeit einen kurzen Nudge-Hinweis. + * -------------------------------------------------------- */ +@keyframes swipe-nudge { + 0% { transform: translateX(0); } + 20% { transform: translateX(-18px); } + 45% { transform: translateX(0); } + 60% { transform: translateX(-9px); } + 80% { transform: translateX(0); } + 100% { transform: translateX(0); } +} + +.swipe-row--hint > :first-child { + animation: swipe-nudge 0.8s var(--ease-out) 1.2s both; +} + /* Gemeinsam: Erledigt / Abhaken (Swipe nach links) */ .swipe-reveal--done { right: 0; diff --git a/public/styles/shopping.css b/public/styles/shopping.css index a587bef..f5afb07 100644 --- a/public/styles/shopping.css +++ b/public/styles/shopping.css @@ -354,12 +354,6 @@ animation: check-pop 0.2s var(--ease-out); } -@keyframes check-pop { - 0% { transform: scale(1); } - 50% { transform: scale(1.2); } - 100% { transform: scale(1); } -} - .item-check__icon { width: var(--space-3); height: var(--space-3); diff --git a/public/styles/tasks.css b/public/styles/tasks.css index f1c5982..83e9d12 100644 --- a/public/styles/tasks.css +++ b/public/styles/tasks.css @@ -254,12 +254,6 @@ animation: check-pop 0.2s var(--ease-out); } -@keyframes check-pop { - 0% { transform: scale(1); } - 50% { transform: scale(1.2); } - 100% { transform: scale(1); } -} - .task-status-btn--in_progress { border-color: var(--color-warning); } @@ -425,6 +419,7 @@ .subtask-item__checkbox--done { background-color: var(--color-success); border-color: var(--color-success); + animation: check-pop 0.15s var(--ease-out); } .subtask-item__title {