chore: exclude docs/superpowers/ from version control
Internal Claude Code working documents (plans, specs) are not relevant for contributors. Remove tracked files and add to .gitignore. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,9 @@ dist/
|
||||
# Git Worktrees
|
||||
.worktrees/
|
||||
|
||||
# Claude Code Arbeitsdokumente (interne Pläne/Specs, nicht für Contributors)
|
||||
docs/superpowers/
|
||||
|
||||
# Textdateien mit Tokens/Keys (Sicherheitsnetz)
|
||||
*.txt
|
||||
!public/robots.txt
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,476 +0,0 @@
|
||||
# Shopping Swipe Gestures Implementation Plan
|
||||
|
||||
> **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:** Add left-swipe-to-toggle and right-swipe-to-delete touch gestures to Shopping list items on mobile, replacing the visible × delete button on small screens.
|
||||
|
||||
**Architecture:** Shared swipe base CSS is moved from `tasks.css` to `layout.css` so both modules can use it. Shopping-specific styles (delete reveal, mobile button hiding) go in `shopping.css`. `shopping.js` wraps each item in a `swipe-row` and registers touch handlers via a new `wireSwipeGestures()` function, called after every list re-render.
|
||||
|
||||
**Tech Stack:** Vanilla JS (ES modules), Touch Events API, CSS custom properties, Node.js built-in test runner (regression tests only — no DOM test framework in this project).
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `public/styles/tasks.css` | Remove `.swipe-row`, `.swipe-reveal`, `.swipe-reveal--done` (moved to layout.css) |
|
||||
| `public/styles/layout.css` | Add moved shared swipe styles + `.swipe-reveal--done` |
|
||||
| `public/styles/shopping.css` | Add `.swipe-reveal--delete`, `.swipe-row .shopping-item`, `.swipe-row--swiping .shopping-item`, mobile hide for `.item-delete` |
|
||||
| `public/pages/shopping.js` | Wrap `renderItem()` output in swipe-row, add `wireSwipeGestures(container)`, call it from `updateItemsList()` |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Move shared swipe CSS from tasks.css to layout.css
|
||||
|
||||
**Files:**
|
||||
- Modify: `public/styles/tasks.css` (lines 170–229)
|
||||
- Modify: `public/styles/layout.css` (append before Print section)
|
||||
|
||||
- [ ] **Step 1: Remove base swipe styles from tasks.css**
|
||||
|
||||
In `public/styles/tasks.css`, delete the block from `/* Swipe-Wrapper (Mobil-Gesten) */` through `.swipe-reveal--done { ... }` (lines 170–216). Keep only the task-specific rules that follow:
|
||||
|
||||
```css
|
||||
/* Kein Margin mehr am Task-Card selbst (übernimmt swipe-row) */
|
||||
.swipe-row .task-card {
|
||||
margin-bottom: 0;
|
||||
border-radius: var(--radius-md);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* Rechts hinter der Karte = Bearbeiten (Swipe nach rechts) */
|
||||
.swipe-reveal--edit {
|
||||
left: 0;
|
||||
background-color: var(--color-accent);
|
||||
color: #fff;
|
||||
border-radius: var(--radius-md) 0 0 var(--radius-md);
|
||||
}
|
||||
|
||||
/* Touch-Feedback: leichte Hervorhebung während Swipe */
|
||||
.swipe-row--swiping .task-card {
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
```
|
||||
|
||||
Add a comment header so the section remains clear:
|
||||
|
||||
```css
|
||||
/* --------------------------------------------------------
|
||||
* Swipe-Wrapper — Task-spezifische Styles
|
||||
* Basis-Styles (.swipe-row, .swipe-reveal, .swipe-reveal--done)
|
||||
* liegen in layout.css
|
||||
* -------------------------------------------------------- */
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add shared swipe styles to layout.css**
|
||||
|
||||
In `public/styles/layout.css`, find the Print section (`/* Print-Styles */`) and insert the following block **directly before it**:
|
||||
|
||||
```css
|
||||
/* --------------------------------------------------------
|
||||
* Swipe-Wrapper — Gemeinsame Basis (Tasks + Shopping)
|
||||
* Modul-spezifische Styles (.swipe-reveal--edit, .swipe-reveal--delete,
|
||||
* .swipe-row .task-card, .swipe-row .shopping-item) liegen in den Modul-CSS.
|
||||
* -------------------------------------------------------- */
|
||||
.swipe-row {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--space-2);
|
||||
/* Verhindert ungewolltes Flackern auf iOS */
|
||||
-webkit-backface-visibility: hidden;
|
||||
}
|
||||
|
||||
/* Reveal-Panels hinter der Karte */
|
||||
.swipe-reveal {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 50%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-1);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
transition: opacity 0.05s linear;
|
||||
}
|
||||
|
||||
/* Gemeinsam: Erledigt / Abhaken (Swipe nach links) */
|
||||
.swipe-reveal--done {
|
||||
right: 0;
|
||||
background-color: var(--color-success);
|
||||
color: #fff;
|
||||
border-radius: 0 var(--radius-md) var(--radius-md) 0;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run regression tests**
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
Expected: `# pass 12` and `# fail 0`
|
||||
|
||||
- [ ] **Step 4: Verify tasks swipe still works visually**
|
||||
|
||||
Open the app in a browser (or mobile DevTools touch emulation), go to Tasks list, swipe an item left and right. The green "Erledigt" and blue "Bearbeiten" panels must still appear correctly.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add public/styles/tasks.css public/styles/layout.css
|
||||
git commit -m "refactor: move shared swipe CSS from tasks.css to layout.css"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Add shopping-specific swipe CSS
|
||||
|
||||
**Files:**
|
||||
- Modify: `public/styles/shopping.css`
|
||||
|
||||
- [ ] **Step 1: Find the correct insertion point in shopping.css**
|
||||
|
||||
In `public/styles/shopping.css`, locate the section for `.shopping-item` styles. The new swipe styles go **after** the existing `.shopping-item` block.
|
||||
|
||||
- [ ] **Step 2: Add the styles**
|
||||
|
||||
Append the following after the existing `.shopping-item` rules in `public/styles/shopping.css`:
|
||||
|
||||
```css
|
||||
/* --------------------------------------------------------
|
||||
* Swipe-Wrapper — Shopping-spezifische Styles
|
||||
* -------------------------------------------------------- */
|
||||
|
||||
/* Kein Margin mehr am shopping-item selbst (übernimmt swipe-row) */
|
||||
.swipe-row .shopping-item {
|
||||
margin-bottom: 0;
|
||||
border-radius: var(--radius-md);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* Rechts hinter der Karte = Löschen (Swipe nach rechts) */
|
||||
.swipe-reveal--delete {
|
||||
left: 0;
|
||||
background-color: var(--color-danger);
|
||||
color: #fff;
|
||||
border-radius: var(--radius-md) 0 0 var(--radius-md);
|
||||
}
|
||||
|
||||
/* Touch-Feedback: leichte Hervorhebung während Swipe */
|
||||
.swipe-row--swiping .shopping-item {
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
/* × Löschen-Button auf Mobile ausblenden — Swipe übernimmt */
|
||||
@media (max-width: 1023px) {
|
||||
.item-delete {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run regression tests**
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
Expected: `# pass 12` and `# fail 0`
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add public/styles/shopping.css
|
||||
git commit -m "feat: add shopping swipe CSS (delete reveal, mobile button hide)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Wrap renderItem with swipe-row in shopping.js
|
||||
|
||||
**Files:**
|
||||
- Modify: `public/pages/shopping.js`
|
||||
|
||||
- [ ] **Step 1: Update renderItem()**
|
||||
|
||||
Find `function renderItem(item)` in `public/pages/shopping.js` (currently returns a `.shopping-item` div). Replace the entire function with:
|
||||
|
||||
```js
|
||||
function renderItem(item) {
|
||||
const isDone = Boolean(item.is_checked);
|
||||
return `
|
||||
<div class="swipe-row" data-swipe-id="${item.id}" data-swipe-checked="${item.is_checked}">
|
||||
<div class="swipe-reveal swipe-reveal--done" aria-hidden="true">
|
||||
<i data-lucide="${isDone ? 'rotate-ccw' : 'check'}" style="width:22px;height:22px" aria-hidden="true"></i>
|
||||
<span>${isDone ? 'Zurück' : 'Abhaken'}</span>
|
||||
</div>
|
||||
<div class="swipe-reveal swipe-reveal--delete" aria-hidden="true">
|
||||
<i data-lucide="trash-2" style="width:22px;height:22px" aria-hidden="true"></i>
|
||||
<span>Löschen</span>
|
||||
</div>
|
||||
<div class="shopping-item ${isDone ? 'shopping-item--checked' : ''}"
|
||||
data-item-id="${item.id}">
|
||||
<button class="item-check ${isDone ? 'item-check--checked' : ''}"
|
||||
data-action="toggle-item" data-id="${item.id}" data-checked="${item.is_checked}"
|
||||
aria-label="${item.name} ${isDone ? 'als nicht erledigt markieren' : 'abhaken'}">
|
||||
<i data-lucide="check" class="item-check__icon" aria-hidden="true"></i>
|
||||
</button>
|
||||
<div class="item-body">
|
||||
<div class="item-name">${item.name}</div>
|
||||
${item.quantity ? `<div class="item-quantity">${item.quantity}</div>` : ''}
|
||||
</div>
|
||||
<button class="item-delete" data-action="delete-item" data-id="${item.id}"
|
||||
aria-label="${item.name} löschen">
|
||||
<i data-lucide="x" style="width:16px;height:16px" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run regression tests**
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
Expected: `# pass 12` and `# fail 0`
|
||||
|
||||
- [ ] **Step 3: Verify shopping list renders without errors**
|
||||
|
||||
Open the app in a browser, navigate to Shopping. Items must render with the swipe-row wrapper. The × button must be hidden on mobile (visible on desktop ≥ 1024px). The existing checkbox toggle must still work.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add public/pages/shopping.js
|
||||
git commit -m "feat: wrap shopping items in swipe-row"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Add wireSwipeGestures and wire into updateItemsList
|
||||
|
||||
**Files:**
|
||||
- Modify: `public/pages/shopping.js`
|
||||
|
||||
- [ ] **Step 1: Add constants at the top of shopping.js**
|
||||
|
||||
After the `import` statements (before the `state` declaration), add:
|
||||
|
||||
```js
|
||||
// Swipe-Gesten Konstanten (identisch zu tasks.js)
|
||||
const SWIPE_THRESHOLD = 80; // px — Mindestweg für Aktion
|
||||
const SWIPE_MAX_VERT = 12; // px — vertikaler Toleranzbereich
|
||||
const SWIPE_LOCK_VERT = 30; // px — ab diesem Weg gilt es als Scroll
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add wireSwipeGestures() function**
|
||||
|
||||
Add the following function **before** `updateItemsList()` in `shopping.js`:
|
||||
|
||||
```js
|
||||
function wireSwipeGestures(container) {
|
||||
const listEl = container.querySelector('#items-list');
|
||||
if (!listEl) return;
|
||||
|
||||
listEl.querySelectorAll('.swipe-row').forEach((row) => {
|
||||
let startX = 0, startY = 0;
|
||||
let dx = 0;
|
||||
let locked = false; // false | 'swipe' | 'scroll'
|
||||
const card = row.querySelector('.shopping-item');
|
||||
if (!card) return;
|
||||
|
||||
function resetCard(animate = true) {
|
||||
card.style.transition = animate ? 'transform 0.25s ease' : '';
|
||||
card.style.transform = '';
|
||||
row.classList.remove('swipe-row--swiping');
|
||||
row.querySelector('.swipe-reveal--done').style.opacity = '0';
|
||||
row.querySelector('.swipe-reveal--delete').style.opacity = '0';
|
||||
}
|
||||
|
||||
row.addEventListener('touchstart', (e) => {
|
||||
if (document.getElementById('shared-modal-overlay')) return;
|
||||
startX = e.touches[0].clientX;
|
||||
startY = e.touches[0].clientY;
|
||||
dx = 0;
|
||||
locked = false;
|
||||
card.style.transition = '';
|
||||
}, { passive: true });
|
||||
|
||||
row.addEventListener('touchmove', (e) => {
|
||||
if (locked === 'scroll') return;
|
||||
|
||||
const currentX = e.touches[0].clientX;
|
||||
const currentY = e.touches[0].clientY;
|
||||
dx = currentX - startX;
|
||||
const dy = Math.abs(currentY - startY);
|
||||
|
||||
if (locked === false) {
|
||||
if (dy > SWIPE_MAX_VERT && Math.abs(dx) < dy) {
|
||||
locked = 'scroll';
|
||||
resetCard(false);
|
||||
return;
|
||||
}
|
||||
if (Math.abs(dx) > SWIPE_MAX_VERT) {
|
||||
locked = 'swipe';
|
||||
}
|
||||
}
|
||||
|
||||
if (locked !== 'swipe') return;
|
||||
|
||||
if (dy < SWIPE_LOCK_VERT) e.preventDefault();
|
||||
|
||||
const dampened = dx > 0
|
||||
? Math.min(dx, SWIPE_THRESHOLD + (dx - SWIPE_THRESHOLD) * 0.2)
|
||||
: Math.max(dx, -(SWIPE_THRESHOLD + (-dx - SWIPE_THRESHOLD) * 0.2));
|
||||
|
||||
card.style.transform = `translateX(${dampened}px)`;
|
||||
row.classList.add('swipe-row--swiping');
|
||||
|
||||
const progress = Math.min(Math.abs(dx) / SWIPE_THRESHOLD, 1);
|
||||
if (dx < 0) {
|
||||
row.querySelector('.swipe-reveal--done').style.opacity = String(progress);
|
||||
row.querySelector('.swipe-reveal--delete').style.opacity = '0';
|
||||
} else {
|
||||
row.querySelector('.swipe-reveal--delete').style.opacity = String(progress);
|
||||
row.querySelector('.swipe-reveal--done').style.opacity = '0';
|
||||
}
|
||||
}, { passive: false });
|
||||
|
||||
row.addEventListener('touchend', async () => {
|
||||
if (locked !== 'swipe') { resetCard(false); return; }
|
||||
|
||||
const itemId = Number(row.dataset.swipeId);
|
||||
const checked = Number(row.dataset.swipeChecked);
|
||||
|
||||
if (dx < -SWIPE_THRESHOLD) {
|
||||
// Swipe links → abhaken / zurück
|
||||
card.style.transition = 'transform 0.2s ease';
|
||||
card.style.transform = 'translateX(-110%)';
|
||||
vibrate(40);
|
||||
setTimeout(async () => {
|
||||
resetCard(false);
|
||||
const newVal = checked ? 0 : 1;
|
||||
const item = state.items.find((i) => i.id === itemId);
|
||||
if (item) {
|
||||
item.is_checked = newVal;
|
||||
updateItemsList(container);
|
||||
updateListCounter(state.activeListId, 0, newVal ? 1 : -1);
|
||||
renderTabs(container);
|
||||
}
|
||||
try {
|
||||
await api.patch(`/shopping/items/${itemId}`, { is_checked: newVal });
|
||||
vibrate(10);
|
||||
} catch (err) {
|
||||
if (item) item.is_checked = checked;
|
||||
updateItemsList(container);
|
||||
window.oikos.showToast(err.message, 'danger');
|
||||
}
|
||||
}, 200);
|
||||
|
||||
} else if (dx > SWIPE_THRESHOLD) {
|
||||
// Swipe rechts → löschen
|
||||
card.style.transition = 'transform 0.2s ease';
|
||||
card.style.transform = 'translateX(110%)';
|
||||
vibrate(40);
|
||||
setTimeout(async () => {
|
||||
const item = state.items.find((i) => i.id === itemId);
|
||||
try {
|
||||
await api.delete(`/shopping/items/${itemId}`);
|
||||
state.items = state.items.filter((i) => i.id !== itemId);
|
||||
updateItemsList(container);
|
||||
updateListCounter(state.activeListId, -1, item?.is_checked ? -1 : 0);
|
||||
renderTabs(container);
|
||||
} catch (err) {
|
||||
resetCard(true);
|
||||
window.oikos.showToast(err.message, 'danger');
|
||||
}
|
||||
}, 200);
|
||||
|
||||
} else {
|
||||
resetCard(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Call wireSwipeGestures from updateItemsList()**
|
||||
|
||||
In `updateItemsList(container)`, after the `stagger(...)` call, add:
|
||||
|
||||
```js
|
||||
wireSwipeGestures(container);
|
||||
```
|
||||
|
||||
The updated block looks like this:
|
||||
|
||||
```js
|
||||
function updateItemsList(container) {
|
||||
const listEl = container.querySelector('#items-list');
|
||||
if (listEl) {
|
||||
listEl.innerHTML = renderItems();
|
||||
if (window.lucide) window.lucide.createIcons();
|
||||
stagger(listEl.querySelectorAll('.shopping-item'));
|
||||
wireSwipeGestures(container); // ← new
|
||||
}
|
||||
// ... rest of function unchanged
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run regression tests**
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
Expected: `# pass 12` and `# fail 0`
|
||||
|
||||
- [ ] **Step 5: Verify swipe gestures work end-to-end**
|
||||
|
||||
Test in mobile DevTools (Chrome → Toggle device toolbar → touch emulation):
|
||||
|
||||
1. Open Shopping, add 3 test items
|
||||
2. Swipe an item **left** past 80 px → green panel appears → item toggles to checked (strikethrough) → swipe again to uncheck
|
||||
3. Swipe an item **right** past 80 px → red panel appears → item is removed from list
|
||||
4. Swipe < 80 px in either direction → card springs back, no action
|
||||
5. Scroll the list vertically → no swipe triggers
|
||||
6. On desktop (≥ 1024px): × delete button is visible, swipe not triggered by mouse
|
||||
|
||||
- [ ] **Step 6: Update CHANGELOG.md**
|
||||
|
||||
Add to `## [Unreleased]` → `### Added`:
|
||||
|
||||
```
|
||||
- Shopping: swipe-left to toggle checked/unchecked, swipe-right to delete items on mobile; × delete button hidden on mobile in favour of swipe gesture
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add public/pages/shopping.js CHANGELOG.md
|
||||
git commit -m "feat: swipe gestures on shopping list items (toggle + delete)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Push
|
||||
|
||||
- [ ] **Push to remote**
|
||||
|
||||
```bash
|
||||
git push
|
||||
```
|
||||
@@ -1,224 +0,0 @@
|
||||
# UX Polish — Design-Spezifikation
|
||||
|
||||
**Datum:** 2026-03-30
|
||||
**Status:** Genehmigt
|
||||
**Scope:** UX-Verbesserungen (Phase 1 vor Featureerweiterungen)
|
||||
|
||||
---
|
||||
|
||||
## Ausgangslage
|
||||
|
||||
Oikos v0.1.0 ist funktional vollständig und alle 146 Tests sind grün. Das UI wirkt jedoch steril und beliebig — es fehlt eine eigene Persönlichkeit. Zusätzlich gibt es Konsistenzlücken zwischen Modulen, abrupte Übergänge, ein suboptimales mobiles Erlebnis und verbesserungswürdige Formular-UX.
|
||||
|
||||
Die Verbesserungen erfolgen in vier aufeinander aufbauenden Schichten (Layer for Layer), sodass jede Schicht das Fundament der nächsten bildet.
|
||||
|
||||
---
|
||||
|
||||
## Schicht 1 — Design-Sprache & Konsistenz
|
||||
|
||||
### Ziel
|
||||
Der App eine eigene, wiedererkennbare Persönlichkeit geben — klar und präzise als Hauptrichtung, mit einem Hauch Wärme und Familiarität.
|
||||
|
||||
### Typografie-Skala
|
||||
Vier klar unterscheidbare Stufen ersetzen die aktuelle flache Hierarchie:
|
||||
|
||||
| Stufe | Größe | Gewicht | Einsatz |
|
||||
|-------|-------|---------|---------|
|
||||
| Display | 24px | 700 | Seitentitel, Modal-Titel |
|
||||
| Title | 18px | 600 | Widget-Überschriften, Gruppen-Header |
|
||||
| Body | 15px | 400 | Fließtext, Listeneinträge |
|
||||
| Caption | 13px | 400 | Metadaten, Zeitstempel, Labels |
|
||||
|
||||
Überschriften (Display, Title) erhalten `letter-spacing: -0.3px` für einen modernen, knappen Look.
|
||||
|
||||
### Farb-Tokens
|
||||
Die Grautöne erhalten einen minimalen Warmton-Shift, um den Charakter von "Tech-App" zu "Familien-App" zu verschieben:
|
||||
|
||||
```css
|
||||
/* Vorher → Nachher */
|
||||
--color-bg: #F5F5F7 → #F6F5F3 /* ganz leicht warmer Tint */
|
||||
--color-surface: #FFFFFF → #FFFFFF /* bleibt rein */
|
||||
--color-text-primary:#1C1C1E → #1A1A1F /* minimal wärmer */
|
||||
```
|
||||
|
||||
Der Accent-Blau (`#007AFF`) bleibt unverändert. Dunkel-Modus erhält analoge Anpassungen.
|
||||
|
||||
### Komponenten-Konsistenz
|
||||
Alle Module erhalten identische Card-Tokens:
|
||||
- Padding: `16px` überall (aktuell variiert zwischen 12px–20px je Modul)
|
||||
- Schatten: `shadow-sm` im Ruhezustand, `shadow-md` bei Hover
|
||||
- Buttons: `:hover` = leichter Helligkeitsshift, `:active` = `scale(0.97)` (haptisches Feedback-Gefühl)
|
||||
|
||||
### Modul-Akzentfarben
|
||||
Jedes Modul erhält eine dezente, eigene Akzentfarbe für Page-Header und FAB. Die Farben sind bereits in der Architektur vorgesehen (theme-color), werden aber vervollständigt und konsequent eingesetzt:
|
||||
|
||||
| Modul | Akzent |
|
||||
|-------|--------|
|
||||
| Dashboard | `#007AFF` (Standard-Blau) |
|
||||
| Aufgaben | `#FF9500` (Orange) |
|
||||
| Einkauf | `#34C759` (Grün) |
|
||||
| Essensplan | `#FF6B35` (Warm-Orange) |
|
||||
| Kalender | `#5AC8FA` (Hellblau) |
|
||||
| Notizen | `#FFCC00` (Gelb) |
|
||||
| Kontakte | `#AF52DE` (Violett) |
|
||||
| Budget | `#30B0C7` (Teal) |
|
||||
|
||||
---
|
||||
|
||||
## Schicht 2 — Animationen & Übergänge
|
||||
|
||||
### Ziel
|
||||
Die App fühlt sich lebendig an. Alle Animationen respektieren `prefers-reduced-motion: reduce`.
|
||||
|
||||
### Seitenübergänge
|
||||
Neue Seite fährt von rechts ein, alte geht nach links raus:
|
||||
- Transform: `translateX(24px) → translateX(0)`
|
||||
- Opacity: `0 → 1`
|
||||
- Dauer: `200ms`, Easing: `ease-out`
|
||||
- Zurück-Navigation: gespiegelte Richtung
|
||||
|
||||
Implementierung im zentralen `router.js` — kein Modul-Code nötig.
|
||||
|
||||
### Gestaffelte Listen-Einblendung (Staggered Fade-In)
|
||||
Beim initialen Laden einer Seite erscheinen Listenelemente und Cards nacheinander:
|
||||
- Jedes Item: `opacity 0 → 1` + `translateY(8px) → 0`
|
||||
- Verzögerung: 30ms pro Item, maximal 5 Items gestaffelt (danach sofort)
|
||||
- Dauer pro Item: `180ms`
|
||||
|
||||
### Micro-Interactions
|
||||
|
||||
**Checkbox (Aufgaben erledigt):**
|
||||
Das SVG-Häkchen zeichnet sich per `stroke-dashoffset`-Animation ein (60ms). Die Karten-Zeile bekommt einen `text-decoration: line-through`-Transition (100ms).
|
||||
|
||||
**FAB:**
|
||||
`scale(0.92)` beim `:active` + Ripple-Effekt (radial expandierender Kreis, 300ms, opacity 0→1→0).
|
||||
|
||||
**Swipe-Reveal (Aufgaben):**
|
||||
Aktuell erscheint der farbige Hintergrund abrupt. Neu: Hintergrundfarbe und Icon blenden proportional zur Swipe-Distanz ein (`opacity: swipeDistance / SWIPE_THRESHOLD`).
|
||||
|
||||
### Skeleton-Loading
|
||||
Dashboard-Widgets zeigen beim Laden animierte Skeleton-Platzhalter:
|
||||
- Shimmer-Animation via `@keyframes` (linearer Gradient läuft durch, 1.4s, unendlich)
|
||||
- Schematische Rechtecke in Card-Form, passend zur jeweiligen Widget-Größe
|
||||
- Ersetzt leere Flächen während des API-Calls
|
||||
|
||||
### Empty States
|
||||
Jede leere Liste erhält einen Inline-SVG-Platzhalter und einen kontextuellen CTA:
|
||||
|
||||
| Modul | Text | CTA |
|
||||
|-------|------|-----|
|
||||
| Aufgaben | "Keine Aufgaben — alles erledigt?" | "+ Aufgabe erstellen" |
|
||||
| Einkauf | "Die Liste ist leer" | "+ Artikel hinzufügen" |
|
||||
| Essensplan | "Kein Essen geplant" | "Mahlzeit eintragen" |
|
||||
| Notizen | "Noch keine Notizen" | "+ Notiz erstellen" |
|
||||
| Kontakte | "Noch keine Kontakte" | "+ Kontakt hinzufügen" |
|
||||
| Budget | "Keine Buchungen diesen Monat" | "+ Buchung eintragen" |
|
||||
|
||||
SVGs sind kleine, themenbezogene Illustrationen (Linien-Icons, kein Clipart), inline im HTML, kein externer Fetch.
|
||||
|
||||
---
|
||||
|
||||
## Schicht 3 — Mobile PWA & Natives Gefühl
|
||||
|
||||
### Ziel
|
||||
Die installierte App fühlt sich auf dem Handy nativ an.
|
||||
|
||||
### PWA-Install-Prompt
|
||||
**Timing:** Prompt erscheint erst nach 2–3 Benutzerinteraktionen (z.B. nach dem ersten erfolgreich erstellten Eintrag), nicht sofort beim ersten Seitenaufruf.
|
||||
|
||||
**Darstellung:** Bottom Sheet von unten einfahrend (nicht abruptes Banner). Enthält App-Icon, Name "Oikos", kurzen Nutzentext.
|
||||
|
||||
**Wiederholung:** Einmal abgelehnt → 7 Tage nicht erneut zeigen (via `localStorage` mit Timestamp).
|
||||
|
||||
**Plattformspezifisch:**
|
||||
- Android: natives `beforeinstallprompt`-Event
|
||||
- iOS: eigene Anleitung ("Teilen → Zum Home-Bildschirm") da kein natives Event
|
||||
|
||||
### Scroll & Overscroll
|
||||
Auf allen scrollbaren Containern:
|
||||
- `overscroll-behavior: contain` — verhindert Browser-Pull-to-Refresh innerhalb der App
|
||||
- `-webkit-overflow-scrolling: touch` — Momentum-Scrolling auf iOS
|
||||
- Bottom Nav und Header: `position: sticky` mit `env(safe-area-inset-bottom)` — kein Layout-Shift durch dynamische Viewport-Höhe (iOS Safari)
|
||||
|
||||
### Vibrations-Feedback
|
||||
`navigator.vibrate()` bei bedeutsamen Aktionen, nur wenn API verfügbar und `prefers-reduced-motion` nicht gesetzt:
|
||||
|
||||
| Aktion | Muster |
|
||||
|--------|--------|
|
||||
| Aufgabe erledigt | `10ms` |
|
||||
| Swipe-Aktion ausgelöst | `15ms` |
|
||||
| Eintrag gelöscht | `[30, 50, 30]ms` |
|
||||
| Fehlermeldung | `[20, 40, 20]ms` |
|
||||
|
||||
### Keyboard-Verhalten (Virtuelles Keyboard)
|
||||
Beim Tippen in ein Eingabefeld springt dieses automatisch in den sichtbaren Bereich:
|
||||
```js
|
||||
input.addEventListener('focus', () => {
|
||||
setTimeout(() => input.scrollIntoView({ behavior: 'smooth', block: 'center' }), 300);
|
||||
});
|
||||
```
|
||||
300ms Verzögerung gibt dem Keyboard Zeit, sich zu öffnen.
|
||||
|
||||
### Theme-Color
|
||||
Dynamische `theme-color` Meta-Tag-Aktualisierung beim Modulwechsel wird vervollständigt — jedes Modul übergibt beim Rendern seine Akzentfarbe, die Browser-Chrome-Farbe wechselt entsprechend.
|
||||
|
||||
---
|
||||
|
||||
## Schicht 4 — Formulare & Modals
|
||||
|
||||
### Ziel
|
||||
Eingaben sind schnell, klar und fehlertolerant — besonders auf Mobil.
|
||||
|
||||
### Auto-Fokus & Tastaturnavigation
|
||||
- Beim Öffnen eines Modals: Fokus springt automatisch auf erstes Eingabefeld (`setTimeout(0)` nach Modal-Render)
|
||||
- `Tab`: logische Feldreihenfolge (entspricht DOM-Reihenfolge)
|
||||
- `Enter` in einzeiligen Inputs: springt zum nächsten Feld
|
||||
- `Enter` im letzten Feld (oder Textarea + `Ctrl+Enter`): löst Submit aus
|
||||
- `Escape`: schließt Modal
|
||||
|
||||
### Inline-Validierung
|
||||
- Trigger: `blur`-Event auf jedem Feld (nicht erst bei Submit)
|
||||
- Fehlermeldung: direkt unter dem Feld, `color: var(--color-danger)`, mit Warn-Icon
|
||||
- Erfolgreiche Pflichtfelder: dezenter grüner Rand (`border-color: var(--color-success)`)
|
||||
- Submit-Button: deaktiviert solange Pflichtfelder leer, aktiv sobald Minimalanforderungen erfüllt
|
||||
|
||||
### Modal-UX auf Mobil
|
||||
Auf Screens < 768px werden Modals als **Bottom Sheet** dargestellt:
|
||||
- Einfähranimation: `translateY(100%) → translateY(0)`, 250ms, `ease-out`
|
||||
- Maximalhöhe: `90dvh`, intern scrollbar
|
||||
- Swipe-to-Close: Swipe nach unten > 80px schließt Modal; zwischen 0–80px gibt es gummibandartigen Widerstand (`transform: translateY(distance * 0.4)`)
|
||||
- Backdrop-Klick: schließt Modal
|
||||
- Schließanimation: `translateY(0) → translateY(100%)`, 200ms
|
||||
|
||||
Auf Desktop (≥ 768px): zentriertes Modal bleibt unverändert (Backdrop-Klick + Escape schließen).
|
||||
|
||||
### Submit-Feedback
|
||||
**Erfolg:**
|
||||
1. Submit-Button: Label wird durch Checkmark-Icon ersetzt (600ms)
|
||||
2. Modal schließt sich mit Slide-Down-Animation
|
||||
3. Liste aktualisiert sich (optimistisch oder via Re-Fetch)
|
||||
|
||||
**Fehler:**
|
||||
1. Submit-Button: `shake`-Animation (300ms, ±4px horizontal)
|
||||
2. Fehlermeldung erscheint unter dem betreffenden Feld oder als Banner oben im Modal
|
||||
3. Kein Datenverlust — alle eingegebenen Werte bleiben erhalten
|
||||
|
||||
---
|
||||
|
||||
## Nicht in Scope
|
||||
|
||||
- Neue Features (Meal Drag&Drop, Budget-Recurrence, Kalender-Auto-Sync) — diese kommen erst nach UX + Code-Qualität
|
||||
- Backend-Änderungen — alle vier Schichten sind rein frontend-seitig
|
||||
- Push-Benachrichtigungen — explizit v1.1 (BACKLOG)
|
||||
- Grundlegende Architekturänderungen am Router oder API-Layer
|
||||
|
||||
---
|
||||
|
||||
## Reihenfolge der Implementierung
|
||||
|
||||
1. Schicht 1: `tokens.css`, `reset.css`, `layout.css`, alle Modul-CSS-Dateien
|
||||
2. Schicht 2: `router.js` (Seitenübergänge), alle Page-Module (Staggering, Micro-Interactions), `dashboard.js` (Skeleton)
|
||||
3. Schicht 3: `oikos-install-prompt.js`, `sw.js`, alle Page-Module (Scroll, Keyboard, Vibration)
|
||||
4. Schicht 4: `components/modal.js`, alle Page-Module (Formulare, Validierung)
|
||||
|
||||
Jede Schicht ist ein eigener Commit-Block und kann unabhängig reviewt werden.
|
||||
@@ -1,73 +0,0 @@
|
||||
# Modul-Akzentfarben stärker nutzen — Design Spec
|
||||
|
||||
**Date:** 2026-03-31
|
||||
**Status:** Approved
|
||||
|
||||
## Ziel
|
||||
|
||||
Die vorhandenen `--module-*` CSS-Tokens (Dashboard=Blau, Tasks=Grün, Kalender=Violett, Mahlzeiten=Orange, Einkauf=Rot-Orange, Notizen=Gold, Kontakte=Kräftiges Blau, Budget=Teal) werden aktuell nur für die FAB-Hintergrundfarbe genutzt. Ziel: drei weitere visuelle Ebenen mit Modul-Akzenten versehen, damit der Nutzer sofort erkennt, in welchem Modul er sich befindet.
|
||||
|
||||
## Drei Änderungsbereiche
|
||||
|
||||
### A — Aktiver Tab in Navigation (Bottom-Nav + Sidebar)
|
||||
|
||||
**Problem:** `--module-accent` ist auf dem Page-Wrapper gesetzt (z. B. `.tasks-page`), aber die Nav-Bar liegt im App-Shell außerhalb dieser Wrapper. CSS-Kaskade funktioniert nicht.
|
||||
|
||||
**Lösung — JS:** In `updateNav(path)` (router.js) wird nach dem Setzen von `aria-current` zusätzlich `--active-module-accent` als CSS Custom Property auf `document.documentElement` geschrieben:
|
||||
|
||||
```js
|
||||
const module = ROUTES.find(r => r.path === path)?.module;
|
||||
const accent = module ? getCSSToken(`--module-${module}`) : '';
|
||||
document.documentElement.style.setProperty('--active-module-accent', accent || '');
|
||||
```
|
||||
|
||||
**Lösung — CSS** (layout.css): Alle drei Stellen, die aktuell `var(--color-accent)` für den aktiven Nav-State nutzen, werden auf `var(--active-module-accent, var(--color-accent))` umgestellt:
|
||||
|
||||
1. `.nav-item[aria-current="page"] { color: ... }` — Bottom-Nav Icon + Label
|
||||
2. `.nav-sidebar .nav-item[aria-current="page"] { color: ...; background-color: ... }` — Sidebar Highlight
|
||||
3. `.nav-sidebar .nav-item[aria-current="page"]::before { background: ... }` — Sidebar linker Akzentstreifen
|
||||
|
||||
Das Fallback `var(--color-accent)` stellt sicher, dass Login-Screen und Fehlerseiten ohne Modul-Kontext korrekt dargestellt werden.
|
||||
|
||||
---
|
||||
|
||||
### B — Seitenkopf-Streifen (3px border-top)
|
||||
|
||||
`border-top: 3px solid var(--module-accent)` wird auf den Toolbar/Header-Selektor jedes Moduls gesetzt. Da diese Elemente innerhalb des Page-Wrappers liegen, erben sie `--module-accent` direkt.
|
||||
|
||||
| Modul | Selektor | CSS-Datei |
|
||||
|-------|----------|-----------|
|
||||
| Tasks | `.tasks-toolbar` | tasks.css |
|
||||
| Notizen | `.notes-toolbar` | notes.css |
|
||||
| Kontakte | `.contacts-toolbar` | contacts.css |
|
||||
| Kalender | `.cal-toolbar` | calendar.css |
|
||||
| Einkauf | `.list-header` | shopping.css |
|
||||
| Budget | `.budget-list-header` | budget.css |
|
||||
| Mahlzeiten | — | entfällt (kein Toolbar) |
|
||||
| Dashboard | — | entfällt (Widget-Grid, kein Toolbar) |
|
||||
|
||||
---
|
||||
|
||||
### C — Karten-Randstreifen (3px border-left)
|
||||
|
||||
`border-left: 3px solid var(--module-accent)` auf den Hauptkarten-/Zeilen-Elementen. Der linke `border-radius` wird auf `0` gesetzt damit der Streifen sauber anliegt (`border-radius: 0 var(--radius-md) var(--radius-md) 0`).
|
||||
|
||||
| Modul | Selektor | CSS-Datei | Bemerkung |
|
||||
|-------|----------|-----------|-----------|
|
||||
| Tasks | `.task-card` | tasks.css | |
|
||||
| Einkauf | `.shopping-item` | shopping.css | |
|
||||
| Kontakte | `.contact-item` | contacts.css | |
|
||||
| Budget | `.budget-entry` | budget.css | Hat bereits Einnahmen/Ausgaben-Dot — kein Konflikt |
|
||||
| Kalender | ❌ | — | Eigene Event-Farblogik |
|
||||
| Notizen | ❌ | — | Eigene Karten-Hintergrundfarben |
|
||||
| Mahlzeiten | ❌ | — | Slot-Layout, keine klassische Liste |
|
||||
| Dashboard | ❌ | — | Widget-Struktur |
|
||||
|
||||
---
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Kein Umbau des Farbsystems oder der Token-Namen
|
||||
- Kein Dark-Mode-spezifisches Anpassen (Dark-Mode-Tokens für `--module-*` existieren bereits in tokens.css)
|
||||
- Keine neuen Modul-Farben
|
||||
- Keine Änderungen an Meals, Dashboard, Calendar-Events, Notes-Karten
|
||||
@@ -1,79 +0,0 @@
|
||||
# Shopping Swipe Gestures — Design Spec
|
||||
|
||||
**Date:** 2026-03-31
|
||||
**Status:** Approved
|
||||
|
||||
## Scope
|
||||
|
||||
Add swipe gestures to Shopping list items on mobile. Notes and other modules are explicitly out of scope.
|
||||
|
||||
## Behaviour
|
||||
|
||||
| Gesture | Action | Reveal colour |
|
||||
|---------|--------|---------------|
|
||||
| Swipe left (> threshold) | Toggle checked/unchecked | Green (`--color-success`) |
|
||||
| Swipe right (> threshold) | Delete item | Red (`--color-danger`) |
|
||||
|
||||
- Reveal label for left swipe: "Abhaken" (unchecked) or "Zurück" (already checked)
|
||||
- Reveal label for right swipe: "Löschen" (always)
|
||||
- Threshold, damping, and scroll-lock logic identical to `tasks.js`
|
||||
- On swipe-right delete: optimistic DOM removal → `DELETE /api/v1/shopping/items/:id` → on error restore item and show danger toast
|
||||
- On swipe-left toggle: optimistic DOM update (class toggle) → `PATCH /api/v1/shopping/items/:id` → on error revert and show danger toast
|
||||
|
||||
## CSS Changes
|
||||
|
||||
**`layout.css`** — receives shared swipe infrastructure (moved from `tasks.css`):
|
||||
- `.swipe-row` base styles
|
||||
- `.swipe-reveal` base styles
|
||||
- `.swipe-reveal--done` (green, used by tasks and shopping)
|
||||
|
||||
**`tasks.css`** — retains only task-specific styles:
|
||||
- `.swipe-row .task-card`
|
||||
- `.swipe-reveal--edit` (blue, tasks only)
|
||||
- `.swipe-row--swiping .task-card`
|
||||
|
||||
**`shopping.css`** — new shopping-specific styles:
|
||||
- `.swipe-row .shopping-item`
|
||||
- `.swipe-row--swiping .shopping-item`
|
||||
- `.swipe-reveal--delete` (red, `--color-danger`)
|
||||
- `@media (max-width: 1023px) .item-delete { display: none }` — × button hidden on mobile, swipe replaces it
|
||||
|
||||
## JavaScript Changes (`shopping.js`)
|
||||
|
||||
### `renderItem(item)` → wrapped in swipe-row
|
||||
|
||||
```html
|
||||
<div class="swipe-row" data-swipe-id="${item.id}" data-swipe-checked="${item.is_checked}">
|
||||
<div class="swipe-reveal swipe-reveal--done">
|
||||
<i data-lucide="check|rotate-ccw"></i>
|
||||
<span>Abhaken|Zurück</span>
|
||||
</div>
|
||||
<div class="swipe-reveal swipe-reveal--delete">
|
||||
<i data-lucide="trash-2"></i>
|
||||
<span>Löschen</span>
|
||||
</div>
|
||||
<!-- existing .shopping-item content, item-delete button kept for desktop -->
|
||||
</div>
|
||||
```
|
||||
|
||||
### New `wireSwipeGestures(container)`
|
||||
|
||||
Registers `touchstart` / `touchmove` (passive: false) / `touchend` on each `.swipe-row` inside `#items-list`. Logic mirrors tasks.js:
|
||||
|
||||
1. `touchstart`: record `startX`, `startY`, clear `locked` flag
|
||||
2. `touchmove`: determine swipe vs. vertical scroll via angle; once locked to swipe, translate card, fade-in appropriate reveal panel proportionally
|
||||
3. `touchend`: if locked and `|dx| > SWIPE_THRESHOLD` trigger action; otherwise spring back
|
||||
|
||||
Constants (same as tasks.js):
|
||||
- `SWIPE_THRESHOLD = 80` px
|
||||
- `SWIPE_LOCK_VERT = 8` px
|
||||
- `SWIPE_MAX_VERT = 10` px
|
||||
|
||||
Called from `renderContent()` after DOM update, alongside existing `wireAutocomplete` and `wireQuickAdd` calls. Also called from `rerenderItems()` after any state change that re-renders the list.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Notes swipe gestures
|
||||
- Any other module
|
||||
- Undo toast after delete (delete is immediate; existing × button provided undo-less delete already)
|
||||
- Desktop swipe via mouse/pointer events
|
||||
Reference in New Issue
Block a user