fix(portal-shell): dark mode actually applies to component-scoped surfaces (#87)
CI / check (push) Successful in 3m0s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m15s
CI / a11y (push) Successful in 1m5s
CI / perf (push) Successful in 2m57s

## Bug

Three component stylesheets (`app.scss`, `sidebar.scss`, `theme-switcher.scss`) carried `:where(.dark) &` rules to react to the `<html>.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] { ... }
```

`<html>` carries `.dark` but no `_ngcontent` attribute — the rule never matched.

Visible symptom: the main page background stayed light even when `.dark` was on `<html>` 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 (`<html>`) 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 `<html>.dark` as an ancestor of `<app-root>` (the host) — exactly what we want.

- **`theme-switcher` switches to `ViewEncapsulation.None`**. Its CDK menu opens in an overlay portal appended to `<body>` — 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 <julien.gautier@apf.asso.fr>
Reviewed-on: #87
This commit was merged in pull request #87.
This commit is contained in:
2026-05-11 10:45:39 +02:00
parent 0ae7e0e23d
commit 3a0a9c700d
4 changed files with 64 additions and 32 deletions
+10 -2
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);