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
@@ -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;