diff --git a/docs/superpowers/plans/2026-03-30-ux-polish.md b/docs/superpowers/plans/2026-03-30-ux-polish.md new file mode 100644 index 0000000..ff15124 --- /dev/null +++ b/docs/superpowers/plans/2026-03-30-ux-polish.md @@ -0,0 +1,1545 @@ +# UX Polish — Implementierungsplan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Die vier UX-Schichten aus dem Design-Spec umsetzen: Konsistenz, Animationen, Mobile-Feel und Formular-UX. + +**Architecture:** Alle Änderungen sind rein frontend-seitig (CSS + Vanilla JS). Kein Backend betroffen. Jede Schicht ist ein eigener Commit-Block. Tests via `npm test` validieren weiterhin die Backend-Schicht; für neue JS-Utilities werden Tests in `test-ux-utils.js` angelegt. + +**Tech Stack:** Vanilla JS (ES modules), CSS Custom Properties, Web Animations API, Vibration API, `scrollIntoView`, `beforeinstallprompt` + +--- + +## Ist-Analyse (Stand v0.1.0) + +**Bereits erledigt — nicht anfassen:** +- Warm-Neutraltöne in `tokens.css` ✓ +- Modul-Akzentfarben als CSS-Tokens ✓ +- Typografie-Skala (Tokens) ✓ +- `letter-spacing` auf `.page__title` ✓ +- Skeleton-Shimmer in `layout.css` ✓ +- Skeleton-Loading auf Dashboard ✓ +- Skeleton in Shopping + Tasks ✓ +- `overscroll-behavior` in `pwa.css` ✓ +- `-webkit-overflow-scrolling` in `pwa.css` ✓ +- Safe-Area-Insets ✓ +- PWA-Banner (Slide-in) in `oikos-install-prompt.js` ✓ +- Theme-color pro Modul in `router.js` ✓ +- Focus-Trap, Escape, Overlay-Click in `modal.js` ✓ +- Auto-Focus (erster Fokus-Wechsel, 50ms) in `modal.js` ✓ +- Vibration in `tasks.js` (partiell) ✓ + +**Echte Lücken — werden umgesetzt:** +1. Schicht 1: Card-Padding inkonsistent in mehreren Modulen; FAB-Farben nicht überall mit Modul-Akzent verknüpft +2. Schicht 2: Seitenübergang nutzt `translateY` (vertikal) statt direktionaler `translateX`; kein Staggered Fade-In; Empty States nutzen modul-eigene CSS-Klassen statt `.empty-state` +3. Schicht 3: Install-Prompt erscheint sofort (kein Delay); Dismiss-Dauer 30d statt 7d; kein `scrollIntoView` bei virtuellem Keyboard; Vibration nur in tasks +4. Schicht 4: Kein Enter-to-Next-Field; keine Blur-Validierung; kein Bottom Sheet auf Mobile; kein Submit-Feedback (Checkmark/Shake) + +--- + +## Dateiübersicht + +| Datei | Aktion | Schicht | +|-------|--------|---------| +| `public/styles/layout.css` | Modify — Card-Padding-Token, FAB-Accent-Klasse, Bottom-Sheet-Styles, Slide-Animation | 1, 2, 4 | +| `public/styles/tasks.css` | Modify — Card-Padding anpassen, Swipe-Reveal-Opacity | 1, 2 | +| `public/styles/contacts.css` | Modify — Card-Padding, Empty-State-Klasse entfernen | 1, 2 | +| `public/styles/budget.css` | Modify — Card-Padding, Empty-State-Klasse entfernen | 1, 2 | +| `public/styles/notes.css` | Modify — Empty-State-Klasse entfernen | 2 | +| `public/styles/shopping.css` | Modify — Empty-State-Klasse entfernen | 2 | +| `public/styles/meals.css` | Modify — Card-Padding | 1 | +| `public/router.js` | Modify — `translateX`-Seitenübergang (directional) | 2 | +| `public/pages/tasks.js` | Modify — Stagger, Swipe-Reveal-Opacity, Vibration-Utility | 2, 3 | +| `public/pages/shopping.js` | Modify — Stagger, Empty-State-Klasse | 2 | +| `public/pages/meals.js` | Modify — Stagger, Empty-State | 2 | +| `public/pages/contacts.js` | Modify — Stagger, Empty-State-Klasse | 2 | +| `public/pages/budget.js` | Modify — Stagger, Empty-State-Klasse | 2 | +| `public/pages/notes.js` | Modify — Stagger, Empty-State-Klasse | 2 | +| `public/pages/calendar.js` | Modify — Stagger | 2 | +| `public/components/oikos-install-prompt.js` | Modify — Timing (Delay), Dismiss 7d | 3 | +| `public/components/modal.js` | Modify — Bottom Sheet, scrollIntoView, Enter-Nav, Blur-Validierung, Submit-Feedback | 3, 4 | +| `public/utils/ux.js` | Create — `stagger()`, `vibrate()` Utilities | 2, 3 | +| `test-ux-utils.js` | Create — Tests für `stagger()` und `vibrate()` | 2, 3 | + +--- + +## Schicht 1 — Design-Sprache & Konsistenz + +### Task 1: Card-Padding vereinheitlichen + +**Files:** +- Modify: `public/styles/tasks.css` +- Modify: `public/styles/contacts.css` +- Modify: `public/styles/budget.css` +- Modify: `public/styles/meals.css` + +- [ ] **Step 1: Aktuelle Padding-Werte prüfen** + +```bash +grep -n "padding" public/styles/tasks.css public/styles/contacts.css public/styles/budget.css public/styles/meals.css | grep -v "padding-bottom\|padding-top\|padding-left\|padding-right" | head -30 +``` + +Erwartung: Unterschiedliche Werte (12px, 14px, 16px, 20px). + +- [ ] **Step 2: tasks.css — Task-Card-Padding auf 16px setzen** + +In `public/styles/tasks.css`, alle `.task-card { padding: ... }` Regeln auf `var(--space-4)` vereinheitlichen. Beispiel: + +```css +/* Vorher (ca. Zeile mit .task-card) */ +.task-card { + padding: var(--space-3) var(--space-4); +} + +/* Nachher */ +.task-card { + padding: var(--space-4); +} +``` + +- [ ] **Step 3: contacts.css — Contact-Card-Padding auf 16px** + +In `public/styles/contacts.css`, `.contact-card { padding: ... }` auf `var(--space-4)` setzen. + +- [ ] **Step 4: budget.css — Budget-Entry-Padding auf 16px** + +In `public/styles/budget.css`, `.budget-entry { padding: ... }` auf `var(--space-4)` setzen. + +- [ ] **Step 5: meals.css — Meal-Card-Padding auf 16px** + +In `public/styles/meals.css`, `.meal-card { padding: ... }` auf `var(--space-4)` setzen. + +- [ ] **Step 6: Visuell prüfen** + +```bash +npm run dev +``` + +Browser öffnen → Tasks, Kontakte, Budget, Essensplan. Cards sollen optisch konsistent wirken — gleiche Innenabstände. Nichts soll abgeschnitten oder zu eng wirken. + +- [ ] **Step 7: Commit** + +```bash +git add public/styles/tasks.css public/styles/contacts.css public/styles/budget.css public/styles/meals.css +git commit -m "style: unify card padding to 16px across all modules" +``` + +--- + +### Task 2: FAB-Farben mit Modul-Akzent verknüpfen + +**Files:** +- Modify: `public/styles/layout.css` +- Modify: `public/styles/dashboard.css` +- Modify: `public/styles/tasks.css` +- Modify: `public/styles/shopping.css` +- Modify: `public/styles/meals.css` +- Modify: `public/styles/notes.css` +- Modify: `public/styles/contacts.css` +- Modify: `public/styles/budget.css` + +- [ ] **Step 1: FAB-Basis-Klasse in layout.css prüfen und anpassen** + +```bash +grep -n "fab\|FAB" public/styles/layout.css | head -10 +``` + +In `public/styles/layout.css` die `.fab`-Klasse auf `var(--module-accent, var(--color-btn-primary))` setzen: + +```css +.fab { + background-color: var(--module-accent, var(--color-btn-primary)); + /* alle anderen Eigenschaften bleiben */ +} +.fab:hover { + background-color: color-mix(in srgb, var(--module-accent, var(--color-btn-primary)) 85%, black); +} +``` + +- [ ] **Step 2: Modul-Akzent-Variable pro Modul setzen** + +In jeder Modul-CSS-Datei die `.page`-Klasse (oder root-Selektor) um `--module-accent` ergänzen. + +In `public/styles/dashboard.css`: +```css +.dashboard-page { --module-accent: var(--module-dashboard); } +``` + +In `public/styles/tasks.css`: +```css +.tasks-page { --module-accent: var(--module-tasks); } +``` + +In `public/styles/shopping.css`: +```css +.shopping-page { --module-accent: var(--module-shopping); } +``` + +In `public/styles/meals.css`: +```css +.meals-page { --module-accent: var(--module-meals); } +``` + +In `public/styles/notes.css`: +```css +.notes-page { --module-accent: var(--module-notes); } +``` + +In `public/styles/contacts.css`: +```css +.contacts-page { --module-accent: var(--module-contacts); } +``` + +In `public/styles/budget.css`: +```css +.budget-page { --module-accent: var(--module-budget); } +``` + +- [ ] **Step 3: Prüfen ob Page-Klassen in JS-Modulen gesetzt sind** + +```bash +grep -n "class.*page\|className" public/pages/tasks.js public/pages/shopping.js | head -10 +``` + +Falls die `.tasks-page`/`.shopping-page`-Klasse fehlt, in den jeweiligen `render()`-Funktionen hinzufügen: +```js +container.classList.add('tasks-page'); +``` + +- [ ] **Step 4: Visuell prüfen** + +FAB auf jeder Seite soll in der Akzentfarbe des Moduls erscheinen (Tasks → Grün, Einkauf → Rot-Orange, etc.). + +- [ ] **Step 5: Commit** + +```bash +git add public/styles/layout.css public/styles/dashboard.css public/styles/tasks.css public/styles/shopping.css public/styles/meals.css public/styles/notes.css public/styles/contacts.css public/styles/budget.css +git commit -m "style: tie FAB colors to per-module accent tokens" +``` + +--- + +## Schicht 2 — Animationen & Übergänge + +### Task 3: Seitenübergang auf direktionales Slide-X umstellen + +**Files:** +- Modify: `public/router.js` +- Modify: `public/styles/layout.css` + +- [ ] **Step 1: Aktuelle Transition in layout.css anpassen** + +In `public/styles/layout.css`, die `@keyframes page-in` ersetzen: + +```css +/* Vorher */ +@keyframes page-in { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: translateY(0); } +} + +/* Nachher */ +@keyframes page-slide-in-right { + from { opacity: 0; transform: translateX(20px); } + to { opacity: 1; transform: translateX(0); } +} + +@keyframes page-slide-in-left { + from { opacity: 0; transform: translateX(-20px); } + to { opacity: 1; transform: translateX(0); } +} + +@keyframes page-out-left { + from { opacity: 1; transform: translateX(0); } + to { opacity: 0; transform: translateX(-20px); } +} + +@keyframes page-out-right { + from { opacity: 1; transform: translateX(0); } + to { opacity: 0; transform: translateX(20px); } +} + +.page-transition--in-right { + animation: page-slide-in-right 0.2s var(--ease-out) forwards; +} + +.page-transition--in-left { + animation: page-slide-in-left 0.2s var(--ease-out) forwards; +} + +.page-transition--out-left { + animation: page-out-left 0.12s ease forwards; + pointer-events: none; +} + +.page-transition--out-right { + animation: page-out-right 0.12s ease forwards; + pointer-events: none; +} +``` + +Alte `.page-transition--out` und `@keyframes page-out` entfernen. + +- [ ] **Step 2: router.js — Navigations-Richtung tracken** + +In `public/router.js`, vor der `navigate()`-Funktion: + +```js +// Navigations-Richtung für Slide-Animation +// Reihenfolge der Routen definiert "Tiefe" +const ROUTE_ORDER = ['/', '/tasks', '/shopping', '/meals', '/calendar', + '/notes', '/contacts', '/budget', '/settings']; + +function getDirection(fromPath, toPath) { + const fromIdx = ROUTE_ORDER.indexOf(fromPath ?? '/'); + const toIdx = ROUTE_ORDER.indexOf(toPath); + if (fromIdx === -1 || toIdx === -1 || fromPath === toPath) return 'right'; + return toIdx > fromIdx ? 'right' : 'left'; +} +``` + +- [ ] **Step 3: renderPage() auf direktionale Klassen umstellen** + +In `public/router.js`, die `renderPage()`-Funktion anpassen. Den Block ab `// Alte Seite kurz ausfaden`: + +```js +// Richtung bestimmen +const direction = getDirection(currentPath, route.path); +const outClass = direction === 'right' ? 'page-transition--out-left' : 'page-transition--out-right'; +const inClass = direction === 'right' ? 'page-transition--in-right' : 'page-transition--in-left'; + +// Alte Seite ausfaden +const oldPage = content.querySelector('.page-transition'); +if (oldPage) { + oldPage.classList.add(outClass); + await new Promise(r => setTimeout(r, 120)); +} + +const pageWrapper = document.createElement('div'); +pageWrapper.className = `page-transition ${inClass}`; +content.replaceChildren(pageWrapper); +``` + +Die Zeile `pageWrapper.style.animation = 'page-in 0.2s ease forwards';` entfernen (wird jetzt über Klasse gesteuert). + +**Hinweis:** `currentPath` wird in `navigate()` erst nach der Richtungsberechnung gesetzt. Die Richtungsberechnung muss mit dem alten `currentPath` erfolgen — Zeile `currentPath = path;` erst nach `getDirection()` aufrufen. + +- [ ] **Step 4: prefers-reduced-motion respektieren** + +In `public/styles/layout.css` nach den neuen Keyframes: + +```css +@media (prefers-reduced-motion: reduce) { + .page-transition--in-right, + .page-transition--in-left { + animation: none; + opacity: 1; + } + .page-transition--out-left, + .page-transition--out-right { + animation: none; + } +} +``` + +- [ ] **Step 5: Visuell prüfen** + +Im Browser zwischen mehreren Seiten navigieren. Navigation nach rechts (Dashboard → Aufgaben) soll Slide von rechts zeigen. Browser-Zurück soll von links kommen. Soll flüssig und nicht abrupt sein. + +- [ ] **Step 6: Commit** + +```bash +git add public/router.js public/styles/layout.css +git commit -m "feat: directional slide-x page transitions in router" +``` + +--- + +### Task 4: Shared Utility `stagger()` und Tests + +**Files:** +- Create: `public/utils/ux.js` +- Create: `test-ux-utils.js` + +- [ ] **Step 1: `public/utils/ux.js` erstellen** + +```js +/** + * Modul: UX Utilities + * Zweck: Wiederverwendbare Animationshelfer (Stagger, Vibration) + * Abhängigkeiten: keine + */ + +/** + * Gestaffeltes Einblenden einer NodeList oder eines Arrays von Elementen. + * Maximal MAX_STAGGER Elemente werden verzögert, der Rest sofort eingeblendet. + * + * @param {NodeList|Element[]} elements + * @param {Object} [opts] + * @param {number} [opts.delay=30] — ms zwischen jedem Element + * @param {number} [opts.duration=180] — ms pro Element + * @param {number} [opts.max=5] — Maximale Anzahl gestaffelter Elemente + */ +export function stagger(elements, { delay = 30, duration = 180, max = 5 } = {}) { + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; + const els = Array.from(elements); + els.forEach((el, i) => { + const itemDelay = i < max ? i * delay : max * delay; + el.style.opacity = '0'; + el.style.transform = 'translateY(8px)'; + el.style.transition = `opacity ${duration}ms ease, transform ${duration}ms ease`; + setTimeout(() => { + el.style.opacity = '1'; + el.style.transform = 'translateY(0)'; + }, itemDelay); + }); +} + +/** + * Vibrationsmuster abspielen, wenn die API verfügbar ist und + * keine reduzierte Bewegung gewünscht wird. + * + * @param {number|number[]} pattern — ms oder [an, aus, an, ...]-Array + */ +export function vibrate(pattern) { + if (!navigator.vibrate) return; + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; + navigator.vibrate(pattern); +} +``` + +- [ ] **Step 2: `test-ux-utils.js` erstellen** + +```js +/** + * Tests: UX Utilities (stagger, vibrate) + * Läuft im Node-Kontext — kein DOM verfügbar, daher nur Pure-Logic-Tests. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +// Minimales Window/Navigator-Mock für Node +const { stagger, vibrate } = await (async () => { + // stagger braucht window.matchMedia — wir mocken es + global.window = { + matchMedia: () => ({ matches: false }), + }; + global.navigator = { vibrate: null }; + return import('./public/utils/ux.js'); +})(); + +test('stagger: setzt opacity:0 auf alle Elemente', () => { + const els = [{ style: {} }, { style: {} }, { style: {} }]; + stagger(els, { delay: 0, duration: 0 }); + assert.equal(els[0].style.opacity, '0'); + assert.equal(els[1].style.opacity, '0'); + assert.equal(els[2].style.opacity, '0'); +}); + +test('stagger: tut nichts bei prefers-reduced-motion', () => { + global.window.matchMedia = () => ({ matches: true }); + const els = [{ style: {} }]; + stagger(els); + assert.equal(els[0].style.opacity, undefined); // unverändert + global.window.matchMedia = () => ({ matches: false }); // reset +}); + +test('vibrate: tut nichts wenn API nicht vorhanden', () => { + global.navigator = { vibrate: null }; + assert.doesNotThrow(() => vibrate(10)); +}); + +test('vibrate: ruft navigator.vibrate auf wenn vorhanden', () => { + let called = null; + global.navigator = { vibrate: (p) => { called = p; } }; + vibrate(15); + assert.equal(called, 15); +}); +``` + +- [ ] **Step 3: Test in package.json eintragen** + +In `package.json`, im `"scripts"`-Block: +```json +"test:ux-utils": "node --experimental-vm-modules test-ux-utils.js" +``` + +Und den `"test"`-Script ergänzen: +```json +"test": "... && npm run test:ux-utils" +``` + +- [ ] **Step 4: Tests ausführen** + +```bash +node --experimental-vm-modules test-ux-utils.js +``` + +Erwartung: 4 Tests bestanden, 0 fehlgeschlagen. + +- [ ] **Step 5: Commit** + +```bash +git add public/utils/ux.js test-ux-utils.js package.json +git commit -m "feat: add stagger() and vibrate() UX utilities with tests" +``` + +--- + +### Task 5: Staggered Fade-In in allen Listenmodulen + +**Files:** +- Modify: `public/pages/tasks.js` +- Modify: `public/pages/shopping.js` +- Modify: `public/pages/meals.js` +- Modify: `public/pages/contacts.js` +- Modify: `public/pages/budget.js` +- Modify: `public/pages/notes.js` +- Modify: `public/pages/calendar.js` + +- [ ] **Step 1: Import in tasks.js hinzufügen** + +Am Anfang von `public/pages/tasks.js` nach den bestehenden Imports: + +```js +import { stagger } from '/utils/ux.js'; +``` + +- [ ] **Step 2: stagger() nach Render der Aufgabenliste aufrufen (tasks.js)** + +In der Funktion, die die Aufgabenliste rendert (ca. Zeile 200+), nach dem Einfügen ins DOM: + +```js +// Staggered Fade-In für Listeneinträge +stagger(container.querySelectorAll('.task-item, .kanban-card')); +``` + +- [ ] **Step 3: Import und stagger() in shopping.js** + +```js +import { stagger } from '/utils/ux.js'; +``` + +Nach dem Render der Einkaufsliste: + +```js +stagger(container.querySelectorAll('.shopping-item')); +``` + +- [ ] **Step 4: Import und stagger() in meals.js** + +```js +import { stagger } from '/utils/ux.js'; +``` + +Nach dem Render der Mahlzeiten-Cards: + +```js +stagger(container.querySelectorAll('.meal-card')); +``` + +- [ ] **Step 5: Import und stagger() in contacts.js** + +```js +import { stagger } from '/utils/ux.js'; +``` + +Nach dem Render der Kontaktliste: + +```js +stagger(container.querySelectorAll('.contact-card')); +``` + +- [ ] **Step 6: Import und stagger() in budget.js** + +```js +import { stagger } from '/utils/ux.js'; +``` + +Nach dem Render der Budget-Einträge: + +```js +stagger(container.querySelectorAll('.budget-entry')); +``` + +- [ ] **Step 7: Import und stagger() in notes.js** + +```js +import { stagger } from '/utils/ux.js'; +``` + +Nach dem Render der Notizen-Cards: + +```js +stagger(container.querySelectorAll('.note-card')); +``` + +- [ ] **Step 8: Import und stagger() in calendar.js** + +```js +import { stagger } from '/utils/ux.js'; +``` + +Nach dem Render von Agenda-Einträgen (nur Agenda-Ansicht, nicht Monat/Woche/Tag): + +```js +if (state.view === 'agenda') { + stagger(container.querySelectorAll('.agenda-event')); +} +``` + +- [ ] **Step 9: Visuell prüfen** + +Jede Listenseite öffnen. Einträge sollen beim Laden gestaffelt von unten einblenden (ca. 30ms zwischen jedem, max. 5 gestaffelt). Soll sich lebendig, aber nicht ablenkend anfühlen. + +- [ ] **Step 10: Commit** + +```bash +git add public/pages/tasks.js public/pages/shopping.js public/pages/meals.js public/pages/contacts.js public/pages/budget.js public/pages/notes.js public/pages/calendar.js +git commit -m "feat: staggered fade-in for list items across all modules" +``` + +--- + +### Task 6: Empty States vereinheitlichen (alle Module) + +**Files:** +- Modify: `public/pages/tasks.js` +- Modify: `public/pages/shopping.js` +- Modify: `public/pages/notes.js` +- Modify: `public/pages/meals.js` +- Modify: `public/pages/contacts.js` +- Modify: `public/pages/budget.js` +- Modify: `public/styles/tasks.css` +- Modify: `public/styles/shopping.css` +- Modify: `public/styles/notes.css` +- Modify: `public/styles/contacts.css` +- Modify: `public/styles/budget.css` + +- [ ] **Step 1: tasks.js — `.tasks-empty` auf `.empty-state` umstellen** + +In `public/pages/tasks.js` (ca. Zeile 197), den Block ersetzen: + +```js +// Vorher: +//
+// +//
Keine Aufgaben
+//
...
+//
+ +// Nachher: +return ` +
+ +
Keine Aufgaben — alles erledigt?
+
Neue Aufgaben über den + Button erstellen.
+
+`; +``` + +- [ ] **Step 2: shopping.js — `.shopping-empty` auf `.empty-state` umstellen** + +In `public/pages/shopping.js` (ca. Zeile 161): + +```js +// Nachher: +`
+ +
Die Liste ist leer
+
Artikel über das Eingabefeld oben hinzufügen.
+
` +``` + +- [ ] **Step 3: notes.js — `.notes-empty` auf `.empty-state` umstellen** + +In `public/pages/notes.js` (ca. Zeile 90): + +```js +// Nachher: +`
+ +
Noch keine Notizen
+
Neue Notiz über den + Button erstellen.
+
` +``` + +- [ ] **Step 4: meals.js — Empty State ergänzen (fehlte bisher)** + +In `public/pages/meals.js`, in der Funktion die den Tages-Slot rendert, wenn keine Mahlzeit vorhanden: + +```js +// Wenn kein Meal für diesen Slot: Leeren Slot mit Empty-State rendern +if (!meal) { + return ` +
+
+
Kein Essen geplant
+
+
+ `; +} +``` + +In `public/styles/layout.css` einen Modifier ergänzen: +```css +.empty-state--compact { + padding: var(--space-4) var(--space-3); + gap: var(--space-2); +} +.empty-state--compact .empty-state__description { + font-size: var(--text-sm); +} +``` + +- [ ] **Step 5: contacts.js — `.contacts-empty` auf `.empty-state` umstellen** + +In `public/pages/contacts.js` (ca. Zeile 136): + +```js +// Nachher — als HTML-String: +`
+ +
Noch keine Kontakte
+
Neue Kontakte über den + Button hinzufügen.
+
` +``` + +- [ ] **Step 6: budget.js — `.budget-empty` auf `.empty-state` umstellen** + +In `public/pages/budget.js` (ca. Zeile 242): + +```js +// Nachher: +return ` +
+ +
Keine Einträge diesen Monat
+
Budget-Einträge über den + Button hinzufügen.
+
+`; +``` + +- [ ] **Step 7: Modul-eigene Empty-State-CSS-Klassen entfernen** + +In den folgenden CSS-Dateien die modul-eigenen Klassen entfernen (werden durch `.empty-state` aus `layout.css` ersetzt): +- `public/styles/tasks.css`: `.tasks-empty`, `.tasks-empty__icon`, `.tasks-empty__title`, `.tasks-empty__desc` +- `public/styles/shopping.css`: `.shopping-empty`, `.shopping-empty__icon`, `.shopping-empty__title`, `.shopping-empty__desc` +- `public/styles/notes.css`: `.notes-empty`, `.notes-empty__icon` +- `public/styles/contacts.css`: `.contacts-empty` +- `public/styles/budget.css`: `.budget-empty` + +- [ ] **Step 8: Visuell prüfen** + +Alle sechs Module ohne Einträge öffnen. Empty States sollen einheitlich aussehen (zentriert, Icon, Titel, Beschreibung). Meals soll leere Slots kompakt darstellen. + +- [ ] **Step 9: Commit** + +```bash +git add public/pages/tasks.js public/pages/shopping.js public/pages/notes.js public/pages/meals.js public/pages/contacts.js public/pages/budget.js public/styles/tasks.css public/styles/shopping.css public/styles/notes.css public/styles/contacts.css public/styles/budget.css public/styles/layout.css +git commit -m "style: unify all empty states to shared .empty-state class across all modules" +``` + +--- + +### Task 7: Swipe-Reveal proportionale Opacity (Tasks) + +**Files:** +- Modify: `public/pages/tasks.js` + +- [ ] **Step 1: wireSwipeGestures() in tasks.js finden** + +```bash +grep -n "wireSwipeGestures\|swipe-reveal" public/pages/tasks.js | head -15 +``` + +- [ ] **Step 2: Reveal-Opacity proportional zur Swipe-Distanz machen** + +In `public/pages/tasks.js`, in der `wireSwipeGestures()`-Funktion (ca. ab Zeile 680), im `touchmove`-Event-Handler: + +Den Block, der die swipe-reveal-Elemente steuert, ergänzen: + +```js +// Proportionale Opacity des Reveal-Bereichs +const doneReveal = row.querySelector('.swipe-reveal--done'); +const editReveal = row.querySelector('.swipe-reveal--edit'); +const progress = Math.min(Math.abs(deltaX) / SWIPE_THRESHOLD, 1); + +if (deltaX < 0 && doneReveal) { + doneReveal.style.opacity = String(progress); +} else if (deltaX > 0 && editReveal) { + editReveal.style.opacity = String(progress); +} +``` + +Im `touchend`-Handler: Opacity zurücksetzen: + +```js +if (doneReveal) doneReveal.style.opacity = ''; +if (editReveal) editReveal.style.opacity = ''; +``` + +- [ ] **Step 3: Visuell prüfen (mobiler Viewport)** + +Im Browser DevTools auf mobilen Viewport wechseln. Eine Aufgabe nach links wischen. Der grüne Bereich soll proportional zur Wischweite einblenden, nicht abrupt erscheinen. + +- [ ] **Step 4: Commit** + +```bash +git add public/pages/tasks.js +git commit -m "feat: proportional opacity on swipe-reveal action areas" +``` + +--- + +## Schicht 3 — Mobile PWA & Natives Gefühl + +### Task 8: Install-Prompt Timing und Dismiss-Dauer anpassen + +**Files:** +- Modify: `public/components/oikos-install-prompt.js` + +- [ ] **Step 1: Dismiss-Dauer von 30d auf 7d ändern** + +In `public/components/oikos-install-prompt.js`, Zeile 14: + +```js +// Vorher +const DISMISS_DURATION_MS = 30 * 24 * 60 * 60 * 1000; // 30 Tage + +// Nachher +const DISMISS_DURATION_MS = 7 * 24 * 60 * 60 * 1000; // 7 Tage +``` + +- [ ] **Step 2: Interaktions-Zähler einführen** + +Vor der Klassen-Definition: + +```js +const INTERACTION_KEY = 'oikos-install-interactions'; +const INTERACTION_THRESHOLD = 2; // Anzahl Interaktionen vor Prompt +``` + +- [ ] **Step 3: connectedCallback anpassen — erst nach Interaktionen zeigen** + +Die `connectedCallback()`-Methode um Interaktions-Check erweitern: + +```js +connectedCallback() { + if ( + window.matchMedia('(display-mode: standalone)').matches || + navigator.standalone === true + ) return; + + const dismissed = localStorage.getItem(DISMISS_KEY); + if (dismissed && Date.now() - Number(dismissed) < DISMISS_DURATION_MS) 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 { + this._listenForInstallPrompt(); + } +} +``` + +- [ ] **Step 4: `_waitForInteractions()` implementieren** + +```js +_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); +} +``` + +- [ ] **Step 5: disconnectedCallback cleanup** + +```js +disconnectedCallback() { + window.removeEventListener('beforeinstallprompt', this._onBeforeInstall); + if (this._offInteraction) this._offInteraction(); +} +``` + +- [ ] **Step 6: Visuell prüfen** + +`localStorage.removeItem('oikos-install-interactions')` in DevTools Console ausführen. App neu laden. Banner soll NICHT sofort erscheinen. Zweimal auf Buttons klicken → Banner soll einblenden. + +- [ ] **Step 7: Commit** + +```bash +git add public/components/oikos-install-prompt.js +git commit -m "feat: delay install prompt until 2 interactions, reduce dismiss to 7 days" +``` + +--- + +### Task 9: Virtual-Keyboard scrollIntoView in modal.js + +**Files:** +- Modify: `public/components/modal.js` + +- [ ] **Step 1: scrollIntoView-Listener in trapFocus() hinzufügen** + +In `public/components/modal.js`, in der `trapFocus()`-Funktion nach dem `container.addEventListener('keydown', ...)`: + +```js +// 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); + +// Cleanup beim Schließen: Referenz speichern +container._onInputFocus = onInputFocus; +``` + +- [ ] **Step 2: Listener in closeModal() entfernen** + +In `closeModal()`, vor `activeOverlay.remove()`: + +```js +const panel = activeOverlay.querySelector('.modal-panel'); +if (panel?._onInputFocus) { + panel.removeEventListener('focusin', panel._onInputFocus); +} +``` + +- [ ] **Step 3: Prüfen im mobilen Viewport** + +DevTools: iPhone-Viewport. Ein Modal öffnen. Auf ein unteres Eingabefeld tippen. Das Feld soll sanft in die Mitte des sichtbaren Bereichs scrollen, wenn die Tastatur erscheint. + +- [ ] **Step 4: Commit** + +```bash +git add public/components/modal.js +git commit -m "feat: scroll focused input into view when virtual keyboard opens" +``` + +--- + +### Task 10: Vibrations-Feedback konsistenzieren + +**Files:** +- Modify: `public/pages/tasks.js` +- Modify: `public/pages/shopping.js` +- Modify: `public/pages/contacts.js` +- Modify: `public/pages/budget.js` +- Modify: `public/pages/notes.js` + +- [ ] **Step 1: Import in allen Modulen ergänzen** + +In jedem der o.g. Module: + +```js +import { vibrate } from '/utils/ux.js'; +``` + +- [ ] **Step 2: tasks.js — bestehende `navigator.vibrate()`-Calls ersetzen** + +```bash +grep -n "navigator.vibrate" public/pages/tasks.js +``` + +Jeden Fund ersetzen: +```js +// Vorher +if (navigator.vibrate) navigator.vibrate(40); + +// Nachher +vibrate(40); +``` + +- [ ] **Step 3: shopping.js — Vibration bei Artikel abhaken** + +In `public/pages/shopping.js`, im Checkbox-Toggle-Handler: + +```js +// Nach erfolgreichem API-Call beim Abhaken: +vibrate(10); +``` + +- [ ] **Step 4: contacts.js — Vibration bei Löschen** + +In `public/pages/contacts.js`, im Delete-Handler (nach API-Aufruf): + +```js +vibrate([30, 50, 30]); +``` + +- [ ] **Step 5: budget.js — Vibration bei Löschen** + +In `public/pages/budget.js`, im Delete-Handler: + +```js +vibrate([30, 50, 30]); +``` + +- [ ] **Step 6: notes.js — Vibration bei Löschen** + +In `public/pages/notes.js`, im Delete-Handler: + +```js +vibrate([30, 50, 30]); +``` + +- [ ] **Step 7: Commit** + +```bash +git add public/pages/tasks.js public/pages/shopping.js public/pages/contacts.js public/pages/budget.js public/pages/notes.js +git commit -m "feat: consistent vibration feedback via vibrate() utility across modules" +``` + +--- + +## Schicht 4 — Formulare & Modals + +### Task 11: Bottom Sheet Modal auf Mobile + +**Files:** +- Modify: `public/components/modal.js` +- Modify: `public/styles/layout.css` + +- [ ] **Step 1: Bottom-Sheet-Styles in layout.css ergänzen** + +In `public/styles/layout.css`, nach den bestehenden Modal-Styles: + +```css +/* ── Bottom Sheet (Mobile < 768px) ── */ +@media (max-width: 767px) { + .modal-overlay { + align-items: flex-end; + } + + .modal-panel { + width: 100%; + max-width: 100%; + max-height: 90dvh; + border-radius: var(--radius-xl) var(--radius-xl) 0 0; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + /* Sheet-Handle */ + padding-top: calc(var(--space-4) + 20px); + animation: sheet-in 0.25s var(--ease-out) forwards; + } + + .modal-panel::before { + content: ''; + position: absolute; + top: var(--space-3); + left: 50%; + transform: translateX(-50%); + width: 36px; + height: 4px; + background: var(--color-border); + border-radius: var(--radius-full); + } + + .modal-panel--closing { + animation: sheet-out 0.2s ease forwards; + } +} + +@keyframes sheet-in { + from { transform: translateY(100%); } + to { transform: translateY(0); } +} + +@keyframes sheet-out { + from { transform: translateY(0); } + to { transform: translateY(100%); } +} + +@media (prefers-reduced-motion: reduce) { + .modal-panel { + animation: none; + } + .modal-panel--closing { + animation: none; + } +} +``` + +- [ ] **Step 2: Animierte Schließung in closeModal() ergänzen** + +In `public/components/modal.js`, die `closeModal()`-Funktion anpassen: + +```js +export function closeModal() { + if (!activeOverlay) return; + + document.removeEventListener('keydown', onEscape); + + if (focusTrapHandler) { + const panel = activeOverlay.querySelector('.modal-panel'); + if (panel) { + panel.removeEventListener('keydown', focusTrapHandler); + if (panel._onInputFocus) panel.removeEventListener('focusin', panel._onInputFocus); + // Sheet-Out-Animation auf Mobile + const isMobile = window.innerWidth < 768; + if (isMobile) { + panel.classList.add('modal-panel--closing'); + panel.addEventListener('animationend', () => { + _doClose(); + }, { once: true }); + return; + } + } + focusTrapHandler = null; + } + + _doClose(); +} + +function _doClose() { + if (!activeOverlay) return; + activeOverlay.remove(); + activeOverlay = null; + document.body.style.overflow = ''; + if (previouslyFocused?.focus) { + previouslyFocused.focus(); + previouslyFocused = null; + } + if (window.oikos?.restoreThemeColor) window.oikos.restoreThemeColor(); +} +``` + +- [ ] **Step 3: Swipe-to-Close für Bottom Sheet** + +In `public/components/modal.js`, in `openModal()` nach `trapFocus(panel)`: + +```js +// Swipe-to-Close auf Mobile +if (window.innerWidth < 768) { + _wireSheetSwipe(panel); +} +``` + +Neue Funktion: + +```js +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(); + } + }); +} +``` + +- [ ] **Step 4: Visuell prüfen (Mobile Viewport)** + +Modal öffnen. Auf Mobile: Sheet fährt von unten ein, Sheet-Handle sichtbar. Nach unten wischen → Modal schließt sich. + +- [ ] **Step 5: Commit** + +```bash +git add public/components/modal.js public/styles/layout.css +git commit -m "feat: bottom sheet modal on mobile with swipe-to-close" +``` + +--- + +### Task 12: Enter-Tastennavigation zwischen Formularfeldern + +**Files:** +- Modify: `public/components/modal.js` + +- [ ] **Step 1: Enter-Navigation in trapFocus() einbauen** + +In `public/components/modal.js`, in der `trapFocus()`-Funktion, den `focusTrapHandler` erweitern: + +```js +focusTrapHandler = (e) => { + // Tab-Trap (bestehend) + 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(); + } + } + + // Enter in einzeiligen Inputs → 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(); + } + } + } + } +}; +``` + +- [ ] **Step 2: Prüfen in einem Modal** + +Ein beliebiges Formular öffnen (z.B. neue Aufgabe). Mit Enter durch die Felder navigieren. Letztes Feld + Enter soll den Speichern-Button auslösen. + +- [ ] **Step 3: Commit** + +```bash +git add public/components/modal.js +git commit -m "feat: Enter-key advances focus between form fields and triggers submit on last field" +``` + +--- + +### Task 13: Inline Blur-Validierung in Formularfeldern + +**Files:** +- Modify: `public/styles/layout.css` +- Modify: `public/components/modal.js` + +- [ ] **Step 1: Validierungs-CSS in layout.css ergänzen** + +In `public/styles/layout.css`, nach den Input-Styles: + +```css +/* ── Inline-Validierung ── */ +.form-group { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.form-field--error .form-control { + border-color: var(--color-danger); +} + +.form-field--valid .form-control { + border-color: var(--color-success); +} + +.form-field__error { + display: none; + font-size: var(--text-sm); + color: var(--color-danger); + gap: var(--space-1); + align-items: center; +} + +.form-field--error .form-field__error { + display: flex; +} +``` + +- [ ] **Step 2: Blur-Validierung als Utility in modal.js einbauen** + +In `public/components/modal.js`, neue Hilfsfunktion: + +```js +/** + * Aktiviert Blur-Validierung für alle required-Inputs in einem Container. + * Aufgerufen von onSave-Callbacks der einzelnen Seiten-Module. + * + * @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); + }); + }); +} +``` + +- [ ] **Step 3: Fehleranzeige-HTML-Struktur in tasks.js einbauen (als Beispiel)** + +In `public/pages/tasks.js`, im Formular-HTML der Aufgaben-Modal, Titelfeld anpassen: + +```html + + + + + +
+ + +
+ + Dieses Feld ist erforderlich. +
+
+``` + +- [ ] **Step 4: wireBlurValidation() im onSave-Callback von tasks.js aufrufen** + +In `public/pages/tasks.js`, im `openModal()`-Aufruf, innerhalb von `onSave`: + +```js +import { openModal, closeModal, wireBlurValidation } from '/components/modal.js'; + +// ... +onSave: (panel) => { + wireBlurValidation(panel); + // ... bestehender Event-Binding-Code +} +``` + +- [ ] **Step 5: Visuell prüfen** + +Neue Aufgabe → Titel-Feld anklicken → wieder verlassen ohne Eingabe → roter Rand + Fehlermeldung. Etwas eingeben → grüner Rand. + +- [ ] **Step 6: Commit** + +```bash +git add public/components/modal.js public/styles/layout.css public/pages/tasks.js +git commit -m "feat: blur-triggered inline validation for required form fields" +``` + +--- + +### Task 14: Submit-Feedback (Checkmark-Erfolg, Shake-Fehler) + +**Files:** +- Modify: `public/styles/layout.css` +- Modify: `public/components/modal.js` + +- [ ] **Step 1: Animationen in layout.css ergänzen** + +In `public/styles/layout.css`: + +```css +/* ── Submit-Feedback Animationen ── */ +@keyframes btn-shake { + 0%, 100% { transform: translateX(0); } + 20% { transform: translateX(-4px); } + 40% { transform: translateX(4px); } + 60% { transform: translateX(-4px); } + 80% { transform: translateX(4px); } +} + +.btn--shaking { + animation: btn-shake 0.3s ease; +} + +.btn--success { + background-color: var(--color-success) !important; + color: #fff !important; + pointer-events: none; +} +``` + +- [ ] **Step 2: Submit-Feedback Utility in modal.js** + +In `public/components/modal.js`, neue exportierte Funktionen: + +```js +/** + * Zeigt Erfolgs-Feedback auf einem Button (Checkmark für 600ms). + * @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'); + // Reflow erzwingen damit Animation neu startet + void btn.offsetWidth; + btn.classList.add('btn--shaking'); + btn.addEventListener('animationend', () => btn.classList.remove('btn--shaking'), { once: true }); +} +``` + +- [ ] **Step 3: btnSuccess/btnError in tasks.js verwenden** + +In `public/pages/tasks.js`, Import erweitern: + +```js +import { openModal, closeModal, wireBlurValidation, btnSuccess, btnError } from '/components/modal.js'; +``` + +Im Form-Submit-Handler der Aufgaben: + +```js +// Nach erfolgreichem API-Call: +btnSuccess(saveBtn, 'Speichern'); +setTimeout(() => closeModal(), 700); + +// Bei API-Fehler: +btnError(saveBtn); +showToast('Fehler beim Speichern.', 'danger'); +``` + +- [ ] **Step 4: Visuell prüfen** + +Neue Aufgabe erstellen → Speichern → Button soll kurz grün mit Checkmark werden → Modal schließt sich. Absichtlich Netzwerkfehler simulieren (DevTools → Offline) → Button soll schütteln. + +- [ ] **Step 5: Commit** + +```bash +git add public/styles/layout.css public/components/modal.js public/pages/tasks.js +git commit -m "feat: checkmark success and shake error feedback on form submit buttons" +``` + +--- + +## Abschluss + +- [ ] **Alle Tests ausführen** + +```bash +npm test +``` + +Erwartung: Alle bisherigen Tests + neue `test-ux-utils.js`-Tests grün. + +- [ ] **Vollständige Durchsicht im Browser (Mobile + Desktop)** + +Jede Seite öffnen und prüfen: +- Cards haben einheitliche Abstände ✓ +- FAB-Farben entsprechen dem Modul ✓ +- Seitenübergänge sind direktional (links/rechts) ✓ +- Listen blenden gestaffelt ein ✓ +- Empty States sind einheitlich ✓ +- Swipe-Reveal in Aufgaben blendet proportional ein ✓ +- Install-Prompt erscheint erst nach 2 Interaktionen ✓ +- Virtuelles Keyboard scrollt Inputs in den sichtbaren Bereich ✓ +- Vibration bei Aktionen (Mobile) ✓ +- Modals öffnen als Bottom Sheet auf Mobile ✓ +- Swipe-to-Close funktioniert ✓ +- Enter navigiert durch Formularfelder ✓ +- Blur-Validierung zeigt Fehler unmittelbar ✓ +- Submit-Button gibt Erfolg/Fehler-Feedback ✓ + +- [ ] **CHANGELOG.md aktualisieren** + +Im `[Unreleased]`-Block: + +```markdown +### Changed +- Directional slide-x page transitions (forward = right, backward = left) +- PWA install prompt delayed until 2 user interactions, dismiss window reduced to 7 days +- Unified card padding to 16px across all modules + +### Added +- Staggered fade-in animation for list items on page load +- Unified empty states using shared `.empty-state` class in contacts and budget +- Proportional opacity on swipe-reveal action areas (tasks) +- scrollIntoView for focused inputs when virtual keyboard opens +- Consistent vibration feedback via `vibrate()` utility across modules +- Bottom sheet modal on mobile (< 768px) with swipe-to-close and sheet handle +- Enter-key navigation between form fields; Enter on last field triggers submit +- Blur-triggered inline validation for required fields with error/success states +- Submit button checkmark-success (700ms) and shake-error feedback animations +- FAB colors tied to per-module accent tokens +``` + +- [ ] **Final Commit** + +```bash +git add CHANGELOG.md +git commit -m "docs: update CHANGELOG for UX polish (all 4 layers)" +```