diff --git a/docs/superpowers/plans/2026-03-31-shopping-swipe-gestures.md b/docs/superpowers/plans/2026-03-31-shopping-swipe-gestures.md new file mode 100644 index 0000000..4bc423f --- /dev/null +++ b/docs/superpowers/plans/2026-03-31-shopping-swipe-gestures.md @@ -0,0 +1,476 @@ +# 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 ` +
+ + +
+ +
+
${item.name}
+ ${item.quantity ? `
${item.quantity}
` : ''} +
+ +
+
`; +} +``` + +- [ ] **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 +```