diff --git a/apps/portal-shell/src/app/components/header/header.html b/apps/portal-shell/src/app/components/header/header.html
index aac8858..e7693dc 100644
--- a/apps/portal-shell/src/app/components/header/header.html
+++ b/apps/portal-shell/src/app/components/header/header.html
@@ -29,7 +29,7 @@
-
+
-
+
-
+
{
beforeEach(async () => {
diff --git a/apps/portal-shell/src/app/components/header/header.ts b/apps/portal-shell/src/app/components/header/header.ts
index 05710ba..2240519 100644
--- a/apps/portal-shell/src/app/components/header/header.ts
+++ b/apps/portal-shell/src/app/components/header/header.ts
@@ -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.
diff --git a/apps/portal-shell/src/app/components/locale-switcher/locale-switcher.html b/apps/portal-shell/src/app/components/locale-switcher/locale-switcher.html
index ca49c7d..89de522 100644
--- a/apps/portal-shell/src/app/components/locale-switcher/locale-switcher.html
+++ b/apps/portal-shell/src/app/components/locale-switcher/locale-switcher.html
@@ -4,9 +4,9 @@
[cdkMenuTriggerFor]="localeMenu"
[attr.aria-label]="triggerAriaLabel"
>
-
+
{{ currentDisplayName }}
-
+
@@ -30,7 +30,7 @@
>
{{ locale.displayName }}
@if (locale.code === currentCode) {
-
+
}
}
diff --git a/apps/portal-shell/src/app/components/locale-switcher/locale-switcher.ts b/apps/portal-shell/src/app/components/locale-switcher/locale-switcher.ts
index 74e42e8..86563a7 100644
--- a/apps/portal-shell/src/app/components/locale-switcher/locale-switcher.ts
+++ b/apps/portal-shell/src/app/components/locale-switcher/locale-switcher.ts
@@ -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.
diff --git a/apps/portal-shell/src/app/components/sidebar/sidebar.html b/apps/portal-shell/src/app/components/sidebar/sidebar.html
index 6976d0f..257d22e 100644
--- a/apps/portal-shell/src/app/components/sidebar/sidebar.html
+++ b/apps/portal-shell/src/app/components/sidebar/sidebar.html
@@ -31,7 +31,7 @@
[attr.aria-label]="collapsed() ? item.label : null"
[title]="collapsed() ? item.label : ''"
>
-
+
@if (!collapsed()) {
}
@@ -43,7 +43,7 @@
[attr.aria-label]="collapsed() ? item.label : null"
[title]="collapsed() ? item.label : ''"
>
-
+
@if (!collapsed()) {
}
@@ -77,7 +77,7 @@
[attr.aria-expanded]="!collapsed()"
[attr.aria-label]="toggleAriaLabel()"
>
-
+
@if (!collapsed()) {
}
diff --git a/apps/portal-shell/src/app/components/sidebar/sidebar.ts b/apps/portal-shell/src/app/components/sidebar/sidebar.ts
index c1486aa..b495204 100644
--- a/apps/portal-shell/src/app/components/sidebar/sidebar.ts
+++ b/apps/portal-shell/src/app/components/sidebar/sidebar.ts
@@ -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.
diff --git a/apps/portal-shell/src/app/components/theme-switcher/theme-switcher.html b/apps/portal-shell/src/app/components/theme-switcher/theme-switcher.html
index 44c1335..c244318 100644
--- a/apps/portal-shell/src/app/components/theme-switcher/theme-switcher.html
+++ b/apps/portal-shell/src/app/components/theme-switcher/theme-switcher.html
@@ -4,7 +4,7 @@
[cdkMenuTriggerFor]="themeMenu"
[attr.aria-label]="triggerAriaLabel()"
>
-
+
@@ -25,10 +25,10 @@
role="menuitemradio"
(click)="select(option.mode)"
>
-
+
{{ option.label }}
@if (mode() === option.mode) {
-
+
}
}
diff --git a/apps/portal-shell/src/app/components/theme-switcher/theme-switcher.spec.ts b/apps/portal-shell/src/app/components/theme-switcher/theme-switcher.spec.ts
index 033170c..74f4aaf 100644
--- a/apps/portal-shell/src/app/components/theme-switcher/theme-switcher.spec.ts
+++ b/apps/portal-shell/src/app/components/theme-switcher/theme-switcher.spec.ts
@@ -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', () => {
diff --git a/apps/portal-shell/src/app/components/theme-switcher/theme-switcher.ts b/apps/portal-shell/src/app/components/theme-switcher/theme-switcher.ts
index 642741b..50e2ffb 100644
--- a/apps/portal-shell/src/app/components/theme-switcher/theme-switcher.ts
+++ b/apps/portal-shell/src/app/components/theme-switcher/theme-switcher.ts
@@ -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.
diff --git a/apps/portal-shell/src/styles.css b/apps/portal-shell/src/styles.css
index f94bca5..9713bed 100644
--- a/apps/portal-shell/src/styles.css
+++ b/apps/portal-shell/src/styles.css
@@ -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';
diff --git a/apps/portal-shell/tsconfig.app.json b/apps/portal-shell/tsconfig.app.json
index 2ee35fa..c951182 100644
--- a/apps/portal-shell/tsconfig.app.json
+++ b/apps/portal-shell/tsconfig.app.json
@@ -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"
+ }
+ ]
}
diff --git a/libs/shared/state/README.md b/libs/shared/state/README.md
new file mode 100644
index 0000000..52f8324
--- /dev/null
+++ b/libs/shared/state/README.md
@@ -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.
diff --git a/libs/shared/state/eslint.config.mjs b/libs/shared/state/eslint.config.mjs
new file mode 100644
index 0000000..8e41cc1
--- /dev/null
+++ b/libs/shared/state/eslint.config.mjs
@@ -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: {},
+ },
+];
diff --git a/libs/shared/state/project.json b/libs/shared/state/project.json
new file mode 100644
index 0000000..b56011b
--- /dev/null
+++ b/libs/shared/state/project.json
@@ -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"
+ }
+ }
+}
diff --git a/libs/shared/state/src/index.ts b/libs/shared/state/src/index.ts
new file mode 100644
index 0000000..6f50c4e
--- /dev/null
+++ b/libs/shared/state/src/index.ts
@@ -0,0 +1 @@
+export { LayoutStateService, type ThemeMode } from './lib/layout-state.service';
diff --git a/apps/portal-shell/src/app/state/layout-state.service.spec.ts b/libs/shared/state/src/lib/layout-state.service.spec.ts
similarity index 100%
rename from apps/portal-shell/src/app/state/layout-state.service.spec.ts
rename to libs/shared/state/src/lib/layout-state.service.spec.ts
diff --git a/apps/portal-shell/src/app/state/layout-state.service.ts b/libs/shared/state/src/lib/layout-state.service.ts
similarity index 100%
rename from apps/portal-shell/src/app/state/layout-state.service.ts
rename to libs/shared/state/src/lib/layout-state.service.ts
diff --git a/libs/shared/state/src/test-setup.ts b/libs/shared/state/src/test-setup.ts
new file mode 100644
index 0000000..17b7965
--- /dev/null
+++ b/libs/shared/state/src/test-setup.ts
@@ -0,0 +1,5 @@
+import '@angular/compiler';
+import '@analogjs/vitest-angular/setup-snapshots';
+import { setupTestBed } from '@analogjs/vitest-angular/setup-testbed';
+
+setupTestBed();
diff --git a/libs/shared/state/tsconfig.json b/libs/shared/state/tsconfig.json
new file mode 100644
index 0000000..116e088
--- /dev/null
+++ b/libs/shared/state/tsconfig.json
@@ -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"
+ }
+ ]
+}
diff --git a/libs/shared/state/tsconfig.lib.json b/libs/shared/state/tsconfig.lib.json
new file mode 100644
index 0000000..a9b98ba
--- /dev/null
+++ b/libs/shared/state/tsconfig.lib.json
@@ -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"
+ ]
+}
diff --git a/libs/shared/state/tsconfig.spec.json b/libs/shared/state/tsconfig.spec.json
new file mode 100644
index 0000000..39a8c3f
--- /dev/null
+++ b/libs/shared/state/tsconfig.spec.json
@@ -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"]
+}
diff --git a/libs/shared/state/vite.config.mts b/libs/shared/state/vite.config.mts
new file mode 100644
index 0000000..10136c8
--- /dev/null
+++ b/libs/shared/state/vite.config.mts
@@ -0,0 +1,28 @@
+///
+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,
+ },
+ },
+}));
diff --git a/libs/shared/tokens/src/brand-tokens.css b/libs/shared/tokens/src/brand-tokens.css
new file mode 100644
index 0000000..f02177e
--- /dev/null
+++ b/libs/shared/tokens/src/brand-tokens.css
@@ -0,0 +1,46 @@
+/*
+ * 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.
+ *
+ * Owned by `libs/shared/tokens` so every app in the workspace
+ * (`portal-shell`, `portal-admin`) reads from the same source. To
+ * consume, `@import` this file from the app's entry stylesheet.
+ */
+@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;
+}
diff --git a/libs/shared/tokens/src/index.ts b/libs/shared/tokens/src/index.ts
index 4915f95..6e38c18 100644
--- a/libs/shared/tokens/src/index.ts
+++ b/libs/shared/tokens/src/index.ts
@@ -1 +1,7 @@
-export * from './lib/shared-tokens';
+// `shared-tokens` is currently CSS-only — see `./brand-tokens.css`,
+// which apps import from their entry stylesheet (e.g.
+// `apps/portal-shell/src/styles.css`). The TS module exists so the
+// Nx lib boilerplate / package layout stays uniform with the other
+// shared libs; expand when a typed token API (e.g. CSS-variable
+// name constants for runtime lookups) is genuinely needed.
+export {};
diff --git a/libs/shared/tokens/src/lib/shared-tokens.spec.ts b/libs/shared/tokens/src/lib/shared-tokens.spec.ts
deleted file mode 100644
index b9fc896..0000000
--- a/libs/shared/tokens/src/lib/shared-tokens.spec.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import { sharedTokens } from './shared-tokens';
-
-describe('sharedTokens', () => {
- it('should work', () => {
- expect(sharedTokens()).toEqual('shared-tokens');
- });
-});
diff --git a/libs/shared/tokens/src/lib/shared-tokens.ts b/libs/shared/tokens/src/lib/shared-tokens.ts
deleted file mode 100644
index 2e2c8c6..0000000
--- a/libs/shared/tokens/src/lib/shared-tokens.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export function sharedTokens(): string {
- return 'shared-tokens';
-}
diff --git a/libs/shared/tokens/vitest.config.mts b/libs/shared/tokens/vitest.config.mts
index e44534d..81c8506 100644
--- a/libs/shared/tokens/vitest.config.mts
+++ b/libs/shared/tokens/vitest.config.mts
@@ -12,6 +12,9 @@ export default defineConfig(() => ({
globals: true,
environment: 'node',
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
+ // CSS-only library — no TS to test yet. Pass instead of failing
+ // the gate; revisit when a typed token API lands.
+ passWithNoTests: true,
reporters: ['default'],
coverage: {
reportsDirectory: '../../../coverage/libs/shared/tokens',
diff --git a/libs/shared/ui/project.json b/libs/shared/ui/project.json
index 3f44d25..803c896 100644
--- a/libs/shared/ui/project.json
+++ b/libs/shared/ui/project.json
@@ -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"
diff --git a/libs/shared/ui/src/index.ts b/libs/shared/ui/src/index.ts
index 59dee3e..e648a49 100644
--- a/libs/shared/ui/src/index.ts
+++ b/libs/shared/ui/src/index.ts
@@ -1 +1 @@
-export * from './lib/shared-ui/shared-ui';
+export { Icon, type IconName } from './lib/icon/icon';
diff --git a/apps/portal-shell/src/app/components/icon/icon.spec.ts b/libs/shared/ui/src/lib/icon/icon.spec.ts
similarity index 85%
rename from apps/portal-shell/src/app/components/icon/icon.spec.ts
rename to libs/shared/ui/src/lib/icon/icon.spec.ts
index fa8f1de..b59bed9 100644
--- a/apps/portal-shell/src/app/components/icon/icon.spec.ts
+++ b/libs/shared/ui/src/lib/icon/icon.spec.ts
@@ -3,9 +3,9 @@ import { TestBed } from '@angular/core/testing';
import { Icon } from './icon';
@Component({
- selector: 'app-icon-test-host',
+ selector: 'lib-icon-test-host',
imports: [Icon],
- template: ``,
+ template: ``,
})
class IconTestHost {
name: 'home' | 'bell' = 'home';
@@ -21,7 +21,7 @@ describe('Icon', () => {
it('renders a lucide