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-left: 0px;
--safe-right: 0px;
/* Height actually consumed by the fixed header, safe-area included. */
--header-offset: calc(var(--header-h) + var(--safe-top));
/* Height actually consumed by the fixed header, safe-area included.
`--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. */
--app-vh: 100vh;
}
@@ -181,18 +185,27 @@ body {
}
.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 {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 2rem;
height: 2rem;
border-radius: 9999px;
border-width: 1px;
min-width: 2rem;
min-height: 2rem;
aspect-ratio: 1 / 1;
border-radius: 9999px !important;
border-width: 2px;
border-style: solid;
font-size: 0.875rem;
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. */
+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.paddingLeft]="'var(--safe-left)'"
[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 -->
<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>
<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)">
<span class="avatar-circle" [ngClass]="avatarClasses()">{{ avatarLetter() }}</span>
<span class="avatar-circle rounded-full" [ngClass]="avatarClasses()">{{ avatarLetter() }}</span>
</button>
@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">
@@ -120,7 +128,7 @@
<!-- 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
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()">
<div class="relative">
<app-search-box [placeholder]="('search.placeholder' | t)"
@@ -183,7 +191,7 @@
@if (user()) {
<div class="border-t border-slate-800 px-4 py-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="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>
+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 { Router, RouterLink, NavigationEnd } from '@angular/router';
import { InstanceService, Provider } from '../../services/instance.service';
@@ -26,7 +26,7 @@ import { SearchBoxComponent } from '../search/search-box.component';
SearchBoxComponent
]
})
export class HeaderComponent {
export class HeaderComponent implements AfterViewInit {
private router = inject(Router);
instances = inject(InstanceService);
private auth = inject(AuthService);
@@ -39,6 +39,40 @@ export class HeaderComponent {
// Two <app-search-box> instances exist (desktop slot + mobile sheet); only one
// is ever visible, so focus must target the visible one.
@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`. */
@Input() drawerOpen = false;
@@ -47,16 +47,18 @@
</button>
<!-- 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. -->
On phones the rail is edge-to-edge and shows icons only — the labels do
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)">
<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">
<button
#pill
role="tab"
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.text-white]="activeSlug() === t.slug"
[class.border-red-500]="activeSlug() === t.slug"
@@ -66,8 +68,8 @@
(click)="goToTheme(t.slug)"
(keydown)="onKeydown($event, i)"
>
<span class="mr-1 select-none">{{ t.emoji }}</span>
<span>{{ themesSvc.i18nLabel(t) }}</span>
<span class="select-none text-base leading-none md:text-base">{{ t.emoji }}</span>
<span class="hidden md:inline">{{ themesSvc.i18nLabel(t) }}</span>
</button>
</li>
</ul>