a5d8ae3f5f
Covers: card-padding consistency, module FAB accents, directional slide-x page transitions, staggered list fade-in, unified empty states across all modules, swipe-reveal proportional opacity, install prompt timing, virtual keyboard scroll, vibration utility, bottom sheet modals, Enter-navigation, blur validation, submit feedback animations. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1546 lines
44 KiB
Markdown
1546 lines
44 KiB
Markdown
# 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:
|
|
// <div class="tasks-empty">
|
|
// <i data-lucide="check-circle-2" class="tasks-empty__icon" ...></i>
|
|
// <div class="tasks-empty__title">Keine Aufgaben</div>
|
|
// <div class="tasks-empty__desc">...</div>
|
|
// </div>
|
|
|
|
// Nachher:
|
|
return `
|
|
<div class="empty-state">
|
|
<svg class="empty-state__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
stroke-width="1.5" aria-hidden="true">
|
|
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
|
|
<polyline points="22 4 12 14.01 9 11.01"/>
|
|
</svg>
|
|
<div class="empty-state__title">Keine Aufgaben — alles erledigt?</div>
|
|
<div class="empty-state__description">Neue Aufgaben über den + Button erstellen.</div>
|
|
</div>
|
|
`;
|
|
```
|
|
|
|
- [ ] **Step 2: shopping.js — `.shopping-empty` auf `.empty-state` umstellen**
|
|
|
|
In `public/pages/shopping.js` (ca. Zeile 161):
|
|
|
|
```js
|
|
// Nachher:
|
|
`<div class="empty-state">
|
|
<svg class="empty-state__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
stroke-width="1.5" aria-hidden="true">
|
|
<circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/>
|
|
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/>
|
|
</svg>
|
|
<div class="empty-state__title">Die Liste ist leer</div>
|
|
<div class="empty-state__description">Artikel über das Eingabefeld oben hinzufügen.</div>
|
|
</div>`
|
|
```
|
|
|
|
- [ ] **Step 3: notes.js — `.notes-empty` auf `.empty-state` umstellen**
|
|
|
|
In `public/pages/notes.js` (ca. Zeile 90):
|
|
|
|
```js
|
|
// Nachher:
|
|
`<div class="empty-state">
|
|
<svg class="empty-state__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
stroke-width="1.5" aria-hidden="true">
|
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
|
<polyline points="14 2 14 8 20 8"/>
|
|
<line x1="16" y1="13" x2="8" y2="13"/>
|
|
<line x1="16" y1="17" x2="8" y2="17"/>
|
|
<polyline points="10 9 9 9 8 9"/>
|
|
</svg>
|
|
<div class="empty-state__title">Noch keine Notizen</div>
|
|
<div class="empty-state__description">Neue Notiz über den + Button erstellen.</div>
|
|
</div>`
|
|
```
|
|
|
|
- [ ] **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 `
|
|
<div class="meal-slot meal-slot--empty" data-date="${date}" data-type="${type}">
|
|
<div class="empty-state empty-state--compact">
|
|
<div class="empty-state__description">Kein Essen geplant</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
```
|
|
|
|
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:
|
|
`<div class="empty-state">
|
|
<svg class="empty-state__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
stroke-width="1.5" aria-hidden="true">
|
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
|
<circle cx="9" cy="7" r="4"/>
|
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
|
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
|
</svg>
|
|
<div class="empty-state__title">Noch keine Kontakte</div>
|
|
<div class="empty-state__description">Neue Kontakte über den + Button hinzufügen.</div>
|
|
</div>`
|
|
```
|
|
|
|
- [ ] **Step 6: budget.js — `.budget-empty` auf `.empty-state` umstellen**
|
|
|
|
In `public/pages/budget.js` (ca. Zeile 242):
|
|
|
|
```js
|
|
// Nachher:
|
|
return `
|
|
<div class="empty-state">
|
|
<svg class="empty-state__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
stroke-width="1.5" aria-hidden="true">
|
|
<line x1="12" y1="1" x2="12" y2="23"/>
|
|
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>
|
|
</svg>
|
|
<div class="empty-state__title">Keine Einträge diesen Monat</div>
|
|
<div class="empty-state__description">Budget-Einträge über den + Button hinzufügen.</div>
|
|
</div>
|
|
`;
|
|
```
|
|
|
|
- [ ] **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
|
|
<!-- Vorher -->
|
|
<label for="task-title">Titel</label>
|
|
<input id="task-title" class="form-control" required>
|
|
|
|
<!-- Nachher -->
|
|
<div class="form-field">
|
|
<label for="task-title">Titel *</label>
|
|
<input id="task-title" class="form-control" required>
|
|
<div class="form-field__error">
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="10"/>
|
|
<line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12" y2="16.01"/>
|
|
</svg>
|
|
Dieses Feld ist erforderlich.
|
|
</div>
|
|
</div>
|
|
```
|
|
|
|
- [ ] **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 = `
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
stroke-width="2.5" aria-hidden="true">
|
|
<polyline points="20 6 9 17 4 12"/>
|
|
</svg>
|
|
`;
|
|
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)"
|
|
```
|