Keep/_bmad-output/implementation-artifacts/tech-spec-fix-muuri-masonry-grid.md
sepehr ddb67ba9e5 fix: unify theme system - fix theme switching persistence
- Unified localStorage key to 'theme-preference' across all components
- Fixed header.tsx using wrong localStorage key ('theme' instead of 'theme-preference')
- Added localStorage hybrid persistence for instant theme changes
- Removed router.refresh() which was causing stale data revert
- Replaced Blue theme with Sepia
- Consolidated auth() calls to prevent race conditions
- Updated UserSettingsData types to include all themes
2026-01-18 22:33:41 +01:00

13 KiB

title slug created status stepsCompleted tech_stack files_to_modify code_patterns test_patterns
Fix Muuri Masonry Grid - Drag & Drop et Layout Responsive fix-muuri-masonry-grid 2026-01-18 ready-for-dev
1
2
3
4
muuri@0.9.5
react@19.2.3
typescript@5.x
next.js@16.1.1
web-animations-js
components/masonry-grid.tsx
components/note-card.tsx
components/masonry-grid.css
config/masonry-layout.ts
tests/drag-drop.spec.ts
Dynamic Muuri import (SSR-safe)
useResizeObserver hook with RAF debounce
NotebookDragContext for cross-component state
dragHandle: .muuri-drag-handle (mobile only)
NoteSize type: small | medium | large
Playwright E2E with [data-draggable="true"] selectors
API cleanup in beforeAll/afterEach
dragTo() for reliable drag operations

Tech-Spec: Fix Muuri Masonry Grid - Drag & Drop et Layout Responsive

Created: 2026-01-18
Status: 🔍 Review

Overview

Problem Statement

Le système de grille masonry avec Muuri présente 4 problèmes critiques:

  1. Drag & Drop cassé - Les tests Playwright cherchent data-draggable="true" mais l'attribut est sur NoteCard (ligne 273), pas sur le MasonryItem wrapper que Muuri manipule.

  2. Tailles de notes non gérées - Les notes ont data-size mais Muuri ne recalcule pas le layout après le rendu du contenu. La fonction getItemDimensions est définie mais jamais réutilisée lors des syncs.

  3. Layout non responsive - Les colonnes sont calculées via calculateColumns() mais les largeurs ne sont appliquées qu'une seule fois. Le useEffect de sync (lignes 295-322) ne gère pas l'ajout/suppression d'items.

  4. Synchronisation items cassée - Quand React ajoute/supprime des notes, Muuri n'est pas notifié. Les nouveaux items ne sont pas ajoutés à la grille Muuri.

Solution

Refactoriser l'intégration Muuri en 5 tâches:

  1. Propager data-draggable="true" au MasonryItem wrapper
  2. Centraliser le calcul des dimensions dans une fonction réutilisable
  3. Utiliser ResizeObserver sur le conteneur principal
  4. Synchroniser les items DOM avec Muuri après chaque rendu React
  5. Vérifier les tests Playwright

Scope

In Scope:

  • Correction du drag & drop Muuri
  • Layout responsive avec colonnes dynamiques (1→5 selon largeur)
  • Gestion correcte des tailles (small/medium/large)
  • Compatibilité tests Playwright existants

Out of Scope:

  • Nouvelles tailles de notes
  • Migration vers autre librairie
  • Modification persistance ordre

Context for Development

Codebase Patterns

Import dynamique Muuri (SSR-safe):

const MuuriClass = (await import('muuri')).default;
pinnedMuuri.current = new MuuriClass(pinnedGridRef.current, layoutOptions);

Hook useResizeObserver existant:

// hooks/use-resize-observer.ts
const observer = new ResizeObserver((entries) => {
  if (frameId.current) cancelAnimationFrame(frameId.current);
  frameId.current = requestAnimationFrame(() => {
    for (const entry of entries) callback(entry);
  });
});

NotebookDragContext (état cross-component):

const { startDrag, endDrag, draggedNoteId } = useNotebookDrag();

Drag handle mobile:

dragHandle: isMobile ? '.muuri-drag-handle' : undefined,

Files to Reference

File Purpose Lines clés
masonry-grid.tsx Composant grille Muuri 116-292 (init), 295-322 (sync)
note-card.tsx Carte note avec data-draggable 271-301 (Card props)
masonry-grid.css Styles tailles et drag 54-67, 70-97
masonry-layout.ts Config breakpoints 81-90 (calculateColumns)
drag-drop.spec.ts Tests E2E 45, 75-78 (data-draggable)

Technical Decisions

  1. Garder Muuri - Fonctionne pour masonry, on corrige l'intégration
  2. Réutiliser useResizeObserver - Hook existant avec RAF debounce
  3. Hauteur auto - Comme Google Keep, contenu détermine hauteur
  4. Largeur fixe - Toutes notes même largeur par colonne

Implementation Plan

Tasks

Task 1: Ajouter data-draggable au MasonryItem wrapper

  • File: components/masonry-grid.tsx
  • Action: Ajouter data-draggable="true" au div wrapper .masonry-item
  • Lignes: 32-37
// AVANT (ligne 32-37)
<div
  className="masonry-item absolute py-1"
  data-id={note.id}
  data-size={note.size}
  ref={resizeRef as any}
  style={{ width: 'auto', height: 'auto' }}
>

// APRÈS
<div
  className="masonry-item absolute py-1"
  data-id={note.id}
  data-size={note.size}
  data-draggable="true"
  ref={resizeRef as any}
  style={{ width: 'auto', height: 'auto' }}
>

Task 2: Créer fonction applyItemDimensions réutilisable

  • File: components/masonry-grid.tsx
  • Action: Extraire la logique de calcul des dimensions dans une fonction callback
  • Position: Après la ligne 109 (refreshLayout)
// Nouvelle fonction à ajouter après refreshLayout
const applyItemDimensions = useCallback((grid: any, containerWidth: number) => {
  if (!grid) return;
  
  const columns = calculateColumns(containerWidth);
  const itemWidth = calculateItemWidth(containerWidth, columns);
  
  const items = grid.getItems();
  items.forEach((item: any) => {
    const el = item.getElement();
    if (el) {
      el.style.width = `${itemWidth}px`;
      // Height auto - determined by content (Google Keep style)
    }
  });
}, []);

Task 3: Améliorer la gestion du resize avec ResizeObserver sur conteneur

  • File: components/masonry-grid.tsx
  • Action: Remplacer window.addEventListener('resize') par ResizeObserver sur .masonry-container
  • Lignes: 325-378 (useEffect resize)
// REMPLACER le useEffect de resize (lignes 325-378)
const containerRef = useRef<HTMLDivElement>(null);

useEffect(() => {
  if (!containerRef.current || (!pinnedMuuri.current && !othersMuuri.current)) return;
  
  let resizeTimeout: NodeJS.Timeout;
  
  const handleResize = (entries: ResizeObserverEntry[]) => {
    clearTimeout(resizeTimeout);
    resizeTimeout = setTimeout(() => {
      const containerWidth = entries[0]?.contentRect.width || window.innerWidth - 32;
      const columns = calculateColumns(containerWidth);
      const itemWidth = calculateItemWidth(containerWidth, columns);
      
      console.log(`[Masonry Resize] Width: ${containerWidth}px, Columns: ${columns}`);
      
      // Apply dimensions to both grids
      applyItemDimensions(pinnedMuuri.current, containerWidth);
      applyItemDimensions(othersMuuri.current, containerWidth);
      
      // Refresh layouts
      requestAnimationFrame(() => {
        pinnedMuuri.current?.refreshItems().layout();
        othersMuuri.current?.refreshItems().layout();
      });
    }, 150);
  };
  
  const observer = new ResizeObserver(handleResize);
  observer.observe(containerRef.current);
  
  // Initial layout
  handleResize([{ contentRect: containerRef.current.getBoundingClientRect() } as ResizeObserverEntry]);
  
  return () => {
    clearTimeout(resizeTimeout);
    observer.disconnect();
  };
}, [applyItemDimensions]);
  • Action: Ajouter ref={containerRef} au div .masonry-container (ligne 381)
// AVANT
<div className="masonry-container">

// APRÈS
<div ref={containerRef} className="masonry-container">

Task 4: Synchroniser items DOM ↔ Muuri après rendu React

  • File: components/masonry-grid.tsx
  • Action: Améliorer le useEffect de sync pour gérer ajout/suppression d'items
  • Lignes: 295-322
// REMPLACER le useEffect de sync (lignes 295-322)
useEffect(() => {
  const syncGridItems = (grid: any, gridRef: React.RefObject<HTMLDivElement>, notesArray: Note[]) => {
    if (!grid || !gridRef.current) return;
    
    const containerWidth = containerRef.current?.getBoundingClientRect().width || window.innerWidth - 32;
    const columns = calculateColumns(containerWidth);
    const itemWidth = calculateItemWidth(containerWidth, columns);
    
    // Get current DOM elements and Muuri items
    const domElements = Array.from(gridRef.current.children) as HTMLElement[];
    const muuriItems = grid.getItems();
    const muuriElements = muuriItems.map((item: any) => item.getElement());
    
    // Find new elements to add
    const newElements = domElements.filter(el => !muuriElements.includes(el));
    
    // Find removed elements
    const removedItems = muuriItems.filter((item: any) => 
      !domElements.includes(item.getElement())
    );
    
    // Remove old items
    if (removedItems.length > 0) {
      grid.remove(removedItems, { layout: false });
    }
    
    // Add new items with correct width
    if (newElements.length > 0) {
      newElements.forEach(el => {
        el.style.width = `${itemWidth}px`;
      });
      grid.add(newElements, { layout: false });
    }
    
    // Update all item widths
    domElements.forEach(el => {
      el.style.width = `${itemWidth}px`;
    });
    
    // Refresh and layout
    grid.refreshItems().layout();
  };
  
  requestAnimationFrame(() => {
    syncGridItems(pinnedMuuri.current, pinnedGridRef, pinnedNotes);
    syncGridItems(othersMuuri.current, othersGridRef, othersNotes);
  });
}, [pinnedNotes, othersNotes]);

Task 5: Vérifier les tests Playwright

  • File: tests/drag-drop.spec.ts
  • Action: Exécuter les tests et vérifier que les sélecteurs [data-draggable="true"] matchent le wrapper
  • Commande: npx playwright test drag-drop.spec.ts

Points de vérification:

  • Ligne 45: page.locator('[data-draggable="true"]') doit trouver les .masonry-item wrappers
  • Ligne 149: firstNote.dragTo(secondNote) doit fonctionner avec Muuri

Acceptance Criteria

AC1: Drag & Drop fonctionnel

  • Given une grille de notes affichée
  • When je drag une note vers une autre position
  • Then la note se déplace visuellement avec placeholder
  • And l'ordre est persisté après le drop

AC2: Layout responsive

  • Given une grille de notes avec différentes tailles
  • When je redimensionne la fenêtre du navigateur
  • Then le nombre de colonnes s'adapte:
    • < 480px: 1 colonne
    • 480-768px: 2 colonnes
    • 768-1024px: 2 colonnes
    • 1024-1280px: 3 colonnes
    • 1280-1600px: 4 colonnes
    • 1600px: 5 colonnes

AC3: Tailles de notes respectées

  • Given une note avec data-size="large"
  • When la note est affichée dans la grille
  • Then elle a une min-height de 300px
  • And sa hauteur finale est déterminée par son contenu

AC4: Synchronisation React-Muuri

  • Given une grille avec des notes
  • When j'ajoute une nouvelle note via l'input
  • Then la note apparaît dans la grille avec les bonnes dimensions
  • And elle est draggable immédiatement

AC5: Tests Playwright passants

  • Given les tests Playwright existants
  • When j'exécute npx playwright test drag-drop.spec.ts
  • Then tous les tests passent avec les sélecteurs [data-draggable="true"]

Additional Context

Dependencies

Dépendance Version Usage
muuri ^0.9.5 Grille masonry avec drag & drop
web-animations-js (bundled) Polyfill animations
ResizeObserver Native Détection resize conteneur

Testing Strategy

Tests automatisés:

# Exécuter tests drag-drop
npx playwright test drag-drop.spec.ts

# Exécuter tests responsive (à ajouter)
npx playwright test --grep "responsive"

Tests manuels:

  1. Ouvrir l'app sur différentes tailles d'écran
  2. Vérifier le nombre de colonnes selon breakpoints
  3. Drag une note et vérifier le placeholder
  4. Ajouter une note et vérifier qu'elle est draggable
  5. Redimensionner la fenêtre et vérifier le re-layout

Notes & Risques

Warning

Risque: Synchronisation timing Le requestAnimationFrame dans syncGridItems doit s'exécuter APRÈS que React ait rendu les nouveaux éléments DOM. Si des problèmes de timing apparaissent, utiliser setTimeout(..., 0) ou MutationObserver.

Note

Comportement Google Keep Google Keep utilise des hauteurs automatiques basées sur le contenu. On ne fixe pas de hauteur, seulement la largeur. Muuri gère le positionnement vertical automatiquement.

Tip

Debug Muuri Ajouter console.log dans handleDragEnd pour vérifier que l'ordre est bien capturé après un drag.


Ordre d'exécution recommandé

flowchart TD
    T1[Task 1: data-draggable] --> T4[Task 4: Sync React-Muuri]
    T2[Task 2: applyItemDimensions] --> T3[Task 3: ResizeObserver]
    T3 --> T4
    T4 --> T5[Task 5: Tests Playwright]
  1. Task 1 (5 min) - Modification simple, débloque les tests
  2. Task 2 (10 min) - Refactoring fonction, prépare Task 3
  3. Task 3 (15 min) - ResizeObserver, dépend de Task 2
  4. Task 4 (20 min) - Sync React-Muuri, le plus critique
  5. Task 5 (5 min) - Validation finale

Temps estimé total: ~55 minutes