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

31 KiB
Raw Blame History

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/):

  • 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
  • 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/):

  • 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

  • apps/web/src/lib/components/layout/DesktopSidebar.svelte — extraída del layout; hidden md:flex con data-testid="desktop-sidebar"
  • 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
  • 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
  • apps/web/src/lib/components/layout/MobileDrawer.svelte — backdrop + collective switcher + manage collective + settings + logout; se cierra con click en backdrop o ESC
  • (app)/+layout.sveltefix 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

  • apps/web/src/lib/components/ScreenMasthead.svelte — props label, title, opcional meta / actions snippets. Label 13px uppercase + title 26px mobile / 32px desktop.
  • Aplicado en /lists (Workspace/Espacio), /tasks (Task Manager/Tareas), /notes (Repository/Repositorio), /search (Omni-Search/Buscar + title "Find Anything./Encuéntralo todo.")
  • px-8px-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

  • Grid responsivo: 2-col mobile, 3-col md+ (antes 2-col fijo)
  • Sticky input con offset para el bottom tab bar: inset-x-0 mb-16 md:mb-0 md:left-56
  • 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

  • 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

  • apps/web/src/lib/sync/undoQueue.tsscheduleUndoable({ label, restore, commit, ttlMs? }) devuelve id; undo(id) cancela timer y llama restore; TTL por defecto 4s → auto-commit
  • 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"
  • 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

  • ItemSuggestions.svelteflex 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.
  • 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

  • /tasks: masthead aplicado. Secciones pendientes/completadas con flip animation (desde Fase 3).
  • /notes: masthead + contador pendiente de meta. Grid responsivo (1 col mobile → 2/3/4 col en md/lg/xl) desde Fase 3.
  • /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):
    <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]:
    <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:
    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:
    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.tsmobile-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>:
    {#if !isDetailRoute($page.url.pathname)}
        <MobileTopBar onMenu={() => (drawerOpen = true)} />
    {/if}
    
    con un helper:
    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:

  • Nuevo helper apps/web/src/lib/utils/id.tsgenerateId() 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).
  • 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.
  • Regla del repo (añadida a CLAUDE.md): nadie llama crypto.randomUUID() directamente — siempre generateId().

Tests:

  • 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.
  • 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

  • just test-all233 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).
  • loginAs(USERS.borja) con page.setViewportSize(MOBILE) activa el shell mobile automáticamente (verificado en M-01..M-04).
  • 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.