fix(portal-shell): dark mode actually applies to component-scoped surfaces (#87)
## 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:
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user