# 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 ```