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
@@ -1,23 +1,26 @@
<div class="container mx-auto p-4 sm:p-6">
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3 text-slate-200">
<a [routerLink]="['/t', theme()]" class="px-3 py-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 border border-slate-700 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<!-- Stacks below `sm`: title + provider picker side by side cannot fit a phone. -->
<!-- Wraps at `lg`: between md and lg the desktop sidebar leaves too little
room for the title and the provider picker to share a row. -->
<div class="mb-6 flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div class="flex min-w-0 items-center gap-3 text-slate-200">
<a [routerLink]="['/t', theme()]" aria-label="Retour aux thèmes" class="chip shrink-0 rounded-lg border border-slate-700 bg-slate-800 px-3 py-2 transition-colors hover:bg-slate-700">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
</a>
<h1 class="text-3xl font-bold">
<span class="text-4xl mr-2">{{ themes.bySlug(theme())?.emoji }}</span>
{{ themes.bySlug(theme())?.label }}
<h1 class="min-w-0 text-2xl font-bold sm:text-3xl">
<span class="mr-2 text-3xl sm:text-4xl">{{ themes.bySlug(theme())?.emoji }}</span>
<span class="break-anywhere">{{ themes.bySlug(theme())?.label }}</span>
</h1>
</div>
<div class="flex items-center gap-2">
<label class="text-sm font-medium text-slate-400">{{ 'themes.changeProvider' | t }}</label>
<div class="relative">
<select [ngModel]="provider()" (ngModelChange)="changeProvider($event)" class="appearance-none bg-slate-800 text-white rounded-md px-3 py-2 pr-8 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all">
<div class="flex shrink-0 items-center gap-2">
<label for="providerSel" class="text-sm font-medium text-slate-400">{{ 'themes.changeProvider' | t }}</label>
<div class="relative min-w-0 flex-1 sm:flex-none">
<select id="providerSel" [ngModel]="provider()" (ngModelChange)="changeProvider($event)" class="w-full appearance-none rounded-md border border-slate-700 bg-slate-800 px-3 py-2 pr-9 text-white transition-all focus:outline-none focus:ring-2 focus:ring-blue-500 sm:w-auto">
<option *ngFor="let p of providersList()" [value]="p.id">{{ p.label }}</option>
</select>
<svg class="w-5 h-5 absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<svg class="pointer-events-none absolute right-2.5 top-1/2 h-5 w-5 -translate-y-1/2 text-slate-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</div>
@@ -25,19 +28,19 @@
</div>
<!-- Filtre rapide Vidéos / Shorts (masqué sur Twitch : onglets à sections ci-dessous) -->
<div *ngIf="provider() !== 'twitch'" class="flex items-center gap-2 mb-4" role="tablist" aria-label="Filtrer vidéos ou shorts">
<div *ngIf="provider() !== 'twitch'" class="scroll-rail no-scrollbar mb-4 flex items-center gap-2" role="tablist" aria-label="Filtrer vidéos ou shorts">
<button (click)="setContentFilter('all')" [attr.aria-pressed]="contentFilter() === 'all'"
class="px-3 py-1.5 rounded-full text-sm border transition-colors"
class="chip shrink-0 px-3 py-2 rounded-full text-sm border transition-colors"
[class]="contentFilter() === 'all' ? 'bg-red-600 border-red-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
Tout
</button>
<button (click)="setContentFilter('videos')" [attr.aria-pressed]="contentFilter() === 'videos'"
class="px-3 py-1.5 rounded-full text-sm border transition-colors"
class="chip shrink-0 px-3 py-2 rounded-full text-sm border transition-colors"
[class]="contentFilter() === 'videos' ? 'bg-red-600 border-red-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
Vidéos
</button>
<button (click)="setContentFilter('shorts')" [attr.aria-pressed]="contentFilter() === 'shorts'"
class="px-3 py-1.5 rounded-full text-sm border transition-colors"
class="chip shrink-0 px-3 py-2 rounded-full text-sm border transition-colors"
[class]="contentFilter() === 'shorts' ? 'bg-red-600 border-red-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
Shorts<span *ngIf="allVideos().length"> ({{ shortsCount() }})</span>
</button>
@@ -52,29 +55,29 @@
<div class="font-semibold text-slate-100">{{ twitchGame()?.name }}</div>
</div>
</div>
<div class="flex items-center gap-2 overflow-x-auto pb-1" role="tablist" aria-label="Sections Twitch">
<div class="scroll-rail no-scrollbar flex items-center gap-2 pb-1" role="tablist" aria-label="Sections Twitch">
<button (click)="setTwitchTab('all')" [attr.aria-pressed]="twitchTab() === 'all'"
class="px-3 py-1.5 rounded-full text-sm border transition-colors whitespace-nowrap"
class="chip shrink-0 px-3 py-2 rounded-full text-sm border transition-colors whitespace-nowrap"
[class]="twitchTab() === 'all' ? 'bg-purple-600 border-purple-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
Tout
</button>
<button (click)="setTwitchTab('live')" [attr.aria-pressed]="twitchTab() === 'live'"
class="px-3 py-1.5 rounded-full text-sm border transition-colors whitespace-nowrap"
class="chip shrink-0 px-3 py-2 rounded-full text-sm border transition-colors whitespace-nowrap"
[class]="twitchTab() === 'live' ? 'bg-purple-600 border-purple-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
En direct<span *ngIf="twitchLives().length"> ({{ twitchLives().length }})</span>
</button>
<button (click)="setTwitchTab('video')" [attr.aria-pressed]="twitchTab() === 'video'"
class="px-3 py-1.5 rounded-full text-sm border transition-colors whitespace-nowrap"
class="chip shrink-0 px-3 py-2 rounded-full text-sm border transition-colors whitespace-nowrap"
[class]="twitchTab() === 'video' ? 'bg-purple-600 border-purple-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
Vidéos<span *ngIf="twitchVods().length"> ({{ twitchVods().length }})</span>
</button>
<button (click)="setTwitchTab('clip')" [attr.aria-pressed]="twitchTab() === 'clip'"
class="px-3 py-1.5 rounded-full text-sm border transition-colors whitespace-nowrap"
class="chip shrink-0 px-3 py-2 rounded-full text-sm border transition-colors whitespace-nowrap"
[class]="twitchTab() === 'clip' ? 'bg-purple-600 border-purple-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
Clips<span *ngIf="twitchClips().length"> ({{ twitchClips().length }})</span>
</button>
<button (click)="setTwitchTab('channel')" [attr.aria-pressed]="twitchTab() === 'channel'"
class="px-3 py-1.5 rounded-full text-sm border transition-colors whitespace-nowrap"
class="chip shrink-0 px-3 py-2 rounded-full text-sm border transition-colors whitespace-nowrap"
[class]="twitchTab() === 'channel' ? 'bg-purple-600 border-purple-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
Chaînes<span *ngIf="twitchChannels().length"> ({{ twitchChannels().length }})</span>
</button>
@@ -1,5 +1,5 @@
<div class="container mx-auto p-4 sm:p-6">
<h2 class="text-3xl font-bold mb-4 text-slate-100 border-l-4 border-red-500 pl-4 flex items-center gap-2">
<h2 class="mb-4 flex items-center gap-2 border-l-4 border-red-500 pl-4 text-2xl font-bold text-slate-100 sm:text-3xl">
<span>{{ themeLabel() }}</span>
</h2>
+36 -18
View File
@@ -1,53 +1,62 @@
@if (collapsed()) {
<!-- Barre repliée : fine, rouvrable à tout moment -->
<div class="sticky top-16 left-0 right-0 z-40 flex w-full items-center gap-2 border-b border-slate-700/60 bg-slate-800/60 px-3 py-1 backdrop-blur-sm">
<span class="text-xs font-medium text-slate-400">{{ 'themes.title' | t : 'Thèmes' }}</span>
@if (activeThemeLabel(); as label) {
<span class="rounded-full border border-red-500/60 bg-slate-100/10 px-2 py-0.5 text-xs text-white">{{ label }}</span>
}
<div class="sticky z-40 flex w-full items-center gap-2 border-b border-slate-700/60 bg-slate-800/60 px-3 py-1 backdrop-blur-sm"
[style.top]="'var(--header-offset)'"
[style.paddingLeft]="'max(0.75rem, var(--safe-left))'"
[style.paddingRight]="'max(0.75rem, var(--safe-right))'">
<span class="shrink-0 text-xs font-medium text-slate-400">{{ 'themes.title' | t : 'Thèmes' }}</span>
<span class="min-w-0 flex-1 truncate text-xs text-slate-500">
@if (activeThemeLabel(); as label) {
<span class="inline-block max-w-full truncate rounded-full border border-red-500/60 bg-slate-100/10 px-2 py-0.5 text-slate-200">{{ label }}</span>
}
</span>
<button type="button" (click)="toggleCollapsed()" [attr.aria-expanded]="false" aria-controls="themes-bar"
[title]="'themes.show' | t : 'Afficher la bande des thèmes'"
class="ml-auto inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-semibold text-slate-300 transition hover:bg-slate-700 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500">
class="chip ml-auto inline-flex shrink-0 items-center gap-1 rounded-full px-3 text-xs font-semibold text-slate-300 transition hover:bg-slate-700 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500">
<span>{{ 'themes.show' | t : 'Afficher' }}</span>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" /></svg>
</button>
</div>
} @else {
<nav id="themes-bar" class="sticky top-16 left-0 right-0 z-40 w-full relative"
<nav id="themes-bar" class="sticky z-40 w-full relative"
[style.top]="'var(--header-offset)'"
[ngClass]="{
'bg-slate-800 border-b border-slate-700': isDarkTheme(),
'bg-slate-900 border-b border-slate-800': isBlackTheme(),
'bg-slate-700 border-b border-slate-600': isBlueTheme()
'bg-slate-700 border-b border-slate-600': isBlueTheme(),
'bg-slate-50 border-b border-slate-200': isLightTheme()
}">
<!-- Left scroll button -->
<button type="button" (click)="scrollBy(-320)" aria-label="Scroll left"
<!-- Left scroll button: pointer devices only. Touch users swipe. -->
<button type="button" (click)="scrollBy(-320)" aria-label="Scroll left" tabindex="-1"
class="hidden md:flex items-center justify-center absolute left-2 top-1/2 -translate-y-1/2 h-9 w-9 rounded-full shadow z-10"
[ngClass]="{
'bg-white/90 border border-slate-300 text-slate-700 hover:bg-slate-100': isLightTheme(),
'bg-slate-800/90 border border-slate-700 text-slate-200 hover:bg-slate-700': isDarkTheme() || isBlackTheme() || isBlueTheme()
'bg-slate-800/90 border border-slate-700 text-slate-200 hover:bg-slate-700': !isLightTheme()
}">
«
</button>
<!-- Right scroll button -->
<button type="button" (click)="scrollBy(320)" aria-label="Scroll right"
<!-- Right scroll button: pointer devices only. Touch users swipe. -->
<button type="button" (click)="scrollBy(320)" aria-label="Scroll right" tabindex="-1"
class="hidden md:flex items-center justify-center absolute right-2 top-1/2 -translate-y-1/2 h-9 w-9 rounded-full shadow z-10"
[ngClass]="{
'bg-white/90 border border-slate-300 text-slate-700 hover:bg-slate-100': isLightTheme(),
'bg-slate-800/90 border border-slate-700 text-slate-200 hover:bg-slate-700': isDarkTheme() || isBlackTheme() || isBlueTheme()
'bg-slate-800/90 border border-slate-700 text-slate-200 hover:bg-slate-700': !isLightTheme()
}">
»
</button>
<!-- Replier la bande : action disponible uniquement dans le header -->
<div class="w-full overflow-x-auto no-scrollbar" #scroll (wheel)="onWheel($event)">
<ul role="tablist" aria-label="Themes" class="flex gap-2 px-12 py-2 min-w-max items-center">
<!-- Replier la bande : action disponible uniquement dans le header.
On phones the rail is edge-to-edge with a swipe affordance instead of
the reserved arrow gutters. -->
<div class="scroll-rail no-scrollbar md:overflow-x-auto" #scroll (wheel)="onWheel($event)">
<ul role="tablist" aria-label="Themes" class="flex min-w-max items-center gap-2 px-3 py-2 md:px-12">
<li *ngFor="let t of displayedThemes(); let i = index" class="shrink-0">
<button
#pill
role="tab"
type="button"
class="px-3 py-1.5 rounded-full text-sm font-medium border transition-colors whitespace-nowrap focus:outline-none focus:ring-2 focus:ring-red-500"
class="chip px-3 py-1.5 rounded-full text-sm font-medium border transition-colors whitespace-nowrap focus:outline-none focus:ring-2 focus:ring-red-500"
[class.bg-slate-100/10]="activeSlug() === t.slug"
[class.text-white]="activeSlug() === t.slug"
[class.border-red-500]="activeSlug() === t.slug"
@@ -63,5 +72,14 @@
</li>
</ul>
</div>
<!-- Scroll affordance: the rail hides its scrollbar, so fade the trailing
edge to signal that more themes exist to the right. -->
<span class="pointer-events-none absolute inset-y-0 right-0 w-10"
aria-hidden="true"
[ngClass]="{
'bg-gradient-to-l from-slate-800 to-transparent': !isLightTheme(),
'bg-gradient-to-l from-slate-50 to-transparent': isLightTheme()
}"></span>
</nav>
}