refactor(libs): graduate Icon / LayoutStateService / brand tokens to libs/shared/* #99
@@ -29,7 +29,7 @@
|
|||||||
<div
|
<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"
|
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
|
<input
|
||||||
id="global-search"
|
id="global-search"
|
||||||
type="search"
|
type="search"
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
i18n-aria-label="@@header.action.notifications"
|
i18n-aria-label="@@header.action.notifications"
|
||||||
aria-label="Notifications"
|
aria-label="Notifications"
|
||||||
>
|
>
|
||||||
<app-icon name="bell" />
|
<lib-icon name="bell" />
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
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"
|
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"
|
i18n-aria-label="@@header.action.help"
|
||||||
aria-label="Help"
|
aria-label="Help"
|
||||||
>
|
>
|
||||||
<app-icon name="circle-help" />
|
<lib-icon name="circle-help" />
|
||||||
</button>
|
</button>
|
||||||
<app-theme-switcher />
|
<app-theme-switcher />
|
||||||
<button
|
<button
|
||||||
@@ -68,7 +68,7 @@
|
|||||||
i18n-aria-label="@@header.action.settings"
|
i18n-aria-label="@@header.action.settings"
|
||||||
aria-label="Settings"
|
aria-label="Settings"
|
||||||
>
|
>
|
||||||
<app-icon name="settings" />
|
<lib-icon name="settings" />
|
||||||
</button>
|
</button>
|
||||||
<span
|
<span
|
||||||
i18n-aria-label="@@header.action.userMenu"
|
i18n-aria-label="@@header.action.userMenu"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter } from '@angular/router';
|
||||||
import { Header } from './header';
|
import { Header } from './header';
|
||||||
import { LayoutStateService } from '../../state/layout-state.service';
|
import { LayoutStateService } from 'shared-state';
|
||||||
|
|
||||||
describe('Header', () => {
|
describe('Header', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||||
import { RouterLink } from '@angular/router';
|
import { RouterLink } from '@angular/router';
|
||||||
import { Icon } from '../icon/icon';
|
import { Icon } from 'shared-ui';
|
||||||
import { ThemeSwitcher } from '../theme-switcher/theme-switcher';
|
import { ThemeSwitcher } from '../theme-switcher/theme-switcher';
|
||||||
import { LayoutStateService } from '../../state/layout-state.service';
|
import { LayoutStateService } from 'shared-state';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Top-of-page banner.
|
* Top-of-page banner.
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
[cdkMenuTriggerFor]="localeMenu"
|
[cdkMenuTriggerFor]="localeMenu"
|
||||||
[attr.aria-label]="triggerAriaLabel"
|
[attr.aria-label]="triggerAriaLabel"
|
||||||
>
|
>
|
||||||
<app-icon name="globe" [size]="14" />
|
<lib-icon name="globe" [size]="14" />
|
||||||
<span>{{ currentDisplayName }}</span>
|
<span>{{ currentDisplayName }}</span>
|
||||||
<app-icon name="chevron-down" [size]="12" />
|
<lib-icon name="chevron-down" [size]="12" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<ng-template #localeMenu>
|
<ng-template #localeMenu>
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
>
|
>
|
||||||
<span class="locale-switcher__label">{{ locale.displayName }}</span>
|
<span class="locale-switcher__label">{{ locale.displayName }}</span>
|
||||||
@if (locale.code === currentCode) {
|
@if (locale.code === currentCode) {
|
||||||
<app-icon name="check" [size]="16" />
|
<lib-icon name="check" [size]="16" />
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { DOCUMENT } from '@angular/common';
|
import { DOCUMENT } from '@angular/common';
|
||||||
import { CdkMenu, CdkMenuItem, CdkMenuTrigger } from '@angular/cdk/menu';
|
import { CdkMenu, CdkMenuItem, CdkMenuTrigger } from '@angular/cdk/menu';
|
||||||
import { ChangeDetectionStrategy, Component, ViewEncapsulation, inject } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, ViewEncapsulation, inject } from '@angular/core';
|
||||||
import { Icon } from '../icon/icon';
|
import { Icon } from 'shared-ui';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Locale switcher rendered in the footer.
|
* Locale switcher rendered in the footer.
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
[attr.aria-label]="collapsed() ? item.label : null"
|
[attr.aria-label]="collapsed() ? item.label : null"
|
||||||
[title]="collapsed() ? item.label : ''"
|
[title]="collapsed() ? item.label : ''"
|
||||||
>
|
>
|
||||||
<app-icon [name]="item.icon" />
|
<lib-icon [name]="item.icon" />
|
||||||
@if (!collapsed()) {
|
@if (!collapsed()) {
|
||||||
<span class="sidebar__label">{{ item.label }}</span>
|
<span class="sidebar__label">{{ item.label }}</span>
|
||||||
}
|
}
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
[attr.aria-label]="collapsed() ? item.label : null"
|
[attr.aria-label]="collapsed() ? item.label : null"
|
||||||
[title]="collapsed() ? item.label : ''"
|
[title]="collapsed() ? item.label : ''"
|
||||||
>
|
>
|
||||||
<app-icon [name]="item.icon" />
|
<lib-icon [name]="item.icon" />
|
||||||
@if (!collapsed()) {
|
@if (!collapsed()) {
|
||||||
<span class="sidebar__label">{{ item.label }}</span>
|
<span class="sidebar__label">{{ item.label }}</span>
|
||||||
}
|
}
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
[attr.aria-expanded]="!collapsed()"
|
[attr.aria-expanded]="!collapsed()"
|
||||||
[attr.aria-label]="toggleAriaLabel()"
|
[attr.aria-label]="toggleAriaLabel()"
|
||||||
>
|
>
|
||||||
<app-icon [name]="collapsed() ? 'chevron-right' : 'chevron-left'" />
|
<lib-icon [name]="collapsed() ? 'chevron-right' : 'chevron-left'" />
|
||||||
@if (!collapsed()) {
|
@if (!collapsed()) {
|
||||||
<span class="sidebar__label" i18n="@@sidebar.toggle.collapse">Collapse</span>
|
<span class="sidebar__label" i18n="@@sidebar.toggle.collapse">Collapse</span>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
|
||||||
import { RouterLink, RouterLinkActive } from '@angular/router';
|
import { RouterLink, RouterLinkActive } from '@angular/router';
|
||||||
import { Icon, type IconName } from '../icon/icon';
|
import { Icon, type IconName } from 'shared-ui';
|
||||||
import { LayoutStateService } from '../../state/layout-state.service';
|
import { LayoutStateService } from 'shared-state';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Primary side navigation.
|
* Primary side navigation.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
[cdkMenuTriggerFor]="themeMenu"
|
[cdkMenuTriggerFor]="themeMenu"
|
||||||
[attr.aria-label]="triggerAriaLabel()"
|
[attr.aria-label]="triggerAriaLabel()"
|
||||||
>
|
>
|
||||||
<app-icon [name]="currentIcon()" />
|
<lib-icon [name]="currentIcon()" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<ng-template #themeMenu>
|
<ng-template #themeMenu>
|
||||||
@@ -25,10 +25,10 @@
|
|||||||
role="menuitemradio"
|
role="menuitemradio"
|
||||||
(click)="select(option.mode)"
|
(click)="select(option.mode)"
|
||||||
>
|
>
|
||||||
<app-icon [name]="option.icon" />
|
<lib-icon [name]="option.icon" />
|
||||||
<span class="theme-switcher__label">{{ option.label }}</span>
|
<span class="theme-switcher__label">{{ option.label }}</span>
|
||||||
@if (mode() === option.mode) {
|
@if (mode() === option.mode) {
|
||||||
<app-icon name="check" [size]="16" />
|
<lib-icon name="check" [size]="16" />
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { LayoutStateService } from '../../state/layout-state.service';
|
import { LayoutStateService } from 'shared-state';
|
||||||
import { ThemeSwitcher } from './theme-switcher';
|
import { ThemeSwitcher } from './theme-switcher';
|
||||||
|
|
||||||
describe('ThemeSwitcher', () => {
|
describe('ThemeSwitcher', () => {
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import {
|
|||||||
computed,
|
computed,
|
||||||
inject,
|
inject,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { Icon, type IconName } from '../icon/icon';
|
import { Icon, type IconName } from 'shared-ui';
|
||||||
import { LayoutStateService, type ThemeMode } from '../../state/layout-state.service';
|
import { LayoutStateService, type ThemeMode } from 'shared-state';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Light / dark / auto color-scheme switcher rendered in the header.
|
* Light / dark / auto color-scheme switcher rendered in the header.
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
/*
|
/*
|
||||||
* Global styles for portal-shell.
|
* Global styles for portal-shell.
|
||||||
*
|
*
|
||||||
* Tailwind CSS 4 (CSS-first config). Theme tokens (palette, contrast tiers,
|
* Tailwind CSS 4 (CSS-first config). The brand palette lives in
|
||||||
* spacing, motion) will be defined under @theme via the libs/shared/tokens
|
* `libs/shared/tokens/src/brand-tokens.css` so every app in the
|
||||||
* library. spartan-ng components copy-pasted into libs/shared/ui will read
|
* workspace (`portal-shell`, `portal-admin`) reads the same source.
|
||||||
* from these tokens.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
@@ -19,45 +18,5 @@
|
|||||||
*/
|
*/
|
||||||
@custom-variant dark (&:where(.dark, .dark *));
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
/*
|
/* Brand palette tokens — shared with `portal-admin` once it ships. */
|
||||||
* Brand palette — APF charte graphique.
|
@import '../../../libs/shared/tokens/src/brand-tokens.css';
|
||||||
*
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,5 +5,13 @@
|
|||||||
"types": ["@angular/localize"]
|
"types": ["@angular/localize"]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"],
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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: {},
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { LayoutStateService, type ThemeMode } from './lib/layout-state.service';
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import '@angular/compiler';
|
||||||
|
import '@analogjs/vitest-angular/setup-snapshots';
|
||||||
|
import { setupTestBed } from '@analogjs/vitest-angular/setup-testbed';
|
||||||
|
|
||||||
|
setupTestBed();
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 {};
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
import { sharedTokens } from './shared-tokens';
|
|
||||||
|
|
||||||
describe('sharedTokens', () => {
|
|
||||||
it('should work', () => {
|
|
||||||
expect(sharedTokens()).toEqual('shared-tokens');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export function sharedTokens(): string {
|
|
||||||
return 'shared-tokens';
|
|
||||||
}
|
|
||||||
@@ -12,6 +12,9 @@ export default defineConfig(() => ({
|
|||||||
globals: true,
|
globals: true,
|
||||||
environment: 'node',
|
environment: 'node',
|
||||||
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
|
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'],
|
reporters: ['default'],
|
||||||
coverage: {
|
coverage: {
|
||||||
reportsDirectory: '../../../coverage/libs/shared/tokens',
|
reportsDirectory: '../../../coverage/libs/shared/tokens',
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"sourceRoot": "libs/shared/ui/src",
|
"sourceRoot": "libs/shared/ui/src",
|
||||||
"prefix": "lib",
|
"prefix": "lib",
|
||||||
"projectType": "library",
|
"projectType": "library",
|
||||||
"tags": ["scope:portal-shell", "type:shared"],
|
"tags": ["scope:shared", "type:shared"],
|
||||||
"targets": {
|
"targets": {
|
||||||
"lint": {
|
"lint": {
|
||||||
"executor": "@nx/eslint:lint"
|
"executor": "@nx/eslint:lint"
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export * from './lib/shared-ui/shared-ui';
|
export { Icon, type IconName } from './lib/icon/icon';
|
||||||
|
|||||||
+5
-5
@@ -3,9 +3,9 @@ import { TestBed } from '@angular/core/testing';
|
|||||||
import { Icon } from './icon';
|
import { Icon } from './icon';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-icon-test-host',
|
selector: 'lib-icon-test-host',
|
||||||
imports: [Icon],
|
imports: [Icon],
|
||||||
template: `<app-icon [name]="name" [size]="size" [strokeWidth]="strokeWidth" />`,
|
template: `<lib-icon [name]="name" [size]="size" [strokeWidth]="strokeWidth" />`,
|
||||||
})
|
})
|
||||||
class IconTestHost {
|
class IconTestHost {
|
||||||
name: 'home' | 'bell' = 'home';
|
name: 'home' | 'bell' = 'home';
|
||||||
@@ -21,7 +21,7 @@ describe('Icon', () => {
|
|||||||
it('renders a lucide <svg> for the given name', async () => {
|
it('renders a lucide <svg> for the given name', async () => {
|
||||||
const fixture = TestBed.createComponent(IconTestHost);
|
const fixture = TestBed.createComponent(IconTestHost);
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
const svg = fixture.nativeElement.querySelector('app-icon svg') as SVGElement | null;
|
const svg = fixture.nativeElement.querySelector('lib-icon svg') as SVGElement | null;
|
||||||
expect(svg).not.toBeNull();
|
expect(svg).not.toBeNull();
|
||||||
expect(svg?.classList.contains('lucide')).toBe(true);
|
expect(svg?.classList.contains('lucide')).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -30,7 +30,7 @@ describe('Icon', () => {
|
|||||||
const fixture = TestBed.createComponent(IconTestHost);
|
const fixture = TestBed.createComponent(IconTestHost);
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
const host = fixture.nativeElement.querySelector(
|
const host = fixture.nativeElement.querySelector(
|
||||||
'app-icon lucide-angular',
|
'lib-icon lucide-angular',
|
||||||
) as HTMLElement | null;
|
) as HTMLElement | null;
|
||||||
expect(host?.getAttribute('aria-hidden')).toBe('true');
|
expect(host?.getAttribute('aria-hidden')).toBe('true');
|
||||||
});
|
});
|
||||||
@@ -41,7 +41,7 @@ describe('Icon', () => {
|
|||||||
fixture.componentInstance.strokeWidth = 2;
|
fixture.componentInstance.strokeWidth = 2;
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
const svg = fixture.nativeElement.querySelector('app-icon svg') as SVGElement | null;
|
const svg = fixture.nativeElement.querySelector('lib-icon svg') as SVGElement | null;
|
||||||
expect(svg?.getAttribute('width')).toBe('32');
|
expect(svg?.getAttribute('width')).toBe('32');
|
||||||
expect(svg?.getAttribute('height')).toBe('32');
|
expect(svg?.getAttribute('height')).toBe('32');
|
||||||
expect(svg?.getAttribute('stroke-width')).toBe('2');
|
expect(svg?.getAttribute('stroke-width')).toBe('2');
|
||||||
+6
-6
@@ -29,19 +29,19 @@ import {
|
|||||||
} from 'lucide-angular';
|
} from 'lucide-angular';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Icon façade for the SPA.
|
* 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
|
* v1 is backed by `lucide-angular`. The migration to an in-house
|
||||||
* icomoon-generated set (sprite SVG under
|
* icomoon-generated set (sprite SVG) is a single-file change to
|
||||||
* `apps/portal-shell/public/icons/`) is a single-file change to
|
* THIS component — every consumer just writes `<lib-icon name="…">`,
|
||||||
* THIS component — every consumer just writes `<app-icon name="…">`,
|
|
||||||
* so swapping the implementation never touches them.
|
* so swapping the implementation never touches them.
|
||||||
*
|
*
|
||||||
* Convention: `name` is the lowercase, kebab-cased "logical" name we
|
* Convention: `name` is the lowercase, kebab-cased "logical" name we
|
||||||
* will keep across implementations (`home`, `bar-chart`, etc.). The
|
* will keep across implementations (`home`, `bar-chart`, etc.). The
|
||||||
* registry below maps each logical name to its lucide-angular icon
|
* registry below maps each logical name to its lucide-angular icon
|
||||||
* data. When the icomoon export lands, each name will instead map to
|
* data. When the icomoon export lands, each name will instead map to
|
||||||
* a `<symbol id="icon-…">` in the sprite — but the template-side
|
* a `<symbol id="icon-…">` in the sprite — the template-side
|
||||||
* contract stays identical.
|
* contract stays identical.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ const ICON_REGISTRY = {
|
|||||||
export type IconName = keyof typeof ICON_REGISTRY;
|
export type IconName = keyof typeof ICON_REGISTRY;
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-icon',
|
selector: 'lib-icon',
|
||||||
imports: [LucideAngularModule],
|
imports: [LucideAngularModule],
|
||||||
template: `<lucide-angular
|
template: `<lucide-angular
|
||||||
[img]="icon()"
|
[img]="icon()"
|
||||||
@@ -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 {}
|
|
||||||
+2
-1
@@ -6,7 +6,8 @@
|
|||||||
"shared-tokens": ["./libs/shared/tokens/src/index.ts"],
|
"shared-tokens": ["./libs/shared/tokens/src/index.ts"],
|
||||||
"shared-util": ["./libs/shared/util/src/index.ts"],
|
"shared-util": ["./libs/shared/util/src/index.ts"],
|
||||||
"shared-ui": ["./libs/shared/ui/src/index.ts"],
|
"shared-ui": ["./libs/shared/ui/src/index.ts"],
|
||||||
"feature-auth": ["./libs/feature/auth/src/index.ts"]
|
"feature-auth": ["./libs/feature/auth/src/index.ts"],
|
||||||
|
"shared-state": ["./libs/shared/state/src/index.ts"]
|
||||||
},
|
},
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"declaration": false,
|
"declaration": false,
|
||||||
|
|||||||
Reference in New Issue
Block a user