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
+1 -1
View File
@@ -4,7 +4,7 @@
"sourceRoot": "libs/shared/ui/src",
"prefix": "lib",
"projectType": "library",
"tags": ["scope:portal-shell", "type:shared"],
"tags": ["scope:shared", "type:shared"],
"targets": {
"lint": {
"executor": "@nx/eslint:lint"
+1 -1
View File
@@ -1 +1 @@
export * from './lib/shared-ui/shared-ui';
export { Icon, type IconName } from './lib/icon/icon';
+49
View File
@@ -0,0 +1,49 @@
import { Component } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Icon } from './icon';
@Component({
selector: 'lib-icon-test-host',
imports: [Icon],
template: `<lib-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('lib-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(
'lib-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('lib-icon svg') as SVGElement | null;
expect(svg?.getAttribute('width')).toBe('32');
expect(svg?.getAttribute('height')).toBe('32');
expect(svg?.getAttribute('stroke-width')).toBe('2');
});
});
+100
View File
@@ -0,0 +1,100 @@
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 — single point of truth for the icon set across every
* app in the workspace (`portal-shell` today, `portal-admin` next).
*
* v1 is backed by `lucide-angular`. The migration to an in-house
* icomoon-generated set (sprite SVG) is a single-file change to
* THIS component — every consumer just writes `<lib-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 — 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: 'lib-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()]);
}
@@ -1 +0,0 @@
<p>SharedUi works!</p>
@@ -1,21 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { SharedUi } from './shared-ui';
describe('SharedUi', () => {
let component: SharedUi;
let fixture: ComponentFixture<SharedUi>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [SharedUi],
}).compileComponents();
fixture = TestBed.createComponent(SharedUi);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -1,9 +0,0 @@
import { Component } from '@angular/core';
@Component({
selector: 'lib-shared-ui',
imports: [],
templateUrl: './shared-ui.html',
styleUrl: './shared-ui.css',
})
export class SharedUi {}