diff --git a/apps/portal-shell/public/favicon.ico b/apps/portal-shell/public/favicon.ico index 317ebcb..696fec8 100644 Binary files a/apps/portal-shell/public/favicon.ico and b/apps/portal-shell/public/favicon.ico differ diff --git a/apps/portal-shell/public/logos/apf-portal.svg b/apps/portal-shell/public/logos/apf-portal.svg new file mode 100644 index 0000000..a0516bd --- /dev/null +++ b/apps/portal-shell/public/logos/apf-portal.svg @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/apps/portal-shell/src/app/app.html b/apps/portal-shell/src/app/app.html index 9467553..bd16644 100644 --- a/apps/portal-shell/src/app/app.html +++ b/apps/portal-shell/src/app/app.html @@ -1,6 +1,8 @@ -
- -
- +
+ +
+ +
+
diff --git a/apps/portal-shell/src/app/app.scss b/apps/portal-shell/src/app/app.scss index cee7709..d8810e0 100644 --- a/apps/portal-shell/src/app/app.scss +++ b/apps/portal-shell/src/app/app.scss @@ -1,10 +1,26 @@ -// Page-level layout: header + main + footer in a flex column so the -// footer sticks to the viewport bottom even when the main content is -// short. Driven on the host so it covers the whole document. +// App shell: fixed-height header on top, sidebar + main beside each +// other below, both filling the remaining viewport height. The sidebar +// owns its own scrolling (long menus don't push the header off-screen); +// the main pane scrolls independently so opening a long page never +// scrolls the sidebar out of view. :host { display: flex; flex-direction: column; min-height: 100vh; + height: 100vh; +} + +.shell-body { + display: flex; + flex: 1 1 auto; + min-height: 0; +} + +.shell-main { + flex: 1 1 auto; + min-width: 0; + overflow-y: auto; + background-color: #f9fafb; } // Skip-link (WCAG 2.4.1 "Bypass Blocks"). Hidden by default, fully @@ -17,7 +33,7 @@ left: 0.75rem; z-index: 50; padding: 0.5rem 0.75rem; - background: #1d4ed8; + background: var(--color-brand-primary-500); color: #fff; border-radius: 0.25rem; text-decoration: none; @@ -26,7 +42,7 @@ &:focus, &:focus-visible { top: 0.5rem; - outline: 2px solid #93c5fd; + outline: 2px solid var(--color-brand-accent-300); outline-offset: 2px; } } diff --git a/apps/portal-shell/src/app/app.spec.ts b/apps/portal-shell/src/app/app.spec.ts index b5e71e6..1e3cd49 100644 --- a/apps/portal-shell/src/app/app.spec.ts +++ b/apps/portal-shell/src/app/app.spec.ts @@ -10,13 +10,13 @@ describe('App', () => { }).compileComponents(); }); - it('renders the layout shell — skip link, header, main, footer', async () => { + it('renders the layout shell — skip link, header, sidebar, main', async () => { const fixture = TestBed.createComponent(App); await fixture.whenStable(); const root = fixture.nativeElement as HTMLElement; expect(root.querySelector('a.skip-link')).not.toBeNull(); expect(root.querySelector('app-header')).not.toBeNull(); + expect(root.querySelector('app-sidebar')).not.toBeNull(); expect(root.querySelector('main#main-content')).not.toBeNull(); - expect(root.querySelector('app-footer')).not.toBeNull(); }); }); diff --git a/apps/portal-shell/src/app/app.ts b/apps/portal-shell/src/app/app.ts index 5617ede..5f82fdd 100644 --- a/apps/portal-shell/src/app/app.ts +++ b/apps/portal-shell/src/app/app.ts @@ -1,11 +1,11 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; import { RouterOutlet } from '@angular/router'; import { Header } from './components/header/header'; -import { Footer } from './components/footer/footer'; +import { Sidebar } from './components/sidebar/sidebar'; @Component({ selector: 'app-root', - imports: [RouterOutlet, Header, Footer], + imports: [RouterOutlet, Header, Sidebar], templateUrl: './app.html', styleUrl: './app.scss', changeDetection: ChangeDetectionStrategy.OnPush, diff --git a/apps/portal-shell/src/app/components/footer/footer.html b/apps/portal-shell/src/app/components/footer/footer.html deleted file mode 100644 index f8ea508..0000000 --- a/apps/portal-shell/src/app/components/footer/footer.html +++ /dev/null @@ -1,25 +0,0 @@ - diff --git a/apps/portal-shell/src/app/components/footer/footer.spec.ts b/apps/portal-shell/src/app/components/footer/footer.spec.ts deleted file mode 100644 index abffa02..0000000 --- a/apps/portal-shell/src/app/components/footer/footer.spec.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { TestBed } from '@angular/core/testing'; -import { provideRouter } from '@angular/router'; -import { Footer } from './footer'; - -describe('Footer', () => { - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [Footer], - providers: [provideRouter([])], - }).compileComponents(); - }); - - it('renders both accessibility statement links (FR + EN)', async () => { - const fixture = TestBed.createComponent(Footer); - await fixture.whenStable(); - const root = fixture.nativeElement as HTMLElement; - expect(root.querySelector('a[href="/accessibility"]')).not.toBeNull(); - expect(root.querySelector('a[href="/accessibilite"]')).not.toBeNull(); - }); - - it('shows the version badge', async () => { - const fixture = TestBed.createComponent(Footer); - await fixture.whenStable(); - const text = (fixture.nativeElement as HTMLElement).textContent ?? ''; - expect(text).toContain('vdev'); - }); -}); diff --git a/apps/portal-shell/src/app/components/footer/footer.ts b/apps/portal-shell/src/app/components/footer/footer.ts deleted file mode 100644 index 91db109..0000000 --- a/apps/portal-shell/src/app/components/footer/footer.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; -import { RouterLink } from '@angular/router'; - -/** - * Bottom-of-page utility links. v1 ships: - * - Accessibility statement link (RGAA-mandated, both locales) per - * ADR-0016. - * - Build/version badge for support triage. - * - * Same app-level placement as the header — promotion to - * libs/shared/ui/ when a second consumer materialises. - */ -@Component({ - selector: 'app-footer', - imports: [RouterLink], - templateUrl: './footer.html', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class Footer { - // Hard-coded for now; swapped to a build-time injection once the - // env-config story (Angular `environment.ts`) lands. - protected readonly version = 'dev'; -} diff --git a/apps/portal-shell/src/app/components/header/header.html b/apps/portal-shell/src/app/components/header/header.html index 100ec4b..e8a886f 100644 --- a/apps/portal-shell/src/app/components/header/header.html +++ b/apps/portal-shell/src/app/components/header/header.html @@ -1,11 +1,65 @@ -
-
- + + + APF Portal + + +
+ + +
diff --git a/apps/portal-shell/src/app/components/header/header.spec.ts b/apps/portal-shell/src/app/components/header/header.spec.ts index b49c058..1054dd3 100644 --- a/apps/portal-shell/src/app/components/header/header.spec.ts +++ b/apps/portal-shell/src/app/components/header/header.spec.ts @@ -15,7 +15,7 @@ describe('Header', () => { await fixture.whenStable(); const link = fixture.nativeElement.querySelector('a[href="/"]') as HTMLAnchorElement | null; expect(link).not.toBeNull(); - expect(link?.textContent?.trim()).toBe('APF Portal'); + expect(link?.textContent?.trim()).toContain('APF Portal'); }); it('exposes a primary navigation landmark', async () => { @@ -24,4 +24,25 @@ describe('Header', () => { const nav = fixture.nativeElement.querySelector('nav[aria-label="Primary"]'); expect(nav).not.toBeNull(); }); + + it('exposes a search landmark with a labelled input', async () => { + const fixture = TestBed.createComponent(Header); + await fixture.whenStable(); + const root = fixture.nativeElement as HTMLElement; + expect(root.querySelector('form[role="search"]')).not.toBeNull(); + const input = root.querySelector('input#global-search') as HTMLInputElement | null; + expect(input).not.toBeNull(); + expect(input?.type).toBe('search'); + const label = root.querySelector('label[for="global-search"]'); + expect(label).not.toBeNull(); + }); + + it('renders the notifications / help / settings action buttons', async () => { + const fixture = TestBed.createComponent(Header); + await fixture.whenStable(); + const root = fixture.nativeElement as HTMLElement; + expect(root.querySelector('button[aria-label="Notifications"]')).not.toBeNull(); + expect(root.querySelector('button[aria-label="Help"]')).not.toBeNull(); + expect(root.querySelector('button[aria-label="Settings"]')).not.toBeNull(); + }); }); diff --git a/apps/portal-shell/src/app/components/header/header.ts b/apps/portal-shell/src/app/components/header/header.ts index 7588b85..cfcadae 100644 --- a/apps/portal-shell/src/app/components/header/header.ts +++ b/apps/portal-shell/src/app/components/header/header.ts @@ -1,18 +1,24 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; import { RouterLink } from '@angular/router'; +import { Icon } from '../icon/icon'; /** - * Top-of-page banner. v1 holds only the brand link to "/" and the - * primary navigation landmark — the live navigation grows once we - * have real authenticated pages (per ADR-0009). + * Top-of-page banner. * - * Lives at app level (not in libs/shared/ui/) on purpose: there is - * one consumer (portal-shell). Promotion to the shared lib happens - * when a second app needs it. + * v1 layout (left → right): + * - Brand: APF logo + portal name, linking to "/". + * - Global search input (placeholder; wired to a real search service + * once we have content to query). + * - Action cluster: notifications, help, settings, and an avatar + * placeholder. Real menus are added once the auth flow lands + * (ADR-0009). + * + * Lives at app level (not in libs/shared/ui/) on purpose: one consumer + * for now. Promotion when a second app needs it. */ @Component({ selector: 'app-header', - imports: [RouterLink], + imports: [RouterLink, Icon], templateUrl: './header.html', changeDetection: ChangeDetectionStrategy.OnPush, }) diff --git a/apps/portal-shell/src/app/components/icon/icon.spec.ts b/apps/portal-shell/src/app/components/icon/icon.spec.ts new file mode 100644 index 0000000..fa8f1de --- /dev/null +++ b/apps/portal-shell/src/app/components/icon/icon.spec.ts @@ -0,0 +1,49 @@ +import { Component } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { Icon } from './icon'; + +@Component({ + selector: 'app-icon-test-host', + imports: [Icon], + template: ``, +}) +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 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 ', 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'); + }); +}); diff --git a/apps/portal-shell/src/app/components/icon/icon.ts b/apps/portal-shell/src/app/components/icon/icon.ts new file mode 100644 index 0000000..afabafc --- /dev/null +++ b/apps/portal-shell/src/app/components/icon/icon.ts @@ -0,0 +1,88 @@ +import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; +import { + Accessibility, + Bell, + Building2, + Calendar, + ChevronLeft, + ChevronRight, + CircleHelp, + FileText, + Folder, + Grid2x2, + House, + LayoutDashboard, + type LucideIconData, + LucideAngularModule, + Mail, + Search, + Settings, + Store, + 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 ``, + * 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 `` 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, + 'chevron-left': ChevronLeft, + 'chevron-right': ChevronRight, + 'circle-help': CircleHelp, + 'file-text': FileText, + folder: Folder, + 'grid-2x2': Grid2x2, + home: House, + 'layout-dashboard': LayoutDashboard, + mail: Mail, + search: Search, + settings: Settings, + store: Store, + 'users-round': UsersRound, + wrench: Wrench, +} satisfies Record; + +export type IconName = keyof typeof ICON_REGISTRY; + +@Component({ + selector: 'app-icon', + imports: [LucideAngularModule], + template: ``, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class Icon { + readonly name = input.required(); + readonly size = input(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(1.5); + + protected readonly icon = computed(() => ICON_REGISTRY[this.name()]); +} diff --git a/apps/portal-shell/src/app/components/sidebar/sidebar.html b/apps/portal-shell/src/app/components/sidebar/sidebar.html new file mode 100644 index 0000000..cef3013 --- /dev/null +++ b/apps/portal-shell/src/app/components/sidebar/sidebar.html @@ -0,0 +1,109 @@ +
+ @if (!collapsed()) { +

+ {{ group.label }} +

+ } + +
+ } + + +
+ + + @if (!collapsed()) { +

+ Role: Anonymous +

+ } + + +
+ diff --git a/apps/portal-shell/src/app/components/sidebar/sidebar.scss b/apps/portal-shell/src/app/components/sidebar/sidebar.scss new file mode 100644 index 0000000..4f8dece --- /dev/null +++ b/apps/portal-shell/src/app/components/sidebar/sidebar.scss @@ -0,0 +1,81 @@ +// Sidebar widths are tokens-of-tokens: 16rem expanded gives enough +// room for two-word labels in both English and French; 4rem collapsed +// matches the 44px+ touch-target minimum from ADR-0016 with breathing +// room on each side. The transition is short and animation-friendly — +// users with `prefers-reduced-motion: reduce` get the no-animation +// branch below. +.sidebar { + width: 16rem; + transition: width 0.18s ease-out; + + &--collapsed { + width: 4rem; + } +} + +@media (prefers-reduced-motion: reduce) { + .sidebar { + transition: none; + } +} + +// Link + toggle share the same row geometry so transitions between +// expanded/collapsed don't reflow neighbouring items. Tailwind utility +// classes would also work; an SCSS rule keeps the template terse and +// the hover/focus/active variants colocated. +.sidebar__link, +.sidebar__toggle { + display: flex; + align-items: center; + gap: 0.75rem; + width: 100%; + min-height: 2.75rem; + padding: 0.5rem 0.75rem; + border-radius: 0.5rem; + color: #374151; + font-size: 0.875rem; + font-weight: 500; + text-decoration: none; + background: transparent; + border: 0; + cursor: pointer; + text-align: left; + transition: + background-color 0.12s ease-out, + color 0.12s ease-out; + + &:hover { + background-color: #f3f4f6; + color: var(--color-brand-primary-500); + } + + &:focus-visible { + outline: 2px solid var(--color-brand-primary-500); + outline-offset: 2px; + } +} + +.sidebar__link--active { + background-color: var(--color-brand-primary-500); + color: #fff; + + &:hover { + background-color: var(--color-brand-primary-600); + color: #fff; + } +} + +.sidebar__label { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.sidebar--collapsed { + .sidebar__link, + .sidebar__toggle { + justify-content: center; + padding-left: 0.5rem; + padding-right: 0.5rem; + } +} diff --git a/apps/portal-shell/src/app/components/sidebar/sidebar.spec.ts b/apps/portal-shell/src/app/components/sidebar/sidebar.spec.ts new file mode 100644 index 0000000..11bc051 --- /dev/null +++ b/apps/portal-shell/src/app/components/sidebar/sidebar.spec.ts @@ -0,0 +1,57 @@ +import { TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { Sidebar } from './sidebar'; + +describe('Sidebar', () => { + beforeEach(async () => { + localStorage.clear(); + await TestBed.configureTestingModule({ + imports: [Sidebar], + providers: [provideRouter([])], + }).compileComponents(); + }); + + it('renders the three v1 menu groups', async () => { + const fixture = TestBed.createComponent(Sidebar); + await fixture.whenStable(); + const text = (fixture.nativeElement as HTMLElement).textContent ?? ''; + expect(text).toContain('General'); + expect(text).toContain('Establishments'); + expect(text).toContain('Communication'); + }); + + it('renders the FR + EN accessibility links', async () => { + const fixture = TestBed.createComponent(Sidebar); + await fixture.whenStable(); + const root = fixture.nativeElement as HTMLElement; + expect(root.querySelector('a[href="/accessibility"]')).not.toBeNull(); + expect(root.querySelector('a[href="/accessibilite"]')).not.toBeNull(); + }); + + it('starts expanded and collapses on toggle, persisting to localStorage', async () => { + const fixture = TestBed.createComponent(Sidebar); + fixture.detectChanges(); + await fixture.whenStable(); + const root = fixture.nativeElement as HTMLElement; + const aside = root.querySelector('aside'); + expect(aside?.classList.contains('sidebar--collapsed')).toBe(false); + + const toggle = root.querySelector('button.sidebar__toggle') as HTMLButtonElement; + toggle.click(); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(aside?.classList.contains('sidebar--collapsed')).toBe(true); + expect(toggle.getAttribute('aria-expanded')).toBe('false'); + expect(localStorage.getItem('portal-shell:sidebar-collapsed')).toBe('1'); + }); + + it('restores the collapsed state from localStorage on init', async () => { + localStorage.setItem('portal-shell:sidebar-collapsed', '1'); + const fixture = TestBed.createComponent(Sidebar); + fixture.detectChanges(); + await fixture.whenStable(); + const aside = (fixture.nativeElement as HTMLElement).querySelector('aside'); + expect(aside?.classList.contains('sidebar--collapsed')).toBe(true); + }); +}); diff --git a/apps/portal-shell/src/app/components/sidebar/sidebar.ts b/apps/portal-shell/src/app/components/sidebar/sidebar.ts new file mode 100644 index 0000000..46f3c93 --- /dev/null +++ b/apps/portal-shell/src/app/components/sidebar/sidebar.ts @@ -0,0 +1,99 @@ +import { ChangeDetectionStrategy, Component, effect, signal } from '@angular/core'; +import { RouterLink, RouterLinkActive } from '@angular/router'; +import { Icon, type IconName } from '../icon/icon'; + +/** + * Primary side navigation. + * + * Two visual modes: + * - expanded (default): icon + label + group titles. ~256 px wide. + * - collapsed: icon-only. ~64 px wide. Group titles hide; labels + * remain in the accessible name of each link via `aria-label`. + * + * The collapsed state is owned here and persisted in `localStorage` + * so a user's preference survives reloads (zero backend roundtrip). + * + * v1 menu is static and points to `#` placeholders — the live routes + * land alongside the auth flow (ADR-0009). The shape is already + * permission-shaped (`requiredPermissions?: string[]`) so PR 2 can + * plug in a filter without restructuring the data. The Dashboard + * entry uses `routerLink="/"` so the active-state styling is visible + * on the home page; the rest are anchors until real routes exist. + */ + +export interface MenuItem { + readonly label: string; + readonly icon: IconName; + /** v1: anchor placeholder. v2: Angular route path. */ + readonly href?: string; + readonly routerLink?: string; + /** Reserved for PR 2 (permission-aware filtering). */ + readonly requiredPermissions?: readonly string[]; +} + +export interface MenuGroup { + readonly label: string; + readonly items: readonly MenuItem[]; +} + +const MENU: readonly MenuGroup[] = [ + { + label: 'General', + items: [ + { label: 'Dashboard', icon: 'layout-dashboard', routerLink: '/' }, + { label: 'Calendar', icon: 'calendar', href: '#' }, + ], + }, + { + label: 'Establishments', + items: [ + { label: 'Directory', icon: 'building-2', href: '#' }, + { label: 'Activities', icon: 'folder', href: '#' }, + ], + }, + { + label: 'Communication', + items: [ + { label: 'Messages', icon: 'mail', href: '#' }, + { label: 'Documents', icon: 'file-text', href: '#' }, + ], + }, +]; + +const STORAGE_KEY = 'portal-shell:sidebar-collapsed'; + +@Component({ + selector: 'app-sidebar', + imports: [RouterLink, RouterLinkActive, Icon], + templateUrl: './sidebar.html', + styleUrl: './sidebar.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class Sidebar { + protected readonly menu = MENU; + protected readonly collapsed = signal(readPersistedCollapsed()); + + constructor() { + effect(() => { + const value = this.collapsed(); + try { + localStorage.setItem(STORAGE_KEY, value ? '1' : '0'); + } catch { + // localStorage can throw in private-mode contexts; the + // sidebar still works, the preference just doesn't persist. + } + }); + } + + protected toggle(): void { + this.collapsed.update((v) => !v); + } +} + +function readPersistedCollapsed(): boolean { + try { + return localStorage.getItem(STORAGE_KEY) === '1'; + } catch { + return false; + } +} diff --git a/apps/portal-shell/src/styles.css b/apps/portal-shell/src/styles.css index c0fbd2c..2b1d81a 100644 --- a/apps/portal-shell/src/styles.css +++ b/apps/portal-shell/src/styles.css @@ -8,3 +8,46 @@ */ @import 'tailwindcss'; + +/* + * 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; +} diff --git a/package.json b/package.json index d707ae0..fb70ef3 100644 --- a/package.json +++ b/package.json @@ -141,6 +141,7 @@ "axios": "^1.6.0", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", + "lucide-angular": "^1.0.0", "nestjs-cls": "^6.2.0", "nestjs-pino": "^4.6.1", "nestjs-prisma": "^0.27.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 82518b7..7830029 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -107,6 +107,9 @@ importers: class-validator: specifier: ^0.15.1 version: 0.15.1 + lucide-angular: + specifier: ^1.0.0 + version: 1.0.0(@angular/common@21.2.12(@angular/core@21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2)) nestjs-cls: specifier: ^6.2.0 version: 6.2.0(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -7360,6 +7363,12 @@ packages: resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} engines: {node: '>=12'} + lucide-angular@1.0.0: + resolution: {integrity: sha512-YxCNEXHUz2IzAZIlxU4CkD55ljMjOlm3/am4eqadX/qkFszyGDzZwtbWOP1wj6vlbn/BNL4RhJeXbusLz96ajg==} + peerDependencies: + '@angular/common': 13.x - 21.x + '@angular/core': 13.x - 21.x + luxon@3.7.2: resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} engines: {node: '>=12'} @@ -18408,6 +18417,12 @@ snapshots: lru-cache@7.18.3: {} + lucide-angular@1.0.0(@angular/common@21.2.12(@angular/core@21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2)): + dependencies: + '@angular/common': 21.2.12(@angular/core@21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2) + '@angular/core': 21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2) + tslib: 2.8.1 + luxon@3.7.2: {} magic-string@0.25.7: