From 89deb7b0eec644fced05763bfee06b6eb6f31255 Mon Sep 17 00:00:00 2001 From: Ulas Kalayci Date: Thu, 30 Apr 2026 09:09:39 +0200 Subject: [PATCH 1/6] ux: FAB entry animation stops after 5 views (long loop) --- public/router.js | 13 +++++++++++++ public/styles/layout.css | 5 +++++ 2 files changed, 18 insertions(+) diff --git a/public/router.js b/public/router.js index 7e01f94..678f53b 100644 --- a/public/router.js +++ b/public/router.js @@ -366,6 +366,16 @@ async function renderPage(route, previousPath = null) { await module.render(pageWrapper, { user: currentUser }); + // FAB Long Loop: Einstiegsanimation nach FAB_SEEN_MAX Views deaktivieren + if (pageWrapper.querySelector('.page-fab')) { + let fabCount = parseInt(localStorage.getItem(FAB_SEEN_KEY) ?? '0', 10); + if (fabCount < FAB_SEEN_MAX) { + fabCount++; + localStorage.setItem(FAB_SEEN_KEY, String(fabCount)); + } + document.documentElement.classList.toggle('fab-anim-done', fabCount >= FAB_SEEN_MAX); + } + // Route-Announcer: Screenreader über Seitenwechsel informieren (gezielt, nicht gesamter Inhalt) const announcer = document.getElementById('route-announcer'); if (announcer) { @@ -619,6 +629,9 @@ function renderAppShell(container) { initKeyboardShortcuts(); } +const FAB_SEEN_KEY = 'oikos:fabSeenCount'; +const FAB_SEEN_MAX = 5; + const SHORTCUTS = [ { key: '/', description: () => t('shortcuts.search'), action: () => document.getElementById('more-sheet-search')?.click() }, { key: 'n', description: () => t('shortcuts.new'), action: () => document.querySelector('.page-fab')?.click() }, diff --git a/public/styles/layout.css b/public/styles/layout.css index 1a8a395..f01e906 100755 --- a/public/styles/layout.css +++ b/public/styles/layout.css @@ -579,6 +579,11 @@ box-shadow: 0 0 0 4px var(--color-accent); } +/* Long Loop: FAB-Animation nach N Aufrufen deaktivieren */ +html.fab-anim-done .page-fab { + animation: none; +} + /* Desktop: FAB Position anpassen (keine Bottom-Nav) und etwas kleiner */ @media (min-width: 1024px) { .page-fab { From f1f307388efd7b8841e588d2d6133520e2d049fc Mon Sep 17 00:00:00 2001 From: Ulas Kalayci Date: Thu, 30 Apr 2026 09:10:30 +0200 Subject: [PATCH 2/6] ux: hide search kbd hint after first keyboard use (long loop) --- public/router.js | 12 +++++++++++- public/styles/layout.css | 2 +- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/public/router.js b/public/router.js index 678f53b..773b0ae 100644 --- a/public/router.js +++ b/public/router.js @@ -627,13 +627,23 @@ function renderAppShell(container) { initNavHideOnScroll(container); initOfflineBanner(); initKeyboardShortcuts(); + if (localStorage.getItem(SEARCH_KBD_KEY)) { + document.documentElement.classList.add('search-kbd-done'); + } } const FAB_SEEN_KEY = 'oikos:fabSeenCount'; const FAB_SEEN_MAX = 5; +const SEARCH_KBD_KEY = 'oikos:searchKbdUsed'; const SHORTCUTS = [ - { key: '/', description: () => t('shortcuts.search'), action: () => document.getElementById('more-sheet-search')?.click() }, + { key: '/', description: () => t('shortcuts.search'), action: () => { + if (!localStorage.getItem(SEARCH_KBD_KEY)) { + localStorage.setItem(SEARCH_KBD_KEY, '1'); + document.documentElement.classList.add('search-kbd-done'); + } + document.getElementById('more-sheet-search')?.click(); + } }, { key: 'n', description: () => t('shortcuts.new'), action: () => document.querySelector('.page-fab')?.click() }, { key: '?', description: () => t('shortcuts.help'), action: () => showShortcutsModal() }, { key: 'g d', description: () => t('shortcuts.goDash'), action: () => navigate('/') }, diff --git a/public/styles/layout.css b/public/styles/layout.css index f01e906..63ea12d 100755 --- a/public/styles/layout.css +++ b/public/styles/layout.css @@ -305,7 +305,7 @@ } @media (min-width: 1024px) { - .more-sheet__search-kbd { + html:not(.search-kbd-done) .more-sheet__search-kbd { display: inline; } } From acec9db260c3f68eecbb10ec10b013c6b41db1e3 Mon Sep 17 00:00:00 2001 From: Ulas Kalayci Date: Thu, 30 Apr 2026 09:11:04 +0200 Subject: [PATCH 3/6] ux: suppress success toasts after 50 saves (long loop) --- public/router.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/public/router.js b/public/router.js index 773b0ae..62c4adc 100644 --- a/public/router.js +++ b/public/router.js @@ -1130,6 +1130,9 @@ function renderError(container, err) { * @param {'default'|'success'|'danger'|'warning'} type * @param {number} duration - ms */ +const TOAST_SUCCESS_KEY = 'oikos:toastSuccessCount'; +const TOAST_SUCCESS_MAX = 50; + const TOAST_ICONS = { success: '', danger: '', @@ -1140,6 +1143,13 @@ function showToast(message, type = 'default', duration = 3000, onUndo = null) { const container = document.getElementById('toast-container'); if (!container) return; + // Long Loop: Success-Toasts nach TOAST_SUCCESS_MAX Aufrufen unterdrücken + if (type === 'success' && typeof onUndo !== 'function') { + const successCount = parseInt(localStorage.getItem(TOAST_SUCCESS_KEY) ?? '0', 10) + 1; + localStorage.setItem(TOAST_SUCCESS_KEY, String(successCount)); + if (successCount > TOAST_SUCCESS_MAX) return; + } + // Max. 3 gleichzeitige Toasts: ältesten entfernen falls Limit erreicht const existing = container.querySelectorAll('.toast'); if (existing.length >= 3) existing[0].remove(); From d7207729391c8fa3777c45bcf3df266a75fe4cf8 Mon Sep 17 00:00:00 2001 From: Ulas Kalayci Date: Thu, 30 Apr 2026 09:11:45 +0200 Subject: [PATCH 4/6] ux: empty state CTA fades in with delay to draw attention --- public/styles/layout.css | 2 ++ 1 file changed, 2 insertions(+) diff --git a/public/styles/layout.css b/public/styles/layout.css index 63ea12d..63fb3a5 100755 --- a/public/styles/layout.css +++ b/public/styles/layout.css @@ -1510,6 +1510,7 @@ html.fab-anim-done .page-fab { @media (prefers-reduced-motion: reduce) { .empty-state { animation: none; } + .empty-state__cta { animation: none; } } .empty-state__icon { @@ -1545,6 +1546,7 @@ html.fab-anim-done .page-fab { .empty-state__cta { margin-top: var(--space-2); + animation: list-item-in 300ms var(--ease-out) 300ms both; } .empty-state--compact { From e10516d32f5f3b3fe96ed208cb25f64dca81bd73 Mon Sep 17 00:00:00 2001 From: Ulas Kalayci Date: Thu, 30 Apr 2026 09:13:06 +0200 Subject: [PATCH 5/6] ux: pulse error border on repeated validation failures --- public/components/modal.js | 14 ++++++++++++++ public/styles/layout.css | 16 ++++++++++++++++ test-modal-utils.js | 8 ++++++++ 3 files changed, 38 insertions(+) diff --git a/public/components/modal.js b/public/components/modal.js index c124191..23e9133 100644 --- a/public/components/modal.js +++ b/public/components/modal.js @@ -608,6 +608,20 @@ function _validateField(input) { group?.classList.toggle('form-field--error', !hasValue); group?.classList.toggle('form-field--valid', hasValue); input.setAttribute('aria-invalid', String(!hasValue)); + + if (!hasValue && group) { + const count = parseInt(group.dataset.errorCount ?? '0', 10) + 1; + group.dataset.errorCount = String(count); + if (count >= 2) { + group.classList.remove('form-field--error-repeat'); + void group.offsetWidth; + group.classList.add('form-field--error-repeat'); + group.addEventListener('animationend', () => group.classList.remove('form-field--error-repeat'), { once: true }); + } + } else if (hasValue && group) { + group.dataset.errorCount = '0'; + } + return hasValue; } diff --git a/public/styles/layout.css b/public/styles/layout.css index 63fb3a5..bf2853a 100755 --- a/public/styles/layout.css +++ b/public/styles/layout.css @@ -1388,6 +1388,22 @@ html.fab-anim-done .page-fab { display: flex; } +/* Pulse-Animation bei wiederholtem Fehler */ +@keyframes field-error-pulse { + 0%, 100% { box-shadow: none; } + 40% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-danger) 35%, transparent); } +} + +.form-field--error-repeat .input, +.form-field--error-repeat .form-input { + animation: field-error-pulse 500ms var(--ease-out); +} + +@media (prefers-reduced-motion: reduce) { + .form-field--error-repeat .input, + .form-field--error-repeat .form-input { animation: none; } +} + /* -------------------------------------------------------- * Toggle-Switch * Custom iOS-style toggle, ersetzt native Checkboxen. diff --git a/test-modal-utils.js b/test-modal-utils.js index 6dd680e..cd29347 100644 --- a/test-modal-utils.js +++ b/test-modal-utils.js @@ -39,12 +39,20 @@ const _origSetTimeout = setTimeout; function makeField() { const classes = new Set(); + const listeners = {}; + const dataset = {}; return { + dataset, + offsetWidth: 0, classList: { toggle(cls, force) { force ? classes.add(cls) : classes.delete(cls); }, + add(cls) { classes.add(cls); }, + remove(cls) { classes.delete(cls); }, contains(cls) { return classes.has(cls); }, }, + addEventListener(event, fn) { listeners[event] = fn; }, _classes: classes, + _listeners: listeners, }; } From edd8d0889dd9f478f0e0505f5592ebe07719ac96 Mon Sep 17 00:00:00 2001 From: Ulas Kalayci Date: Thu, 30 Apr 2026 09:13:48 +0200 Subject: [PATCH 6/6] ux: accent glow on quick-add input after successful item add --- public/pages/shopping.js | 2 ++ public/styles/layout.css | 17 +++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/public/pages/shopping.js b/public/pages/shopping.js index 3197409..a0f32ef 100644 --- a/public/pages/shopping.js +++ b/public/pages/shopping.js @@ -352,6 +352,8 @@ function wireQuickAdd(container) { // Erfolgs-Feedback auf dem +-Button (DOM-API, kein innerHTML) _flashAddBtn(form.querySelector('.quick-add__btn')); nameInput.focus(); + nameInput.classList.add('quick-add__input--flash'); + nameInput.addEventListener('animationend', () => nameInput.classList.remove('quick-add__input--flash'), { once: true }); } catch (err) { window.oikos.showToast(err.message, 'danger'); } diff --git a/public/styles/layout.css b/public/styles/layout.css index bf2853a..e603af3 100755 --- a/public/styles/layout.css +++ b/public/styles/layout.css @@ -2336,6 +2336,23 @@ textarea.input { resize: vertical; } } } +/* -------------------------------------------------------- + * Signature Moment: Eingabefeld-Glow nach erfolgreichem Item-Add + * -------------------------------------------------------- */ +@keyframes input-add-flash { + 0% { box-shadow: none; } + 25% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--module-accent, var(--color-accent)) 45%, transparent); } + 100% { box-shadow: none; } +} + +.quick-add__input--flash { + animation: input-add-flash 550ms var(--ease-out) forwards; +} + +@media (prefers-reduced-motion: reduce) { + .quick-add__input--flash { animation: none; } +} + /* -------------------------------------------------------- * Windows High Contrast / Forced Colors * -------------------------------------------------------- */