fix(mobile): barre de thèmes masquée, modale @ hors écran, pastille d'avatar
CI / build-and-test (push) Successful in 14m14s

Cinq correctifs signalés sur le mode mobile et le mode desktop.

1. La modale « @ » était confinée dans le header (desktop + mobile)
   <header> portait `backdrop-blur-sm`, et `backdrop-filter` fait de l'élément
   le containing block de ses descendants en position:fixed. Les overlays du
   provider-picker (et du menu Ctrl+K) se résolvaient donc contre le header :
   mesuré 1440x64 au lieu de 1440x900, dialogue réduit à 32px de haut pour 466px
   de contenu, haut de la fenêtre inatteignable.
   Le flou passe sur une couche enfant (`-z-10`) qui n'est pas un ancêtre des
   overlays : `fixed` redevient relatif au viewport.
   Desktop : dialogue 512x468 centré. Mobile : 358x686, entièrement visible.

2. La barre de thèmes disparaissait sous le header (desktop + mobile)
   `--header-offset` vaut 56/64px, mais le header mesure 57/65px (bordure) et
   118px quand la feuille de recherche mobile est ouverte. La barre était donc
   déjà glissée dessous, et totalement invisible dans ce dernier cas
   (hauteur visible mesurée : 0px).
   HeaderComponent publie maintenant sa hauteur réelle dans `--header-measured`
   via ResizeObserver ; `--header-offset` l'utilise quand elle existe et garde
   la valeur statique en repli. Barre visible dans tous les cas, y compris
   feuille de recherche ouverte.

3. Barre de thèmes : icônes seules sur mobile
   Les libellés sont masqués sous md (`aria-label` conservé pour
   l'accessibilité). 7 pastilles visibles au lieu de 3, sans scroll.

4. Avatar en pastille
   `.avatar-circle` est renforcé : `rounded-full` dans le markup plus
   `border-radius: 9999px !important`, `aspect-ratio: 1/1`, min-width/min-height
   et `line-height: 1`, pour qu'aucun style de thème ne puisse le dégrader en
   rectangle.

Vérifié : audit navigateur sur 17 routes x 320/390/768/1440 px — aucun
débordement horizontal, barre de thèmes visible partout ; tests du dépôt verts ;
build production OK.
This commit is contained in:
2026-09-28 16:25:55 -04:00
parent fa775497ee
commit 9fb0ce7549
4 changed files with 74 additions and 17 deletions
+18 -5
View File
@@ -11,8 +11,12 @@
--safe-bottom: 0px; --safe-bottom: 0px;
--safe-left: 0px; --safe-left: 0px;
--safe-right: 0px; --safe-right: 0px;
/* Height actually consumed by the fixed header, safe-area included. */ /* Height actually consumed by the fixed header, safe-area included.
--header-offset: calc(var(--header-h) + var(--safe-top)); `--header-measured` is published at runtime by HeaderComponent via a
ResizeObserver: the header grows when the mobile search sheet opens, when
an iOS safe area applies, or at high zoom, and a hardcoded value would let
the sticky themes bar slide under it (it was fully hidden in that case). */
--header-offset: var(--header-measured, calc(var(--header-h) + var(--safe-top)));
/* Usable viewport height that follows the mobile browser chrome. */ /* Usable viewport height that follows the mobile browser chrome. */
--app-vh: 100vh; --app-vh: 100vh;
} }
@@ -181,18 +185,27 @@ body {
} }
.app-avatar-btn:hover { background-color: var(--panel-2); } .app-avatar-btn:hover { background-color: var(--panel-2); }
/* Circular user badge ("pastille"). Belt and braces: `rounded-full` from the
markup plus these rules, so the badge can never degrade into a rectangle if
a theme stylesheet overrides the radius. */
.avatar-circle { .avatar-circle {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-shrink: 0;
width: 2rem; width: 2rem;
height: 2rem; height: 2rem;
border-radius: 9999px; min-width: 2rem;
border-width: 1px; min-height: 2rem;
aspect-ratio: 1 / 1;
border-radius: 9999px !important;
border-width: 2px;
border-style: solid; border-style: solid;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 600; font-weight: 600;
flex-shrink: 0; line-height: 1;
overflow: hidden;
text-transform: uppercase;
} }
/* The fixed app header: honours the safe area and never overflows. */ /* The fixed app header: honours the safe area and never overflows. */
+12 -4
View File
@@ -1,9 +1,17 @@
<header class="app-header bg-slate-800/50 backdrop-blur-sm fixed inset-x-0 top-0 z-50 shadow-lg border-b border-slate-700/60" <header class="app-header fixed inset-x-0 top-0 z-50 shadow-lg border-b border-slate-700/60"
[style.paddingTop]="'var(--safe-top)'" [style.paddingTop]="'var(--safe-top)'"
[style.paddingLeft]="'var(--safe-left)'" [style.paddingLeft]="'var(--safe-left)'"
[style.paddingRight]="'var(--safe-right)'"> [style.paddingRight]="'var(--safe-right)'">
<!-- Frosted layer lives on its own child, never on <header> itself.
`backdrop-filter` makes an element the containing block for
position:fixed descendants, so blurring the header trapped the
provider-picker and Ctrl+K modals inside the 64px header box (their top
was unreachable). The overlays below are siblings of this layer, so
they resolve against the viewport again. -->
<div aria-hidden="true" class="pointer-events-none absolute inset-0 -z-10 bg-slate-800/50 backdrop-blur-sm"></div>
<!-- Top row: burger + logo (always) / search (desktop) / actions --> <!-- Top row: burger + logo (always) / search (desktop) / actions -->
<div class="flex items-center gap-1 h-14 md:h-16 px-2 md:px-4"> <div class="flex items-center gap-1 h-14 md:h-16 px-2 md:px-4">
@@ -93,7 +101,7 @@
<div class="relative" #userMenuContainer> <div class="relative" #userMenuContainer>
<button type="button" (click)="toggleUserMenu()" class="app-avatar-btn focus-ring" aria-haspopup="true" <button type="button" (click)="toggleUserMenu()" class="app-avatar-btn focus-ring" aria-haspopup="true"
[attr.aria-expanded]="userMenuOpen()" [attr.aria-label]="user()?.username || user()?.email || ('menu.account' | t)"> [attr.aria-expanded]="userMenuOpen()" [attr.aria-label]="user()?.username || user()?.email || ('menu.account' | t)">
<span class="avatar-circle" [ngClass]="avatarClasses()">{{ avatarLetter() }}</span> <span class="avatar-circle rounded-full" [ngClass]="avatarClasses()">{{ avatarLetter() }}</span>
</button> </button>
@if (userMenuOpen()) { @if (userMenuOpen()) {
<div class="absolute right-0 mt-2 w-[min(20rem,calc(100vw-1rem))] overflow-hidden rounded-xl border border-slate-700 bg-slate-900 shadow-xl z-50" role="menu"> <div class="absolute right-0 mt-2 w-[min(20rem,calc(100vw-1rem))] overflow-hidden rounded-xl border border-slate-700 bg-slate-900 shadow-xl z-50" role="menu">
@@ -120,7 +128,7 @@
<!-- Mobile search sheet: the same <app-search-box> instance is rendered here <!-- Mobile search sheet: the same <app-search-box> instance is rendered here
through CSS only when the user asks for it. Kept out of the flow so the through CSS only when the user asks for it. Kept out of the flow so the
page never reflows while typing. --> page never reflows while typing. -->
<div id="mobile-search-sheet" class="md:hidden border-t border-slate-700/60 bg-slate-900/95 backdrop-blur-sm px-2 pb-2" <div id="mobile-search-sheet" class="md:hidden border-t border-slate-700/60 bg-slate-900/95 px-2 pb-2"
[class.hidden]="!mobileSearchOpen()"> [class.hidden]="!mobileSearchOpen()">
<div class="relative"> <div class="relative">
<app-search-box [placeholder]="('search.placeholder' | t)" <app-search-box [placeholder]="('search.placeholder' | t)"
@@ -183,7 +191,7 @@
@if (user()) { @if (user()) {
<div class="border-t border-slate-800 px-4 py-3"> <div class="border-t border-slate-800 px-4 py-3">
<div class="mb-2 flex items-center gap-3"> <div class="mb-2 flex items-center gap-3">
<span class="avatar-circle" [ngClass]="avatarClasses()">{{ avatarLetter() }}</span> <span class="avatar-circle rounded-full" [ngClass]="avatarClasses()">{{ avatarLetter() }}</span>
<span class="min-w-0 flex-1"> <span class="min-w-0 flex-1">
<span class="block truncate text-sm font-semibold text-slate-100">{{ user()?.username || user()?.email }}</span> <span class="block truncate text-sm font-semibold text-slate-100">{{ user()?.username || user()?.email }}</span>
<span class="block truncate text-xs text-slate-400">{{ user()?.email }}</span> <span class="block truncate text-xs text-slate-400">{{ user()?.email }}</span>
+36 -2
View File
@@ -1,4 +1,4 @@
import { ChangeDetectionStrategy, Component, computed, inject, signal, Input, Output, EventEmitter, HostListener, ViewChild, ViewChildren, QueryList, ElementRef } from '@angular/core'; import { ChangeDetectionStrategy, Component, computed, inject, signal, Input, Output, EventEmitter, HostListener, ViewChild, ViewChildren, QueryList, ElementRef, DestroyRef, AfterViewInit } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Router, RouterLink, NavigationEnd } from '@angular/router'; import { Router, RouterLink, NavigationEnd } from '@angular/router';
import { InstanceService, Provider } from '../../services/instance.service'; import { InstanceService, Provider } from '../../services/instance.service';
@@ -26,7 +26,7 @@ import { SearchBoxComponent } from '../search/search-box.component';
SearchBoxComponent SearchBoxComponent
] ]
}) })
export class HeaderComponent { export class HeaderComponent implements AfterViewInit {
private router = inject(Router); private router = inject(Router);
instances = inject(InstanceService); instances = inject(InstanceService);
private auth = inject(AuthService); private auth = inject(AuthService);
@@ -39,6 +39,40 @@ export class HeaderComponent {
// Two <app-search-box> instances exist (desktop slot + mobile sheet); only one // Two <app-search-box> instances exist (desktop slot + mobile sheet); only one
// is ever visible, so focus must target the visible one. // is ever visible, so focus must target the visible one.
@ViewChildren(SearchBoxComponent) searchBoxes!: QueryList<SearchBoxComponent>; @ViewChildren(SearchBoxComponent) searchBoxes!: QueryList<SearchBoxComponent>;
private readonly host = inject(ElementRef);
private readonly destroyRef = inject(DestroyRef);
/**
* Publishes the header's real height as `--header-measured` so every sticky
* element that sits below it (themes bar, channel tabs) is offset exactly.
* A hardcoded `--header-h` is only a pre-measurement fallback: the header
* grows by ~60px when the mobile search sheet opens, which used to push the
* themes bar entirely under the header.
*/
private syncHeaderOffset() {
try {
const el = this.host.nativeElement.querySelector('header.app-header') as HTMLElement | null;
if (!el) return;
const h = Math.ceil(el.getBoundingClientRect().height);
if (!h) return;
const root = document.documentElement.style;
if (root.getPropertyValue('--header-measured') !== `${h}px`) {
root.setProperty('--header-measured', `${h}px`);
}
} catch {}
}
ngAfterViewInit() {
try {
const el = this.host.nativeElement.querySelector('header.app-header') as HTMLElement | null;
if (el && typeof ResizeObserver !== 'undefined') {
const ro = new ResizeObserver(() => this.syncHeaderOffset());
ro.observe(el);
this.destroyRef.onDestroy(() => ro.disconnect());
}
} catch {}
this.syncHeaderOffset();
}
/** Reflects the mobile drawer state so the burger exposes `aria-expanded`. */ /** Reflects the mobile drawer state so the burger exposes `aria-expanded`. */
@Input() drawerOpen = false; @Input() drawerOpen = false;
@@ -47,16 +47,18 @@
</button> </button>
<!-- Replier la bande : action disponible uniquement dans le header. <!-- Replier la bande : action disponible uniquement dans le header.
On phones the rail is edge-to-edge with a swipe affordance instead of On phones the rail is edge-to-edge and shows icons only — the labels do
the reserved arrow gutters. --> not fit and force the strip to scroll for the first few themes. -->
<div class="scroll-rail no-scrollbar md:overflow-x-auto" #scroll (wheel)="onWheel($event)"> <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"> <ul role="tablist" aria-label="Themes" class="flex min-w-max items-center gap-1.5 px-3 py-2 sm:gap-2 md:px-12">
<li *ngFor="let t of displayedThemes(); let i = index" class="shrink-0"> <li *ngFor="let t of displayedThemes(); let i = index" class="shrink-0">
<button <button
#pill #pill
role="tab" role="tab"
type="button" type="button"
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" [attr.aria-label]="themesSvc.i18nLabel(t)"
[attr.title]="themesSvc.i18nLabel(t)"
class="chip inline-flex items-center justify-center gap-1.5 rounded-full border px-3 py-1.5 text-sm font-medium transition-colors whitespace-nowrap focus:outline-none focus:ring-2 focus:ring-red-500 md:px-3.5"
[class.bg-slate-100/10]="activeSlug() === t.slug" [class.bg-slate-100/10]="activeSlug() === t.slug"
[class.text-white]="activeSlug() === t.slug" [class.text-white]="activeSlug() === t.slug"
[class.border-red-500]="activeSlug() === t.slug" [class.border-red-500]="activeSlug() === t.slug"
@@ -66,8 +68,8 @@
(click)="goToTheme(t.slug)" (click)="goToTheme(t.slug)"
(keydown)="onKeydown($event, i)" (keydown)="onKeydown($event, i)"
> >
<span class="mr-1 select-none">{{ t.emoji }}</span> <span class="select-none text-base leading-none md:text-base">{{ t.emoji }}</span>
<span>{{ themesSvc.i18nLabel(t) }}</span> <span class="hidden md:inline">{{ themesSvc.i18nLabel(t) }}</span>
</button> </button>
</li> </li>
</ul> </ul>