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
+7
View File
@@ -0,0 +1,7 @@
# shared-state
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test shared-state` to execute the unit tests.
+34
View File
@@ -0,0 +1,34 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../../eslint.config.mjs';
export default [
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
...baseConfig,
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'lib',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'lib',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];
+13
View File
@@ -0,0 +1,13 @@
{
"name": "shared-state",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/shared/state/src",
"prefix": "lib",
"projectType": "library",
"tags": ["scope:shared", "type:shared"],
"targets": {
"lint": {
"executor": "@nx/eslint:lint"
}
}
}
+1
View File
@@ -0,0 +1 @@
export { LayoutStateService, type ThemeMode } from './lib/layout-state.service';
@@ -0,0 +1,94 @@
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);
});
});
});
@@ -0,0 +1,134 @@
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
View File
@@ -0,0 +1,5 @@
import '@angular/compiler';
import '@analogjs/vitest-angular/setup-snapshots';
import { setupTestBed } from '@analogjs/vitest-angular/setup-testbed';
setupTestBed();
+23
View File
@@ -0,0 +1,23 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"emitDecoratorMetadata": false,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}
+26
View File
@@ -0,0 +1,26 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"include": ["src/**/*.ts"],
"exclude": [
"src/**/*.spec.ts",
"src/**/*.test.ts",
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/test-setup.ts"
]
}
+23
View File
@@ -0,0 +1,23 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"types": ["vitest/globals", "vitest/importMeta", "vite/client", "node", "vitest"]
},
"include": [
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/**/*.d.ts"
],
"files": ["src/test-setup.ts"]
}
+28
View File
@@ -0,0 +1,28 @@
/// <reference types='vitest' />
import { defineConfig } from 'vite';
import angular from '@analogjs/vite-plugin-angular';
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
export default defineConfig(() => ({
root: __dirname,
cacheDir: '../../../node_modules/.vite/libs/shared/state',
plugins: [angular(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
// Uncomment this if you are using workers.
// worker: {
// plugins: () => [ nxViteTsPaths() ],
// },
test: {
name: 'shared-state',
watch: false,
globals: true,
environment: 'jsdom',
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
setupFiles: ['src/test-setup.ts'],
reporters: ['default'],
coverage: {
reportsDirectory: '../../../coverage/libs/shared/state',
provider: 'v8' as const,
},
},
}));