feat(mobile): refonte responsive complète (header, drawer, dvh, cibles tactiles)

Le header était en grid-cols-[auto_1fr_auto] avec une barre de recherche
non-wrappable (7 chips + input + 2 boutons, aucun min-w-0) : ~1000px de
largeur intrinsèque dans un viewport de 375px. Toutes les routes
débordaient horizontalement et les boutons Connexion/Inscription partaient
hors écran.

Socle
- --header-h / --header-offset / --app-vh centralisent la métrique du
  header : supprime les top-16 codés en dur (channel-tabs était en top-0 et
  glissait sous le header au scroll).
- Safe-area iOS, viewport-fit=cover, color-scheme/theme-color.
- dvh partout (100vh > hauteur visible sur iOS), overflow-x: clip au lieu de
  hidden (hidden cassait silencieusement tous les sticky).
- Cible tactile 44px sur pointeur grossier, skip link, focus-ring clavier,
  scroll-rail pour les tab strips.

Header
- Barre mobile (burger, logo, recherche, menu ⋮) + barre desktop inchangée.
- Le menu ⋮ expose ce qui disparaissait : bande des thèmes, préférences,
  historique, favoris, téléchargements, thème, connexion/inscription.
- Recherche en feuille sous le header avec focus automatique.
- Échap / clic extérieur / navigation ferment les panneaux transitoires.

Drawer
- Partait à inset-y-0 : les 56 premiers px du menu étaient derrière le
  header. Descend sous le header, bouton fermer intégré, verrouille le
  scroll, se ferme à Échap.

Barre de recherche
- Chips masquées sous lg, remplacées par un bouton résumé qui ouvre le
  sélecteur de fournisseurs. Suggestions en dvh, menu Ctrl+K plein écran
  sur mobile.

Pages
- Shorts : cadre 9:16 contraint par dvh ET vw (il débordait en paysage),
  Préc./Suiv. qui étaient hors du conteneur height-capped.
- Watch : barre d'actions qui wrappe, mode cinéma en opacity-0 group-hover
  donc invisible au doigt, thumbnails fluides, playsinline sur les iframes.
- Historique, thèmes, chaîne, playlists, préférences, sessions : rails
  swipables, min-w-0, truncate, en-têtes qui wrappent.

Vérifié : audit navigateur (CDP) sur 17 routes x 7 largeurs (320→1440),
zéro débordement horizontal ; 19 tests d'interaction mobile ; suites de
tests du dépôt vertes (api 40/40, transcript 27/27, search, suggest,
playlists, preferences, subscriptions, highlight, telemetry).
This commit is contained in:
2026-09-28 14:38:31 -04:00
parent 69c722fdf9
commit 66f1da1d72
37 changed files with 1231 additions and 509 deletions
@@ -31,21 +31,21 @@ import { forkJoin } from 'rxjs';
<div class="fixed inset-0 z-40 bg-black/60 backdrop-blur-[2px]" (click)="close()"></div>
<!-- Modal -->
<div class="fixed inset-0 z-50 grid place-items-center" aria-modal="true" role="dialog" [attr.aria-labelledby]="'addToPlaylistTitle'">
<div class="w-[min(92vw,720px)] max-h-[min(80vh,700px)] rounded-xl bg-slate-900 border border-slate-700 shadow-2xl focus:outline-none overflow-hidden" (click)="$event.stopPropagation()">
<div class="fixed inset-0 z-50 grid place-items-center p-4" [style.paddingBottom]="'max(1rem, var(--safe-bottom))'" aria-modal="true" role="dialog" [attr.aria-labelledby]="'addToPlaylistTitle'">
<div class="flex w-[min(92vw,720px)] max-h-[min(80dvh,700px)] flex-col overflow-hidden rounded-xl border border-slate-700 bg-slate-900 shadow-2xl focus:outline-none" (click)="$event.stopPropagation()">
<!-- Header -->
<div class="flex items-center justify-between px-5 py-4 border-b border-slate-700">
<div class="flex shrink-0 items-center justify-between gap-3 border-b border-slate-700 px-4 py-4 sm:px-5">
<div id="addToPlaylistTitle" class="text-base font-semibold text-slate-100">Vos playlists</div>
<div class="flex items-center gap-3">
<button class="text-xs text-slate-400 hover:text-slate-200" (click)="reloadPlaylists()">Rafraîchir</button>
<button class="h-8 w-8 grid place-items-center rounded-md text-slate-300 hover:text-white hover:bg-slate-700/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60" (click)="close()" aria-label="Fermer">
<button type="button" class="chip shrink-0 rounded px-3 py-2 text-xs text-slate-400 hover:text-slate-200" (click)="reloadPlaylists()">Rafraîchir</button>
<button type="button" class="app-icon-btn shrink-0 text-slate-300 hover:text-white hover:bg-slate-700/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60" (click)="close()" aria-label="Fermer">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="h-5 w-5"><path d="M6.225 4.811a1 1 0 011.414 0L12 9.172l4.361-4.361a1 1 0 011.415 1.415L13.415 10.586l4.361 4.361a1 1 0 01-1.415 1.415L12 12l-4.361 4.361a1 1 0 01-1.414-1.415l4.36-4.36-4.36-4.36a1 1 0 010-1.415z"/></svg>
</button>
</div>
</div>
<!-- Body -->
<div class="px-5 py-4 space-y-4">
<div class="min-h-0 flex-1 space-y-4 overflow-y-auto px-4 py-4 sm:px-5">
@if (loading()) {
<div class="text-sm text-slate-400">Chargement...</div>
} @else if (error()) {
@@ -53,10 +53,10 @@ import { forkJoin } from 'rxjs';
} @else if (playlists().length === 0) {
<div class="text-sm text-slate-400">Aucune playlist. Créez-en une ci-dessous.</div>
} @else {
<div class="max-h-[42vh] overflow-auto divide-y divide-slate-800 rounded-md border border-slate-800">
<div class="max-h-[min(42dvh,320px)] overflow-auto divide-y divide-slate-800 rounded-md border border-slate-800">
@for (pl of playlists(); track pl.id) {
<label class="flex items-center gap-4 px-4 py-3 hover:bg-slate-800/60 cursor-pointer">
<input type="checkbox" class="accent-red-500 h-4 w-4" [checked]="isSelected(pl.id)" (change)="toggleSelect(pl.id, $event)" />
<input type="checkbox" class="h-5 w-5 shrink-0 accent-red-500" [checked]="isSelected(pl.id)" (change)="toggleSelect(pl.id, $event)" />
<div class="min-w-0 flex-1">
<div class="truncate text-sm text-slate-200">{{ pl.title }}</div>
<div class="text-xs text-slate-400">{{ pl.itemsCount || 0 }} vidéo(s)</div>
@@ -69,16 +69,16 @@ import { forkJoin } from 'rxjs';
<!-- Create new playlist -->
<div class="border-t border-slate-700 pt-4">
<label class="block text-xs text-slate-400 mb-2">Nouvelle playlist</label>
<div class="flex flex-col sm:flex-row gap-3 items-stretch sm:items-end">
<div class="flex-1">
<input type="text" class="w-full bg-slate-950 text-slate-100 border border-slate-700 rounded px-3 py-2 text-sm placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-red-500/60"
<div class="flex flex-col gap-3 sm:flex-row sm:items-end">
<div class="min-w-0 flex-1">
<input type="text" class="w-full min-w-0 rounded border border-slate-700 bg-slate-950 px-3 py-3 text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-red-500/60 sm:py-2"
[value]="newTitle()" (input)="onTitleChange($event)" placeholder="Titre de la playlist" />
</div>
<label class="flex items-center gap-2 text-xs text-slate-300">
<input type="checkbox" class="accent-red-500" [checked]="newPrivate()" (change)="onPrivateChange($event)" />
<label class="flex items-center gap-2 py-1 text-xs text-slate-300">
<input type="checkbox" class="h-5 w-5 accent-red-500" [checked]="newPrivate()" (change)="onPrivateChange($event)" />
Privée
</label>
<button class="px-4 py-2 rounded-md bg-red-600 hover:bg-red-500 text-white text-sm font-semibold disabled:opacity-60 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60"
<button type="button" class="chip rounded-md bg-red-600 px-4 py-3 text-sm font-semibold text-white hover:bg-red-500 disabled:cursor-not-allowed disabled:opacity-60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60 sm:py-2"
[disabled]="!newTitle().trim() || busyAdd()"
(click)="onQuickCreate()">Créer + Ajouter</button>
</div>
@@ -86,11 +86,11 @@ import { forkJoin } from 'rxjs';
</div>
<!-- Footer -->
<div class="px-5 py-4 border-t border-slate-700 bg-slate-900/60 flex items-center justify-between">
<div class="flex shrink-0 flex-col gap-3 border-t border-slate-700 bg-slate-900/60 px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-5">
<div class="text-xs text-slate-400">{{ selectedCount() }} sélectionnée(s)</div>
<div class="flex items-center gap-3">
<button class="px-4 py-2 rounded-md bg-slate-700 hover:bg-slate-600 text-slate-100 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400/60" (click)="close()">Annuler</button>
<button class="px-4 py-2 rounded-md bg-red-600 hover:bg-red-500 text-white text-sm font-semibold disabled:opacity-60 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60"
<div class="flex w-full items-center gap-3 sm:w-auto">
<button type="button" class="chip flex-1 rounded-md bg-slate-700 px-4 py-3 text-sm text-slate-100 hover:bg-slate-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400/60 sm:flex-none sm:py-2" (click)="close()">Annuler</button>
<button type="button" class="chip flex-1 rounded-md bg-red-600 px-4 py-3 text-sm font-semibold text-white hover:bg-red-500 disabled:cursor-not-allowed disabled:opacity-60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60 sm:flex-none sm:py-2"
[disabled]="busyAdd() || selectedCount() === 0"
(click)="onAddSelected()">Ajouter</button>
</div>
@@ -18,7 +18,7 @@ import { AuthService } from '../../../../services/auth.service';
[class.text-slate-400]="!isLiked()"
[class.hover\:text-red-500]="!isLiked()"
[disabled]="isLoading()"
class="transition-colors duration-200 p-1 rounded-full hover:bg-slate-700/50 flex items-center justify-center"
class="flex h-11 w-11 items-center justify-center rounded-full transition-colors duration-200 hover:bg-slate-700/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60"
[attr.aria-label]="isLiked() ? 'Retirer des vidéos aimées' : 'Ajouter aux vidéos aimées'"
[attr.aria-pressed]="isLiked()"
>