Files
oikos/docs/superpowers/plans/2026-03-30-ux-polish.md
T
Ulas a5d8ae3f5f docs: add UX polish implementation plan (14 tasks across 4 layers)
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>
2026-03-30 16:36:03 +02:00

44 KiB

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

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:

/* 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
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
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

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:

.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:

.dashboard-page { --module-accent: var(--module-dashboard); }

In public/styles/tasks.css:

.tasks-page { --module-accent: var(--module-tasks); }

In public/styles/shopping.css:

.shopping-page { --module-accent: var(--module-shopping); }

In public/styles/meals.css:

.meals-page { --module-accent: var(--module-meals); }

In public/styles/notes.css:

.notes-page { --module-accent: var(--module-notes); }

In public/styles/contacts.css:

.contacts-page { --module-accent: var(--module-contacts); }

In public/styles/budget.css:

.budget-page { --module-accent: var(--module-budget); }
  • Step 3: Prüfen ob Page-Klassen in JS-Modulen gesetzt sind
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:

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
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:

/* 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:

// 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:

// 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:

@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
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

/**
 * 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
/**
 * 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:

"test:ux-utils": "node --experimental-vm-modules test-ux-utils.js"

Und den "test"-Script ergänzen:

"test": "... && npm run test:ux-utils"
  • Step 4: Tests ausführen
node --experimental-vm-modules test-ux-utils.js

Erwartung: 4 Tests bestanden, 0 fehlgeschlagen.

  • Step 5: Commit
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:

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:

// Staggered Fade-In für Listeneinträge
stagger(container.querySelectorAll('.task-item, .kanban-card'));
  • Step 3: Import und stagger() in shopping.js
import { stagger } from '/utils/ux.js';

Nach dem Render der Einkaufsliste:

stagger(container.querySelectorAll('.shopping-item'));
  • Step 4: Import und stagger() in meals.js
import { stagger } from '/utils/ux.js';

Nach dem Render der Mahlzeiten-Cards:

stagger(container.querySelectorAll('.meal-card'));
  • Step 5: Import und stagger() in contacts.js
import { stagger } from '/utils/ux.js';

Nach dem Render der Kontaktliste:

stagger(container.querySelectorAll('.contact-card'));
  • Step 6: Import und stagger() in budget.js
import { stagger } from '/utils/ux.js';

Nach dem Render der Budget-Einträge:

stagger(container.querySelectorAll('.budget-entry'));
  • Step 7: Import und stagger() in notes.js
import { stagger } from '/utils/ux.js';

Nach dem Render der Notizen-Cards:

stagger(container.querySelectorAll('.note-card'));
  • Step 8: Import und stagger() in calendar.js
import { stagger } from '/utils/ux.js';

Nach dem Render von Agenda-Einträgen (nur Agenda-Ansicht, nicht Monat/Woche/Tag):

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
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:

// 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):

// 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):

// 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:

// 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:

.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):

// 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):

// 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
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

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:

// 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:

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
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:

// 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:

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:

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
_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
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
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', ...):

// 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():

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
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:

import { vibrate } from '/utils/ux.js';
  • Step 2: tasks.js — bestehende navigator.vibrate()-Calls ersetzen
grep -n "navigator.vibrate" public/pages/tasks.js

Jeden Fund ersetzen:

// 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:

// 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):

vibrate([30, 50, 30]);
  • Step 5: budget.js — Vibration bei Löschen

In public/pages/budget.js, im Delete-Handler:

vibrate([30, 50, 30]);
  • Step 6: notes.js — Vibration bei Löschen

In public/pages/notes.js, im Delete-Handler:

vibrate([30, 50, 30]);
  • Step 7: Commit
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:

/* ── 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:

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):

// Swipe-to-Close auf Mobile
if (window.innerWidth < 768) {
  _wireSheetSwipe(panel);
}

Neue Funktion:

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
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:

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
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:

/* ── 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:

/**
 * 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:

<!-- 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:

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
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:

/* ── 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:

/**
 * 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:

import { openModal, closeModal, wireBlurValidation, btnSuccess, btnError } from '/components/modal.js';

Im Form-Submit-Handler der Aufgaben:

// 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
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
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:

### 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
git add CHANGELOG.md
git commit -m "docs: update CHANGELOG for UX polish (all 4 layers)"