fix(portal-shell): dark mode actually applies to component-scoped surfaces #87

Merged
julien merged 1 commits from fix/portal-shell/dark-mode-scoping into main 2026-05-11 10:45:40 +02:00
Owner

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:

: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:

    .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

  • pnpm exec nx run-many -t lint test build --projects=portal-shell — green (33 / 33 specs).
  • 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.
## 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. - [x] Manual: pick dark mode → `/` shows the dark page background; sidebar hover / active / focus visibly switches; theme-switcher menu opens with the dark surface. - [x] Manual: header no longer shows a vertical hairline between the logo zone and the search/actions cluster.
julien added 1 commit 2026-05-11 10:45:23 +02:00
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
8cac0b4fc1
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.
julien merged commit 3a0a9c700d into main 2026-05-11 10:45:40 +02:00
julien deleted branch fix/portal-shell/dark-mode-scoping 2026-05-11 10:45:42 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: julien/apf_portal#87