refactor(libs): graduate Icon / LayoutStateService / brand tokens to libs/shared/*
CI / commits (pull_request) Successful in 3m9s
CI / scan (pull_request) Successful in 3m25s
CI / check (pull_request) Successful in 3m58s
CI / a11y (pull_request) Successful in 1m59s
CI / perf (pull_request) Successful in 5m11s

Per ADR-0020 §"Shared-libs graduation": the three primitives that
both `portal-shell` (today) and `portal-admin` (next) will share
move out of `apps/portal-shell/src/` and into the workspace libs.
Mechanical move — no behaviour change.

Graduated:

- `Icon` component → `libs/shared/ui/src/lib/icon/`. Selector
  renamed `app-icon` → `lib-icon` (consumed via `<lib-icon>`),
  `IconName` re-exported from `shared-ui`.
- `LayoutStateService` (+ `ThemeMode` type) →
  `libs/shared/state/src/lib/`. New library generated via
  `nx g @nx/angular:library` to match the existing
  `feature-auth` / `shared-ui` shape.
- Brand-palette `@theme` block →
  `libs/shared/tokens/src/brand-tokens.css`. `portal-shell`'s
  `styles.css` imports it by relative path.

Lib tags updated so the module-boundary lint rule lets both apps
consume them:

- `shared-state`: new lib → `scope:shared, type:shared`.
- `shared-ui`: was `scope:portal-shell` (stopgap from the initial
  scaffold) → `scope:shared, type:shared`.

Side-edits:

- `tsconfig.base.json` gains the `shared-state` path alias
  (generator-applied).
- `shared-tokens` is now CSS-only — its placeholder TS function is
  removed and its vitest config flips `passWithNoTests: true` since
  there is nothing to test there yet.
- Placeholder `shared-ui/shared-ui.{ts,html,css,spec.ts}` files
  removed.

Verification:

- Lint / test / build run-many across the four projects — green.
- portal-shell: 28 tests, shared-state: 9, shared-ui: 3 (= the same
  40 specs as before, redistributed across the new libs).
- Production build: 128.7 KB gzip initial per locale (was 128.5 KB
  on main — noise-level delta).
- Brand tokens still inlined in the produced `styles-*.css`.
- Both `dist/.../browser/{en,fr}/` emitted as expected.

No public-API breakage. The migration to `portal-admin` is now a
clean `import { Icon } from 'shared-ui'` + `LayoutStateService`
inject + `@import` of `brand-tokens.css` on admin's `styles.css` —
no duplication.
This commit is contained in:
Julien Gautier
2026-05-12 01:13:52 +02:00
parent 99522540a5
commit f7c3ba24d7
37 changed files with 267 additions and 125 deletions
@@ -29,7 +29,7 @@
<div
class="flex w-full max-w-xl items-center gap-2 rounded-full border border-gray-200 bg-gray-50 px-4 py-2 focus-within:border-brand-primary-500 focus-within:bg-white focus-within:ring-2 focus-within:ring-brand-primary-200 dark:border-gray-700 dark:bg-gray-800 dark:focus-within:border-brand-primary-300 dark:focus-within:bg-gray-900 dark:focus-within:ring-brand-primary-700"
>
<app-icon name="search" [size]="18" />
<lib-icon name="search" [size]="18" />
<input
id="global-search"
type="search"
@@ -47,7 +47,7 @@
i18n-aria-label="@@header.action.notifications"
aria-label="Notifications"
>
<app-icon name="bell" />
<lib-icon name="bell" />
<span
aria-hidden="true"
class="absolute right-2 top-2 h-2 w-2 rounded-full bg-brand-accent-400 ring-2 ring-white dark:ring-gray-900"
@@ -59,7 +59,7 @@
i18n-aria-label="@@header.action.help"
aria-label="Help"
>
<app-icon name="circle-help" />
<lib-icon name="circle-help" />
</button>
<app-theme-switcher />
<button
@@ -68,7 +68,7 @@
i18n-aria-label="@@header.action.settings"
aria-label="Settings"
>
<app-icon name="settings" />
<lib-icon name="settings" />
</button>
<span
i18n-aria-label="@@header.action.userMenu"
@@ -1,7 +1,7 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { Header } from './header';
import { LayoutStateService } from '../../state/layout-state.service';
import { LayoutStateService } from 'shared-state';
describe('Header', () => {
beforeEach(async () => {
@@ -1,8 +1,8 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { RouterLink } from '@angular/router';
import { Icon } from '../icon/icon';
import { Icon } from 'shared-ui';
import { ThemeSwitcher } from '../theme-switcher/theme-switcher';
import { LayoutStateService } from '../../state/layout-state.service';
import { LayoutStateService } from 'shared-state';
/**
* Top-of-page banner.
@@ -1,49 +0,0 @@
import { Component } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Icon } from './icon';
@Component({
selector: 'app-icon-test-host',
imports: [Icon],
template: `<app-icon [name]="name" [size]="size" [strokeWidth]="strokeWidth" />`,
})
class IconTestHost {
name: 'home' | 'bell' = 'home';
size = 20;
strokeWidth = 1.5;
}
describe('Icon', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({ imports: [IconTestHost] }).compileComponents();
});
it('renders a lucide <svg> for the given name', async () => {
const fixture = TestBed.createComponent(IconTestHost);
await fixture.whenStable();
const svg = fixture.nativeElement.querySelector('app-icon svg') as SVGElement | null;
expect(svg).not.toBeNull();
expect(svg?.classList.contains('lucide')).toBe(true);
});
it('marks the lucide host as decorative (aria-hidden)', async () => {
const fixture = TestBed.createComponent(IconTestHost);
await fixture.whenStable();
const host = fixture.nativeElement.querySelector(
'app-icon lucide-angular',
) as HTMLElement | null;
expect(host?.getAttribute('aria-hidden')).toBe('true');
});
it('propagates size and strokeWidth to the underlying <svg>', async () => {
const fixture = TestBed.createComponent(IconTestHost);
fixture.componentInstance.size = 32;
fixture.componentInstance.strokeWidth = 2;
fixture.detectChanges();
await fixture.whenStable();
const svg = fixture.nativeElement.querySelector('app-icon svg') as SVGElement | null;
expect(svg?.getAttribute('width')).toBe('32');
expect(svg?.getAttribute('height')).toBe('32');
expect(svg?.getAttribute('stroke-width')).toBe('2');
});
});
@@ -1,100 +0,0 @@
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
import {
Accessibility,
Bell,
Building2,
Calendar,
Check,
ChevronDown,
ChevronLeft,
ChevronRight,
CircleHelp,
FileText,
Folder,
Globe,
Grid2x2,
House,
LayoutDashboard,
type LucideIconData,
LucideAngularModule,
Mail,
Monitor,
Moon,
Search,
Settings,
Store,
Sun,
UsersRound,
Wrench,
} from 'lucide-angular';
/**
* Icon façade for the SPA.
*
* v1 is backed by `lucide-angular`. The migration to an in-house
* icomoon-generated set (sprite SVG under
* `apps/portal-shell/public/icons/`) is a single-file change to
* THIS component — every consumer just writes `<app-icon name="…">`,
* so swapping the implementation never touches them.
*
* Convention: `name` is the lowercase, kebab-cased "logical" name we
* will keep across implementations (`home`, `bar-chart`, etc.). The
* registry below maps each logical name to its lucide-angular icon
* data. When the icomoon export lands, each name will instead map to
* a `<symbol id="icon-…">` in the sprite — but the template-side
* contract stays identical.
*/
// Adding an icon to the registry: import the lucide pascal-case name
// above (Tailwind eslint sorts the import block), then add a single
// `'kebab-name': PascalImport,` line below. Both lists are alphabetical
// so contributors don't have to think about placement.
const ICON_REGISTRY = {
accessibility: Accessibility,
bell: Bell,
'building-2': Building2,
calendar: Calendar,
check: Check,
'chevron-down': ChevronDown,
'chevron-left': ChevronLeft,
'chevron-right': ChevronRight,
'circle-help': CircleHelp,
'file-text': FileText,
folder: Folder,
globe: Globe,
'grid-2x2': Grid2x2,
home: House,
'layout-dashboard': LayoutDashboard,
mail: Mail,
monitor: Monitor,
moon: Moon,
search: Search,
settings: Settings,
store: Store,
sun: Sun,
'users-round': UsersRound,
wrench: Wrench,
} satisfies Record<string, LucideIconData>;
export type IconName = keyof typeof ICON_REGISTRY;
@Component({
selector: 'app-icon',
imports: [LucideAngularModule],
template: `<lucide-angular
[img]="icon()"
[size]="size()"
[strokeWidth]="strokeWidth()"
aria-hidden="true"
/>`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class Icon {
readonly name = input.required<IconName>();
readonly size = input<number>(20);
/** Lucide draws with stroke-width 2 by default; 1.5 reads slightly
* lighter and matches the visual weight of most icomoon sets. */
readonly strokeWidth = input<number>(1.5);
protected readonly icon = computed(() => ICON_REGISTRY[this.name()]);
}
@@ -4,9 +4,9 @@
[cdkMenuTriggerFor]="localeMenu"
[attr.aria-label]="triggerAriaLabel"
>
<app-icon name="globe" [size]="14" />
<lib-icon name="globe" [size]="14" />
<span>{{ currentDisplayName }}</span>
<app-icon name="chevron-down" [size]="12" />
<lib-icon name="chevron-down" [size]="12" />
</button>
<ng-template #localeMenu>
@@ -30,7 +30,7 @@
>
<span class="locale-switcher__label">{{ locale.displayName }}</span>
@if (locale.code === currentCode) {
<app-icon name="check" [size]="16" />
<lib-icon name="check" [size]="16" />
}
</button>
}
@@ -1,7 +1,7 @@
import { DOCUMENT } from '@angular/common';
import { CdkMenu, CdkMenuItem, CdkMenuTrigger } from '@angular/cdk/menu';
import { ChangeDetectionStrategy, Component, ViewEncapsulation, inject } from '@angular/core';
import { Icon } from '../icon/icon';
import { Icon } from 'shared-ui';
/**
* Locale switcher rendered in the footer.
@@ -31,7 +31,7 @@
[attr.aria-label]="collapsed() ? item.label : null"
[title]="collapsed() ? item.label : ''"
>
<app-icon [name]="item.icon" />
<lib-icon [name]="item.icon" />
@if (!collapsed()) {
<span class="sidebar__label">{{ item.label }}</span>
}
@@ -43,7 +43,7 @@
[attr.aria-label]="collapsed() ? item.label : null"
[title]="collapsed() ? item.label : ''"
>
<app-icon [name]="item.icon" />
<lib-icon [name]="item.icon" />
@if (!collapsed()) {
<span class="sidebar__label">{{ item.label }}</span>
}
@@ -77,7 +77,7 @@
[attr.aria-expanded]="!collapsed()"
[attr.aria-label]="toggleAriaLabel()"
>
<app-icon [name]="collapsed() ? 'chevron-right' : 'chevron-left'" />
<lib-icon [name]="collapsed() ? 'chevron-right' : 'chevron-left'" />
@if (!collapsed()) {
<span class="sidebar__label" i18n="@@sidebar.toggle.collapse">Collapse</span>
}
@@ -1,7 +1,7 @@
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
import { RouterLink, RouterLinkActive } from '@angular/router';
import { Icon, type IconName } from '../icon/icon';
import { LayoutStateService } from '../../state/layout-state.service';
import { Icon, type IconName } from 'shared-ui';
import { LayoutStateService } from 'shared-state';
/**
* Primary side navigation.
@@ -4,7 +4,7 @@
[cdkMenuTriggerFor]="themeMenu"
[attr.aria-label]="triggerAriaLabel()"
>
<app-icon [name]="currentIcon()" />
<lib-icon [name]="currentIcon()" />
</button>
<ng-template #themeMenu>
@@ -25,10 +25,10 @@
role="menuitemradio"
(click)="select(option.mode)"
>
<app-icon [name]="option.icon" />
<lib-icon [name]="option.icon" />
<span class="theme-switcher__label">{{ option.label }}</span>
@if (mode() === option.mode) {
<app-icon name="check" [size]="16" />
<lib-icon name="check" [size]="16" />
}
</button>
}
@@ -1,5 +1,5 @@
import { TestBed } from '@angular/core/testing';
import { LayoutStateService } from '../../state/layout-state.service';
import { LayoutStateService } from 'shared-state';
import { ThemeSwitcher } from './theme-switcher';
describe('ThemeSwitcher', () => {
@@ -6,8 +6,8 @@ import {
computed,
inject,
} from '@angular/core';
import { Icon, type IconName } from '../icon/icon';
import { LayoutStateService, type ThemeMode } from '../../state/layout-state.service';
import { Icon, type IconName } from 'shared-ui';
import { LayoutStateService, type ThemeMode } from 'shared-state';
/**
* Light / dark / auto color-scheme switcher rendered in the header.
@@ -1,94 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { LayoutStateService } from './layout-state.service';
describe('LayoutStateService', () => {
beforeEach(() => {
localStorage.clear();
document.documentElement.classList.remove('dark');
TestBed.resetTestingModule();
});
describe('sidebar state', () => {
it('defaults to expanded when no preference is persisted', () => {
const service = TestBed.inject(LayoutStateService);
expect(service.sidebarCollapsed()).toBe(false);
});
it('initialises from the persisted preference', () => {
localStorage.setItem('portal-shell:sidebar-collapsed', '1');
const service = TestBed.inject(LayoutStateService);
expect(service.sidebarCollapsed()).toBe(true);
});
it('toggleSidebar flips the value and persists it', () => {
const service = TestBed.inject(LayoutStateService);
expect(service.sidebarCollapsed()).toBe(false);
service.toggleSidebar();
expect(service.sidebarCollapsed()).toBe(true);
TestBed.tick();
expect(localStorage.getItem('portal-shell:sidebar-collapsed')).toBe('1');
service.toggleSidebar();
expect(service.sidebarCollapsed()).toBe(false);
TestBed.tick();
expect(localStorage.getItem('portal-shell:sidebar-collapsed')).toBe('0');
});
it('setSidebarCollapsed writes the explicit value', () => {
const service = TestBed.inject(LayoutStateService);
service.setSidebarCollapsed(true);
expect(service.sidebarCollapsed()).toBe(true);
TestBed.tick();
expect(localStorage.getItem('portal-shell:sidebar-collapsed')).toBe('1');
});
});
describe('theme state', () => {
it('defaults to auto when no preference is persisted', () => {
const service = TestBed.inject(LayoutStateService);
expect(service.themeMode()).toBe('auto');
});
it('initialises from the persisted theme mode', () => {
localStorage.setItem('portal-shell:theme-mode', 'dark');
const service = TestBed.inject(LayoutStateService);
expect(service.themeMode()).toBe('dark');
});
it('ignores an invalid persisted value and falls back to auto', () => {
localStorage.setItem('portal-shell:theme-mode', 'sepia' as unknown as string);
const service = TestBed.inject(LayoutStateService);
expect(service.themeMode()).toBe('auto');
});
it('setThemeMode persists the choice and toggles the .dark class on <html>', () => {
const service = TestBed.inject(LayoutStateService);
const html = document.documentElement;
service.setThemeMode('dark');
TestBed.tick();
expect(localStorage.getItem('portal-shell:theme-mode')).toBe('dark');
expect(html.classList.contains('dark')).toBe(true);
expect(service.effectiveTheme()).toBe('dark');
service.setThemeMode('light');
TestBed.tick();
expect(localStorage.getItem('portal-shell:theme-mode')).toBe('light');
expect(html.classList.contains('dark')).toBe(false);
expect(service.effectiveTheme()).toBe('light');
});
it('resolves auto via the system preference (jsdom defaults to light)', () => {
const service = TestBed.inject(LayoutStateService);
service.setThemeMode('auto');
TestBed.tick();
expect(service.themeMode()).toBe('auto');
// jsdom's matchMedia reports no `prefers-color-scheme: dark` match,
// so 'auto' resolves to light. We assert the resolution path —
// not the literal value of the system preference.
expect(service.effectiveTheme()).toBe('light');
expect(document.documentElement.classList.contains('dark')).toBe(false);
});
});
});
@@ -1,134 +0,0 @@
import { DOCUMENT } from '@angular/common';
import { Injectable, computed, effect, inject, signal } from '@angular/core';
/**
* Shell-level layout state shared by the header and the sidebar.
*
* v1 surfaces two settings:
* - `sidebarCollapsed`: whether the sidebar is collapsed to its
* icon-only rail. The header reads it to size its logo zone in
* lock-step with the sidebar.
* - `themeMode`: the user's color-scheme preference among
* `light` / `dark` / `auto`. `effectiveTheme` resolves it to a
* concrete `'light' | 'dark'` by consulting the system preference
* when in `auto` mode, and reacts to OS-level theme changes.
* A side-effect toggles the `.dark` class on <html> so every
* `dark:` Tailwind utility flips at once.
*
* Both pieces of state persist in `localStorage` so the user's
* preferences survive a reload without a backend round-trip.
*
* Lives outside of the components that consume it so multiple
* surfaces stay synchronised without prop-drilling, and so the
* service is the natural home for future cross-cutting shell state
* (density, RTL, panel pinning, ...).
*/
const SIDEBAR_STORAGE_KEY = 'portal-shell:sidebar-collapsed';
const THEME_STORAGE_KEY = 'portal-shell:theme-mode';
const DARK_CLASS = 'dark';
export type ThemeMode = 'light' | 'dark' | 'auto';
@Injectable({ providedIn: 'root' })
export class LayoutStateService {
private readonly document = inject(DOCUMENT);
private readonly _sidebarCollapsed = signal(readPersistedSidebarCollapsed());
private readonly _themeMode = signal<ThemeMode>(readPersistedThemeMode());
private readonly _systemPrefersDark = signal(systemPrefersDark(this.document));
readonly sidebarCollapsed = this._sidebarCollapsed.asReadonly();
readonly themeMode = this._themeMode.asReadonly();
readonly effectiveTheme = computed<'light' | 'dark'>(() => {
const mode = this._themeMode();
if (mode === 'auto') {
return this._systemPrefersDark() ? 'dark' : 'light';
}
return mode;
});
constructor() {
effect(() => {
const value = this._sidebarCollapsed();
try {
localStorage.setItem(SIDEBAR_STORAGE_KEY, value ? '1' : '0');
} catch {
// localStorage can throw in private-mode contexts; the UI
// still works, the preference just doesn't persist.
}
});
effect(() => {
const mode = this._themeMode();
try {
localStorage.setItem(THEME_STORAGE_KEY, mode);
} catch {
// See above.
}
});
effect(() => {
const theme = this.effectiveTheme();
const root = this.document.documentElement;
if (theme === 'dark') {
root.classList.add(DARK_CLASS);
} else {
root.classList.remove(DARK_CLASS);
}
});
this.watchSystemPreference();
}
toggleSidebar(): void {
this._sidebarCollapsed.update((v) => !v);
}
setSidebarCollapsed(value: boolean): void {
this._sidebarCollapsed.set(value);
}
setThemeMode(mode: ThemeMode): void {
this._themeMode.set(mode);
}
private watchSystemPreference(): void {
const mql = this.document.defaultView?.matchMedia?.('(prefers-color-scheme: dark)');
if (!mql) {
return;
}
const listener = (e: MediaQueryListEvent) => this._systemPrefersDark.set(e.matches);
// `addEventListener` is the modern API; older Safari only exposes
// the deprecated `addListener` — guarded for robustness.
if (typeof mql.addEventListener === 'function') {
mql.addEventListener('change', listener);
} else {
mql.addListener(listener);
}
}
}
function readPersistedSidebarCollapsed(): boolean {
try {
return localStorage.getItem(SIDEBAR_STORAGE_KEY) === '1';
} catch {
return false;
}
}
function readPersistedThemeMode(): ThemeMode {
try {
const raw = localStorage.getItem(THEME_STORAGE_KEY);
if (raw === 'light' || raw === 'dark' || raw === 'auto') {
return raw;
}
} catch {
// fall through
}
return 'auto';
}
function systemPrefersDark(doc: Document): boolean {
return doc.defaultView?.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false;
}
+5 -46
View File
@@ -1,10 +1,9 @@
/*
* Global styles for portal-shell.
*
* Tailwind CSS 4 (CSS-first config). Theme tokens (palette, contrast tiers,
* spacing, motion) will be defined under @theme via the libs/shared/tokens
* library. spartan-ng components copy-pasted into libs/shared/ui will read
* from these tokens.
* Tailwind CSS 4 (CSS-first config). The brand palette lives in
* `libs/shared/tokens/src/brand-tokens.css` so every app in the
* workspace (`portal-shell`, `portal-admin`) reads the same source.
*/
@import 'tailwindcss';
@@ -19,45 +18,5 @@
*/
@custom-variant dark (&:where(.dark, .dark *));
/*
* Brand palette — APF charte graphique.
*
* primary: #12546c (dark teal) — header bar, sidebar active items,
* primary buttons, headings on light bg
* accent: #f7a919 (warm orange) — CTAs, highlights, focus rings on
* branded surfaces
*
* The two scales below are hand-tuned (each step roughly halves the
* delta from the previous toward white / black). Tailwind v4 surfaces
* them automatically as utility classes — `bg-brand-primary-700`,
* `text-brand-accent-500`, `ring-brand-primary-500`, etc.
*
* Use the `500` step as the canonical brand value; the lighter / darker
* steps are for hover / focus / muted text without dropping out of the
* brand family.
*/
@theme {
--color-brand-primary-50: #eaf3f7;
--color-brand-primary-100: #cae0e8;
--color-brand-primary-200: #9ac4d2;
--color-brand-primary-300: #5fa2b6;
--color-brand-primary-400: #2f7f96;
--color-brand-primary-500: #12546c;
--color-brand-primary-600: #0f475c;
--color-brand-primary-700: #0c394a;
--color-brand-primary-800: #082a36;
--color-brand-primary-900: #051c24;
--color-brand-primary-950: #020e12;
--color-brand-accent-50: #fef5e0;
--color-brand-accent-100: #fce6b3;
--color-brand-accent-200: #fad17a;
--color-brand-accent-300: #f8bb47;
--color-brand-accent-400: #f7a919;
--color-brand-accent-500: #de9415;
--color-brand-accent-600: #b97a11;
--color-brand-accent-700: #93600d;
--color-brand-accent-800: #6e4709;
--color-brand-accent-900: #4a3006;
--color-brand-accent-950: #271903;
}
/* Brand palette tokens — shared with `portal-admin` once it ships. */
@import '../../../libs/shared/tokens/src/brand-tokens.css';
+9 -1
View File
@@ -5,5 +5,13 @@
"types": ["@angular/localize"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"],
"references": [
{
"path": "../../libs/shared/ui"
},
{
"path": "../../libs/shared/state"
}
]
}