From 3a0a9c700d2ecc04a8b06e82d66314348e9fddb2 Mon Sep 17 00:00:00 2001 From: Julien Gautier Date: Mon, 11 May 2026 10:45:39 +0200 Subject: [PATCH] fix(portal-shell): dark mode actually applies to component-scoped surfaces (#87) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Bug Three component stylesheets (`app.scss`, `sidebar.scss`, `theme-switcher.scss`) carried `:where(.dark) &` rules to react to the `.dark` class — the same pattern Tailwind uses internally. **Angular's emulated CSS encapsulation descends into `:where()` and rewrites its contents with the `_ngcontent-XXX` scoping attribute**, so the produced selector looked like: ```css :where(.dark[_ngcontent-c0]) .shell-main[_ngcontent-c0] { ... } ``` `` carries `.dark` but no `_ngcontent` attribute — the rule never matched. Visible symptom: the main page background stayed light even when `.dark` was on `` and every Tailwind `dark:` utility in the templates was working correctly. Sidebar hover/focus/active states and the theme-switcher menu surface had the same bug. ## Fix - **`app.scss` and `sidebar.scss`** switch to `:host-context(.dark)`. The Angular compiler recognises this directive and expands it without forcing the ancestor (``) to carry the scoping attribute. Compiled output: ```css .dark[_nghost-c0] .shell-main[_ngcontent-c0], .dark [_nghost-c0] .shell-main[_ngcontent-c0] { ... } ``` The second selector matches `.dark` as an ancestor of `` (the host) — exactly what we want. - **`theme-switcher` switches to `ViewEncapsulation.None`**. Its CDK menu opens in an overlay portal appended to `` — outside the component's host subtree — so even `:host-context()` would miss it. With encapsulation disabled, the styles emit globally; the BEM-style class names (`.theme-switcher__menu`, `.theme-switcher__item`, ...) keep the rules contained without leaking. ## Drive-by - Remove the right border on the `.header__logo-zone`. The visible hairline above the sidebar was extra noise — the alignment of widths already carries the visual relationship to the rail below. ## Why not also rip out the `dark:` Tailwind utilities used in the templates? They work. They're emitted into the global Tailwind sheet, sit outside view encapsulation, and already handle the `.dark` ancestor lookup via their own selector (`:where(.dark, .dark *)`). The bug was specifically about component-authored CSS *inside* an SCSS file under emulated encapsulation. ## Test plan - [x] `pnpm exec nx run-many -t lint test build --projects=portal-shell` — green (33 / 33 specs). - [x] Inspect the produced CSS: `:host-context(.dark)` expands to both `.dark[_nghost-XXX]` and `.dark [_nghost-XXX]` selectors, no `_ngcontent` attribute leaked into the `.dark` portion. - [ ] Manual: pick dark mode → `/` shows the dark page background; sidebar hover / active / focus visibly switches; theme-switcher menu opens with the dark surface. - [ ] Manual: header no longer shows a vertical hairline between the logo zone and the search/actions cluster. --------- Co-authored-by: Julien Gautier Reviewed-on: https://git.unespace.com/julien/apf_portal/pulls/87 --- 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);