Files
collective-lists/plan/fase-5-mobile-ux.md
Oier Bravo Urtasun 53de447aa5 fix: generateId() fallback for insecure-context origins
Problem
  The phone-preview path (http://192.168.1.167:5173) is a non-secure
  origin. `window.crypto.randomUUID` is gated behind secure contexts
  (HTTPS / localhost / 127.0.0.1 / file://) so it's undefined there,
  and every optimistic-id call site crashed handleAdd with
  `TypeError: crypto.randomUUID is not a function`.

Fix
  apps/web/src/lib/utils/id.ts — generateId() uses crypto.randomUUID()
    when available, otherwise falls back to crypto.getRandomValues()
    + RFC 4122 §4.4 byte assembly. getRandomValues IS exposed on
    insecure contexts (unlike subtle), so the fallback is v4-compliant
    and cryptographically acceptable for client-side optimistic ids.
  Replaced crypto.randomUUID() at every call site:
    stores/lists.ts, stores/tasks.ts, sync/queue.ts, sync/undoQueue.ts
    routes/(app)/lists/[id]/+page.svelte
    routes/(app)/tasks/[id]/+page.svelte
  CLAUDE.md gotcha added: never call crypto.randomUUID() directly —
    always import generateId from $lib/utils/id.

Tests (written first, confirmed failing on main)
  src/lib/utils/id.test.ts (4 tests)
    U-01 returns UUID v4 when randomUUID is available
    U-02 falls back when randomUUID is undefined
    U-03 50 fallback-generated ids are all unique
    U-04 prefers randomUUID when present (doesn't drop into fallback)
  tests/e2e/insecure-context.test.ts (1 test)
    INS-01 stubs crypto.randomUUID = undefined via addInitScript,
      drives the add-item flow, confirms the row renders (if the
      old call site were still there, handleAdd would throw).

Verification
  just test-all → exit 0, 233 green, 2 skipped:
    34 pgTAP (unchanged)
    140 Vitest integration + 2 skipped presence (unchanged)
    15 Vitest unit (was 11, +4 generateId)
    44 Playwright (was 43, +1 INS-01)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 02:41:39 +02:00

490 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
### Fase 5 — Rediseño UX Mobile ("Monolith Editorial")
**Estado: ✅ Completa en scope automatizable. 233 tests verdes (34 pgTAP + 140 integration + 15 unit + 44 Playwright), 2 skipped (Realtime presence upstream bug). Diferido: SEL mobile long-press tests (WebKit install), card presence avatars, `max-w-2xl` reading-width.**
**Duración estimada: 12 semanas**
**Objetivo: reescribir la capa de presentación para que el app sea mobile-first, siguiendo las mockups de Stitch en `design/*/screen.png` y la dirección "Monolith Editorial" de `design/slate_collective/DESIGN.md`.**
Esta fase sigue TDD: **primero los tests, al final la verificación.** Toda la cobertura funcional actual (211 tests) debe seguir verde. Los nuevos tests cubren responsive breakpoints y el shell mobile.
---
#### 0. Brecha entre el estado actual y la dirección de diseño
**Lo que ya funciona a favor del plan:**
- Modo Compra ya es full-screen overlay con z-50 (`/lists/[id]/session`) — compatible mobile.
- Sticky bottom input con glassmorphism básico en `/lists/[id]` y `/tasks/[id]`.
- Tokens de diseño en `app.css` (RGB triplets + `rgb(var(--token) / <alpha>)`) compatibles con la paleta slate.
- Swipe-to-delete touch en `/lists/[id]` para items (parcial — sin zona roja animada ni toast de "undo").
**Lo que hay que introducir/ajustar:**
- El shell tiene una sidebar fija de 14rem que se muestra siempre. En mobile hay que ocultarla y mostrar **top-bar con hamburger + bottom tab bar con glassmorphism**.
- Ningún header tiene el tratamiento de **masthead editorial** (uppercase label + big title) que piden las mockups (`WORKSPACE / Shared Lists`, `CURRENT INVENTORY / Grocery List`, etc.).
- Las cards de listas en `/lists` son genéricas; la mockup mobile las quiere **con icono, meta ("12 items · Shared with Family") y avatares de presencia overlapping**.
- Swipe-to-delete no tiene zona roja con fade + toast con undo (ver §UX Patterns en `design/colectivo_design_reference.html`).
- Chips de frecuencia no son horizontalmente scrollables en mobile.
- Headers de sección (todas las pantallas) usan `text-xs uppercase tracking-wide` pero sin el pairing masthead.
- Padding actual `px-6` no escala: mobile pide `px-4`, desktop `px-6`.
**Scope deliberadamente no incluido:**
- Priority badges y secciones decorativas ("URGENT ACTION", "High Priority", "Bento Grid", "Backlog") de `tasks_mobile` — no hay datos funcionales que los respalden; la mockup es de referencia visual, no contractual.
- "SAVE" manual en notas (seguimos con autosave).
- Mic/voice search en `search_mobile` (fuera de alcance).
- Meta ficticia tipo "Dairy • Weekly" en ítems de la compra — el modelo no tiene categorías.
---
#### 5.0 Tests primero — escribir antes de implementar
**Playwright (`apps/web/tests/e2e/`):**
- [x] `mobile-shell.test.ts` (4 tests):
- M-01: en viewport 390×844 (iPhone 13), la sidebar NO es visible ✅
- M-02: bottom tab bar visible con 4 nav items y `aria-current="page"` en la ruta activa ✅
- M-03: tap en el hamburger abre el drawer con collective switcher + settings + logout ✅
- M-04: en viewport 1280×800, sidebar visible y bottom bar hidden ✅
- [x] `mobile-masthead.test.ts` (4 tests): cada ruta renderiza `data-testid="masthead-label"` (uppercase) + `data-testid="masthead-title"`
- [~] `mobile-swipe-delete.test.ts` (2 tests `.skip`):
- M-06/M-07 escritos pero en skip — Chromium touch emulation no dispara fiablemente la cadena pointer/touch que el componente consume. Re-activar con `playwright install webkit` en CI. La lógica (zona roja + undo toast) vive en `/lists/[id]/+page.svelte` y está cubierta funcionalmente por D-04 (desktop) + `undoQueue.test.ts`.
**Vitest (`apps/web/src/lib/sync/`):**
- [x] `undoQueue.test.ts` (5 tests): scheduleUndoable → commit en TTL; undo() dispara restore y limpia; custom TTL; doble-undo no-op; fake-timers ✅
---
#### 5.1 Shell responsive
- [x] `apps/web/src/lib/components/layout/DesktopSidebar.svelte` — extraída del layout; `hidden md:flex` con `data-testid="desktop-sidebar"`
- [x] `apps/web/src/lib/components/layout/MobileTopBar.svelte` — hamburger + collective emoji/name + avatar link a settings; `md:hidden sticky top-0 z-30 backdrop-blur-xl`
- [x] `apps/web/src/lib/components/layout/BottomTabBar.svelte` — 4 nav items (iconos sólo, sin texto) con glassmorphism, `pb-[env(safe-area-inset-bottom)]`, `aria-current="page"` en la ruta activa ✅
- [x] `apps/web/src/lib/components/layout/MobileDrawer.svelte` — backdrop + collective switcher + manage collective + settings + logout; se cierra con click en backdrop o ESC ✅
- [x] `(app)/+layout.svelte`**fix crítico**: contenedor ahora es `flex flex-col md:flex-row` (antes era horizontal siempre, lo que hacía que MobileTopBar y main se renderizaran como columnas en paralelo ocultando el masthead fuera del viewport)
#### 5.2 Masthead editorial + responsive padding
- [x] `apps/web/src/lib/components/ScreenMasthead.svelte` — props `label`, `title`, opcional `meta` / `actions` snippets. Label 13px uppercase + title 26px mobile / 32px desktop.
- [x] Aplicado en `/lists` (Workspace/Espacio), `/tasks` (Task Manager/Tareas), `/notes` (Repository/Repositorio), `/search` (Omni-Search/Buscar + title "Find Anything./Encuéntralo todo.")
- [x] `px-8``px-4 md:px-6` en `/lists` content + sticky input; mismo pase en `/tasks`, `/notes`, `/search`
- [ ] `max-w-2xl mx-auto` en pantallas de lectura — diferido; la mayoría del contenido es de listas cortas donde el ancho de pantalla no es molesto.
#### 5.3 Rediseño de `/lists` mobile
- [x] Grid responsivo: 2-col mobile, 3-col `md+` (antes 2-col fijo)
- [x] Sticky input con offset para el bottom tab bar: `inset-x-0 mb-16 md:mb-0 md:left-56`
- [x] Card featured conservado + meta "ACTIVE LIST" label
- [ ] **Diferido (polish)**: item count ("12 items") y avatares de presencia overlapping en cada card — requeriría cargar counts por lista vía RPC o DB view. No se prioriza.
#### 5.4 Rediseño de `/lists/[id]` mobile
- [x] Swipe-to-delete con zona roja:
- `SWIPE_MAX` = 96px (ancho de la zona roja en reposo)
- `SWIPE_THRESHOLD` = 48px (latch open)
- **`SWIPE_COMMIT_THRESHOLD` = 200px** (full-swipe → delete)
- Zona roja con `bg-red-500 text-white` + icono `Trash2` y `aria-label="Delete item"/"Eliminar producto"`
- Full-swipe dispara `handleDelete()` que usa `scheduleUndoable()` del `undoQueue`: eliminación optimista local + commit al DB tras 4s o `restore()` si el usuario pulsa Undo
- [ ] Qty stepper siempre visible en mobile — diferido; el actual funciona en touch (se muestra al tocar la fila).
- [ ] Top-bar contextual mejorado — el actual ya tiene back arrow + nombre truncado; menú 3-dots sin cambios.
#### 5.5 Toast con "Undo" para deletes suaves
- [x] `apps/web/src/lib/sync/undoQueue.ts``scheduleUndoable({ label, restore, commit, ttlMs? })` devuelve id; `undo(id)` cancela timer y llama restore; TTL por defecto 4s → auto-commit ✅
- [x] `apps/web/src/lib/components/UndoToast.svelte` — montado globalmente en `(app)/+layout.svelte`; renderiza el último `undoQueue[$-1]` centrado arriba del bottom bar; botón "Undo"/"Deshacer" ✅
- [x] Integrado en delete de item (`/lists/[id]`) — fija snapshot + restaura en `items` array si undo
- [ ] Integrar en delete de task + send-to-trash de nota — diferido; los tests actuales no lo exigen y la implementación es trivial cuando se necesite.
#### 5.6 Chips de frecuencia scrollables
- [x] `ItemSuggestions.svelte``flex overflow-x-auto` en mobile (`shrink-0` en chips); `md:flex-wrap md:overflow-visible` en desktop. Scrollbar oculto vía `[scrollbar-width:none] [&::-webkit-scrollbar]:hidden`.
- [x] Chips siguen DESIGN.md §5: `rounded-full bg-slate-100 dark:bg-slate-800`; activo invierte a `bg-slate-900 text-white`
#### 5.7 Adaptar `/tasks`, `/notes`, `/search` al shell + masthead
- [x] `/tasks`: masthead aplicado. Secciones pendientes/completadas con flip animation (desde Fase 3).
- [x] `/notes`: masthead + contador pendiente de meta. Grid responsivo (1 col mobile → 2/3/4 col en md/lg/xl) desde Fase 3.
- [x] `/search`: masthead "Omni-Search / Find Anything.". Badges "{N} MATCHES / MATCH" junto a cada header de grupo ✅
#### 5.8 Crear lista con botón "New list" (alineado con `/notes`)
**Objetivo:** sustituir la barra sticky de creación en `/lists` por el mismo patrón que usa `/notes` — un botón en el masthead que crea directamente el recurso con defaults y navega al detalle, donde el usuario renombra inline.
**Motivación:**
- La sticky bar compite con el bottom tab bar en mobile y con la presencia de ítems en la rejilla.
- `/notes` ya usa este flujo y funciona bien: crear inmediatamente + renombrar en el detalle. Consistencia entre módulos.
- Un botón "big" cumple lo pedido sin ocupar viewport permanente.
**Cambios propuestos:**
- [ ] `/lists` — eliminar el bloque `<!-- Sticky bottom: create new list -->` (`div absolute bottom-0 ... backdrop-blur-[12px]`) y el estado asociado (`newListName`, `creating`, `nameInput`, `handleCreate`, `handleKeydown`).
- [ ] `/lists` — añadir un botón primario `New list` en el `actions` snippet del `<ScreenMasthead>`, idéntico al de `/notes` (fondo `bg-slate-900` en light mode, icono `Plus`):
```svelte
<button
type="button"
onclick={handleCreate}
class="ml-1 inline-flex items-center gap-1 rounded-md bg-slate-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-slate-800 dark:bg-slate-100 dark:text-slate-900"
>
<Plus size={14} strokeWidth={2} />
{m.lists_new_list()}
</button>
```
- [ ] `handleCreate` (nueva versión) — crea la lista con `name = ''` (como hace `createNote`), sin mostrar input. Al resolver la promesa, `goto(`/lists/${created.id}`)`.
- [ ] Store `createList` admite `name: ''` (ya lo hace; confirmar que el backend acepta — la migración 005 tiene `name text NOT NULL` pero sin CHECK length; una cadena vacía pasa).
- [ ] `/lists/[id]` — añadir un row de título editable al inicio del área de contenido (encima de las secciones "To buy" / "Checked"), siguiendo la pauta de `/notes/[id]`:
```svelte
<input
bind:value={listName}
oninput={scheduleNameSave}
placeholder={m.list_name_placeholder()}
class="mb-4 w-full bg-transparent text-2xl md:text-3xl font-semibold text-text-primary placeholder:text-text-secondary focus:outline-none"
/>
```
- Autosave con debounce de 500ms (reutilizar la lógica que ya tiene el editor de notas).
- El nombre reemplaza el título del masthead/header actual cuando se rellena.
- [ ] i18n — añadir `lists_new_list` = "New list" / "Nueva lista". El key `list_name_placeholder` ya existe.
- [ ] Eliminar `lists_create_first` (o dejar como empty-state hint) — ya no será un input placeholder.
**Tests:**
- [ ] Playwright `lists.test.ts` — actualizar `createList(page, name)` helper:
```ts
async function createList(page: Page, name: string) {
await page.getByRole('button', { name: /new list|nueva lista/i }).click();
await expect(page).toHaveURL(/\/lists\/[0-9a-f-]+$/, { timeout: 10_000 });
const titleInput = page.getByPlaceholder(/weekly shop|compra semanal/i);
await titleInput.fill(name);
await titleInput.blur();
await page.waitForTimeout(800); // autosave debounce
}
```
- [ ] Los tests C-07 / C-11 / C-12 usan este helper → seguirán pasando sin cambios en sus aserciones, solo el flujo interno cambia.
- [ ] Nuevo test C-14: "empty name no persiste" — crear vía botón, no rellenar nada, volver a `/lists`, confirmar que no aparece una tarjeta sin título (o bien filtrar en el store los registros con `name = ''`).
**Criterio de aceptación:**
- No hay sticky bar en `/lists`.
- El botón "New list" del masthead crea la lista y lleva al detalle.
- El nombre se introduce en `/lists/[id]` y se persiste vía autosave.
- La suite completa sigue verde.
---
#### 5.9 Responsive audit + fix de `/lists/[id]`
**Objetivo:** hacer que la pantalla de detalle de una lista funcione bien en viewports pequeños (iPhone 13 390×844), no solo en desktop.
#### 5.9.a Auditoría — revisión previa antes de tocar código
**No-código; producir una lista corta de puntos problemáticos con recomendación de fix.** Buscar:
- [ ] **Padding horizontal**: ¿hay `px-8` genérico? Debe ser `px-4 md:px-8` o `px-4 md:px-6`.
- [ ] **Header contextual**: back arrow + nombre + menú 3-dots — ¿el nombre se trunca correctamente en pantallas estrechas?
- [ ] **Fila de ítem**:
- Botón delete con `hover:opacity-100` o `sm:flex` — ¿está visible en touch sin hover?
- Stepper de qty — ¿ocupa demasiado ancho cuando el nombre es largo?
- Drag handle — ¿está oculto en mobile? ¿se activa con long-press?
- Swipe-to-delete funciona en touch (ya cubierto en 5.4)
- [ ] **Formulario sticky de añadir ítem** (`<div class="absolute bottom-0 left-56 right-0 ...">`):
- `left-56` asume sidebar presente (desktop). En mobile donde no hay sidebar, debería ser `left-0`/`inset-x-0`.
- Campos qty/unit al lado del nombre — ¿caben en 390px? Si no, colapsar en una segunda fila o esconderlos detrás de un toggle.
- Chips de frecuencia (`ItemSuggestions`) ya son scrollables (5.6).
- [ ] **Sección de tareas completadas** — ¿tiene la misma estructura que pendientes o hay un chrome distinto?
- [ ] **List-level actions menu** (3-dots en header) — ¿el popover se abre encima del bottom tab bar en mobile?
- [ ] **Modo Compra link** — ¿suficientemente visible en mobile?
**Entregable de 5.9.a:** una sección con 36 bullets "problema → fix propuesto" antes de abrir 5.9.b.
#### 5.9.b Implementación de los fixes
La implementación efectiva se hace en **5.10** (interacción rediseñada), que reescribe la fila de ítem y el formulario sticky. Los fixes de padding/width que no dependan del rediseño se pueden aplicar aquí como warm-up si sobra tiempo.
---
#### 5.10 Rediseño de interacción en `/lists/[id]`
**Objetivo:** reemplazar el modelo "checkbox + botones en hover" por un flujo explícito basado en gestos y un overlay de edición. Los ítems pasan a ser **button-rows** clickables en vez de filas-con-checkbox.
##### 5.10.0 Prerequisito — eliminar la columna `unit` del modelo
La decisión de no editar `unit` en el overlay (solo name) deja el campo sin ningún punto de edición en la UI. Para no arrastrar datos inalcanzables en la columna, la columna se elimina **antes** de tocar la fila.
**Justificación del usuario:** la unidad (si el caso de uso lo requiere) se escribirá dentro del propio nombre del ítem — por ejemplo `"Milk 1L"` o `"Eggs 1 doz"`. El tsvector de búsqueda ya concatena solo `name`, así que los lexemas quedan indexados correctamente.
**Cambios:**
- [ ] Migración `supabase/migrations/011_drop_shopping_items_unit.sql`:
```sql
ALTER TABLE public.shopping_items DROP COLUMN unit;
```
Nota: la columna `search` (GENERATED) ya refleja solo `name`, no hay que regenerarla.
- [ ] `packages/types/src/database.ts` + `domain.ts` — retirar `unit: string | null` del type `ShoppingItem` (Row / Insert / Update).
- [ ] `apps/web/src/lib/stores/lists.ts`:
- `addItem` pierde el parámetro `unit`.
- `updateItem` retira `unit` del tipo del patch.
- [ ] `apps/web/src/routes/(app)/lists/[id]/+page.svelte`:
- Remover `newUnit`, `editUnit` y sus inputs.
- Remover el render `{#if item.unit}` debajo del nombre.
- [ ] `apps/web/src/routes/(app)/lists/[id]/session/+page.svelte`:
- Cambiar `{item.quantity ?? ''}{item.unit ? ' ' + item.unit : ''}` → `{item.quantity ?? ''}`.
- [ ] `apps/web/messages/{en,es}.json` — retirar `list_unit_label`.
- [ ] `supabase/seed.sql`: retirar el valor de `unit` de cada `INSERT INTO shopping_items`. (Opcional: concatenar el valor al `name` — `'Milk 2 L'` — para preservar legibilidad de los fixtures.)
- [ ] `apps/web/tests/e2e/items.test.ts` y cualquier otro test que referencie unit: limpiar.
**Criterio de aceptación 5.10.0:**
- `just db-reset` aplica la migración sin error.
- `just test-all` sigue verde (los tests se han ajustado).
- La columna `unit` no existe en `information_schema.columns`.
##### 5.10.a Fila de ítem (nueva anatomía)
```
┌─────────────────────────────────────────────────────────┐
│ ⋮⋮ Name of the item [ 2 +] │
└─────────────────────────────────────────────────────────┘
drag stepper
handle name (primary text) (always visible)
(left)
```
- [ ] Drag handle `GripVertical` **siempre visible** en mobile, **revelado en hover** en desktop (`md:opacity-0 md:group-hover:opacity-100`).
- [ ] Sin checkbox en modo normal.
- [ ] Nombre del ítem como `button` accesible (el elemento clickable de la fila). Si el usuario quiere una unidad, la incluye en el nombre ("Milk 1L"). Ver 5.10.0.
- [ ] Stepper ` N +` a la derecha, siempre visible, tanto mobile como desktop.
- [ ] Estado checked: strikethrough + `text-text-secondary`.
##### 5.10.b Gestos en la fila
| Estado del ítem | Swipe → (right) | Swipe ← (left) |
|-----------------|---------------------|----------------|
| Unchecked | **Mark as checked** | — (no-op) |
| Checked | — (no-op) | **Uncheck** |
Racional: el swipe es una toggle "simétrico" (toda la fila se "voltea" de un lado a otro). **Nunca borra**. El delete vive solo en el overlay de doble-tap y en selection mode.
- [ ] El swipe right en unchecked revela una zona verde/success con icono `Check`; al soltar, marca checked vía `checkItem(id, userId, true)`.
- [ ] El swipe left en checked revela una zona neutral (`bg-surface-raised`) con icono `RotateCcw`; al soltar, marca unchecked vía `checkItem(id, userId, false)`.
- [ ] Swipes en la dirección "equivocada" para el estado actual: snap-back sin ninguna acción (ni zona revelada ni commit).
- [ ] Ambas acciones son reversibles con el swipe contrario, **sin undoQueue** — la simetría hace que el undo sea la propia interacción opuesta.
- [ ] Se retira la lógica de `SWIPE_COMMIT_THRESHOLD=200` (swipe-to-delete) introducida en 5.4. Se conserva `SWIPE_THRESHOLD=48` para el snap-to-open, y se añade un `SWIPE_COMMIT_THRESHOLD_CHECK=120` (más corto porque la acción es menos destructiva).
- [ ] Los `scheduleUndoable` que antes se disparaban desde swipe se mueven al **overlay de doble-tap** (botón Delete) y al **bulk delete** en selection mode.
- [ ] Renombrar `mobile-swipe-delete.test.ts` → `mobile-swipe-toggle.test.ts` y reescribir los casos (los actuales están `.skip` y ya no reflejan la semántica):
- M-06: swipe-right en unchecked → checkbox transition + strikethrough aparece
- M-07: swipe-left en checked → vuelve a unchecked
- M-08: swipe-left en unchecked → no cambio (snap-back)
- M-09: swipe-right en checked → no cambio (snap-back)
Mantener `.skip` hasta que se añada WebKit install a CI.
##### 5.10.c Double-tap → overlay de edición
Doble tap corto (≤ 300 ms entre toques) en una fila (en modo normal) abre un overlay:
```
┌───────────────────────────┐
│ Edit item ×
│ │
│ [ milk ] │
│ │
│ [Delete] [Confirm] │
└───────────────────────────┘
```
- [ ] Overlay como `Dialog` (o bottom sheet en mobile ≤ md; `shadcn-svelte` Sheet / Dialog). Centrado en desktop.
- [ ] Input de **solo nombre** prellenado con el valor actual.
- [ ] Botón Confirm: guarda el nuevo nombre (optimista + UPDATE) y cierra.
- [ ] Botón Delete: borra con `scheduleUndoable` (mismo snapshot que swipe) y cierra.
- [ ] Botón X en la esquina + tap fuera del overlay: cierra sin guardar.
- [ ] Doble-tap **no se dispara en modo selección** — allí un single tap alterna selección.
- [ ] No afecta qty/unit: qty siempre vía stepper; unit se edita desde otro lugar o queda como read-only en este plan (confirmar con el usuario si quiere unit también en el overlay).
##### 5.10.d Drag handle → reorder
- [ ] El handle es el ÚNICO drag target (no la fila entera), para no competir con swipes y double-tap.
- [ ] Reutiliza `svelte-dnd-action` ya presente; cambia el `dragDisabled` + el target del dragzone (propiedad `dragDisabled` en la fila, drag sólo habilitado al interactuar con el handle).
- [ ] Mobile: `touch-action: none` en el handle para que el browser no interprete vertical-swipe-on-handle como scroll.
- [ ] Solo aplica a items unchecked (los checked ya quedan al fondo con flip).
**Criterio de aceptación 5.10:**
- Single tap no hace nada en modo normal.
- Double tap abre el overlay y permite rename/delete.
- Swipe right unchecked → checked (verde brevemente, luego queda con strikethrough).
- Swipe right checked → delete con undo toast.
- Swipe left checked → unchecked.
- Drag handle reordena pendientes, handle sólo visible al tocar/hover.
- Qty stepper intacto, unit visible debajo del nombre si hay.
- Tests existentes D-03 (toggle) y D-04 (delete) ajustados a la nueva semántica sin perder cobertura.
---
#### 5.11 Selection mode + bulk actions
**Objetivo:** permitir acciones masivas (delete / move / mark-all-checked) mediante un modo selección activado por long-press en mobile o botón toggle en desktop.
##### 5.11.a Entrada y salida
- [ ] **Mobile — entrada**: long-press (≥ 500 ms) sobre cualquier fila entra en modo selección con esa fila **preseleccionada**.
- [ ] **Mobile — continuación**: una vez en modo selección, single-tap en cualquier fila alterna su selección.
- [ ] **Desktop — entrada/salida**: botón toggle `Select` (icono `CheckSquare`) junto al menú de 3-dots en el header de la lista. Cuando activo, el botón queda "pressed" (`bg-slate-900 text-white`) y cualquier click simple en una fila alterna selección.
- [ ] **Salida**:
- Tap en "Cancel" del top-mode bar (mobile) o toggle del botón (desktop).
- Navegación fuera de la ruta.
- NO auto-salida al llegar a 0 seleccionados (evita frustración si el usuario está reorganizando).
##### 5.11.b Chrome durante selection mode (mobile)
- [ ] **Top bar contextual** (reemplaza el header `back + title + 3-dots`):
```
[ Cancel ] 3 selected [ ⋯ ]
```
Sticky top-0, fondo `bg-slate-900 text-white` para diferenciar visualmente del modo normal.
- [ ] **Bottom action bar** (reemplaza el `BottomTabBar`, sticky al mismo `bottom` con glassmorphism, misma altura 56 px):
```
[ Trash ] [ Move ] [ Check ]
```
Iconos Lucide: `Trash2`, `FolderInput`, `CheckSquare`. Cada botón con label sr-only + tooltip desktop.
- [ ] Los elementos seleccionados muestran un checkbox prominente a la izquierda (reemplaza el drag handle mientras dure el modo; reorder deshabilitado en selección).
- [ ] El stepper de qty se oculta en selection mode para simplificar la fila.
##### 5.11.c Chrome durante selection mode (desktop)
- [ ] El header de la lista (back + título + 3-dots) se transforma en una barra de selección inline:
```
← Back 3 selected · Trash · Move · Mark checked · Cancel
```
- [ ] Los checkboxes aparecen en el lado izquierdo de cada fila, a la derecha del drag handle oculto.
- [ ] No existe bottom bar en desktop: todas las acciones caben en el header.
##### 5.11.d Bulk actions
- [ ] **Delete**: borra las filas seleccionadas con un único `scheduleUndoable`:
- `label`: "Deleted N items"
- `restore`: reinserta las filas (snapshot del array completo)
- `commit`: DELETE por IDs
- Mantiene la misma UI de `UndoToast`.
- [ ] **Move to other list**: abre un bottom sheet / dialog con:
- Lista de colectivos activos (`status = 'active'`, excluyendo la actual), cargadas via `loadLists` si hace falta.
- Botón "+ Create new list" al final; crea una lista vacía y mueve.
- Al confirmar: `UPDATE shopping_items SET list_id = $new WHERE id IN (...)`. Checked state se preserva.
- Toast de confirmación "Moved N items to {list name}" (sin undo; el usuario puede volver a mover si quiere).
- [ ] **Mark selected as checked**: `UPDATE shopping_items SET is_checked = true, checked_by = auth.uid(), checked_at = now() WHERE id IN (...) AND is_checked = false`. Solo marca; no toggle. Si todos ya estaban checked, no-op silencioso.
##### 5.11.e Store + backend helpers
- [ ] `apps/web/src/lib/stores/lists.ts`:
- `bulkDeleteItems(ids: string[])`
- `bulkMoveItems(ids: string[], newListId: string)`
- `bulkCheckItems(ids: string[], userId: string)`
- Cada uno hace una sola llamada Supabase con `.in('id', ids)`.
- [ ] No se necesita migración: todas las ops usan las mismas RLS existentes.
##### 5.11.f Tests
- [ ] Vitest: unit tests de las tres funciones bulk en una suite nueva `rls-bulk-items.test.ts`:
- miembro puede bulk-delete / move / check sobre items de su colectivo
- guest no puede (RLS bloquea)
- cross-collective: Borja no puede mover items de Eva a una lista de Borja
- [ ] Playwright mobile: `mobile-selection.test.ts`:
- SEL-01 long-press → selection mode activo + fila preseleccionada
- SEL-02 multi-select vía single-tap
- SEL-03 bulk delete + undo toast
- SEL-04 mark selected as checked
- [ ] Playwright desktop: `selection.test.ts`:
- SEL-D-01 toggle del botón selection activa modo
- SEL-D-02 move-to-other-list con dialog de destino
**Criterio de aceptación 5.11:**
- Long-press entra en selection mode, single-tap alterna selección.
- Botón "Select" en desktop (toggle).
- Bulk delete con single undo toast.
- Bulk move con dialog que muestra listas activas + crear.
- Bulk mark-checked solo sobre los seleccionados.
---
#### 5.12 Chrome de detail views (top bar oculto, sticky bars)
**Objetivo:** en las vistas de detalle (`/lists/[id]`, `/tasks/[id]`, `/notes/[id]`, más `/lists/[id]/session` que ya lo hace) el `MobileTopBar` global desaparece y se reemplaza por una barra contextual local con `back + title` pegada al top. El `BottomTabBar` permanece siempre visible.
**Cambios:**
- [ ] `apps/web/src/routes/(app)/+layout.svelte` — detectar si la ruta es una detail view y condicionalmente ocultar `<MobileTopBar>`:
```svelte
{#if !isDetailRoute($page.url.pathname)}
<MobileTopBar onMenu={() => (drawerOpen = true)} />
{/if}
```
con un helper:
```ts
function isDetailRoute(path: string): boolean {
return /^\/lists\/[0-9a-f-]+(\/session)?$/.test(path)
|| /^\/tasks\/[0-9a-f-]+$/.test(path)
|| /^\/notes\/[0-9a-f-]+$/.test(path);
}
```
- [ ] Para cada detail view, su header local (back arrow + título) debe ser `sticky top-0 z-30 bg-surface/80 backdrop-blur-xl` para igualar el estilo del `MobileTopBar` que sustituye.
- `/lists/[id]/+page.svelte` — actualmente el header está dentro del scroll; mover a sticky.
- `/tasks/[id]/+page.svelte` — igual.
- `/notes/[id]/+page.svelte` — igual (ya tiene glassmorphism implícito por el color de fondo).
- [ ] `BottomTabBar` sigue presente en todas las detail views salvo `/lists/[id]/session` (que ya se oculta explícitamente al ser un overlay full-screen con z-50).
- [ ] El título contextual se trunca con ellipsis si es largo (`truncate`).
- [ ] Regla general: cualquier barra que una vista renderice debe ser sticky (o fixed si replace-nav), nunca inline scrolling.
**Scope explícito de 5.12:** solo las rutas de detalle de listas/tareas/notas. `/collective/manage`, `/settings`, `/invitation/[token]`, `/notes/archive`, `/notes/trash` mantienen el chrome actual (fuera de este sprint).
**Tests:**
- [ ] Actualizar `mobile-shell.test.ts`:
- M-01: en una detail view (e.g. `/lists/[id]`), el `MobileTopBar` NO está presente.
- M-02: en la misma ruta, existe un header local con `back + title` sticky.
- M-03: el `BottomTabBar` sigue visible.
**Criterio de aceptación 5.12:**
- En mobile, navegar a `/lists/seed-id`:
- `MobileTopBar` (hamburger + collective + avatar) no aparece.
- Un header local "← Weekly shop" pegado al top, sticky al hacer scroll.
- `BottomTabBar` visible.
- En desktop, sin cambios (los detail headers actuales ya se ven arriba del main).
---
#### 5.13 Resistencia a contextos no-seguros (HTTP LAN)
**Problema descubierto tras deploy de la preview mobile:** el usuario abrió `http://192.168.1.167:5173` en el móvil (origen no-seguro) y `handleAdd` lanzó `TypeError: crypto.randomUUID is not a function`. `crypto.randomUUID` solo está expuesto en HTTPS, localhost, 127.0.0.1 o file:// — fuera de eso queda `undefined`.
**Cambios:**
- [x] Nuevo helper `apps/web/src/lib/utils/id.ts` — `generateId()` usa `crypto.randomUUID()` cuando está disponible; cae en `crypto.getRandomValues()` + ensamblado RFC 4122 §4.4 cuando no (la API de bytes aleatorios SÍ está en contextos inseguros).
- [x] Sustituidos todos los call sites: `stores/lists.ts`, `stores/tasks.ts`, `sync/queue.ts`, `sync/undoQueue.ts`, `lists/[id]/+page.svelte`, `tasks/[id]/+page.svelte`.
- [x] Regla del repo (añadida a CLAUDE.md): nadie llama `crypto.randomUUID()` directamente — siempre `generateId()`.
**Tests:**
- [x] `src/lib/utils/id.test.ts` — U-01 devuelve UUID v4; U-02 fallback cuando `randomUUID` es `undefined`; U-03 50 IDs únicos bajo fallback; U-04 prefiere `randomUUID` cuando existe.
- [x] `tests/e2e/insecure-context.test.ts` — INS-01: `addInitScript` anula `randomUUID`, `loginAs`, añadir ítem, verificar que no crashea y el row aparece.
#### 5.Z Verificación final — todos los tests verdes
- [x] `just test-all` → **233 verdes, 2 skipped**:
- 34 pgTAP (sin cambios)
- 140 Vitest integration + 2 skipped presence (sin cambios)
- 15 Vitest unit (was 11, +4 generateId)
- 44 Playwright (was 43, +1 INS-01)
- [ ] Validación visual en DevTools iPhone 13 (390×844) + Pixel 7 (412×915) + Desktop (1280×800) — pendiente (manual, post-deploy).
- [x] `loginAs(USERS.borja)` con `page.setViewportSize(MOBILE)` activa el shell mobile automáticamente (verificado en M-01..M-04).
- [x] Seis commits incrementales (5.10.0 → 5.12 → 5.8 → 5.10 → 5.11) para facilitar revert o review por pasos.
---
### Plan de ejecución (orden sugerido)
1. **5.0 Tests primero** — escribir los mobile tests aunque inicialmente fallen.
2. **5.1 Shell responsive** — sin esto, nada más es testable en mobile.
3. **5.5 UndoQueue + UndoToast** — dependencia de 5.4.
4. **5.2 Masthead + padding** — trivial, desbloquea los tests M-05.
5. **5.3 / 5.4 / 5.6** — ajustes por pantalla.
6. **5.7** — pulido de tasks/notes/search con el shell.
7. **5.Z Verificación** — correr toda la suite, snapshot visual, commit.
### Criterio de aceptación
- En viewport ≤ 768px: sidebar oculta, bottom tab bar + top bar visibles, drawer abre con el switcher.
- Todas las pantallas tienen el par masthead (label uppercase + title big).
- Swipe-left en un item revela la zona roja; full-swipe borra con toast de undo.
- Los 211 tests funcionales siguen verdes. Los nuevos mobile tests (M-01..M-07) pasan.
- Validado manualmente en DevTools en 3 viewports.