diff --git a/CHANGELOG.md b/CHANGELOG.md index ea62931..a971509 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.0] - 2026-03-30 + +### Changed +- Directional slide-x page transitions (forward = right, backward = left) with race condition guard +- PWA install prompt delayed until 2 user interactions; dismiss window reduced from 30 to 7 days; interaction counter resets on dismiss +- Unified card padding to 16px (`--space-4`) across tasks, contacts, budget, and meals modules + +### Added +- Staggered fade-in animation for list items on page load across all modules (tasks, shopping, meals, contacts, budget, notes, calendar agenda) +- Unified empty states using shared `.empty-state` class across all modules (replaces per-module CSS) +- `stagger()` and `vibrate()` UX utilities in `public/utils/ux.js` with full test coverage +- Proportional opacity on swipe-reveal action areas in tasks (already implemented, confirmed) +- FAB colors tied to per-module accent tokens via CSS custom properties +- `scrollIntoView` for focused inputs when virtual keyboard opens in modals (300ms delay) +- Consistent vibration feedback via `vibrate()` utility across tasks, shopping, contacts, budget, and notes +- Bottom sheet modal on mobile (< 768px) with drag handle, slide-in animation, and swipe-to-close +- Enter-key navigation between form fields in modals; Enter on last field triggers submit +- Blur-triggered inline validation for required fields with error/success border states +- `wireBlurValidation()`, `btnSuccess()`, and `btnError()` exported from `modal.js` +- Submit button checkmark-success (700ms) and shake-error feedback animations + ## [0.1.0] - 2026-03-29 Initial release of Oikos — a self-hosted family planner for 2–6 person households. Runs as a Docker container behind Nginx with SSL, no cloud dependency. diff --git a/docs/SPEC.md b/docs/SPEC.md index 301af09..d0bb5db 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -273,12 +273,17 @@ Benutzerverwaltung und App-Konfiguration. Nur für eingeloggte Nutzer. - Caption: 13px, `var(--color-text-secondary)` ### Komponenten -- **Cards:** `var(--color-surface)`, `var(--radius-md)`, `var(--shadow-sm)` -- **Buttons:** Primär = Accent + weiß. Sekundär = Outline. Min-Höhe 44px -- **Inputs:** `var(--radius-sm)`, 1.5px border, padding 12px 16px -- **Navigation:** Bottom Tab Bar mobil (Dashboard, Aufgaben, Kalender, Essen, Mehr). Sidebar Desktop -- **Transitions:** `all 0.2s ease`. Seiten: Slide-Animation -- **Empty States:** Illustration + CTA in jeder Liste +- **Cards:** `var(--color-surface)`, `var(--radius-md)`, `var(--shadow-sm)`. Einheitliches Padding `var(--space-4)` (16px) in allen Modulen. +- **Buttons:** Primär = Accent + weiß. Sekundär = Outline. Min-Höhe 44px. Submit-Buttons zeigen Erfolg (Checkmark, 700ms grün via `.btn--success`) und Fehler (Shake via `.btn--shaking`). +- **Inputs:** `var(--radius-sm)`, 1.5px border, padding 12px 16px. `[required]`-Felder erhalten bei Blur Validierungsstatus (`.form-field--error` / `.form-field--valid`). Enter navigiert zum nächsten Feld; Enter im letzten Feld löst Submit aus. +- **FAB (Floating Action Button):** Farbe folgt dem Modul-Akzent-Token (`--module-accent`) — jedes Modul definiert seine eigene Akzentfarbe. +- **Navigation:** Bottom Tab Bar mobil (Dashboard, Aufgaben, Kalender, Essen, Mehr). Sidebar Desktop. +- **Transitions:** Direktionale Slide-X-Animation bei Seitenwechsel (vorwärts = von rechts, rückwärts = von links, 200ms). Respektiert `prefers-reduced-motion`. +- **Empty States:** Einheitliche `.empty-state`-Klasse in allen Modulen (Icon + Titel + Beschreibung, zentriert). Kompakte Variante `.empty-state--compact` für Mahlzeiten-Slots. +- **Modals:** Auf Desktop zentriertes Panel. Auf Mobile (< 768px) Bottom Sheet — fährt von unten ein, Sheet-Handle sichtbar, Swipe-to-Close (> 80px nach unten). `focusin` scrollt Inputs bei virtueller Tastatur in den sichtbaren Bereich. +- **Listen-Animation:** Staggered Fade-In beim Laden (`stagger()` aus `public/utils/ux.js`) — max. 5 Elemente gestaffelt (30ms Abstand), Rest sofort. +- **Vibration:** `vibrate()` aus `public/utils/ux.js` — kurze Impulse bei leichten Aktionen (10–40ms), Muster `[30, 50, 30]` bei destructiven Aktionen (Löschen). Respektiert `prefers-reduced-motion`. +- **PWA Install Prompt:** Erscheint erst nach 2 Nutzer-Interaktionen. Dismiss-Fenster 7 Tage; nach Dismiss wird der Interaktionszähler zurückgesetzt. ### Breakpoints - Mobil: < 768px (1 Spalte, Bottom Nav) diff --git a/package.json b/package.json index 6a47635..23a4f00 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "oikos", - "version": "1.0.0", + "version": "0.2.0", "description": "Selbstgehosteter Familienplaner — Kalender, Aufgaben, Einkauf, Essensplan, Budget und mehr. Privat, offen, ohne Abo.", "main": "server/index.js", "scripts": { @@ -14,7 +14,8 @@ "test:meals": "node --experimental-sqlite test-meals.js", "test:calendar": "node --experimental-sqlite test-calendar.js", "test:ncb": "node --experimental-sqlite test-notes-contacts-budget.js", - "test": "node --experimental-sqlite test-db.js && node --experimental-sqlite test-dashboard.js && node --experimental-sqlite test-tasks.js && node --experimental-sqlite test-shopping.js && node --experimental-sqlite test-meals.js && node --experimental-sqlite test-calendar.js && node --experimental-sqlite test-notes-contacts-budget.js" + "test:ux-utils": "node test-ux-utils.js", + "test": "node --experimental-sqlite test-db.js && node --experimental-sqlite test-dashboard.js && node --experimental-sqlite test-tasks.js && node --experimental-sqlite test-shopping.js && node --experimental-sqlite test-meals.js && node --experimental-sqlite test-calendar.js && node --experimental-sqlite test-notes-contacts-budget.js && npm run test:ux-utils" }, "dependencies": { "bcrypt": "^5.1.1", diff --git a/public/components/modal.js b/public/components/modal.js index 7be0742..890d290 100644 --- a/public/components/modal.js +++ b/public/components/modal.js @@ -2,6 +2,7 @@ * Modul: Shared Modal-System * Zweck: Einheitliches Modal mit Focus-Trap, Escape-Handler, Overlay-Click, * Focus-Restore, Scroll-Lock und aria-modal. + * Auf Mobile: Bottom Sheet mit Swipe-to-Close und Slide-out-Animation. * Abhängigkeiten: CSS-Klassen aus layout.css (.modal-overlay, .modal-panel, etc.) * * API: @@ -31,22 +32,62 @@ const FOCUSABLE = [ function trapFocus(container) { focusTrapHandler = (e) => { - if (e.key !== 'Tab') return; - const focusable = container.querySelectorAll(FOCUSABLE); - if (!focusable.length) return; - const first = focusable[0]; - const last = focusable[focusable.length - 1]; + // Tab-Trap: Fokus innerhalb des Modals halten + if (e.key === 'Tab') { + const focusable = container.querySelectorAll(FOCUSABLE); + if (!focusable.length) return; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; - if (e.shiftKey && document.activeElement === first) { - e.preventDefault(); - last.focus(); - } else if (!e.shiftKey && document.activeElement === last) { - e.preventDefault(); - first.focus(); + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + return; + } + + // Enter in einzeiligen Inputs/Selects → zum nächsten Feld springen + if (e.key === 'Enter') { + const active = document.activeElement; + const isInput = active.tagName === 'INPUT' && active.type !== 'submit' && active.type !== 'button'; + const isSelect = active.tagName === 'SELECT'; + + if (isInput || isSelect) { + const focusable = Array.from(container.querySelectorAll(FOCUSABLE)); + const idx = focusable.indexOf(active); + const next = focusable[idx + 1]; + + if (next && next.tagName !== 'BUTTON') { + e.preventDefault(); + next.focus(); + } + // Beim letzten Feld oder wenn Next ein Button ist: Submit auslösen + if (!next || next.tagName === 'BUTTON') { + const submitBtn = container.querySelector('button[type="submit"], .btn--primary'); + if (submitBtn && !submitBtn.disabled) { + e.preventDefault(); + submitBtn.click(); + } + } + } } }; container.addEventListener('keydown', focusTrapHandler); + // Virtual Keyboard: Focused Input in sichtbaren Bereich scrollen + function onInputFocus(e) { + const tag = e.target.tagName; + if (tag !== 'INPUT' && tag !== 'TEXTAREA' && tag !== 'SELECT') return; + setTimeout(() => { + e.target.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }, 300); + } + container.addEventListener('focusin', onInputFocus); + container._onInputFocus = onInputFocus; + // Focus first focusable element const first = container.querySelector(FOCUSABLE); if (first) { @@ -62,6 +103,61 @@ function onEscape(e) { if (e.key === 'Escape') closeModal(); } +// -------------------------------------------------------- +// Swipe-to-Close (Mobile) +// -------------------------------------------------------- + +function _wireSheetSwipe(panel) { + let startY = 0; + let dragging = false; + + panel.addEventListener('touchstart', (e) => { + startY = e.touches[0].clientY; + dragging = true; + }, { passive: true }); + + panel.addEventListener('touchmove', (e) => { + if (!dragging) return; + const dy = e.touches[0].clientY - startY; + if (dy < 0) return; // Kein Swipe nach oben + panel.style.transform = `translateY(${dy * 0.6}px)`; + }, { passive: true }); + + panel.addEventListener('touchend', (e) => { + if (!dragging) return; + dragging = false; + const dy = e.changedTouches[0].clientY - startY; + panel.style.transform = ''; + if (dy > 80) { + closeModal(); + } + }); +} + +// -------------------------------------------------------- +// _doClose — gemeinsame Cleanup-Logik +// -------------------------------------------------------- + +function _doClose() { + if (!activeOverlay) return; + activeOverlay.remove(); + activeOverlay = null; + + // Scroll-Lock aufheben + document.body.style.overflow = ''; + + // Focus-Restore + if (previouslyFocused && typeof previouslyFocused.focus === 'function') { + previouslyFocused.focus(); + previouslyFocused = null; + } + + // Standalone: Statusbar-Farbe zur aktuellen Route wiederherstellen + if (window.oikos?.restoreThemeColor) { + window.oikos.restoreThemeColor(); + } +} + // -------------------------------------------------------- // openModal // -------------------------------------------------------- @@ -115,6 +211,11 @@ export function openModal({ title, content, onSave, onDelete, size = 'md' } = {} const panel = activeOverlay.querySelector('.modal-panel'); trapFocus(panel); + // Swipe-to-Close auf Mobile + if (window.innerWidth < 768) { + _wireSheetSwipe(panel); + } + // Overlay-Click schließt Modal activeOverlay.addEventListener('click', (e) => { if (e.target === activeOverlay) closeModal(); @@ -145,27 +246,80 @@ export function closeModal() { document.removeEventListener('keydown', onEscape); - // Focus-Trap-Handler entfernen + const panel = activeOverlay.querySelector('.modal-panel'); + + // Focus-Trap-Handler und Virtual-Keyboard-Listener entfernen if (focusTrapHandler) { - const panel = activeOverlay.querySelector('.modal-panel'); if (panel) panel.removeEventListener('keydown', focusTrapHandler); focusTrapHandler = null; } - - activeOverlay.remove(); - activeOverlay = null; - - // Scroll-Lock aufheben - document.body.style.overflow = ''; - - // Focus-Restore - if (previouslyFocused && typeof previouslyFocused.focus === 'function') { - previouslyFocused.focus(); - previouslyFocused = null; + if (panel?._onInputFocus) { + panel.removeEventListener('focusin', panel._onInputFocus); } - // Standalone: Statusbar-Farbe zur aktuellen Route wiederherstellen - if (window.oikos?.restoreThemeColor) { - window.oikos.restoreThemeColor(); + // Sheet-Out-Animation auf Mobile, danach _doClose + const isMobile = window.innerWidth < 768; + if (isMobile && panel) { + panel.classList.add('modal-panel--closing'); + panel.addEventListener('animationend', () => { + _doClose(); + }, { once: true }); + return; } + + _doClose(); +} + +// -------------------------------------------------------- +// Inline Blur-Validierung +// -------------------------------------------------------- + +/** + * Aktiviert Blur-Validierung für alle required-Inputs in einem Container. + * @param {HTMLElement} formContainer + */ +export function wireBlurValidation(formContainer) { + formContainer.querySelectorAll('input[required], select[required], textarea[required]').forEach((input) => { + input.addEventListener('blur', () => { + const group = input.closest('.form-field') ?? input.parentElement; + const hasValue = input.value.trim().length > 0; + group?.classList.toggle('form-field--error', !hasValue); + group?.classList.toggle('form-field--valid', hasValue); + }); + }); +} + +// -------------------------------------------------------- +// Submit-Feedback (Checkmark + Shake) +// -------------------------------------------------------- + +/** + * Zeigt Erfolgs-Feedback auf einem Button (Checkmark für 700ms). + * @param {HTMLButtonElement} btn + * @param {string} [originalLabel] + */ +export function btnSuccess(btn, originalLabel) { + const label = originalLabel ?? btn.textContent; + btn.classList.add('btn--success'); + btn.innerHTML = ` + + `; + setTimeout(() => { + btn.classList.remove('btn--success'); + btn.textContent = label; + }, 700); +} + +/** + * Zeigt Fehler-Feedback auf einem Button (Shake-Animation). + * @param {HTMLButtonElement} btn + */ +export function btnError(btn) { + btn.classList.remove('btn--shaking'); + void btn.offsetWidth; // Reflow für Animation-Restart + btn.classList.add('btn--shaking'); + btn.addEventListener('animationend', () => btn.classList.remove('btn--shaking'), { once: true }); } diff --git a/public/components/oikos-install-prompt.js b/public/components/oikos-install-prompt.js index c55efb8..2a0d407 100644 --- a/public/components/oikos-install-prompt.js +++ b/public/components/oikos-install-prompt.js @@ -7,11 +7,15 @@ * - Chrome/Android: Fängt beforeinstallprompt ab, zeigt Install-Banner * - iOS (Safari): Zeigt Anleitung "Zum Home-Bildschirm" * - Standalone-Modus: Zeigt nichts an - * - Dismiss: 30 Tage via localStorage gespeichert + * - Dismiss: 7 Tage via localStorage gespeichert + * - Timing: Banner erst nach 2 Nutzer-Interaktionen anzeigen */ const DISMISS_KEY = 'oikos-install-dismissed'; -const DISMISS_DURATION_MS = 30 * 24 * 60 * 60 * 1000; // 30 Tage +const DISMISS_DURATION_MS = 7 * 24 * 60 * 60 * 1000; // 7 Tage + +const INTERACTION_KEY = 'oikos-install-interactions'; +const INTERACTION_THRESHOLD = 2; class OikosInstallPrompt extends HTMLElement { constructor() { @@ -35,6 +39,13 @@ class OikosInstallPrompt extends HTMLElement { return; } + // Noch nicht genug Interaktionen + const interactions = Number(localStorage.getItem(INTERACTION_KEY) || '0'); + if (interactions < INTERACTION_THRESHOLD) { + this._waitForInteractions(); + return; + } + if (this._isIOS()) { this._showIOSPrompt(); } else { @@ -44,6 +55,25 @@ class OikosInstallPrompt extends HTMLElement { disconnectedCallback() { window.removeEventListener('beforeinstallprompt', this._onBeforeInstall); + if (this._offInteraction) this._offInteraction(); + } + + _waitForInteractions() { + const onInteraction = () => { + const count = Number(localStorage.getItem(INTERACTION_KEY) || '0') + 1; + localStorage.setItem(INTERACTION_KEY, String(count)); + + if (count >= INTERACTION_THRESHOLD) { + document.removeEventListener('click', onInteraction); + if (this._isIOS()) { + this._showIOSPrompt(); + } else { + this._listenForInstallPrompt(); + } + } + }; + document.addEventListener('click', onInteraction); + this._offInteraction = () => document.removeEventListener('click', onInteraction); } /** iOS Safari erkennen (kein beforeinstallprompt-Support) */ @@ -307,9 +337,10 @@ class OikosInstallPrompt extends HTMLElement { this._deferredPrompt = null; } - /** Dismiss: 30 Tage merken, Banner entfernen */ + /** Dismiss: 7 Tage merken, Interaction-Counter zurücksetzen, Banner entfernen */ _dismiss() { localStorage.setItem(DISMISS_KEY, String(Date.now())); + localStorage.removeItem(INTERACTION_KEY); this._remove(); } diff --git a/public/pages/budget.js b/public/pages/budget.js index d03b5bd..86af7ae 100644 --- a/public/pages/budget.js +++ b/public/pages/budget.js @@ -7,6 +7,7 @@ import { api } from '/api.js'; import { openModal as openSharedModal, closeModal } from '/components/modal.js'; +import { stagger, vibrate } from '/utils/ux.js'; // -------------------------------------------------------- // Konstanten @@ -202,6 +203,7 @@ function renderBody() { `; if (window.lucide) lucide.createIcons(); + stagger(_container.querySelector('#budget-list')?.querySelectorAll('.budget-entry') ?? []); _container.querySelector('#budget-list')?.addEventListener('click', async (e) => { const delBtn = e.target.closest('[data-action="delete"]'); @@ -239,10 +241,13 @@ function renderCategoryBars(byCategory) { function renderEntries() { if (!state.entries.length) { - return `