fix(portal-shell): dark mode actually applies to component-scoped surfaces
CI / commits (pull_request) Successful in 2m1s
CI / scan (pull_request) Successful in 2m21s
CI / check (pull_request) Successful in 2m29s
CI / a11y (pull_request) Successful in 2m52s
CI / perf (pull_request) Successful in 5m8s

Three component stylesheets carried `:where(.dark) &` rules to react
to the `<html>.dark` class. Angular's emulated CSS encapsulation
descends into `:where()` and rewrites its contents with the
`_ngcontent-XXX` scoping attribute — the produced selector then
demands that `<html>` (which carries `.dark`) also carry the scoping
attribute, which it doesn't, so the rules never matched.

Visible symptom: the main page background and a handful of inner
states (sidebar hover/focus/active, theme-switcher menu surface)
stayed light even when `.dark` was on `<html>` and every Tailwind
`dark:` utility in the templates was working correctly.

Fixes:

- `app.scss` and `sidebar.scss` switch to `:host-context(.dark)` for
  their dark variants. The Angular template compiler recognises
  `:host-context()` and expands it without forcing the ancestor to
  carry the scoping attribute.
- `theme-switcher` switches to `ViewEncapsulation.None`. Its CDK menu
  opens in an overlay portal outside the component's host, so even
  `:host-context()` would miss it. The styles ship globally; the
  BEM-style class names (`.theme-switcher__menu`, ...) are specific
  enough to keep the rules contained.

Drive-by:

- Remove the right border on the header logo zone — the visible
  vertical hairline above the sidebar was extra noise; the alignment
  of widths already carries the visual relationship to the rail below.
This commit is contained in:
Julien Gautier
2026-05-11 10:12:57 +02:00
parent 0ae7e0e23d
commit 8cac0b4fc1
4 changed files with 64 additions and 32 deletions
+12 -4
View File
@@ -3,6 +3,14 @@
// owns its own scrolling (long menus don't push the header off-screen); // owns its own scrolling (long menus don't push the header off-screen);
// the main pane scrolls independently so opening a long page never // the main pane scrolls independently so opening a long page never
// scrolls the sidebar out of view. // scrolls the sidebar out of view.
//
// Dark mode goes through `:host-context(.dark)` — the `.dark` class
// sits on <html>, outside this component's view encapsulation, so a
// nested `:where(.dark) &` would compile to a selector with the
// `_ngcontent` attribute appended to `.dark` itself, which <html>
// doesn't carry. `:host-context()` is the Angular-canonical escape
// hatch that walks the DOM up from the host and matches without
// applying the scoping attribute to the ancestor selector.
:host { :host {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -22,11 +30,11 @@
overflow-y: auto; overflow-y: auto;
background-color: #f9fafb; background-color: #f9fafb;
color: #111827; color: #111827;
}
:where(.dark) & { :host-context(.dark) .shell-main {
background-color: #0b1220; background-color: #0b1220;
color: #e5e7eb; color: #e5e7eb;
}
} }
// Skip-link (WCAG 2.4.1 "Bypass Blocks"). Hidden by default, fully // Skip-link (WCAG 2.4.1 "Bypass Blocks"). Hidden by default, fully
@@ -5,6 +5,10 @@
// change, update both files together — extracting a shared SCSS // change, update both files together — extracting a shared SCSS
// variable would be the right move only if a third surface needs the // variable would be the right move only if a third surface needs the
// same coupling. // same coupling.
//
// No vertical divider between the logo zone and the rest of the
// header on purpose — the alignment carries the visual relationship
// to the sidebar rail below; a second hairline would only add noise.
.header__logo-zone { .header__logo-zone {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -12,13 +16,8 @@
width: 16rem; width: 16rem;
height: 100%; height: 100%;
padding: 0 0.75rem; padding: 0 0.75rem;
border-right: 1px solid #e5e7eb;
transition: width 0.18s ease-out; transition: width 0.18s ease-out;
:where(.dark) & {
border-right-color: #1f2937;
}
&--collapsed { &--collapsed {
width: 4rem; width: 4rem;
padding: 0 0.5rem; padding: 0 0.5rem;
@@ -4,6 +4,12 @@
// room on each side. The transition is short and animation-friendly — // room on each side. The transition is short and animation-friendly —
// users with `prefers-reduced-motion: reduce` get the no-animation // users with `prefers-reduced-motion: reduce` get the no-animation
// branch below. // branch below.
//
// Dark mode rules sit OUTSIDE the nested blocks and key off
// `:host-context(.dark)` — `.dark` lives on <html>, outside this
// component's encapsulation, and a nested `:where(.dark) &` would be
// rewritten by Angular's emulated CSS scoping into a selector that
// no longer matches <html>.
.sidebar { .sidebar {
width: 16rem; width: 16rem;
transition: width 0.18s ease-out; transition: width 0.18s ease-out;
@@ -44,27 +50,14 @@
background-color 0.12s ease-out, background-color 0.12s ease-out,
color 0.12s ease-out; color 0.12s ease-out;
:where(.dark) & {
color: #e5e7eb;
}
&:hover { &:hover {
background-color: #f3f4f6; background-color: #f3f4f6;
color: var(--color-brand-primary-500); color: var(--color-brand-primary-500);
:where(.dark) & {
background-color: #1f2937;
color: var(--color-brand-primary-300);
}
} }
&:focus-visible { &:focus-visible {
outline: 2px solid var(--color-brand-primary-500); outline: 2px solid var(--color-brand-primary-500);
outline-offset: 2px; outline-offset: 2px;
:where(.dark) & {
outline-color: var(--color-brand-primary-300);
}
} }
} }
@@ -76,15 +69,6 @@
background-color: var(--color-brand-primary-600); background-color: var(--color-brand-primary-600);
color: #fff; color: #fff;
} }
:where(.dark) & {
background-color: var(--color-brand-primary-400);
&:hover {
background-color: var(--color-brand-primary-300);
color: #0b1620;
}
}
} }
.sidebar__label { .sidebar__label {
@@ -101,3 +85,30 @@
padding-right: 0.5rem; padding-right: 0.5rem;
} }
} }
// --- Dark mode overrides --------------------------------------------------
:host-context(.dark) {
.sidebar__link,
.sidebar__toggle {
color: #e5e7eb;
&:hover {
background-color: #1f2937;
color: var(--color-brand-primary-300);
}
&:focus-visible {
outline-color: var(--color-brand-primary-300);
}
}
.sidebar__link--active {
background-color: var(--color-brand-primary-400);
&:hover {
background-color: var(--color-brand-primary-300);
color: #0b1620;
}
}
}
@@ -1,5 +1,11 @@
import { CdkMenu, CdkMenuItem, CdkMenuTrigger } from '@angular/cdk/menu'; import { CdkMenu, CdkMenuItem, CdkMenuTrigger } from '@angular/cdk/menu';
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; import {
ChangeDetectionStrategy,
Component,
ViewEncapsulation,
computed,
inject,
} from '@angular/core';
import { Icon, type IconName } from '../icon/icon'; import { Icon, type IconName } from '../icon/icon';
import { LayoutStateService, type ThemeMode } from '../../state/layout-state.service'; import { LayoutStateService, type ThemeMode } from '../../state/layout-state.service';
@@ -36,6 +42,14 @@ const MODE_OPTIONS: readonly ModeOption[] = [
templateUrl: './theme-switcher.html', templateUrl: './theme-switcher.html',
styleUrl: './theme-switcher.scss', styleUrl: './theme-switcher.scss',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
// The CDK menu opens in an overlay portal appended to <body>, so it
// is NOT a descendant of this component's host. With the default
// emulated encapsulation, the menu DOM would lose the scoping
// attribute and our `.theme-switcher__*` rules would not apply.
// Disabling encapsulation emits the styles into the global sheet —
// the BEM-style class names below are specific enough to avoid
// collisions, and `.dark` ancestor matching just works.
encapsulation: ViewEncapsulation.None,
}) })
export class ThemeSwitcher { export class ThemeSwitcher {
private readonly layout = inject(LayoutStateService); private readonly layout = inject(LayoutStateService);