From fdcde40cb008243abffd3084cf41b745c7ec9dfa Mon Sep 17 00:00:00 2001 From: Nuno Eufrasio Date: Thu, 14 May 2026 14:52:41 +0100 Subject: [PATCH 01/11] feat(nav): merge foundation/project lenses for hybrid personas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add isHybridPersona signal to LensService; availableLenses suppresses the separate foundation button for hybrid users - Preload both lens item sets in NavigationService for hybrid users so the merged dropdown has both ready immediately - Add isLensActive() helper to LensSwitcherComponent so the merged Projects button appears active for both foundation and project states - Rewrite ProjectSelectorComponent with hybrid mode: All/Foundations/ Projects tabs, role-sorted items (ED→BM→alpha / Maintainer→Contributor →alpha), projects nested under parent foundation in the All tab, and a transparent/borderless search input - Update SidebarComponent to switch the active lens based on item.isFoundation when an item is selected in hybrid mode - Update project lens constant: icon → fa-layer-group, label → Projects Signed-off-by: Nuno Eufrasio Signed-off-by: Nuno Eufrasio --- .../lens-switcher.component.html | 6 +- .../lens-switcher/lens-switcher.component.ts | 11 + .../project-selector.component.html | 85 +++++--- .../project-selector.component.ts | 196 ++++++++++++++---- .../components/sidebar/sidebar.component.html | 3 +- .../components/sidebar/sidebar.component.ts | 9 +- .../src/app/shared/services/lens.service.ts | 6 +- .../app/shared/services/navigation.service.ts | 11 +- .../shared/src/constants/lens.constants.ts | 8 +- 9 files changed, 260 insertions(+), 75 deletions(-) diff --git a/apps/lfx-one/src/app/shared/components/lens-switcher/lens-switcher.component.html b/apps/lfx-one/src/app/shared/components/lens-switcher/lens-switcher.component.html index 381eb6899..f0c5f1931 100644 --- a/apps/lfx-one/src/app/shared/components/lens-switcher/lens-switcher.component.html +++ b/apps/lfx-one/src/app/shared/components/lens-switcher/lens-switcher.component.html @@ -9,8 +9,8 @@ (click)="setLens(lens.id)" [attr.data-testid]="lens.testId + '-mobile'" class="flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium transition-colors" - [ngClass]="activeLens() === lens.id ? 'bg-indigo-900 text-white' : 'text-gray-500 hover:bg-gray-100 hover:text-gray-700'"> - + [ngClass]="isLensActive(lens.id) ? 'bg-indigo-900 text-white' : 'text-gray-500 hover:bg-gray-100 hover:text-gray-700'"> + {{ lens.shortLabel }} } @@ -65,7 +65,7 @@
@for (lens of lenses(); track lens.id) { - @let isActive = activeLens() === lens.id; + @let isActive = isLensActive(lens.id); + } +
+ } +
- @for (item of items(); track item.uid; let i = $index) { - + @for (displayItem of displayedItems(); track displayItem.item.uid; let i = $index) { + @if (i === autoLoadTriggerIndex() && hasMore()) { @defer (on viewport) { @@ -57,28 +78,44 @@ } } -
-
-

{{ item.name || item.slug }}

-
- +
+

{{ displayItem.item.name || displayItem.item.slug }}

+

+ @if (displayItem.item.isFoundation) { + Foundation + } @else { + Project + } + @if (getRoleLabel(displayItem.item)) { +  · Role: {{ getRoleLabel(displayItem.item) }} + } +

+
+ + } @empty { @if (!loading()) {
{{ emptyMessage() }}
diff --git a/apps/lfx-one/src/app/shared/components/project-selector/project-selector.component.ts b/apps/lfx-one/src/app/shared/components/project-selector/project-selector.component.ts index d03441637..f1fb16379 100644 --- a/apps/lfx-one/src/app/shared/components/project-selector/project-selector.component.ts +++ b/apps/lfx-one/src/app/shared/components/project-selector/project-selector.component.ts @@ -1,17 +1,27 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT -import { Component, computed, inject, input, model, output, Signal } from '@angular/core'; +import { Component, computed, inject, input, model, output, signal, Signal } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormControl, ReactiveFormsModule } from '@angular/forms'; -import { LensItem, NavLens, ProjectContext } from '@lfx-one/shared/interfaces'; +import { BOARD_SCOPED_PERSONA_PRIORITY, PROJECT_SCOPED_PERSONA_PRIORITY } from '@lfx-one/shared/constants'; +import { LensItem, NavLens, PersonaType, ProjectContext } from '@lfx-one/shared/interfaces'; +import { LensService } from '@services/lens.service'; import { NavigationService } from '@services/navigation.service'; +import { PersonaService } from '@services/persona.service'; import { UserService } from '@services/user.service'; import { OnRenderDirective } from '@shared/directives/on-render.directive'; import { AutoFocus } from 'primeng/autofocus'; import { InputTextModule } from 'primeng/inputtext'; import { Popover, PopoverModule } from 'primeng/popover'; +export interface DisplayLensItem { + item: LensItem; + isNested: boolean; +} + +type SelectorTab = 'all' | 'foundations' | 'projects'; + @Component({ selector: 'lfx-project-selector', imports: [ReactiveFormsModule, PopoverModule, InputTextModule, AutoFocus, OnRenderDirective], @@ -21,34 +31,89 @@ import { Popover, PopoverModule } from 'primeng/popover'; export class ProjectSelectorComponent { private readonly userService = inject(UserService); private readonly navigationService = inject(NavigationService); + private readonly lensService = inject(LensService); + private readonly personaService = inject(PersonaService); public readonly lens = input.required(); public readonly selectedProject = input(null); public readonly searchPlaceholder = input('Search...'); public readonly emptyMessage = input('No results found'); + public readonly hybridMode = input(false); public readonly itemSelected = output(); public readonly isPanelOpen = model(false); + protected readonly activeTab = signal('all'); + protected readonly selectorTabs: readonly SelectorTab[] = ['all', 'foundations', 'projects']; protected readonly searchControl = new FormControl('', { nonNullable: true }); - // Offset the popover when the impersonation banner is visible. protected readonly panelStyleClass = computed(() => this.userService.impersonating() ? 'project-selector-panel project-selector-panel--with-banner' : 'project-selector-panel' ); - protected readonly lensTypeLabel = computed(() => (this.lens() === 'foundation' ? 'Foundation' : 'Project')); + protected readonly lensTypeLabel = computed(() => { + if (this.hybridMode()) { + return this.lensService.activeLens() === 'foundation' ? 'Foundation' : 'Project'; + } + return this.lens() === 'foundation' ? 'Foundation' : 'Project'; + }); + + protected readonly displayName: Signal = computed(() => { + const project = this.selectedProject(); + return project?.name?.trim() || `Select ${this.lensTypeLabel()}`; + }); + + protected readonly displayLogo: Signal = computed(() => this.selectedProject()?.logoUrl || ''); + + protected readonly foundationItems: Signal = computed(() => (this.hybridMode() ? this.navigationService.items('foundation')() : [])); + + protected readonly rawProjectItems: Signal = computed(() => + this.hybridMode() ? this.navigationService.items('project')() : this.navigationService.items(this.lens())() + ); - protected readonly displayName: Signal = this.initializeDisplayName(); - protected readonly displayLogo: Signal = this.initializeDisplayLogo(); - protected readonly items: Signal = this.initializeItems(); - protected readonly loading: Signal = this.initializeLoading(); - protected readonly hasMore: Signal = this.initializeHasMore(); - protected readonly autoLoadTriggerIndex: Signal = this.initializeAutoLoadTriggerIndex(); + // Kept for template backward-compat (auto-load sentinel uses items() in non-hybrid path) + protected readonly items: Signal = computed(() => this.rawProjectItems()); + + protected readonly loading: Signal = computed(() => { + if (this.hybridMode()) { + return this.navigationService.loading('foundation')() || this.navigationService.loading('project')(); + } + return this.navigationService.loading(this.lens())(); + }); + + protected readonly hasMore: Signal = computed(() => { + if (this.hybridMode()) { + return this.navigationService.hasMore('foundation')() || this.navigationService.hasMore('project')(); + } + return this.navigationService.hasMore(this.lens())(); + }); + + protected readonly displayedItems: Signal = computed(() => { + if (!this.hybridMode()) { + return this.sortByRole(this.rawProjectItems()).map((item) => ({ item, isNested: false })); + } + const tab = this.activeTab(); + if (tab === 'foundations') { + return this.sortByRole(this.foundationItems()).map((item) => ({ item, isNested: false })); + } + if (tab === 'projects') { + return this.sortByRole(this.rawProjectItems()).map((item) => ({ item, isNested: false })); + } + return this.buildAllTabItems(); + }); + + // Sentinel shifts on each page load so Angular re-creates OnRenderDirective and re-fires the fetch. + protected readonly autoLoadTriggerIndex: Signal = computed(() => Math.max(0, this.displayedItems().length - 8)); public constructor() { - // Service applies its own debounce — push raw emissions here. - this.searchControl.valueChanges.pipe(takeUntilDestroyed()).subscribe((term) => this.navigationService.setSearchTerm(this.lens(), term)); + this.searchControl.valueChanges.pipe(takeUntilDestroyed()).subscribe((term) => { + if (this.hybridMode()) { + this.navigationService.setSearchTerm('foundation', term); + this.navigationService.setSearchTerm('project', term); + } else { + this.navigationService.setSearchTerm(this.lens(), term); + } + }); } protected selectItem(item: LensItem, popover: Popover): void { @@ -66,44 +131,101 @@ export class ProjectSelectorComponent { protected onPopoverHide(): void { this.isPanelOpen.set(false); - // Skip emit so UI + service reset in sync via the explicit setSearchTerm below. + this.activeTab.set('all'); this.searchControl.setValue('', { emitEvent: false }); - this.navigationService.setSearchTerm(this.lens(), ''); + if (this.hybridMode()) { + this.navigationService.setSearchTerm('foundation', ''); + this.navigationService.setSearchTerm('project', ''); + } else { + this.navigationService.setSearchTerm(this.lens(), ''); + } } protected loadMore(): void { - this.navigationService.loadNextPage(this.lens()); + if (this.hybridMode()) { + if (this.navigationService.hasMore('foundation')()) { + this.navigationService.loadNextPage('foundation'); + } else { + this.navigationService.loadNextPage('project'); + } + } else { + this.navigationService.loadNextPage(this.lens()); + } } - private initializeDisplayName(): Signal { - return computed(() => { - const project = this.selectedProject(); - return project?.name?.trim() || `Select ${this.lensTypeLabel()}`; - }); + protected getRoleLabel(item: LensItem): string { + const priority = item.isFoundation ? BOARD_SCOPED_PERSONA_PRIORITY : PROJECT_SCOPED_PERSONA_PRIORITY; + const personaProjects = this.personaService.personaProjects(); + for (const persona of priority) { + if ((personaProjects[persona] ?? []).some((p) => p.projectUid === item.uid)) { + return this.personaTypeToLabel(persona); + } + } + return ''; } - private initializeDisplayLogo(): Signal { - return computed(() => { - const project = this.selectedProject(); - return project?.logoUrl || ''; - }); + private personaTypeToLabel(persona: PersonaType): string { + const map: Record = { + 'executive-director': 'Executive Director', + 'board-member': 'Board Member', + maintainer: 'Maintainer', + contributor: 'Contributor', + }; + return map[persona] ?? ''; } - private initializeItems(): Signal { - return computed(() => this.navigationService.items(this.lens())()); + private roleIndex(item: LensItem): number { + const priority = item.isFoundation ? BOARD_SCOPED_PERSONA_PRIORITY : PROJECT_SCOPED_PERSONA_PRIORITY; + const personaProjects = this.personaService.personaProjects(); + for (let i = 0; i < priority.length; i++) { + if ((personaProjects[priority[i]] ?? []).some((p) => p.projectUid === item.uid)) { + return i; + } + } + return priority.length; } - private initializeLoading(): Signal { - return computed(() => this.navigationService.loading(this.lens())()); - } - - private initializeHasMore(): Signal { - return computed(() => this.navigationService.hasMore(this.lens())()); + private sortByRole(items: LensItem[]): LensItem[] { + return [...items].sort((a, b) => { + const diff = this.roleIndex(a) - this.roleIndex(b); + return diff !== 0 ? diff : (a.name ?? '').localeCompare(b.name ?? ''); + }); } - // Sentinel at items.length - 8; signal-based index shifts on each page so Angular re-creates - // OnRenderDirective and re-fires the fetch. - private initializeAutoLoadTriggerIndex(): Signal { - return computed(() => Math.max(0, this.items().length - 8)); + private buildAllTabItems(): DisplayLensItem[] { + const sortedFoundations = this.sortByRole(this.foundationItems()); + const sortedProjects = this.sortByRole(this.rawProjectItems()); + + // Build parentProjectUid → project uid map for nesting lookup + const detectedProjects = this.personaService.detectedProjects(); + const parentMap = new Map(); + for (const dp of detectedProjects) { + if (dp.parentProjectUid) { + parentMap.set(dp.projectUid, dp.parentProjectUid); + } + } + + const foundationUidSet = new Set(sortedFoundations.map((f) => f.uid)); + const result: DisplayLensItem[] = []; + const nestedProjectUids = new Set(); + + for (const foundation of sortedFoundations) { + result.push({ item: foundation, isNested: false }); + for (const project of sortedProjects) { + const parentUid = parentMap.get(project.uid); + if (parentUid && parentUid === foundation.uid && foundationUidSet.has(foundation.uid)) { + result.push({ item: project, isNested: true }); + nestedProjectUids.add(project.uid); + } + } + } + + for (const project of sortedProjects) { + if (!nestedProjectUids.has(project.uid)) { + result.push({ item: project, isNested: false }); + } + } + + return result; } } diff --git a/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.html b/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.html index c6db941ef..1205fc498 100644 --- a/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.html +++ b/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.html @@ -19,8 +19,9 @@ } @else { diff --git a/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.ts b/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.ts index c02016153..3f50cb6c1 100644 --- a/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.ts +++ b/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.ts @@ -49,6 +49,7 @@ export class SidebarComponent { protected readonly activeLens = this.lensService.activeLens; protected readonly isOrgLens = computed(() => this.activeLens() === 'org'); + protected readonly isHybridPersona = this.lensService.isHybridPersona; protected readonly selectedProject: Signal = computed(() => this.projectContextService.activeContext()); protected readonly navLens: Signal = this.initNavLens(); protected readonly lensLoaded: Signal = this.initLensLoaded(); @@ -82,11 +83,11 @@ export class SidebarComponent { protected onItemSelected(item: LensItem): void { const context = lensItemToProjectContext(item); - const lens = this.lensService.activeLens(); - - if (lens === 'foundation') { + if (item.isFoundation) { + this.lensService.setLens('foundation'); this.projectContextService.setFoundation(context); - } else if (lens === 'project') { + } else { + this.lensService.setLens('project'); this.projectContextService.setProject(context); } } diff --git a/apps/lfx-one/src/app/shared/services/lens.service.ts b/apps/lfx-one/src/app/shared/services/lens.service.ts index 299a03660..c7a6d7b05 100644 --- a/apps/lfx-one/src/app/shared/services/lens.service.ts +++ b/apps/lfx-one/src/app/shared/services/lens.service.ts @@ -22,6 +22,8 @@ export class LensService { /** Active lens clamped to the current persona's allowed set; falls back to default if disallowed. */ public readonly activeLens: Signal = this.initActiveLens(); public readonly availableLenses: Signal = this.initAvailableLenses(); + /** True when the user holds both a board role (ED/Board Member) AND a project role (Maintainer/Contributor). */ + public readonly isHybridPersona: Signal = computed(() => this.personaService.hasBoardRole() && this.personaService.hasProjectRole()); public constructor() { const stored = this.loadFromCookie(); @@ -51,7 +53,9 @@ export class LensService { private initAvailableLenses(): Signal { return computed(() => { const lensIds = this.getAllowedLensIds(); - return lensIds.map((id) => ALL_LENSES[id]); + // For hybrid personas the 'project' button serves as the merged entry — hide the separate foundation button. + const filtered = this.isHybridPersona() ? lensIds.filter((id) => id !== 'foundation') : lensIds; + return filtered.map((id) => ALL_LENSES[id]); }); } diff --git a/apps/lfx-one/src/app/shared/services/navigation.service.ts b/apps/lfx-one/src/app/shared/services/navigation.service.ts index e45ea4381..b134f29ab 100644 --- a/apps/lfx-one/src/app/shared/services/navigation.service.ts +++ b/apps/lfx-one/src/app/shared/services/navigation.service.ts @@ -36,7 +36,16 @@ export class NavigationService { map((lens): NavLens | null => (lens === 'foundation' || lens === 'project' ? lens : null)), distinctUntilChanged(), filter((lens): lens is NavLens => lens !== null), - tap((lens) => this.resetAndReload(lens)) + tap((lens) => { + this.resetAndReload(lens); + // For hybrid personas, preload the sibling lens so the merged dropdown has both sets ready. + if (this.lensService.isHybridPersona()) { + const sibling: NavLens = lens === 'foundation' ? 'project' : 'foundation'; + if (!this.getState(sibling).loaded()) { + this.resetAndReload(sibling); + } + } + }) ), { initialValue: null } ); diff --git a/packages/shared/src/constants/lens.constants.ts b/packages/shared/src/constants/lens.constants.ts index edadf32b1..4ef45ae78 100644 --- a/packages/shared/src/constants/lens.constants.ts +++ b/packages/shared/src/constants/lens.constants.ts @@ -35,10 +35,10 @@ export const ALL_LENSES: Readonly> = { }, project: { id: 'project', - label: 'Project', - shortLabel: 'Project', - icon: 'fa-light fa-laptop-code', - activeIcon: 'fa-solid fa-laptop-code', + label: 'Projects', + shortLabel: 'Projects', + icon: 'fa-light fa-layer-group', + activeIcon: 'fa-solid fa-layer-group', defaultRoute: LENS_DEFAULT_ROUTES.project, testId: 'lens-project', }, From ecbf9199af7b4400d365d0c1abe6ba6a83aa18b2 Mon Sep 17 00:00:00 2001 From: Nuno Eufrasio Date: Thu, 14 May 2026 15:07:48 +0100 Subject: [PATCH 02/11] fix(nav): exclude foundations from project lens dropdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The project-lens API can include foundations the user has access to, but those belong in the foundation lens (or the Foundations tab in hybrid mode) — never in the projects list. Filter them out so a project-only Contributor doesn't see foundation entries. Signed-off-by: Nuno Eufrasio --- .../project-selector/project-selector.component.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/lfx-one/src/app/shared/components/project-selector/project-selector.component.ts b/apps/lfx-one/src/app/shared/components/project-selector/project-selector.component.ts index f1fb16379..a9e66e515 100644 --- a/apps/lfx-one/src/app/shared/components/project-selector/project-selector.component.ts +++ b/apps/lfx-one/src/app/shared/components/project-selector/project-selector.component.ts @@ -67,9 +67,13 @@ export class ProjectSelectorComponent { protected readonly foundationItems: Signal = computed(() => (this.hybridMode() ? this.navigationService.items('foundation')() : [])); - protected readonly rawProjectItems: Signal = computed(() => - this.hybridMode() ? this.navigationService.items('project')() : this.navigationService.items(this.lens())() - ); + // The project-lens API can include foundations the user has access to. Those belong in the + // foundation lens (or the Foundations tab in hybrid mode), never in the projects list. + protected readonly rawProjectItems: Signal = computed(() => { + const lens: NavLens = this.hybridMode() ? 'project' : this.lens(); + const items = this.navigationService.items(lens)(); + return lens === 'project' ? items.filter((item) => !item.isFoundation) : items; + }); // Kept for template backward-compat (auto-load sentinel uses items() in non-hybrid path) protected readonly items: Signal = computed(() => this.rawProjectItems()); From 656de0e91c53ee636a9d596b925ac1f57cc115cb Mon Sep 17 00:00:00 2001 From: Nuno Eufrasio Date: Fri, 15 May 2026 09:45:38 +0100 Subject: [PATCH 03/11] refactor(nav): refine role display in lens selector and me card Polish how persona roles surface across the hybrid lens experience: - selector trigger now shows a compact role-icon badge with tooltip instead of inline text, with left-aligned, truncating description - dropdown rows render the role icon + label inline beneath each project/foundation name - nested project connector lines align with the parent foundation logo - me-lens user card collapses to inline " Role" for a single persona and to circular icon-only badges with a rich HTML tooltip (role name + bulleted project/foundation list) for multiple personas - tooltip width hugs content so role/project entries never wrap Generated with [Claude Code](https://claude.ai/code) Signed-off-by: Nuno Eufrasio --- .../project-selector.component.html | 51 ++++++++++----- .../project-selector.component.ts | 63 ++++++++++++++++++- .../components/sidebar/sidebar.component.html | 27 +++++--- .../components/sidebar/sidebar.component.scss | 9 +++ .../components/sidebar/sidebar.component.ts | 20 ++++-- 5 files changed, 138 insertions(+), 32 deletions(-) diff --git a/apps/lfx-one/src/app/shared/components/project-selector/project-selector.component.html b/apps/lfx-one/src/app/shared/components/project-selector/project-selector.component.html index ce34569a9..ec9cde8de 100644 --- a/apps/lfx-one/src/app/shared/components/project-selector/project-selector.component.html +++ b/apps/lfx-one/src/app/shared/components/project-selector/project-selector.component.html @@ -17,9 +17,20 @@ -
+
- {{ lensTypeLabel() }} +
+ {{ lensTypeLabel() }} + @if (selectedRoleLabel()) { + + + + + } +
@@ -49,14 +60,14 @@ @if (hybridMode()) { -
+
@for (tab of selectorTabs; track tab) {
} @empty { diff --git a/apps/lfx-one/src/app/shared/components/project-selector/project-selector.component.ts b/apps/lfx-one/src/app/shared/components/project-selector/project-selector.component.ts index a9e66e515..fbbe8a6af 100644 --- a/apps/lfx-one/src/app/shared/components/project-selector/project-selector.component.ts +++ b/apps/lfx-one/src/app/shared/components/project-selector/project-selector.component.ts @@ -14,6 +14,7 @@ import { OnRenderDirective } from '@shared/directives/on-render.directive'; import { AutoFocus } from 'primeng/autofocus'; import { InputTextModule } from 'primeng/inputtext'; import { Popover, PopoverModule } from 'primeng/popover'; +import { TooltipModule } from 'primeng/tooltip'; export interface DisplayLensItem { item: LensItem; @@ -24,7 +25,7 @@ type SelectorTab = 'all' | 'foundations' | 'projects'; @Component({ selector: 'lfx-project-selector', - imports: [ReactiveFormsModule, PopoverModule, InputTextModule, AutoFocus, OnRenderDirective], + imports: [ReactiveFormsModule, PopoverModule, InputTextModule, AutoFocus, OnRenderDirective, TooltipModule], templateUrl: './project-selector.component.html', styleUrl: './project-selector.component.scss', }) @@ -53,6 +54,13 @@ export class ProjectSelectorComponent { protected readonly lensTypeLabel = computed(() => { if (this.hybridMode()) { + const selectedUid = this.selectedProject()?.uid; + if (selectedUid) { + const detected = this.personaService.detectedProjects().find((p) => p.projectUid === selectedUid); + if (detected) { + return detected.isFoundation ? 'Foundation' : 'Project'; + } + } return this.lensService.activeLens() === 'foundation' ? 'Foundation' : 'Project'; } return this.lens() === 'foundation' ? 'Foundation' : 'Project'; @@ -65,6 +73,31 @@ export class ProjectSelectorComponent { protected readonly displayLogo: Signal = computed(() => this.selectedProject()?.logoUrl || ''); + protected readonly selectedRolePersona: Signal = computed(() => { + const uid = this.selectedProject()?.uid; + if (!uid) return null; + const detected = this.personaService.detectedProjects().find((p) => p.projectUid === uid); + const isFoundation = detected?.isFoundation ?? this.lensService.activeLens() === 'foundation'; + const priority = isFoundation ? BOARD_SCOPED_PERSONA_PRIORITY : PROJECT_SCOPED_PERSONA_PRIORITY; + const personaProjects = this.personaService.personaProjects(); + for (const persona of priority) { + if ((personaProjects[persona] ?? []).some((p) => p.projectUid === uid)) { + return persona; + } + } + return null; + }); + + protected readonly selectedRoleLabel: Signal = computed(() => { + const persona = this.selectedRolePersona(); + return persona ? this.personaTypeToLabel(persona) : ''; + }); + + protected readonly selectedRoleIcon: Signal = computed(() => { + const persona = this.selectedRolePersona(); + return persona ? this.personaTypeToIcon(persona) : ''; + }); + protected readonly foundationItems: Signal = computed(() => (this.hybridMode() ? this.navigationService.items('foundation')() : [])); // The project-lens API can include foundations the user has access to. Those belong in the @@ -157,15 +190,29 @@ export class ProjectSelectorComponent { } } + protected isItemSelected(item: LensItem): boolean { + return this.selectedProject()?.uid === item.uid; + } + protected getRoleLabel(item: LensItem): string { + const persona = this.resolveRolePersona(item); + return persona ? this.personaTypeToLabel(persona) : ''; + } + + protected getRoleIcon(item: LensItem): string { + const persona = this.resolveRolePersona(item); + return persona ? this.personaTypeToIcon(persona) : ''; + } + + private resolveRolePersona(item: LensItem): PersonaType | null { const priority = item.isFoundation ? BOARD_SCOPED_PERSONA_PRIORITY : PROJECT_SCOPED_PERSONA_PRIORITY; const personaProjects = this.personaService.personaProjects(); for (const persona of priority) { if ((personaProjects[persona] ?? []).some((p) => p.projectUid === item.uid)) { - return this.personaTypeToLabel(persona); + return persona; } } - return ''; + return null; } private personaTypeToLabel(persona: PersonaType): string { @@ -178,6 +225,16 @@ export class ProjectSelectorComponent { return map[persona] ?? ''; } + private personaTypeToIcon(persona: PersonaType): string { + const map: Record = { + 'executive-director': 'fa-light fa-briefcase', + 'board-member': 'fa-light fa-building-columns', + maintainer: 'fa-light fa-code', + contributor: 'fa-light fa-code', + }; + return map[persona] ?? ''; + } + private roleIndex(item: LensItem): number { const priority = item.isFoundation ? BOARD_SCOPED_PERSONA_PRIORITY : PROJECT_SCOPED_PERSONA_PRIORITY; const personaProjects = this.personaService.personaProjects(); diff --git a/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.html b/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.html index 1205fc498..9e60d5863 100644 --- a/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.html +++ b/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.html @@ -35,14 +35,25 @@

{{ user()?.name }}

- @if (showPersonaBadge()) { -
- @for (tag of personaLabels(); track $index) { - - {{ tag.label }} - - } -
+ @if (showPersonaBadge() && personaLabels().length > 0) { + @if (personaLabels().length === 1) { + + {{ personaLabels()[0].label }} + + } @else { +
+ @for (tag of personaLabels(); track $index) { + + + + } +
+ } }
diff --git a/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.scss b/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.scss index fbb2d34cb..fc9ae4e71 100644 --- a/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.scss +++ b/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.scss @@ -14,3 +14,12 @@ height: 1rem; width: auto; } + +// Tooltip used by the persona role circles in the Me lens. +// Override PrimeNG defaults so the tooltip width hugs its content and bullet rows don't wrap. +::ng-deep .persona-tooltip .p-tooltip-text { + max-width: none; + width: max-content; + white-space: nowrap; +} + diff --git a/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.ts b/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.ts index 3f50cb6c1..1957edc1f 100644 --- a/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.ts +++ b/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.ts @@ -17,6 +17,7 @@ import { PersonaService } from '@services/persona.service'; import { ProjectContextService } from '@services/project-context.service'; import { UserService } from '@services/user.service'; import { SkeletonModule } from 'primeng/skeleton'; +import { TooltipModule } from 'primeng/tooltip'; const PERSONA_ICONS: Partial> = { 'executive-director': 'fa-light fa-briefcase', @@ -27,7 +28,7 @@ const PERSONA_ICONS: Partial> = { @Component({ selector: 'lfx-sidebar', - imports: [NgClass, NgTemplateOutlet, RouterModule, AvatarComponent, BadgeComponent, ProjectSelectorComponent, SkeletonModule], + imports: [NgClass, NgTemplateOutlet, RouterModule, AvatarComponent, BadgeComponent, ProjectSelectorComponent, SkeletonModule, TooltipModule], templateUrl: './sidebar.component.html', styleUrl: './sidebar.component.scss', }) @@ -56,7 +57,7 @@ export class SidebarComponent { protected readonly user = this.userService.user; protected readonly userInitials = this.userService.userInitials; - protected readonly personaLabels: Signal<{ label: string; icon: string }[]> = this.initPersonaLabels(); + protected readonly personaLabels: Signal<{ label: string; icon: string; tooltip: string }[]> = this.initPersonaLabels(); // Hide the persona badge when the user is a root-writer — executive-director is spoofed, not naturally detected. protected readonly showPersonaBadge: Signal = computed(() => !this.personaService.isRootWriter()); @@ -108,11 +109,22 @@ export class SidebarComponent { }); } - private initPersonaLabels(): Signal<{ label: string; icon: string }[]> { + private initPersonaLabels(): Signal<{ label: string; icon: string; tooltip: string }[]> { return computed(() => { + const personaProjects = this.personaService.personaProjects(); + const escapeHtml = (s: string) => + s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); const toTag = (p: PersonaType) => { const option = PERSONA_OPTIONS.find((o) => o.value === p); - return { label: option?.label ?? toTitleCase(p), icon: PERSONA_ICONS[p] ?? 'fa-light fa-user' }; + const label = option?.label ?? toTitleCase(p); + const icon = PERSONA_ICONS[p] ?? 'fa-light fa-user'; + const names = (personaProjects[p] ?? []).map((proj) => proj.projectName).filter((n): n is string => !!n); + const header = `
${escapeHtml(label)}
`; + const list = + names.length > 0 + ? `
    ${names.map((n) => `
  • ${escapeHtml(n)}
  • `).join('')}
` + : ''; + return { label, icon, tooltip: header + list }; }; if (this.activeLens() === 'me') { From 876dd000c8b8b98cb625cf877ecdf6c7d0f1e266 Mon Sep 17 00:00:00 2001 From: Nuno Eufrasio Date: Fri, 15 May 2026 10:26:07 +0100 Subject: [PATCH 04/11] fix(nav): address review feedback on hybrid lens selector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Scope hybrid pagination (hasMore/loadMore) to the active tab so the Projects tab can keep advancing instead of exhausting foundations first. - Restore `availableLenses` to the full allowed set and add a separate `displayLenses` for the sidebar switcher, so downstream consumers (NavigationService.applyVisibilityFilters / foundationVisibilityWatcher) keep filtering foundations out of the project lens for hybrid users. - Replace `[class]` with `[ngClass]` on role icons so the static `text-[10px]` styling isn't wiped out. - Make tooltip-only role badges (selector trigger + Me-card multi-role) focusable via `tabindex="0"` + `role="img"` + `aria-label`, with a focus ring so keyboard users can reveal the role info. - Drop `role=tablist`/`role=tab` from the hybrid filter pills (they aren't a true tabs widget); use `role=group` + `aria-pressed` instead. - Swap hard-coded SVG hex fills on the fallback logo for `fill-blue-500` and `fill-blue-800` tokens. - Pre-group nested projects by parent uid so `buildAllTabItems` runs in O(F + P) rather than O(F × P). - Fix the stale comment that described the parent-uid map as the inverse of what the code actually builds. Generated with [Claude Code](https://claude.ai/code) Signed-off-by: Nuno Eufrasio --- .../lens-switcher/lens-switcher.component.ts | 2 +- .../project-selector.component.html | 21 +++++---- .../project-selector.component.ts | 47 +++++++++++++++---- .../components/sidebar/sidebar.component.html | 5 +- .../components/sidebar/sidebar.component.scss | 1 - .../components/sidebar/sidebar.component.ts | 7 +-- .../src/app/shared/services/lens.service.ts | 12 +++-- 7 files changed, 66 insertions(+), 29 deletions(-) diff --git a/apps/lfx-one/src/app/shared/components/lens-switcher/lens-switcher.component.ts b/apps/lfx-one/src/app/shared/components/lens-switcher/lens-switcher.component.ts index 53cd97316..6119dc660 100644 --- a/apps/lfx-one/src/app/shared/components/lens-switcher/lens-switcher.component.ts +++ b/apps/lfx-one/src/app/shared/components/lens-switcher/lens-switcher.component.ts @@ -35,7 +35,7 @@ export class LensSwitcherComponent { public readonly mobile = input(false); protected readonly activeLens = this.lensService.activeLens; - protected readonly lenses = this.lensService.availableLenses; + protected readonly lenses = this.lensService.displayLenses; protected readonly isHybrid = this.lensService.isHybridPersona; protected readonly user = this.userService.user; protected readonly insightsUrl = buildInsightsUrl(); diff --git a/apps/lfx-one/src/app/shared/components/project-selector/project-selector.component.html b/apps/lfx-one/src/app/shared/components/project-selector/project-selector.component.html index ec9cde8de..4e1a29f62 100644 --- a/apps/lfx-one/src/app/shared/components/project-selector/project-selector.component.html +++ b/apps/lfx-one/src/app/shared/components/project-selector/project-selector.component.html @@ -24,10 +24,13 @@ @if (selectedRoleLabel()) { - + class="inline-flex items-center justify-center size-5 rounded-full bg-white border border-gray-200 text-gray-700 shrink-0 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"> + }
@@ -58,14 +61,14 @@ data-testid="project-search-input" /> - + @if (hybridMode()) { -
+
@for (tab of selectorTabs; track tab) {
@@ -120,7 +123,7 @@

{{ displayItem.item.name || displayItem.item.slug }}

@if (getRoleLabel(displayItem.item)) {

- {{ getRoleLabel(displayItem.item) }} + {{ getRoleLabel(displayItem.item) }}

}
diff --git a/apps/lfx-one/src/app/shared/components/project-selector/project-selector.component.ts b/apps/lfx-one/src/app/shared/components/project-selector/project-selector.component.ts index fbbe8a6af..e79f13637 100644 --- a/apps/lfx-one/src/app/shared/components/project-selector/project-selector.component.ts +++ b/apps/lfx-one/src/app/shared/components/project-selector/project-selector.component.ts @@ -1,6 +1,7 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT +import { NgClass } from '@angular/common'; import { Component, computed, inject, input, model, output, signal, Signal } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormControl, ReactiveFormsModule } from '@angular/forms'; @@ -25,7 +26,7 @@ type SelectorTab = 'all' | 'foundations' | 'projects'; @Component({ selector: 'lfx-project-selector', - imports: [ReactiveFormsModule, PopoverModule, InputTextModule, AutoFocus, OnRenderDirective, TooltipModule], + imports: [NgClass, ReactiveFormsModule, PopoverModule, InputTextModule, AutoFocus, OnRenderDirective, TooltipModule], templateUrl: './project-selector.component.html', styleUrl: './project-selector.component.scss', }) @@ -120,6 +121,9 @@ export class ProjectSelectorComponent { protected readonly hasMore: Signal = computed(() => { if (this.hybridMode()) { + const tab = this.activeTab(); + if (tab === 'foundations') return this.navigationService.hasMore('foundation')(); + if (tab === 'projects') return this.navigationService.hasMore('project')(); return this.navigationService.hasMore('foundation')() || this.navigationService.hasMore('project')(); } return this.navigationService.hasMore(this.lens())(); @@ -180,6 +184,18 @@ export class ProjectSelectorComponent { protected loadMore(): void { if (this.hybridMode()) { + const tab = this.activeTab(); + // Scope pagination to the active tab so the visible list keeps advancing instead of + // exhausting the inactive lens first. The All tab fetches whichever side still has pages, + // preferring foundations so they're complete before standalone projects pile on. + if (tab === 'foundations') { + this.navigationService.loadNextPage('foundation'); + return; + } + if (tab === 'projects') { + this.navigationService.loadNextPage('project'); + return; + } if (this.navigationService.hasMore('foundation')()) { this.navigationService.loadNextPage('foundation'); } else { @@ -257,26 +273,39 @@ export class ProjectSelectorComponent { const sortedFoundations = this.sortByRole(this.foundationItems()); const sortedProjects = this.sortByRole(this.rawProjectItems()); - // Build parentProjectUid → project uid map for nesting lookup + // Pre-group sortedProjects by parentProjectUid in a single pass so the nesting loop is O(F + P) + // instead of O(F × P). const detectedProjects = this.personaService.detectedProjects(); - const parentMap = new Map(); + const parentByProjectUid = new Map(); for (const dp of detectedProjects) { if (dp.parentProjectUid) { - parentMap.set(dp.projectUid, dp.parentProjectUid); + parentByProjectUid.set(dp.projectUid, dp.parentProjectUid); } } const foundationUidSet = new Set(sortedFoundations.map((f) => f.uid)); - const result: DisplayLensItem[] = []; + const childrenByFoundationUid = new Map(); const nestedProjectUids = new Set(); + for (const project of sortedProjects) { + const parentUid = parentByProjectUid.get(project.uid); + if (parentUid && foundationUidSet.has(parentUid)) { + const bucket = childrenByFoundationUid.get(parentUid); + if (bucket) { + bucket.push(project); + } else { + childrenByFoundationUid.set(parentUid, [project]); + } + nestedProjectUids.add(project.uid); + } + } + const result: DisplayLensItem[] = []; for (const foundation of sortedFoundations) { result.push({ item: foundation, isNested: false }); - for (const project of sortedProjects) { - const parentUid = parentMap.get(project.uid); - if (parentUid && parentUid === foundation.uid && foundationUidSet.has(foundation.uid)) { + const children = childrenByFoundationUid.get(foundation.uid); + if (children) { + for (const project of children) { result.push({ item: project, isNested: true }); - nestedProjectUids.add(project.uid); } } } diff --git a/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.html b/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.html index 9e60d5863..243398b35 100644 --- a/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.html +++ b/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.html @@ -44,11 +44,14 @@
@for (tag of personaLabels(); track $index) { + class="inline-flex items-center justify-center size-6 rounded-full bg-white border border-gray-200 text-gray-700 shrink-0 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"> } diff --git a/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.scss b/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.scss index fc9ae4e71..7fa3fcb43 100644 --- a/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.scss +++ b/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.scss @@ -22,4 +22,3 @@ width: max-content; white-space: nowrap; } - diff --git a/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.ts b/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.ts index 1957edc1f..664841f19 100644 --- a/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.ts +++ b/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.ts @@ -112,8 +112,7 @@ export class SidebarComponent { private initPersonaLabels(): Signal<{ label: string; icon: string; tooltip: string }[]> { return computed(() => { const personaProjects = this.personaService.personaProjects(); - const escapeHtml = (s: string) => - s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); + const escapeHtml = (s: string) => s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); const toTag = (p: PersonaType) => { const option = PERSONA_OPTIONS.find((o) => o.value === p); const label = option?.label ?? toTitleCase(p); @@ -121,9 +120,7 @@ export class SidebarComponent { const names = (personaProjects[p] ?? []).map((proj) => proj.projectName).filter((n): n is string => !!n); const header = `
${escapeHtml(label)}
`; const list = - names.length > 0 - ? `
    ${names.map((n) => `
  • ${escapeHtml(n)}
  • `).join('')}
` - : ''; + names.length > 0 ? `
    ${names.map((n) => `
  • ${escapeHtml(n)}
  • `).join('')}
` : ''; return { label, icon, tooltip: header + list }; }; diff --git a/apps/lfx-one/src/app/shared/services/lens.service.ts b/apps/lfx-one/src/app/shared/services/lens.service.ts index c7a6d7b05..c3cba85c2 100644 --- a/apps/lfx-one/src/app/shared/services/lens.service.ts +++ b/apps/lfx-one/src/app/shared/services/lens.service.ts @@ -21,7 +21,10 @@ export class LensService { /** Active lens clamped to the current persona's allowed set; falls back to default if disallowed. */ public readonly activeLens: Signal = this.initActiveLens(); + /** Full set of lenses the current persona is authorised to use — drives routing and downstream visibility filters. */ public readonly availableLenses: Signal = this.initAvailableLenses(); + /** Lenses shown in the sidebar switcher. Mirrors {@link availableLenses} except for hybrid personas, who get a merged project entry instead of separate foundation + project buttons. */ + public readonly displayLenses: Signal = this.initDisplayLenses(); /** True when the user holds both a board role (ED/Board Member) AND a project role (Maintainer/Contributor). */ public readonly isHybridPersona: Signal = computed(() => this.personaService.hasBoardRole() && this.personaService.hasProjectRole()); @@ -51,11 +54,14 @@ export class LensService { } private initAvailableLenses(): Signal { + return computed(() => this.getAllowedLensIds().map((id) => ALL_LENSES[id])); + } + + private initDisplayLenses(): Signal { return computed(() => { - const lensIds = this.getAllowedLensIds(); + const lenses = this.availableLenses(); // For hybrid personas the 'project' button serves as the merged entry — hide the separate foundation button. - const filtered = this.isHybridPersona() ? lensIds.filter((id) => id !== 'foundation') : lensIds; - return filtered.map((id) => ALL_LENSES[id]); + return this.isHybridPersona() ? lenses.filter((option) => option.id !== 'foundation') : lenses; }); } From ff3ea630c7b63612070f4304f01e4675b359e5e4 Mon Sep 17 00:00:00 2001 From: Nuno Eufrasio Date: Fri, 15 May 2026 10:44:12 +0100 Subject: [PATCH 05/11] refactor(nav): apply second-round review feedback on lens merge - Precompute per-row selector state (isSelected, roleLabel, roleIcon) into DisplayLensItem so the template stops re-invoking methods on every change-detection pass. - Move DisplayLensItem + SelectorTab into @lfx-one/shared/interfaces. - Restore the initializeXxx() pattern for complex computeds in project-selector so the field block stays scannable. - Replace lens-switcher.isLensActive() method with an activeLensId computed and use it directly in templates. - Replace sidebar persona-tooltip HTML-string building (with [escape]=false) with structured data + an inline rendered through pTooltip, so Angular handles escaping. - Collapse the two remaining multi-line inline comments to single lines. Signed-off-by: Nuno Eufrasio --- .../lens-switcher.component.html | 7 +- .../lens-switcher/lens-switcher.component.ts | 17 +- .../project-selector.component.html | 18 +- .../project-selector.component.ts | 222 +++++++++--------- .../components/sidebar/sidebar.component.html | 13 +- .../components/sidebar/sidebar.component.ts | 10 +- .../shared/src/interfaces/lens.interface.ts | 20 ++ 7 files changed, 168 insertions(+), 139 deletions(-) diff --git a/apps/lfx-one/src/app/shared/components/lens-switcher/lens-switcher.component.html b/apps/lfx-one/src/app/shared/components/lens-switcher/lens-switcher.component.html index f0c5f1931..b4d23949c 100644 --- a/apps/lfx-one/src/app/shared/components/lens-switcher/lens-switcher.component.html +++ b/apps/lfx-one/src/app/shared/components/lens-switcher/lens-switcher.component.html @@ -4,13 +4,14 @@ @if (mobile()) {
@for (lens of lenses(); track lens.id) { + @let isActive = activeLensId() === lens.id; } @@ -65,7 +66,7 @@
@for (lens of lenses(); track lens.id) { - @let isActive = isLensActive(lens.id); + @let isActive = activeLensId() === lens.id;
} @empty { diff --git a/apps/lfx-one/src/app/shared/components/project-selector/project-selector.component.ts b/apps/lfx-one/src/app/shared/components/project-selector/project-selector.component.ts index e79f13637..1708836ac 100644 --- a/apps/lfx-one/src/app/shared/components/project-selector/project-selector.component.ts +++ b/apps/lfx-one/src/app/shared/components/project-selector/project-selector.component.ts @@ -6,7 +6,7 @@ import { Component, computed, inject, input, model, output, signal, Signal } fro import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormControl, ReactiveFormsModule } from '@angular/forms'; import { BOARD_SCOPED_PERSONA_PRIORITY, PROJECT_SCOPED_PERSONA_PRIORITY } from '@lfx-one/shared/constants'; -import { LensItem, NavLens, PersonaType, ProjectContext } from '@lfx-one/shared/interfaces'; +import { DisplayLensItem, LensItem, NavLens, PersonaType, ProjectContext, SelectorTab } from '@lfx-one/shared/interfaces'; import { LensService } from '@services/lens.service'; import { NavigationService } from '@services/navigation.service'; import { PersonaService } from '@services/persona.service'; @@ -17,13 +17,6 @@ import { InputTextModule } from 'primeng/inputtext'; import { Popover, PopoverModule } from 'primeng/popover'; import { TooltipModule } from 'primeng/tooltip'; -export interface DisplayLensItem { - item: LensItem; - isNested: boolean; -} - -type SelectorTab = 'all' | 'foundations' | 'projects'; - @Component({ selector: 'lfx-project-selector', imports: [NgClass, ReactiveFormsModule, PopoverModule, InputTextModule, AutoFocus, OnRenderDirective, TooltipModule], @@ -49,101 +42,27 @@ export class ProjectSelectorComponent { protected readonly selectorTabs: readonly SelectorTab[] = ['all', 'foundations', 'projects']; protected readonly searchControl = new FormControl('', { nonNullable: true }); - protected readonly panelStyleClass = computed(() => - this.userService.impersonating() ? 'project-selector-panel project-selector-panel--with-banner' : 'project-selector-panel' - ); - - protected readonly lensTypeLabel = computed(() => { - if (this.hybridMode()) { - const selectedUid = this.selectedProject()?.uid; - if (selectedUid) { - const detected = this.personaService.detectedProjects().find((p) => p.projectUid === selectedUid); - if (detected) { - return detected.isFoundation ? 'Foundation' : 'Project'; - } - } - return this.lensService.activeLens() === 'foundation' ? 'Foundation' : 'Project'; - } - return this.lens() === 'foundation' ? 'Foundation' : 'Project'; - }); - - protected readonly displayName: Signal = computed(() => { - const project = this.selectedProject(); - return project?.name?.trim() || `Select ${this.lensTypeLabel()}`; - }); - + protected readonly panelStyleClass: Signal = this.initPanelStyleClass(); + protected readonly lensTypeLabel: Signal = this.initLensTypeLabel(); + protected readonly displayName: Signal = this.initDisplayName(); protected readonly displayLogo: Signal = computed(() => this.selectedProject()?.logoUrl || ''); - - protected readonly selectedRolePersona: Signal = computed(() => { - const uid = this.selectedProject()?.uid; - if (!uid) return null; - const detected = this.personaService.detectedProjects().find((p) => p.projectUid === uid); - const isFoundation = detected?.isFoundation ?? this.lensService.activeLens() === 'foundation'; - const priority = isFoundation ? BOARD_SCOPED_PERSONA_PRIORITY : PROJECT_SCOPED_PERSONA_PRIORITY; - const personaProjects = this.personaService.personaProjects(); - for (const persona of priority) { - if ((personaProjects[persona] ?? []).some((p) => p.projectUid === uid)) { - return persona; - } - } - return null; - }); - + protected readonly selectedRolePersona: Signal = this.initSelectedRolePersona(); protected readonly selectedRoleLabel: Signal = computed(() => { const persona = this.selectedRolePersona(); return persona ? this.personaTypeToLabel(persona) : ''; }); - protected readonly selectedRoleIcon: Signal = computed(() => { const persona = this.selectedRolePersona(); return persona ? this.personaTypeToIcon(persona) : ''; }); - protected readonly foundationItems: Signal = computed(() => (this.hybridMode() ? this.navigationService.items('foundation')() : [])); - - // The project-lens API can include foundations the user has access to. Those belong in the - // foundation lens (or the Foundations tab in hybrid mode), never in the projects list. - protected readonly rawProjectItems: Signal = computed(() => { - const lens: NavLens = this.hybridMode() ? 'project' : this.lens(); - const items = this.navigationService.items(lens)(); - return lens === 'project' ? items.filter((item) => !item.isFoundation) : items; - }); - - // Kept for template backward-compat (auto-load sentinel uses items() in non-hybrid path) + protected readonly foundationItems: Signal = this.initFoundationItems(); + protected readonly rawProjectItems: Signal = this.initRawProjectItems(); protected readonly items: Signal = computed(() => this.rawProjectItems()); + protected readonly loading: Signal = this.initLoading(); + protected readonly hasMore: Signal = this.initHasMore(); + protected readonly displayedItems: Signal = this.initDisplayedItems(); - protected readonly loading: Signal = computed(() => { - if (this.hybridMode()) { - return this.navigationService.loading('foundation')() || this.navigationService.loading('project')(); - } - return this.navigationService.loading(this.lens())(); - }); - - protected readonly hasMore: Signal = computed(() => { - if (this.hybridMode()) { - const tab = this.activeTab(); - if (tab === 'foundations') return this.navigationService.hasMore('foundation')(); - if (tab === 'projects') return this.navigationService.hasMore('project')(); - return this.navigationService.hasMore('foundation')() || this.navigationService.hasMore('project')(); - } - return this.navigationService.hasMore(this.lens())(); - }); - - protected readonly displayedItems: Signal = computed(() => { - if (!this.hybridMode()) { - return this.sortByRole(this.rawProjectItems()).map((item) => ({ item, isNested: false })); - } - const tab = this.activeTab(); - if (tab === 'foundations') { - return this.sortByRole(this.foundationItems()).map((item) => ({ item, isNested: false })); - } - if (tab === 'projects') { - return this.sortByRole(this.rawProjectItems()).map((item) => ({ item, isNested: false })); - } - return this.buildAllTabItems(); - }); - - // Sentinel shifts on each page load so Angular re-creates OnRenderDirective and re-fires the fetch. protected readonly autoLoadTriggerIndex: Signal = computed(() => Math.max(0, this.displayedItems().length - 8)); public constructor() { @@ -185,9 +104,7 @@ export class ProjectSelectorComponent { protected loadMore(): void { if (this.hybridMode()) { const tab = this.activeTab(); - // Scope pagination to the active tab so the visible list keeps advancing instead of - // exhausting the inactive lens first. The All tab fetches whichever side still has pages, - // preferring foundations so they're complete before standalone projects pile on. + // All-tab drains foundations first so the higher-priority group completes before standalone projects appear. if (tab === 'foundations') { this.navigationService.loadNextPage('foundation'); return; @@ -206,18 +123,110 @@ export class ProjectSelectorComponent { } } - protected isItemSelected(item: LensItem): boolean { - return this.selectedProject()?.uid === item.uid; + private initPanelStyleClass(): Signal { + return computed(() => (this.userService.impersonating() ? 'project-selector-panel project-selector-panel--with-banner' : 'project-selector-panel')); } - protected getRoleLabel(item: LensItem): string { - const persona = this.resolveRolePersona(item); - return persona ? this.personaTypeToLabel(persona) : ''; + private initLensTypeLabel(): Signal { + return computed(() => { + if (this.hybridMode()) { + const selectedUid = this.selectedProject()?.uid; + if (selectedUid) { + const detected = this.personaService.detectedProjects().find((p) => p.projectUid === selectedUid); + if (detected) { + return detected.isFoundation ? 'Foundation' : 'Project'; + } + } + return this.lensService.activeLens() === 'foundation' ? 'Foundation' : 'Project'; + } + return this.lens() === 'foundation' ? 'Foundation' : 'Project'; + }); + } + + private initDisplayName(): Signal { + return computed(() => { + const project = this.selectedProject(); + return project?.name?.trim() || `Select ${this.lensTypeLabel()}`; + }); + } + + private initSelectedRolePersona(): Signal { + return computed(() => { + const uid = this.selectedProject()?.uid; + if (!uid) return null; + const detected = this.personaService.detectedProjects().find((p) => p.projectUid === uid); + const isFoundation = detected?.isFoundation ?? this.lensService.activeLens() === 'foundation'; + const priority = isFoundation ? BOARD_SCOPED_PERSONA_PRIORITY : PROJECT_SCOPED_PERSONA_PRIORITY; + const personaProjects = this.personaService.personaProjects(); + for (const persona of priority) { + if ((personaProjects[persona] ?? []).some((p) => p.projectUid === uid)) { + return persona; + } + } + return null; + }); + } + + private initFoundationItems(): Signal { + return computed(() => (this.hybridMode() ? this.navigationService.items('foundation')() : [])); } - protected getRoleIcon(item: LensItem): string { + private initRawProjectItems(): Signal { + return computed(() => { + // /project lens API returns foundations too — keep them only in the foundation list/tab. + const lens: NavLens = this.hybridMode() ? 'project' : this.lens(); + const items = this.navigationService.items(lens)(); + return lens === 'project' ? items.filter((item) => !item.isFoundation) : items; + }); + } + + private initLoading(): Signal { + return computed(() => { + if (this.hybridMode()) { + return this.navigationService.loading('foundation')() || this.navigationService.loading('project')(); + } + return this.navigationService.loading(this.lens())(); + }); + } + + private initHasMore(): Signal { + return computed(() => { + if (this.hybridMode()) { + const tab = this.activeTab(); + if (tab === 'foundations') return this.navigationService.hasMore('foundation')(); + if (tab === 'projects') return this.navigationService.hasMore('project')(); + return this.navigationService.hasMore('foundation')() || this.navigationService.hasMore('project')(); + } + return this.navigationService.hasMore(this.lens())(); + }); + } + + private initDisplayedItems(): Signal { + return computed(() => { + const selectedUid = this.selectedProject()?.uid ?? null; + if (!this.hybridMode()) { + return this.sortByRole(this.rawProjectItems()).map((item) => this.toDisplayItem(item, false, selectedUid)); + } + const tab = this.activeTab(); + if (tab === 'foundations') { + return this.sortByRole(this.foundationItems()).map((item) => this.toDisplayItem(item, false, selectedUid)); + } + if (tab === 'projects') { + return this.sortByRole(this.rawProjectItems()).map((item) => this.toDisplayItem(item, false, selectedUid)); + } + return this.buildAllTabItems(selectedUid); + }); + } + + private toDisplayItem(item: LensItem, isNested: boolean, selectedUid: string | null): DisplayLensItem { const persona = this.resolveRolePersona(item); - return persona ? this.personaTypeToIcon(persona) : ''; + return { + item, + isNested, + isSelected: selectedUid === item.uid, + roleLabel: persona ? this.personaTypeToLabel(persona) : '', + roleIcon: persona ? this.personaTypeToIcon(persona) : '', + }; } private resolveRolePersona(item: LensItem): PersonaType | null { @@ -269,12 +278,11 @@ export class ProjectSelectorComponent { }); } - private buildAllTabItems(): DisplayLensItem[] { + private buildAllTabItems(selectedUid: string | null): DisplayLensItem[] { const sortedFoundations = this.sortByRole(this.foundationItems()); const sortedProjects = this.sortByRole(this.rawProjectItems()); - // Pre-group sortedProjects by parentProjectUid in a single pass so the nesting loop is O(F + P) - // instead of O(F × P). + // Pre-group projects by parent foundation in a single pass so nesting is O(F + P), not O(F × P). const detectedProjects = this.personaService.detectedProjects(); const parentByProjectUid = new Map(); for (const dp of detectedProjects) { @@ -301,18 +309,18 @@ export class ProjectSelectorComponent { const result: DisplayLensItem[] = []; for (const foundation of sortedFoundations) { - result.push({ item: foundation, isNested: false }); + result.push(this.toDisplayItem(foundation, false, selectedUid)); const children = childrenByFoundationUid.get(foundation.uid); if (children) { for (const project of children) { - result.push({ item: project, isNested: true }); + result.push(this.toDisplayItem(project, true, selectedUid)); } } } for (const project of sortedProjects) { if (!nestedProjectUids.has(project.uid)) { - result.push({ item: project, isNested: false }); + result.push(this.toDisplayItem(project, false, selectedUid)); } } diff --git a/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.html b/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.html index 243398b35..e56600203 100644 --- a/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.html +++ b/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.html @@ -43,13 +43,22 @@ } @else {
@for (tag of personaLabels(); track $index) { + +
{{ tag.label }}
+ @if (tag.names.length > 0) { +
    + @for (name of tag.names; track name) { +
  • {{ name }}
  • + } +
+ } +
diff --git a/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.ts b/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.ts index 664841f19..94df198b7 100644 --- a/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.ts +++ b/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.ts @@ -57,7 +57,7 @@ export class SidebarComponent { protected readonly user = this.userService.user; protected readonly userInitials = this.userService.userInitials; - protected readonly personaLabels: Signal<{ label: string; icon: string; tooltip: string }[]> = this.initPersonaLabels(); + protected readonly personaLabels: Signal<{ label: string; icon: string; names: string[] }[]> = this.initPersonaLabels(); // Hide the persona badge when the user is a root-writer — executive-director is spoofed, not naturally detected. protected readonly showPersonaBadge: Signal = computed(() => !this.personaService.isRootWriter()); @@ -109,19 +109,15 @@ export class SidebarComponent { }); } - private initPersonaLabels(): Signal<{ label: string; icon: string; tooltip: string }[]> { + private initPersonaLabels(): Signal<{ label: string; icon: string; names: string[] }[]> { return computed(() => { const personaProjects = this.personaService.personaProjects(); - const escapeHtml = (s: string) => s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); const toTag = (p: PersonaType) => { const option = PERSONA_OPTIONS.find((o) => o.value === p); const label = option?.label ?? toTitleCase(p); const icon = PERSONA_ICONS[p] ?? 'fa-light fa-user'; const names = (personaProjects[p] ?? []).map((proj) => proj.projectName).filter((n): n is string => !!n); - const header = `
${escapeHtml(label)}
`; - const list = - names.length > 0 ? `
    ${names.map((n) => `
  • ${escapeHtml(n)}
  • `).join('')}
` : ''; - return { label, icon, tooltip: header + list }; + return { label, icon, names }; }; if (this.activeLens() === 'me') { diff --git a/packages/shared/src/interfaces/lens.interface.ts b/packages/shared/src/interfaces/lens.interface.ts index 20c7c57a6..27551d421 100644 --- a/packages/shared/src/interfaces/lens.interface.ts +++ b/packages/shared/src/interfaces/lens.interface.ts @@ -1,6 +1,8 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT +import { LensItem } from './navigation.interface'; + /** * Navigation lens types * Each lens represents a top-level navigation context that determines sidebar content @@ -26,3 +28,21 @@ export interface LensOption { /** Test ID for the lens button */ testId: string; } + +/** + * Tab options for the project selector in hybrid persona mode + */ +export type SelectorTab = 'all' | 'foundations' | 'projects'; + +/** + * Precomputed per-row state for the project selector dropdown. + * All fields are derived once in the displayedItems computed so the template binds to plain + * values — no functions in template bindings (signals-first / zoneless memoization). + */ +export interface DisplayLensItem { + item: LensItem; + isNested: boolean; + isSelected: boolean; + roleLabel: string; + roleIcon: string; +} From c58021d2418bc5333388ddebc8b48c97e263b785 Mon Sep 17 00:00:00 2001 From: Nuno Eufrasio Date: Fri, 15 May 2026 10:55:15 +0100 Subject: [PATCH 06/11] fix(nav): address third-round review feedback on lens merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use a type-only import in lens.interface.ts to break a runtime circular dependency with navigation.interface.ts. - Reorder sidebar.onItemSelected so the project/foundation context is set before the lens flips. NavigationService injects selected_uid from ProjectContextService at reload time, and the previous ordering let the lens reload run with the stale selection. - Add a preloadSibling() path in NavigationService that triggers a reload without setting pendingDefaultSelection and bails when the sibling lens is already loaded or in flight, so hybrid preload can't race with the active lens or overwrite the URL/context. - Hybrid-aware emptyMessage on the project selector — show 'No results found' instead of a single-lens-specific copy when the dropdown is rendering mixed All/Foundations/Projects tabs. Signed-off-by: Nuno Eufrasio --- .../components/sidebar/sidebar.component.html | 2 +- .../components/sidebar/sidebar.component.ts | 6 ++++-- .../app/shared/services/navigation.service.ts | 16 +++++++++++++--- packages/shared/src/interfaces/lens.interface.ts | 2 +- 4 files changed, 19 insertions(+), 7 deletions(-) diff --git a/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.html b/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.html index e56600203..63d4fe12e 100644 --- a/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.html +++ b/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.html @@ -22,7 +22,7 @@ [hybridMode]="isHybridPersona()" [selectedProject]="selectedProject()" [searchPlaceholder]="isHybridPersona() ? 'Search...' : lens === 'foundation' ? 'Search foundations...' : 'Search projects...'" - [emptyMessage]="lens === 'foundation' ? 'No foundations found' : 'No projects found'" + [emptyMessage]="isHybridPersona() ? 'No results found' : lens === 'foundation' ? 'No foundations found' : 'No projects found'" [(isPanelOpen)]="selectorPanelOpen" (itemSelected)="onItemSelected($event)"> } diff --git a/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.ts b/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.ts index 94df198b7..67a9ae689 100644 --- a/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.ts +++ b/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.ts @@ -84,12 +84,14 @@ export class SidebarComponent { protected onItemSelected(item: LensItem): void { const context = lensItemToProjectContext(item); + // Set the context before switching lenses: NavigationService injects selected_uid from + // ProjectContextService at reload time, so the lens flip must see the clicked UID. if (item.isFoundation) { - this.lensService.setLens('foundation'); this.projectContextService.setFoundation(context); + this.lensService.setLens('foundation'); } else { - this.lensService.setLens('project'); this.projectContextService.setProject(context); + this.lensService.setLens('project'); } } diff --git a/apps/lfx-one/src/app/shared/services/navigation.service.ts b/apps/lfx-one/src/app/shared/services/navigation.service.ts index b134f29ab..ea0f5386f 100644 --- a/apps/lfx-one/src/app/shared/services/navigation.service.ts +++ b/apps/lfx-one/src/app/shared/services/navigation.service.ts @@ -39,11 +39,11 @@ export class NavigationService { tap((lens) => { this.resetAndReload(lens); // For hybrid personas, preload the sibling lens so the merged dropdown has both sets ready. + // preloadSibling() skips default-selection side effects so it can't overwrite the active + // lens's context/URL, and it bails out if the sibling is already loaded or in flight. if (this.lensService.isHybridPersona()) { const sibling: NavLens = lens === 'foundation' ? 'project' : 'foundation'; - if (!this.getState(sibling).loaded()) { - this.resetAndReload(sibling); - } + this.preloadSibling(sibling); } }) ), @@ -102,6 +102,16 @@ export class NavigationService { state.reload$.next(); } + private preloadSibling(lens: NavLens): void { + const state = this.getState(lens); + if (state.loaded() || state.loading()) { + return; + } + // Deliberately do NOT set pendingDefaultSelection — preload must not race with the active + // lens's selection and can't be allowed to overwrite the URL/context. + state.reload$.next(); + } + private getState(lens: NavLens): LensState { return lens === 'foundation' ? this.foundationState : this.projectState; } diff --git a/packages/shared/src/interfaces/lens.interface.ts b/packages/shared/src/interfaces/lens.interface.ts index 27551d421..5983e66df 100644 --- a/packages/shared/src/interfaces/lens.interface.ts +++ b/packages/shared/src/interfaces/lens.interface.ts @@ -1,7 +1,7 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT -import { LensItem } from './navigation.interface'; +import type { LensItem } from './navigation.interface'; /** * Navigation lens types From 99b578faafeca916f324689ed1560e441456b2d3 Mon Sep 17 00:00:00 2001 From: Nuno Eufrasio Date: Fri, 15 May 2026 11:48:22 +0100 Subject: [PATCH 07/11] fix(nav): fall back to user persona when item lookup misses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For non-hybrid users, personaProjects only contains projects with explicit role detections, but the navigation API returns every accessible project. The role badge/description was missing whenever a listed item had no detection entry — even though the user's scope (board-only or project-only) makes the role unambiguous. When the per-item lookup misses for a non-hybrid user, surface the highest-priority persona they hold (executive-director → board-member, maintainer → contributor) so every row in the dropdown and the trigger badge consistently show the role icon and label. Generated with [Claude Code](https://claude.ai/code) Signed-off-by: Nuno Eufrasio --- .../project-selector.component.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/apps/lfx-one/src/app/shared/components/project-selector/project-selector.component.ts b/apps/lfx-one/src/app/shared/components/project-selector/project-selector.component.ts index 1708836ac..0261954a7 100644 --- a/apps/lfx-one/src/app/shared/components/project-selector/project-selector.component.ts +++ b/apps/lfx-one/src/app/shared/components/project-selector/project-selector.component.ts @@ -163,7 +163,7 @@ export class ProjectSelectorComponent { return persona; } } - return null; + return this.fallbackRolePersona(priority); }); } @@ -237,6 +237,22 @@ export class ProjectSelectorComponent { return persona; } } + return this.fallbackRolePersona(priority); + } + + // personaProjects only covers projects with explicit detections, but the navigation API returns + // every accessible project. For non-hybrid users the scope is unambiguous (board-only or + // project-only), so we surface the highest-priority persona they hold within that scope. + private fallbackRolePersona(priority: readonly PersonaType[]): PersonaType | null { + if (this.hybridMode()) { + return null; + } + const allPersonas = this.personaService.allPersonas(); + for (const persona of priority) { + if (allPersonas.includes(persona)) { + return persona; + } + } return null; } From a9b049da311aa5d42066b487a48a12de5130b1b5 Mon Sep 17 00:00:00 2001 From: Nuno Eufrasio Date: Fri, 15 May 2026 12:07:28 +0100 Subject: [PATCH 08/11] fix(nav): address PR #713 review feedback - project-selector.component.html: remove nested tabindex="0" from role badge span inside trigger button; the parent button already handles focus/hover for the pTooltip, so the inner span doesn't need its own tab stop (invalid HTML). - navigation.service.ts: add hybridTransitionPreloader so the sibling lens preloads when a persona refresh promotes the user to hybrid mid-session without an activeLens change. Signed-off-by: Nuno Eufrasio --- .../project-selector.component.html | 3 +-- .../app/shared/services/navigation.service.ts | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/apps/lfx-one/src/app/shared/components/project-selector/project-selector.component.html b/apps/lfx-one/src/app/shared/components/project-selector/project-selector.component.html index a476e7444..2cbfc3201 100644 --- a/apps/lfx-one/src/app/shared/components/project-selector/project-selector.component.html +++ b/apps/lfx-one/src/app/shared/components/project-selector/project-selector.component.html @@ -24,12 +24,11 @@ @if (selectedRoleLabel()) { + class="inline-flex items-center justify-center size-5 rounded-full bg-white border border-gray-200 text-gray-700 shrink-0"> } diff --git a/apps/lfx-one/src/app/shared/services/navigation.service.ts b/apps/lfx-one/src/app/shared/services/navigation.service.ts index ea0f5386f..2335f161a 100644 --- a/apps/lfx-one/src/app/shared/services/navigation.service.ts +++ b/apps/lfx-one/src/app/shared/services/navigation.service.ts @@ -63,6 +63,24 @@ export class NavigationService { { initialValue: false } ); + // Persona refresh can promote a user to hybrid without changing activeLens — in that case the + // activeLensPreloader doesn't re-run, leaving the sibling lens unloaded and the merged tabs empty. + private readonly hybridTransitionPreloader = toSignal( + toObservable(this.lensService.isHybridPersona).pipe( + distinctUntilChanged(), + skip(1), + filter((isHybrid) => isHybrid), + tap(() => { + const active = this.lensService.activeLens(); + if (active === 'foundation' || active === 'project') { + const sibling: NavLens = active === 'foundation' ? 'project' : 'foundation'; + this.preloadSibling(sibling); + } + }) + ), + { initialValue: false } + ); + public items(lens: NavLens): Signal { return this.getState(lens).items; } From 100c82e1889e412594290f3b684f9531e8947642 Mon Sep 17 00:00:00 2001 From: Nuno Eufrasio Date: Fri, 15 May 2026 20:22:36 +0100 Subject: [PATCH 09/11] fix(nav): address PR #713 fourth-round review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sidebar.component.ts: project-only users see foundations in their project list (NavigationService only filters foundations out when the foundation lens is visible). onItemSelected now treats foundation rows as a project context for those users so setLens('foundation') doesn't silently no-op and leave the selection unchanged. - sidebar.component.html: switch persona tooltip @for from `track name` to `track $index` — project/foundation names can legitimately repeat and would produce duplicate track keys. Signed-off-by: Nuno Eufrasio --- .../app/shared/components/sidebar/sidebar.component.html | 2 +- .../app/shared/components/sidebar/sidebar.component.ts | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.html b/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.html index 63d4fe12e..bcbee5f6c 100644 --- a/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.html +++ b/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.html @@ -47,7 +47,7 @@
{{ tag.label }}
@if (tag.names.length > 0) {
    - @for (name of tag.names; track name) { + @for (name of tag.names; track $index) {
  • {{ name }}
  • }
diff --git a/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.ts b/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.ts index 67a9ae689..511e115c2 100644 --- a/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.ts +++ b/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.ts @@ -84,9 +84,11 @@ export class SidebarComponent { protected onItemSelected(item: LensItem): void { const context = lensItemToProjectContext(item); - // Set the context before switching lenses: NavigationService injects selected_uid from - // ProjectContextService at reload time, so the lens flip must see the clicked UID. - if (item.isFoundation) { + // Project-only users still see foundations in their project list (NavigationService only filters + // foundations out when the foundation lens is visible). Treat a foundation row as a project context + // for those users — setLens('foundation') would be a no-op and the selection would silently fail. + const foundationAllowed = this.lensService.availableLenses().some((option) => option.id === 'foundation'); + if (item.isFoundation && foundationAllowed) { this.projectContextService.setFoundation(context); this.lensService.setLens('foundation'); } else { From f07259ed579a71cf20dc4c18522311458dd9b8bf Mon Sep 17 00:00:00 2001 From: Nuno Eufrasio Date: Fri, 15 May 2026 20:34:24 +0100 Subject: [PATCH 10/11] fix(nav): address PR #713 fifth-round review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - project-selector.component.ts: remove unused `items` signal alias — template binds to `displayedItems()` and the class never reads it. - sidebar.component.ts/html: include the associated project names in the persona badge `aria-label` so screen-reader users get the same info as the hover tooltip without relying on hover-only UI. Signed-off-by: Nuno Eufrasio --- .../project-selector/project-selector.component.ts | 1 - .../app/shared/components/sidebar/sidebar.component.html | 2 +- .../src/app/shared/components/sidebar/sidebar.component.ts | 7 ++++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/lfx-one/src/app/shared/components/project-selector/project-selector.component.ts b/apps/lfx-one/src/app/shared/components/project-selector/project-selector.component.ts index 0261954a7..59e25f2cf 100644 --- a/apps/lfx-one/src/app/shared/components/project-selector/project-selector.component.ts +++ b/apps/lfx-one/src/app/shared/components/project-selector/project-selector.component.ts @@ -58,7 +58,6 @@ export class ProjectSelectorComponent { protected readonly foundationItems: Signal = this.initFoundationItems(); protected readonly rawProjectItems: Signal = this.initRawProjectItems(); - protected readonly items: Signal = computed(() => this.rawProjectItems()); protected readonly loading: Signal = this.initLoading(); protected readonly hasMore: Signal = this.initHasMore(); protected readonly displayedItems: Signal = this.initDisplayedItems(); diff --git a/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.html b/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.html index bcbee5f6c..ab9ac91c4 100644 --- a/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.html +++ b/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.html @@ -56,7 +56,7 @@ = this.initPersonaLabels(); + protected readonly personaLabels: Signal<{ label: string; icon: string; names: string[]; ariaLabel: string }[]> = this.initPersonaLabels(); // Hide the persona badge when the user is a root-writer — executive-director is spoofed, not naturally detected. protected readonly showPersonaBadge: Signal = computed(() => !this.personaService.isRootWriter()); @@ -113,7 +113,7 @@ export class SidebarComponent { }); } - private initPersonaLabels(): Signal<{ label: string; icon: string; names: string[] }[]> { + private initPersonaLabels(): Signal<{ label: string; icon: string; names: string[]; ariaLabel: string }[]> { return computed(() => { const personaProjects = this.personaService.personaProjects(); const toTag = (p: PersonaType) => { @@ -121,7 +121,8 @@ export class SidebarComponent { const label = option?.label ?? toTitleCase(p); const icon = PERSONA_ICONS[p] ?? 'fa-light fa-user'; const names = (personaProjects[p] ?? []).map((proj) => proj.projectName).filter((n): n is string => !!n); - return { label, icon, names }; + const ariaLabel = names.length ? `Role: ${label} (${names.join(', ')})` : `Role: ${label}`; + return { label, icon, names, ariaLabel }; }; if (this.activeLens() === 'me') { From 9fc554b2297be08cff395ae8052bc72a0e6f5413 Mon Sep 17 00:00:00 2001 From: Nuno Eufrasio Date: Fri, 15 May 2026 20:46:05 +0100 Subject: [PATCH 11/11] fix(nav): stop double-filtering foundations from project lens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NavigationService.applyVisibilityFilters already strips foundations from the project lens for users who can switch to the foundation lens (hybrid + ED/Board-only). For project-only users it intentionally keeps them so they can still be selected. The component-level filter was redundant for the first group and silently hid foundations from project-only users — also bypassing sidebar.onItemSelected's path for handling foundation rows in that case. Signed-off-by: Nuno Eufrasio --- .../project-selector/project-selector.component.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/apps/lfx-one/src/app/shared/components/project-selector/project-selector.component.ts b/apps/lfx-one/src/app/shared/components/project-selector/project-selector.component.ts index 59e25f2cf..09dfdd2b3 100644 --- a/apps/lfx-one/src/app/shared/components/project-selector/project-selector.component.ts +++ b/apps/lfx-one/src/app/shared/components/project-selector/project-selector.component.ts @@ -171,12 +171,8 @@ export class ProjectSelectorComponent { } private initRawProjectItems(): Signal { - return computed(() => { - // /project lens API returns foundations too — keep them only in the foundation list/tab. - const lens: NavLens = this.hybridMode() ? 'project' : this.lens(); - const items = this.navigationService.items(lens)(); - return lens === 'project' ? items.filter((item) => !item.isFoundation) : items; - }); + // NavigationService.applyVisibilityFilters already filters foundations from the project lens when the foundation lens is available; re-filtering here would hide rows project-only users are meant to select. + return computed(() => this.navigationService.items(this.hybridMode() ? 'project' : this.lens())()); } private initLoading(): Signal {