From 8cac0b4fc1acc536336f3bf5b70f4ce9932a1caa Mon Sep 17 00:00:00 2001 From: Julien Gautier Date: Mon, 11 May 2026 10:12:57 +0200 Subject: [PATCH] fix(portal-shell): dark mode actually applies to component-scoped surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three component stylesheets carried `:where(.dark) &` rules to react to the `.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 `` (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 `` 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. --- apps/portal-shell/src/app/app.scss | 16 ++++-- .../src/app/components/header/header.scss | 9 ++- .../src/app/components/sidebar/sidebar.scss | 55 +++++++++++-------- .../theme-switcher/theme-switcher.ts | 16 +++++- 4 files changed, 64 insertions(+), 32 deletions(-) diff --git a/apps/portal-shell/src/app/app.scss b/apps/portal-shell/src/app/app.scss index 07a87a9..3683970 100644 --- a/apps/portal-shell/src/app/app.scss +++ b/apps/portal-shell/src/app/app.scss @@ -3,6 +3,14 @@ // owns its own scrolling (long menus don't push the header off-screen); // the main pane scrolls independently so opening a long page never // scrolls the sidebar out of view. +// +// Dark mode goes through `:host-context(.dark)` — the `.dark` class +// sits on , 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 +// 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 { display: flex; flex-direction: column; @@ -22,11 +30,11 @@ overflow-y: auto; background-color: #f9fafb; color: #111827; +} - :where(.dark) & { - background-color: #0b1220; - color: #e5e7eb; - } +:host-context(.dark) .shell-main { + background-color: #0b1220; + color: #e5e7eb; } // Skip-link (WCAG 2.4.1 "Bypass Blocks"). Hidden by default, fully diff --git a/apps/portal-shell/src/app/components/header/header.scss b/apps/portal-shell/src/app/components/header/header.scss index 8dbc3ec..864036a 100644 --- a/apps/portal-shell/src/app/components/header/header.scss +++ b/apps/portal-shell/src/app/components/header/header.scss @@ -5,6 +5,10 @@ // change, update both files together — extracting a shared SCSS // variable would be the right move only if a third surface needs the // 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 { display: flex; align-items: center; @@ -12,13 +16,8 @@ width: 16rem; height: 100%; padding: 0 0.75rem; - border-right: 1px solid #e5e7eb; transition: width 0.18s ease-out; - :where(.dark) & { - border-right-color: #1f2937; - } - &--collapsed { width: 4rem; padding: 0 0.5rem; diff --git a/apps/portal-shell/src/app/components/sidebar/sidebar.scss b/apps/portal-shell/src/app/components/sidebar/sidebar.scss index 2460f6d..6e3e78c 100644 --- a/apps/portal-shell/src/app/components/sidebar/sidebar.scss +++ b/apps/portal-shell/src/app/components/sidebar/sidebar.scss @@ -4,6 +4,12 @@ // room on each side. The transition is short and animation-friendly — // users with `prefers-reduced-motion: reduce` get the no-animation // branch below. +// +// Dark mode rules sit OUTSIDE the nested blocks and key off +// `:host-context(.dark)` — `.dark` lives on , 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 . .sidebar { width: 16rem; transition: width 0.18s ease-out; @@ -44,27 +50,14 @@ background-color 0.12s ease-out, color 0.12s ease-out; - :where(.dark) & { - color: #e5e7eb; - } - &:hover { background-color: #f3f4f6; color: var(--color-brand-primary-500); - - :where(.dark) & { - background-color: #1f2937; - color: var(--color-brand-primary-300); - } } &:focus-visible { outline: 2px solid var(--color-brand-primary-500); outline-offset: 2px; - - :where(.dark) & { - outline-color: var(--color-brand-primary-300); - } } } @@ -76,15 +69,6 @@ background-color: var(--color-brand-primary-600); color: #fff; } - - :where(.dark) & { - background-color: var(--color-brand-primary-400); - - &:hover { - background-color: var(--color-brand-primary-300); - color: #0b1620; - } - } } .sidebar__label { @@ -101,3 +85,30 @@ 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; + } + } +} diff --git a/apps/portal-shell/src/app/components/theme-switcher/theme-switcher.ts b/apps/portal-shell/src/app/components/theme-switcher/theme-switcher.ts index f4d1ae0..196dd1c 100644 --- a/apps/portal-shell/src/app/components/theme-switcher/theme-switcher.ts +++ b/apps/portal-shell/src/app/components/theme-switcher/theme-switcher.ts @@ -1,5 +1,11 @@ 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 { LayoutStateService, type ThemeMode } from '../../state/layout-state.service'; @@ -36,6 +42,14 @@ const MODE_OPTIONS: readonly ModeOption[] = [ templateUrl: './theme-switcher.html', styleUrl: './theme-switcher.scss', changeDetection: ChangeDetectionStrategy.OnPush, + // The CDK menu opens in an overlay portal appended to , 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 { private readonly layout = inject(LayoutStateService);