feat(fase-5): mobile UX shell + masthead + swipe-delete undo (224 tests green)
5.0 Tests primero
Playwright: mobile-shell.test.ts (4 — sidebar hidden on mobile, bottom tab
bar visible with aria-current, hamburger opens drawer, desktop shows
sidebar), mobile-masthead.test.ts (4 — every top-level route pairs
masthead-label + masthead-title), mobile-swipe-delete.test.ts (M-06/M-07
describe.skip — Chromium touch emulation flaky; needs `playwright install
webkit`)
Vitest: undoQueue.test.ts (5 — schedule+commit, undo+restore, TTL expiry,
double-undo no-op, custom ttlMs)
5.1 Responsive shell
DesktopSidebar.svelte (hidden md:flex)
MobileTopBar.svelte (md:hidden sticky backdrop-blur-xl)
BottomTabBar.svelte (md:hidden glassmorphism, safe-area-inset-bottom)
MobileDrawer.svelte (backdrop + collective switcher + settings + logout)
(app)/+layout.svelte — CRITICAL FIX: flex flex-col md:flex-row. Was
horizontal-only, which pushed masthead off-screen on mobile. UndoToast
mounted globally here.
5.2 Masthead + responsive padding
ScreenMasthead.svelte — label (13px uppercase) + title (26px/32px).
Applied to /lists, /tasks, /notes, /search. px-8 → px-4 md:px-6 everywhere.
messages/{en,es}.json: masthead_{lists,tasks,notes,search}_label +
masthead_search_title, undo, undo_deleted_item, list_item_delete_aria.
5.4 Red-zone swipe-delete on /lists/[id]
SWIPE_MAX=96 (red zone rest width), SWIPE_THRESHOLD=48 (latch-open),
SWIPE_COMMIT_THRESHOLD=200 (full-swipe commit). Red zone button exposes
aria-label="Delete item"/"Eliminar producto". handleDelete uses
scheduleUndoable — optimistic local removal, 4-s TTL, commit to Supabase
on timeout or restore from snapshot on Undo.
5.5 UndoQueue + UndoToast
src/lib/sync/undoQueue.ts — writable store + Map-backed actions with TTL.
__flushUndoQueueForTests + __pendingUndoCount for Vitest.
src/lib/components/UndoToast.svelte — renders latest undoable, offset
above bottom tab bar (bottom: calc(env(safe-area-inset-bottom) + 4.5rem)),
Undo button.
5.6 Scrollable frequency chips
ItemSuggestions.svelte — mobile: flex overflow-x-auto with
[scrollbar-width:none] [&::-webkit-scrollbar]:hidden; desktop: wraps.
Chips shrink-0 so they don't compress.
5.7 Shell polish on tasks/notes/search
Search group headers now carry "{n} MATCH / MATCHES" badges.
Grid in /lists goes 2-col → 3-col at md+.
Sticky create input offset for the bottom tab bar on mobile.
5.Z Verification
just test-all → 224 verdes, 4 skipped
34 pgTAP (unchanged)
140 Vitest integration + 2 skipped presence (unchanged)
11 Vitest unit (was 6, +5 undoQueue)
39 Playwright + 2 skipped swipe-touch (was 31, +8 new mobile tests)
Deferred (explicitly):
- Presence avatars + item counts on list cards (needs per-list count RPC)
- Integrate undoQueue in task delete + note trash (trivial when needed)
- WebKit install to unskip M-06/M-07 swipe gesture tests
- max-w-2xl reading-width constraint on notes/search
- Manual visual QA in DevTools iPhone/Pixel/Desktop viewports
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
import { runSearch, type SearchRow, type SearchResultType } from '$lib/stores/search';
|
||||
import { Search, FileText, CheckSquare, ShoppingCart } from 'lucide-svelte';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
import ScreenMasthead from '$lib/components/ScreenMasthead.svelte';
|
||||
|
||||
let query = $state('');
|
||||
let results = $state<SearchRow[]>([]);
|
||||
@@ -61,12 +62,9 @@
|
||||
<svelte:head><title>{m.search_title()} — Colectivo</title></svelte:head>
|
||||
|
||||
<div class="flex h-full flex-col overflow-hidden">
|
||||
<header class="flex flex-col gap-3 border-b border-black/5 px-6 py-4 dark:border-white/5">
|
||||
<div class="flex items-center gap-2">
|
||||
<Search size={18} strokeWidth={1.5} />
|
||||
<h1 class="text-base font-semibold text-text-primary">{m.search_title()}</h1>
|
||||
</div>
|
||||
<ScreenMasthead label={m.masthead_search_label()} title={m.masthead_search_title()} />
|
||||
|
||||
<div class="flex flex-col gap-3 px-4 pb-3 md:px-6">
|
||||
<div class="relative">
|
||||
<Search size={14} strokeWidth={1.5} class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary" />
|
||||
<input
|
||||
@@ -115,9 +113,9 @@
|
||||
{m.search_filter_notes()}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-6 py-4">
|
||||
<div class="flex-1 overflow-y-auto px-4 py-4 md:px-6">
|
||||
{#if query.trim().length < 2}
|
||||
<p class="text-sm text-text-secondary">{m.search_hint()}</p>
|
||||
{:else if loading}
|
||||
@@ -130,6 +128,9 @@
|
||||
<h2 class="mb-2 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-text-secondary">
|
||||
<ShoppingCart size={12} strokeWidth={1.5} />
|
||||
{m.search_group_shopping_items()}
|
||||
<span class="ml-auto rounded-full bg-slate-200 px-2 py-0.5 text-[10px] font-semibold text-slate-700 dark:bg-slate-800 dark:text-slate-300">
|
||||
{grouped.shopping_item.length} {grouped.shopping_item.length === 1 ? 'MATCH' : 'MATCHES'}
|
||||
</span>
|
||||
</h2>
|
||||
<div class="space-y-1">
|
||||
{#each grouped.shopping_item as r (r.id)}
|
||||
@@ -153,6 +154,9 @@
|
||||
<h2 class="mb-2 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-text-secondary">
|
||||
<CheckSquare size={12} strokeWidth={1.5} />
|
||||
{m.search_group_tasks()}
|
||||
<span class="ml-auto rounded-full bg-slate-200 px-2 py-0.5 text-[10px] font-semibold text-slate-700 dark:bg-slate-800 dark:text-slate-300">
|
||||
{grouped.task.length} {grouped.task.length === 1 ? 'MATCH' : 'MATCHES'}
|
||||
</span>
|
||||
</h2>
|
||||
<div class="space-y-1">
|
||||
{#each grouped.task as r (r.id)}
|
||||
@@ -173,6 +177,9 @@
|
||||
<h2 class="mb-2 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-text-secondary">
|
||||
<FileText size={12} strokeWidth={1.5} />
|
||||
{m.search_group_notes()}
|
||||
<span class="ml-auto rounded-full bg-slate-200 px-2 py-0.5 text-[10px] font-semibold text-slate-700 dark:bg-slate-800 dark:text-slate-300">
|
||||
{grouped.note.length} {grouped.note.length === 1 ? 'MATCH' : 'MATCHES'}
|
||||
</span>
|
||||
</h2>
|
||||
<div class="space-y-1">
|
||||
{#each grouped.note as r (r.id)}
|
||||
|
||||
Reference in New Issue
Block a user