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/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/router.js b/public/router.js
index 7e01f94..62c4adc 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) {
@@ -617,10 +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('/') },
@@ -1107,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: '',
@@ -1117,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();
diff --git a/public/styles/layout.css b/public/styles/layout.css
index 1a8a395..e603af3 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;
}
}
@@ -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 {
@@ -1383,6 +1388,22 @@
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.
@@ -1505,6 +1526,7 @@
@media (prefers-reduced-motion: reduce) {
.empty-state { animation: none; }
+ .empty-state__cta { animation: none; }
}
.empty-state__icon {
@@ -1540,6 +1562,7 @@
.empty-state__cta {
margin-top: var(--space-2);
+ animation: list-item-in 300ms var(--ease-out) 300ms both;
}
.empty-state--compact {
@@ -2313,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
* -------------------------------------------------------- */
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,
};
}