ObsiViewer/docs/PROMPTS/Nimbus_Interface/ObsiViewer_NewInterface_Nimbus_V2_perplexity.md

38 KiB

🎯 Prompt Windsurf — ObsiViewer Nimbus UI (Desktop + Mobile)

ObsiViewer → Interface "Nimbus-like" 100% Responsive

Rôle & mode : Agis comme Staff Frontend Engineer Angular 20 + UX Designer expert. Raisonnement détaillé autorisé. Tu as les pleins pouvoirs de refactor UI, d'ajout de composants, et de migration CSS vers Tailwind. Conserve la compatibilité de toutes features existantes.

Contrainte majeure : L'interface doit être 100% responsive (Desktop + Mobile + Tablet). Un bouton toggle dans la navbar permet de basculer entre l'ancienne interface et la nouvelle sans perte d'état.


Contexte

  • Projet : ObsiViewer (Angular 20 + Tailwind, Node/Express backend)
  • Objectif : Refondre l'interface selon un design Nimbus Notes/FuseBase-like avec responsive design complet
  • Cœurs d'usage : navigation par dossiers, tags, recherche, lecture markdown plein écran, ToC à droite, tri et filtres rapides[1][2][3]
  • Nouveauté : Design adaptatif complet (Desktop/Mobile/Tablet) avec UI toggle persisté

🎯 Architecture Responsive Cible

Desktop (≥1024px) - Layout 3 Colonnes

┌─────────────────────────────────────────────────────────┐
│ NAVBAR (Dark, fixed, h-14) + Toggle UI                 │
├────────────────┬──────────────────┬──────────────────────┤
│                │                  │                      │
│  SIDEBAR       │   RESULT LIST    │   NOTE VIEW + TOC    │
│  (240-440px)   │   (virtualized)  │   (Resizable)        │
│  Resizable     │                  │                      │
│                │                  │                      │
│  - Quick Links │  - Search bar    │  - Markdown          │
│  - Folders     │  - Chips filters │  - ToC drawer        │  
│  - Tags        │  - Items (80px)  │  - Actions bar       │
│                │  - Pagination    │                      │
│                │                  │                      │
└────────────────┴──────────────────┴──────────────────────┘

Mobile (<768px) - Navigation par Onglets

┌──────────────────────────────────┐
│ NAVBAR (compact, h-12)           │
│ [≡] [Search] [Toggle]            │
├──────────────────────────────────┤
│                                  │
│  CONTENT AREA (Full-width)       │
│  - Drawer sidebar (80vw gauche)  │
│  - Liste swipeable (tab liste)   │  
│  - Markdown full-screen (page)   │
│  - ToC inline (toggle button)    │
│                                  │
├──────────────────────────────────┤
│ BOTTOM NAVIGATION (sticky)       │  
│ [📁] [🔍] [📄] [📋] (4 onglets)  │
└──────────────────────────────────┘

Tablet (768px ≤ width < 1024px) - Hybride

┌──────────────────────────────────────┐
│ NAVBAR + Toggle (fixed, h-14)        │
├──────────────────────────────────────┤
│  TAB NAVIGATION (3 onglets)          │
│  [Sidebar] [List] [Page]             │
├──────────────────────────────────────┤
│                                      │
│  ACTIVE TAB CONTENT (scrollable)     │
│  - Panneau full-width par tab        │  
│  - Drawer si besoin                  │
│                                      │
└──────────────────────────────────────┘

📋 Architecture Feature Flag & Toggle

1) Toggle UI dans la NavBar

Ajouter un bouton toggle dans src/app/layout/app-navbar/app-navbar.component.ts :[4]

<!-- app-navbar.component.html (snippet) -->
<div class="flex items-center gap-2">
  <!-- Autres boutons -->
  <button 
    (click)="toggleUIMode()"
    [attr.aria-label]="'Basculer vers ' + (isNimbusMode$ | async ? 'ancienne' : 'nouvelle') + ' interface'"
    class="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800 
           transition-colors duration-200 text-sm font-medium">
    <span *ngIf="(isNimbusMode$ | async)" class="flex items-center gap-1"><span class="hidden sm:inline">Nimbus</span>
    </span>
    <span *ngIf="!(isNimbusMode$ | async)" class="flex items-center gap-1">
      🔧 <span class="hidden sm:inline">Legacy</span>
    </span>
  </button>
</div>

2) Service de Gestion du Mode UI

Créer src/app/shared/services/ui-mode.service.ts :

import { Injectable, signal, effect } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class UiModeService {
  // Signal pour réactivité fine-grained[3]
  isNimbusMode = signal<boolean>(this.loadUIMode());

  constructor() {
    // Persister les changements automatiquement
    effect(() => {
      if (typeof localStorage !== 'undefined') {
        localStorage.setItem('obsiviewer-ui-mode', 
          this.isNimbusMode() ? 'nimbus' : 'legacy');
      }
    });
  }

  toggleUIMode() {
    const newMode = !this.isNimbusMode();
    this.isNimbusMode.set(newMode);
  }

  private loadUIMode(): boolean {
    if (typeof localStorage === 'undefined') return true;
    const saved = localStorage.getItem('obsiviewer-ui-mode');
    return saved ? saved === 'nimbus' : true; // Nimbus par défaut
  }
}

3) Layout Wrapper avec Feature Flag

Créer src/app/layout/app-shell-adaptive/app-shell-adaptive.component.ts :

import { Component, inject } from '@angular/core';
import { UiModeService } from '@app/shared/services/ui-mode.service';
import { AppShellNimbusLayoutComponent } from '../app-shell-nimbus/app-shell-nimbus.component';
import { AppShellLegacyLayoutComponent } from '../app-shell-legacy/app-shell-legacy.component';

@Component({
  selector: 'app-shell-adaptive',
  template: `
    @if (uiMode.isNimbusMode()) {
      <app-shell-nimbus-layout></app-shell-nimbus-layout>
    } @else {
      <app-shell-legacy-layout></app-shell-legacy-layout>
    }
  `,
  standalone: true,
  imports: [AppShellNimbusLayoutComponent, AppShellLegacyLayoutComponent],
})
export class AppShellAdaptiveComponent {
  uiMode = inject(UiModeService);
}

🎨 Responsive Design Strategy

Breakpoints Tailwind (Mobile-First)[5][6]

// tailwind.config.js
module.exports = {
  theme: {
    screens: {
      'xs': '320px',   // iPhone SE
      'sm': '640px',   // Petites tablettes
      'md': '768px',   // iPad, tablettes
      'lg': '1024px',  // Desktop compact
      'xl': '1280px',  // Desktop standard  
      '2xl': '1536px', // Larges écrans
    },
    extend: {
      // Tokens personnalisés Nimbus-like
      colors: {
        nimbus: {
          50: '#f0f9ff',
          500: '#0ea5e9', // Turquoise actions
          900: '#0c4a6e'  // Dark mode
        }
      }
    }
  },
};

Service de Détection Responsive

// src/shared/services/breakpoint.service.ts
import { Injectable, signal, inject } from '@angular/core';
import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
import { map } from 'rxjs/operators';

@Injectable({ providedIn: 'root' })
export class ResponsiveService {
  private breakpointObserver = inject(BreakpointObserver);
  
  // Signaux réactifs pour chaque breakpoint[25][28]
  isMobile = signal<boolean>(false);
  isTablet = signal<boolean>(false);
  isDesktop = signal<boolean>(false);
  
  constructor() {
    // Mobile (< 768px)
    this.breakpointObserver.observe('(max-width: 767px)').subscribe(result => {
      this.isMobile.set(result.matches);
    });
    
    // Tablet (768px - 1023px)
    this.breakpointObserver.observe('(min-width: 768px) and (max-width: 1023px)').subscribe(result => {
      this.isTablet.set(result.matches);
    });
    
    // Desktop (>= 1024px)
    this.breakpointObserver.observe('(min-width: 1024px)').subscribe(result => {
      this.isDesktop.set(result.matches);
    });
  }
}

📱 Composants Responsifs Spécifiques

1) Shell Principal Nimbus (Responsive)

// app-shell-nimbus.component.ts
import { Component, inject } from '@angular/core';
import { ResponsiveService } from '@app/shared/services/responsive.service';

@Component({
  selector: 'app-shell-nimbus-layout',
  template: `
    <!-- Desktop: 3 colonnes -->
    <div *ngIf="responsive.isDesktop()" class="h-screen flex flex-col">
      <app-navbar class="h-14 flex-none"></app-navbar>
      <div class="flex-1 flex overflow-hidden">
        <app-left-sidebar class="w-80 flex-none border-r"></app-left-sidebar>
        <app-center-list class="w-96 flex-none border-r"></app-center-list>
        <app-note-view class="flex-1 relative"></app-note-view>
      </div>
    </div>

    <!-- Tablet: Onglets -->
    <div *ngIf="responsive.isTablet()" class="h-screen flex flex-col">
      <app-navbar class="h-14 flex-none"></app-navbar>
      <app-tab-navigation class="h-12 flex-none border-b"></app-tab-navigation>
      <app-tab-content class="flex-1 overflow-hidden"></app-tab-content>
    </div>

    <!-- Mobile: Bottom nav + drawer -->  
    <div *ngIf="responsive.isMobile()" class="h-screen flex flex-col">
      <app-navbar-mobile class="h-12 flex-none"></app-navbar-mobile>
      <app-mobile-content class="flex-1 overflow-hidden"></app-mobile-content>
      <app-bottom-navigation class="h-16 flex-none"></app-bottom-navigation>
    </div>
  `,
  standalone: true,
  imports: [/* tous les composants */],
})
export class AppShellNimbusLayoutComponent {
  responsive = inject(ResponsiveService);
}

2) Bottom Navigation Mobile

// app-bottom-navigation.component.ts
import { Component, inject } from '@angular/core';
import { MobileNavService } from '@app/shared/services/mobile-nav.service';

@Component({
  selector: 'app-bottom-navigation',
  template: `
    <nav class="fixed bottom-0 left-0 right-0 bg-white dark:bg-gray-900 
                border-t border-gray-200 dark:border-gray-800 h-16 
                flex justify-around items-center z-50">
      
      <button *ngFor="let tab of tabs"
              (click)="setActiveTab(tab.id)"
              [class.active]="mobileNav.activeTab() === tab.id"
              class="flex-1 flex flex-col items-center justify-center gap-1 
                     text-xs hover:bg-gray-50 dark:hover:bg-gray-800
                     transition-colors duration-200 py-2 px-1
                     text-gray-600 dark:text-gray-400"
              [class.text-nimbus-500]="mobileNav.activeTab() === tab.id"
              [class.dark:text-nimbus-400]="mobileNav.activeTab() === tab.id">
        <span class="text-lg">{{ tab.icon }}</span>
        <span class="truncate">{{ tab.label }}</span>
      </button>
    </nav>
  `,
  standalone: true,
})
export class AppBottomNavigationComponent {
  mobileNav = inject(MobileNavService);
  
  tabs = [
    { id: 'sidebar', icon: '📁', label: 'Dossiers' },
    { id: 'list', icon: '🔍', label: 'Liste' },  
    { id: 'page', icon: '📄', label: 'Page' },
    { id: 'toc', icon: '📋', label: 'Sommaire' }
  ];

  setActiveTab(tabId: string) {
    this.mobileNav.setActiveTab(tabId as any);
  }
}

3) Service Navigation Mobile

// src/shared/services/mobile-nav.service.ts
import { Injectable, signal } from '@angular/core';

type MobileTab = 'sidebar' | 'list' | 'page' | 'toc';

@Injectable({ providedIn: 'root' })
export class MobileNavService {
  activeTab = signal<MobileTab>('list');
  
  // État des drawers
  sidebarOpen = signal<boolean>(false);
  tocOpen = signal<boolean>(false);
  
  setActiveTab(tab: MobileTab) {
    this.activeTab.set(tab);
    
    // Auto-fermer les drawers quand on change d'onglet
    if (tab !== 'sidebar') this.sidebarOpen.set(false);
    if (tab !== 'toc') this.tocOpen.set(false);
  }
  
  toggleSidebar() {
    this.sidebarOpen.update(open => !open);
  }
  
  toggleToc() {
    this.tocOpen.update(open => !open);
  }
}

4) Drawer Sidebar Mobile

// app-sidebar-drawer.component.ts
import { Component, inject } from '@angular/core';
import { MobileNavService } from '@app/shared/services/mobile-nav.service';

@Component({
  selector: 'app-sidebar-drawer',
  template: `
    <!-- Drawer sidebar -->
    <aside class="fixed left-0 top-0 bottom-0 w-80 max-w-[80vw]
                  bg-white dark:bg-gray-900 shadow-lg z-40
                  transform transition-transform duration-300 ease-in-out"
           [class.-translate-x-full]="!mobileNav.sidebarOpen()">
      
      <!-- Header avec bouton fermer -->
      <div class="flex items-center justify-between p-4 border-b">
        <h2 class="text-lg font-semibold">Navigation</h2>
        <button (click)="mobileNav.toggleSidebar()" 
                class="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800">
        </button>
      </div>
      
      <!-- Contenu sidebar (même composant que desktop) -->
      <div class="flex-1 overflow-y-auto p-4">
        <app-sidebar-content></app-sidebar-content>
      </div>
    </aside>

    <!-- Backdrop -->
    <div *ngIf="mobileNav.sidebarOpen()" 
         (click)="mobileNav.toggleSidebar()"
         class="fixed inset-0 bg-black/50 z-30"></div>
  `,
  standalone: true,
  imports: [AppSidebarContentComponent],
})
export class AppSidebarDrawerComponent {
  mobileNav = inject(MobileNavService);
}

5) Search Bar Responsive

// app-search-bar.component.ts
import { Component, inject } from '@angular/core';
import { ResponsiveService } from '@app/shared/services/responsive.service';
import { MobileNavService } from '@app/shared/services/mobile-nav.service';

@Component({
  selector: 'app-search-bar',
  template: `
    <!-- Mobile: Compact avec bouton menu -->
    <div *ngIf="responsive.isMobile()" 
         class="sticky top-0 bg-white dark:bg-gray-900 p-2 shadow-sm z-10">
      <div class="flex gap-2">
        <!-- Menu toggle -->
        <button (click)="mobileNav.toggleSidebar()" 
                class="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800">
        </button>
        
        <!-- Search input (full-width sur mobile) -->
        <input type="text" 
               placeholder="Rechercher..." 
               [(ngModel)]="searchQuery"
               class="flex-1 px-3 py-2 rounded border border-gray-300 
                      dark:border-gray-700 dark:bg-gray-800">
        
        <!-- Filters button -->
        <button (click)="openFilters()" 
                class="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800">
          ⚙️
        </button>
      </div>
      
      <!-- Chips actifs (scroll horizontal) -->
      <div class="flex gap-1 mt-2 overflow-x-auto">
        <span *ngFor="let badge of activeFilters" 
              class="inline-flex items-center gap-1 px-2 py-1 bg-nimbus-100 
                     text-nimbus-800 rounded-full text-xs whitespace-nowrap">
          {{ badge }}
          <button (click)="removeFilter(badge)" class="hover:bg-nimbus-200 rounded-full">
          </button>
        </span>
      </div>
    </div>

    <!-- Desktop: Barre complète avec chips -->
    <div *ngIf="responsive.isDesktop() || responsive.isTablet()" 
         class="p-4 border-b border-gray-200 dark:border-gray-800">
      
      <!-- Search + Filters -->
      <div class="flex gap-3 mb-3">
        <div class="relative flex-1">
          <input type="text"
                 placeholder="Rechercher dans toutes les notes..."
                 [(ngModel)]="searchQuery"
                 class="w-full px-4 py-2 pl-10 rounded-lg border border-gray-300 
                        dark:border-gray-700 dark:bg-gray-800">
          <span class="absolute left-3 top-2.5 text-gray-400">🔍</span>
        </div>
        
        <button (click)="openFilters()" 
                class="px-4 py-2 bg-nimbus-500 text-white rounded-lg 
                       hover:bg-nimbus-600 transition-colors">
          + Page
        </button>
      </div>
      
      <!-- Chips de filtres[1] -->
      <div class="flex gap-2 flex-wrap">
        <app-filter-chip label="Tous dossiers" [active]="true" 
                        (click)="openFolderPicker()"></app-filter-chip>
        <app-filter-chip label="Tous tags" [active]="false"
                        (click)="openTagPicker()"></app-filter-chip>  
        <app-filter-chip label="Toutes pages" [active]="false"
                        (click)="openPagePicker()"></app-filter-chip>
      </div>
    </div>
  `,
  standalone: true,
})
export class AppSearchBarComponent {
  responsive = inject(ResponsiveService);
  mobileNav = inject(MobileNavService);
  
  searchQuery = '';
  activeFilters: string[] = [];

  openFilters() { /* */ }
  removeFilter(filter: string) { /* */ }
  openFolderPicker() { /* */ }
  openTagPicker() { /* */ }
  openPagePicker() { /* */ }
}

6) Liste de Résultats Responsive

// app-result-list.component.ts
import { Component, inject, Input } from '@angular/core';
import { ResponsiveService } from '@app/shared/services/responsive.service';

@Component({
  selector: 'app-result-list',
  template: `
    <!-- Liste virtualisée pour performance -->
    dk-virtual-scroll-viewport 
      [itemSize]="responsive.isMobile() ? 70 : 80" 
      class="h"h-full">
      
      <app-result-list-item
        *cdkVirtualFor="let item of items; trackBy: trackByFn"
        [item]="item"
        [compact]="responsive.isMobile()"
        (click)="openNote(item)"
        class="cursor-pointer">
      </app-result-list-item>
      
    </cdk-virtual-scroll-viewport>
  `,
  standalone: true,
  imports: [ScrollingModule],
})
export class AppResultListComponent {
  responsive = inject(ResponsiveService);
  
  @Input() items: any[] = [];
  
  trackByFn(index: number, item: any) {
    return item.id;
  }
  
  openNote(item: any) {
    // Navigation vers la note
  }
}

7) Item de Résultat Mobile-Optimized

// app-result-list-item.component.ts  
@Component({
  selector: 'app-result-list-item',
  template: `
    <div class="p-3 border-b border-gray-100 dark:border-gray-800
                hover:bg-gray-50 dark:hover:bg-gray-800 
                transition-colors duration-150">
      
      <!-- Titre -->
      <h3 class="font-semibold text-sm sm:text-base truncate"
          [class.text-base]="!compact"
          [class.text-sm]="compact">
        {{ item.title }}
      </h3>
      
      <!-- Date + Tags (empilés sur mobile) -->
      <div class="flex flex-col sm:flex-row sm:items-center gap-1 mt-1 
                  text-xs text-gray-600 dark:text-gray-400">
        <span>{{ item.modified | date:'short' }}</span>
        <div class="flex gap-1 flex-wrap">
          <span *ngFor="let tag of item.tags" 
                class="inline-block px-2 py-1 bg-gray-100 dark:bg-gray-700 
                       rounded text-xs">
            {{ tag }}
          </span>
        </div>
      </div>
      
      <!-- Extrait (tronqué) -->
      <p *ngIf="!compact" 
         class="mt-2 text-xs text-gray-600 dark:text-gray-400 
                line-clamp-2">
        {{ item.excerpt }}
      </p>
    </div>
  `,
  standalone: true,
})
export class AppResultListItemComponent {
  @Input() item: any;
  @Input() compact = false;
}

8) Markdown Viewer avec ToC Responsive

// app-markdown-viewer.component.ts
import { Component, inject } from '@angular/core';
import { ResponsiveService } from '@app/shared/services/responsive.service';
import { MobileNavService } from '@app/shared/services/mobile-nav.service';

@Component({
  selector: 'app-markdown-viewer',
  template: `
    <!-- Container principal -->
    <div class="h-full flex flex-col">
      
      <!-- Actions bar -->
      <div class="flex-none p-3 border-b border-gray-200 dark:border-gray-800 
                  flex items-center justify-between">
        <h1 class="text-lg font-semibold truncate">{{ currentNote?.title }}</h1>
        
        <div class="flex items-center gap-2">
          <!-- ToC toggle (mobile seulement) -->
          <button *ngIf="responsive.isMobile() && headings.length > 0"
                  (click)="mobileNav.toggleToc()"
                  class="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800">
            📋
          </button>
          
          <!-- Actions Desktop -->
          <div *ngIf="responsive.isDesktop()" class="flex gap-1">
            <button class="p-2 rounded hover:bg-gray-100">🤖 Ask AI</button>
            <button class="p-2 rounded hover:bg-gray-100">📤 Share</button>
            <button class="p-2 rounded hover:bg-gray-100">⛫ Portal</button>
          </div>
        </div>
      </div>
      
      <!-- Contenu markdown -->
      <article class="flex-1 overflow-y-auto prose dark:prose-invert max-w-none 
                      prose-sm sm:prose-base px-3 sm:px-6 py-4 sm:py-8">
        <div [innerHTML]="markdownHTML"></div>
      </article>
      
    </div>

    <!-- ToC Desktop: Panel fixe à droite -->
    <app-toc-panel *ngIf="responsive.isDesktop() && headings.length > 0"
                   [headings]="headings"
                   class="fixed right-0 top-14 bottom-0 w-64 
                          bg-gray-50 dark:bg-gray-800 border-l 
                          border-gray-200 dark:border-gray-700">
    </app-toc-panel>

    <!-- ToC Mobile: Overlay -->
    <app-toc-overlay *ngIf="responsive.isMobile() && mobileNav.tocOpen()"
                     [headings]="headings"
                     (close)="mobileNav.toggleToc()">
    </app-toc-overlay>
  `,
  standalone: true,
})
export class AppMarkdownViewerComponent {
  responsive = inject(ResponsiveService);
  mobileNav = inject(MobileNavService);
  
  @Input() currentNote: any;
  @Input() markdownHTML = '';
  @Input() headings: any[] = [];
}

🎬 Gestures & Navigation Tactile

Directive Swipe Navigation

// src/shared/directives/swipe-nav.directive.ts
import { Directive, Output, EventEmitter, HostListener } from '@angular/core';

@Directive({
  selector: '[appSwipeNav]',
  standalone: true,
})
export class SwipeNavDirective {
  @Output() swipeLeft = new EventEmitter<void>();
  @Output() swipeRight = new EventEmitter<void>();

  private startX = 0;
  private startY = 0;
  private threshold = 50; // Minimum distance
  private restraint = 100; // Maximum perpendicular distance

  @HostListener('touchstart', ['$event'])
  onTouchStart(e: TouchEvent) {
    const touch = e.touches[0];
    this.startX = touch.clientX;
    this.startY = touch.clientY;
  }

  @HostListener('touchend', ['$event'])
  onTouchEnd(e: TouchEvent) {
    const touch = e.changedTouches[0];
    const endX = touch.clientX;
    const endY = touch.clientY;
    
    const distX = this.startX - endX;
    const distY = Math.abs(this.startY - endY);
    
    // Vérifier que c'est bien un swipe horizontal
    if (Math.abs(distX) >= this.threshold && distY <= this.restraint) {
      if (distX > 0) {
        this.swipeLeft.emit();
      } else {
        this.swipeRight.emit();
      }
    }
  }
}

Usage Mobile avec Swipe

<!-- app-mobile-content.component.html -->
<div appSwipeNav 
     (swipeLeft)="nextTab()"
     (swipeRight)="prevTab()"
     class="h-full relative">
  
  <!-- Contenu selon tab active -->
  <div [ngSwitch]="mobileNav.activeTab()">
    
    <app-sidebar-drawer *ngSwitchCase="'sidebar'"></app-sidebar-drawer>
    
    <div *ngSwitchCase="'list'" class="h-full">
      <app-search-bar></app-search-bar>  
      <app-result-list [items]="filteredNotes"></app-result-list>
    </div>
    
    <div *ngSwitchCase="'page'" class="h-full">
      <app-markdown-viewer [currentNote]="selectedNote"></app-markdown-viewer>
    </div>
    
    <app-toc-overlay *ngSwitchCase="'toc'" 
                     [headings]="currentHeadings">
    </app-toc-overlay>
    
  </div>
</div>

Performance & Optimisations Mobile

Critical Optimizations[7][8]

// Lazy loading des images
// app-image-lazy.directive.ts
@Directive({
  selector: 'img[appLazyLoad]'
})
export class LazyLoadDirective implements OnInit {
  @Input() src!: string;
  @Input() alt = '';
  
  ngOnInit() {
    const observer = new IntersectionObserver(entries => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          const img = entry.target as HTMLImageElement;
          img.src = this.src;
          img.alt = this.alt;
          observer.unobserve(img);
        }
      });
    });
    
    observer.observe(this.el.nativeElement);
  }
}

Skeleton Loaders Mobile

// app-skeleton.component.ts
@Component({
  selector: 'app-skeleton',
  template: `
    <div class="animate-pulse">
      <!-- Mobile: Stack vertical -->
      <div *ngIf="responsive.isMobile()" class="p-3 space-y-2">
        <div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-3/4"></div>
        <div class="h-3 bg-gray-200 dark:bg-gray-700 rounded w-1/2"></div>
        <div class="h-3 bg-gray-200 dark:bg-gray-700 rounded w-full"></div>
      </div>
      
      <!-- Desktop: Layout horizontal -->
      <div *ngIf="responsive.isDesktop()" class="p-4 space-y-3">
        <div class="h-6 bg-gray-200 dark:bg-gray-700 rounded w-2/3"></div>
        <div class="flex space-x-4">
          <div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-1/4"></div>
          <div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-1/3"></div>
        </div>
        <div class="h-4 bg-gray-200 dark:bg-gray-700 rounded"></div>
      </div>
    </div>
  `,
  standalone: true,
})
export class AppSkeletonComponent {
  responsive = inject(ResponsiveService);
}

📅 Plan d'Implémentation Priorisé

Phase 1: Infrastructure (2-3j)

  • UiModeService + localStorage persistence
  • ResponsiveService avec BreakpointObserver[9][10]
  • AppShellAdaptiveComponent wrapper
  • Toggle button dans navbar
  • Breakpoints Tailwind mobile-first[6][5]

Phase 2: Layout Desktop (3-4j)

  • Desktop layout 3 colonnes (≥1024px)
  • Sidebar resizable avec dossiers/tags
  • Liste virtualisée performante
  • ToC panel fixe à droite
  • Search bar avec chips de filtres[2][1]

Phase 3: Navigation Mobile (2-3j)

  • MobileNavService (state management)
  • BottomNavigationComponent (4 onglets)[11][12]
  • Tab routing et state persistence
  • Swipe gestures directive[13][14]

Phase 4: Composants Mobile (3-4j)

  • Drawer sidebar mobile[15][16]
  • Search bar responsive compacte
  • Result list item mobile-optimized
  • Markdown viewer full-screen mobile
  • ToC overlay mobile avec animations

Phase 5: Tablet & Transitions (1-2j)

  • Layout hybride tablet (768-1023px)
  • Animations fluides entre breakpoints
  • Touch targets ≥ 44px[6]
  • Gesture handling avancé

Phase 6: Testing & Polish (2-3j)

  • Tests E2E (toggle, breakpoints, gestures)
  • Lighthouse mobile audit (≥85 perf, ≥95 a11y)
  • Visual regression (3 breakpoints)
  • Keyboard navigation complète
  • WCAG 2.1 AA compliance

Total estimé : 13-19 jours (équipe 1 FE engineer)


🗂️ Structure des Fichiers

src/app/
├── layout/
│   ├── app-shell-adaptive/
│   │   └── app-shell-adaptive.component.ts      # Feature flag wrapper
│   ├── app-shell-nimbus/
│   │   ├── app-shell-nimbus.component.ts        # Layout responsive
│   │   ├── app-shell-nimbus.desktop.html        # Template desktop  
│   │   ├── app-shell-nimbus.tablet.html         # Template tablet
│   │   └── app-shell-nimbus.mobile.html         # Template mobile
│   └── app-navbar/
│       ├── app-navbar.component.ts
│       ├── app-navbar.desktop.html              # NavBar desktop
│       └── app-navbar.mobile.html               # NavBar mobile compact
│
├── features/
│   ├── sidebar/
│   │   ├── app-left-sidebar.component.ts        # Desktop sidebar
│   │   ├── app-sidebar-drawer.component.ts      # Mobile drawer[39]
│   │   └── app-sidebar-content.component.ts     # Contenu partagé
│   │
│   ├── search-bar/
│   │   ├── app-search-bar.component.ts          # Responsive wrapper
│   │   ├── app-search-desktop.component.ts      # Desktop full
│   │   └── app-search-mobile.component.ts       # Mobile compact
│   │
│   ├── result-list/
│   │   ├── app-result-list.component.ts         # Liste virtualisée
│   │   └── app-result-list-item.component.ts    # Item responsive
│   │
│   ├── note-view/
│   │   ├── app-markdown-viewer.component.ts     # Viewer responsive
│   │   ├── app-toc-panel.component.ts           # ToC desktop fixe
│   │   └── app-toc-overlay.component.ts         # ToC mobile overlay
│   │
│   ├── bottom-nav/ [NEW]
│   │   ├── app-bottom-navigation.component.ts   # Navigation mobile[40]
│   │   └── app-tab-navigation.component.ts      # Navigation tablet
│   │
│   └── mobile-content/ [NEW]
│       ├── app-mobile-content.component.ts      # Container mobile
│       └── app-tab-content.component.ts         # Container tablet
│
├── shared/
│   ├── services/
│   │   ├── ui-mode.service.ts                   # Toggle UI management
│   │   ├── responsive.service.ts                # Breakpoint detection[25]
│   │   └── mobile-nav.service.ts                # Tab/drawer state mobile
│   │
│   ├── directives/
│   │   ├── swipe-nav.directive.ts               # Gesture detection[41]
│   │   └── lazy-load.directive.ts               # Image lazy loading
│   │
│   └── components/
│       ├── skeleton/
│       │   └── app-skeleton.component.ts        # Loading states
│       └── filter-chip/
│           └── app-filter-chip.component.ts     # Chips réutilisables[1]
│
└── styles/
    ├── tokens.scss                              # Variables Nimbus
    ├── responsive.scss                          # Utilitaires responsive[21]
    ├── mobile.scss                              # Styles mobile-specific
    └── animations.scss                          # Transitions smooth

Critères d'Acceptation

Desktop (≥1024px)

  • Layout 3 colonnes (sidebar fixe/resizable, liste, page+ToC)[1][2]
  • Changement dossier/tag/tri reflété en URL
  • 1000+ items fluide (60fps virtual scroll)
  • ToC synchronisé + repliable côté droit
  • Navigation clavier-seule possible partout

Mobile (<768px)

  • Drawer sidebar (80vw max) avec backdrop[16][15]
  • Bottom nav (4 onglets) sticky[12][11]
  • Search bar compact (menu + search + filters)
  • List items optimisés (titre + date + excerpt)
  • Markdown full-screen avec ToC overlay
  • Touch targets ≥ 44x44px[6]
  • Swipe navigation entre onglets[13]

Tablet (768-1023px)

  • Navigation par 3 onglets principaux
  • Contenu full-width par tab
  • Drawer optionnel selon besoin
  • Bottom navigation hybride

Feature Flag

  • Toggle UI visible dans navbar
  • État persisté (localStorage)
  • Pas de perte d'état lors du switch
  • Legacy UI reste intacte et fonctionnelle

Performance & Accessibilité

  • TTI < 2.5s cold start
  • Scroll 60fps sur 1000+ items
  • Lighthouse Mobile ≥ 85 perf, ≥ 95 a11y[8][7]
  • WCAG 2.1 AA sur tous les breakpoints
  • Keyboard navigation complète (Tab, Arrow, Enter)
  • Focus visible partout, zoom ≤ 200% sans scroll horizontal

🚀 Scripts & Commandes

# Dev complet (Nimbus activé par défaut)
npm run dev

# Build production avec optimisations mobile
npm run build:mobile

# Tests responsifs (plusieurs breakpoints)  
npm run test:responsive

# Lighthouse audit mobile
npm run audit:lighthouse:mobile

# Feature flag override
NIMBUS_UI=false npm run dev        # Force legacy UI
NIMBUS_UI=true npm run dev         # Force Nimbus UI

# Screenshots responsive pour tests visuels
npm run screenshot:all-breakpoints

📖 Documentation à Produire

  1. README_UI_RESPONSIVE.md : Architecture, breakpoints, captures d'écran 3 formats
  2. MOBILE_GUIDE.md : Navigation onglets, gestures, patterns drawer
  3. RESPONSIVE_CHECKLIST.md : Tests par breakpoint, device testing
  4. DEPLOYMENT_PROGRESSIVE.md : Feature flag pour migration douce
  5. PERFORMANCE_MOBILE.md : Optimisations, lazy loading, metrics

💡 Notes Cruciales

  • Mobile First Absolu : Développer mobile d'abord, puis enrichir tablet/desktop[5][6]
  • Zero Regression : L'interface legacy reste 100% intacte et fonctionnelle
  • État Persistant : Toggle UI, filtres actifs, tab mobile via localStorage
  • Performance : Virtual scroll adapté mobile, lazy-load images, skeleton loaders[7]
  • Accessibility : 44px touch targets, ARIA labels complets, keyboard nav[6]
  • Testing : Visual regression sur 3 breakpoints clés (375px/768px/1440px)
  • Gestures : Swipe navigation fluide, long-press menus, pull-to-refresh optionnel[14][13]

Exécute maintenant ce plan complet : crée tous les composants responsifs, implémente les services de state management, configure les breakpoints Tailwind mobile-first, branche la navigation tactile avec gestures, et livre le MR conforme aux critères ci-dessus avec toggle UI et compatibilité 100% Desktop/Tablet/Mobile.[5][13]

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58