diff --git a/docs/implplan/SPRINT_20260310_024_FE_topbar_status_chip_ownership_split.md b/docs/implplan/SPRINT_20260310_024_FE_topbar_status_chip_ownership_split.md new file mode 100644 index 000000000..a891aaf6d --- /dev/null +++ b/docs/implplan/SPRINT_20260310_024_FE_topbar_status_chip_ownership_split.md @@ -0,0 +1,44 @@ +# Sprint 20260310-024 - FE Topbar Status Chip Ownership Split + +## Topic & Scope +- Finish and verify the topbar/context-controls split so global scope selectors stay in `ContextChipsComponent` while status chips live in the authenticated topbar row. +- Capture the missed verification work for the already-implemented layout refactor and keep the commit scoped to the Web layout layer. +- Working directory: `src/Web/StellaOps.Web`. +- Allowed coordination edits: `docs/implplan/SPRINT_20260310_024_FE_topbar_status_chip_ownership_split.md`. +- Expected evidence: focused Angular layout specs and a live authenticated Playwright shell check. + +## Dependencies & Concurrency +- Depends on the rebuilt local shell being reachable through `https://stella-ops.local`. +- Safe parallelism: avoid mixing unrelated Mission Control or search work into this layout cleanup iteration. + +## Documentation Prerequisites +- `AGENTS.md` +- `docs/qa/feature-checks/FLOW.md` + +## Delivery Tracker + +### FE-TOPBAR-SPLIT-001 - Finalize topbar ownership for status chips +Status: DONE +Dependency: none +Owners: QA, Developer, Product Manager +Task description: +- Verify the refactor that moved system-status chips out of `ContextChipsComponent` and into the authenticated topbar row, then bring the stale unit tests and component comments in line with the new ownership split. + +Completion criteria: +- [x] `ContextChipsComponent` describes and renders only shared scope controls. +- [x] `AppTopbarComponent` focused spec covers the authenticated status-chip row. +- [x] Live authenticated shell check confirms tenant, context controls, and status chips render together without console errors. + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-03-10 | Sprint created while auditing forgotten uncommitted Web files; confirmed the live shell already renders tenant, context controls, and five status chips after authentication. | Developer | +| 2026-03-10 | Updated the stale topbar/context-chips specs to match the ownership split, aligned the `ContextChipsComponent` contract text/ARIA label, and reran the focused Angular layout tests with `7/7` passing. | QA | + +## Decisions & Risks +- Decision: keep status chips in the topbar rather than `ContextChipsComponent` so layout can separate persistent shell health state from the interactive scope controls. +- Risk: unit tests can drift again when shell ownership changes without matching spec updates. +- Mitigation: keep focused topbar/context-chips specs near the components and validate the live authenticated shell with Playwright when ownership changes. + +## Next Checkpoints +- Run the focused layout specs and commit the topbar/context-control split as its own FE iteration. diff --git a/src/Web/StellaOps.Web/src/app/layout/app-topbar/app-topbar.component.spec.ts b/src/Web/StellaOps.Web/src/app/layout/app-topbar/app-topbar.component.spec.ts index a1db2fdaa..599b7fd71 100644 --- a/src/Web/StellaOps.Web/src/app/layout/app-topbar/app-topbar.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/layout/app-topbar/app-topbar.component.spec.ts @@ -19,6 +19,21 @@ class StubContextChipsComponent {} @Component({ selector: 'app-user-menu', standalone: true, template: '' }) class StubUserMenuComponent {} +@Component({ selector: 'app-live-event-stream-chip', standalone: true, template: 'Events: CONNECTED' }) +class StubLiveEventStreamChipComponent {} + +@Component({ selector: 'app-policy-baseline-chip', standalone: true, template: 'Policy: Core Policy Pack latest' }) +class StubPolicyBaselineChipComponent {} + +@Component({ selector: 'app-evidence-mode-chip', standalone: true, template: 'Evidence: ON' }) +class StubEvidenceModeChipComponent {} + +@Component({ selector: 'app-feed-snapshot-chip', standalone: true, template: 'Feed: Live' }) +class StubFeedSnapshotChipComponent {} + +@Component({ selector: 'app-offline-status-chip', standalone: true, template: 'Offline: OK' }) +class StubOfflineStatusChipComponent {} + class MockAuthSessionStore { readonly isAuthenticated = signal(true); } @@ -79,8 +94,6 @@ describe('AppTopbarComponent', () => { let component: AppTopbarComponent; let sessionService: MockConsoleSessionService; let sessionStore: MockConsoleSessionStore; - let i18nService: MockI18nService; - let localePreferenceService: MockUserLocalePreferenceService; beforeEach(async () => { await TestBed.configureTestingModule({ @@ -101,6 +114,11 @@ describe('AppTopbarComponent', () => { StubGlobalSearchComponent, StubContextChipsComponent, StubUserMenuComponent, + StubLiveEventStreamChipComponent, + StubPolicyBaselineChipComponent, + StubEvidenceModeChipComponent, + StubFeedSnapshotChipComponent, + StubOfflineStatusChipComponent, RouterLink, ], }, @@ -111,8 +129,6 @@ describe('AppTopbarComponent', () => { component = fixture.componentInstance; sessionService = TestBed.inject(ConsoleSessionService) as unknown as MockConsoleSessionService; sessionStore = TestBed.inject(ConsoleSessionStore) as unknown as MockConsoleSessionStore; - i18nService = TestBed.inject(I18nService) as unknown as MockI18nService; - localePreferenceService = TestBed.inject(UserLocalePreferenceService) as unknown as MockUserLocalePreferenceService; fixture.detectChanges(); }); @@ -159,16 +175,15 @@ describe('AppTopbarComponent', () => { expect(sessionService.loadConsoleContext).toHaveBeenCalled(); }); - it('switches locale when a new locale is selected', async () => { - const select = fixture.nativeElement.querySelector('#topbar-locale-select') as HTMLSelectElement; - expect(select).toBeTruthy(); + it('renders topbar status chips in the authenticated shell row', () => { + const statusRow = fixture.nativeElement.querySelector('.topbar__status-chips') as HTMLElement; + expect(statusRow).toBeTruthy(); - select.value = 'de-DE'; - select.dispatchEvent(new Event('change')); - fixture.detectChanges(); - await fixture.whenStable(); - - expect(i18nService.setLocale).toHaveBeenCalledWith('de-DE'); - expect(localePreferenceService.setLocaleAsync).toHaveBeenCalledWith('de-DE'); + const text = statusRow.textContent ?? ''; + expect(text).toContain('Events: CONNECTED'); + expect(text).toContain('Policy: Core Policy Pack latest'); + expect(text).toContain('Evidence: ON'); + expect(text).toContain('Feed: Live'); + expect(text).toContain('Offline: OK'); }); }); diff --git a/src/Web/StellaOps.Web/src/app/layout/app-topbar/app-topbar.component.ts b/src/Web/StellaOps.Web/src/app/layout/app-topbar/app-topbar.component.ts index bf522be6c..acaf2297e 100644 --- a/src/Web/StellaOps.Web/src/app/layout/app-topbar/app-topbar.component.ts +++ b/src/Web/StellaOps.Web/src/app/layout/app-topbar/app-topbar.component.ts @@ -25,6 +25,12 @@ import { ContextChipsComponent } from '../context-chips/context-chips.component' import { UserMenuComponent } from '../../shared/components/user-menu/user-menu.component'; import { I18nService, UserLocalePreferenceService } from '../../core/i18n'; +import { OfflineStatusChipComponent } from '../context-chips/offline-status-chip.component'; +import { FeedSnapshotChipComponent } from '../context-chips/feed-snapshot-chip.component'; +import { PolicyBaselineChipComponent } from '../context-chips/policy-baseline-chip.component'; +import { EvidenceModeChipComponent } from '../context-chips/evidence-mode-chip.component'; +import { LiveEventStreamChipComponent } from '../context-chips/live-event-stream-chip.component'; + /** * AppTopbarComponent - Top bar with global search, context chips, tenant, and user menu. * @@ -40,7 +46,12 @@ import { I18nService, UserLocalePreferenceService } from '../../core/i18n'; GlobalSearchComponent, ContextChipsComponent, RouterLink, - UserMenuComponent + UserMenuComponent, + OfflineStatusChipComponent, + FeedSnapshotChipComponent, + PolicyBaselineChipComponent, + EvidenceModeChipComponent, + LiveEventStreamChipComponent, ], template: ` `, @@ -565,12 +598,52 @@ import { I18nService, UserLocalePreferenceService } from '../../core/i18n'; letter-spacing: 0.03em; } + .topbar__tenant-key { + font-size: 0.5rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--color-text-muted); + font-weight: 700; + } + .topbar__tenant-label { max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + + .topbar__tenant-badge { + display: flex; + align-items: center; + gap: 0.25rem; + height: 24px; + padding: 0 0.45rem; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-sm); + background: transparent; + color: var(--color-text-secondary); + font-family: var(--font-family-mono); + font-size: 0.625rem; + font-weight: 500; + letter-spacing: 0.02em; + white-space: nowrap; + } + + /* ---- Status chips (right-aligned) ---- */ + .topbar__status-chips { + display: flex; + align-items: center; + gap: 0.375rem; + flex-wrap: nowrap; + margin-left: auto; + } + + @media (max-width: 575px) { + .topbar__status-chips { + display: none; + } + } `], changeDetection: ChangeDetectionStrategy.OnPush, }) @@ -595,9 +668,6 @@ export class AppTopbarComponent { readonly activeTenantDisplayName = computed(() => this.consoleStore.currentTenant()?.displayName ?? this.activeTenant() ?? 'Tenant', ); - readonly showTenantSelector = computed(() => - this.isAuthenticated() && this.tenants().length > 1, - ); readonly localePreferenceSyncAttempted = signal(false); readonly scopePanelOpen = signal(false); readonly mobileContextOpen = signal(false); diff --git a/src/Web/StellaOps.Web/src/app/layout/context-chips/context-chips.component.spec.ts b/src/Web/StellaOps.Web/src/app/layout/context-chips/context-chips.component.spec.ts index a021eaa06..ad5ccac68 100644 --- a/src/Web/StellaOps.Web/src/app/layout/context-chips/context-chips.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/layout/context-chips/context-chips.component.spec.ts @@ -1,94 +1,89 @@ -import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { provideRouter } from '@angular/router'; -import { of } from 'rxjs'; -import { AUTH_SERVICE, MockAuthService } from '../../core/auth'; -import { OfflineModeService } from '../../core/services/offline-mode.service'; -import { PolicyPackStore } from '../../features/policy-studio/services/policy-pack.store'; +import { PlatformContextStore } from '../../core/context/platform-context.store'; import { ContextChipsComponent } from './context-chips.component'; -class OfflineModeServiceStub { - readonly isOffline = signal(false); - readonly bundleFreshness = signal<{ - status: 'fresh' | 'stale' | 'expired'; - bundleCreatedAt: string; - ageInDays: number; - message: string; - } | null>(null); - readonly offlineBannerMessage = signal(null); -} - -class PolicyPackStoreStub { - getPacks() { - return of([ +function createContextStore(overrides: Partial> = {}) { + return { + initialize: jasmine.createSpy('initialize'), + loading: () => false, + error: () => null, + regions: () => [{ regionId: 'us-east', displayName: 'US East' }], + regionSummary: () => 'US East', + selectedRegions: () => ['us-east'], + setRegions: jasmine.createSpy('setRegions'), + environments: () => [ { - id: 'pack-1', - name: 'Core Policy', - description: '', - version: 'v3.1', - status: 'active', - createdAt: '', - modifiedAt: '', - createdBy: '', - modifiedBy: '', - tags: [], + environmentId: 'stage', + displayName: 'Stage', + regionId: 'us-east', + environmentType: 'staging', }, - ]); - } + ], + environmentSummary: () => 'Stage', + selectedEnvironments: () => ['stage'], + setEnvironments: jasmine.createSpy('setEnvironments'), + timeWindow: () => '7d', + setTimeWindow: jasmine.createSpy('setTimeWindow'), + stage: () => 'prod', + setStage: jasmine.createSpy('setStage'), + ...overrides, + }; } describe('ContextChipsComponent', () => { - let offlineModeStub: OfflineModeServiceStub; + let contextStore: ReturnType; beforeEach(async () => { - offlineModeStub = new OfflineModeServiceStub(); + contextStore = createContextStore(); await TestBed.configureTestingModule({ imports: [ContextChipsComponent], providers: [ - provideRouter([]), - { provide: AUTH_SERVICE, useClass: MockAuthService }, - { provide: OfflineModeService, useValue: offlineModeStub }, - { provide: PolicyPackStore, useClass: PolicyPackStoreStub }, + { provide: PlatformContextStore, useValue: contextStore }, ], }).compileComponents(); }); - it('renders all context chips', () => { + it('renders shared context controls and summaries', () => { const fixture = TestBed.createComponent(ContextChipsComponent); fixture.detectChanges(); const text = (fixture.nativeElement as HTMLElement).textContent ?? ''; - expect(text).toContain('Offline:'); - expect(text).toContain('Feed:'); - expect(text).toContain('Policy:'); - expect(text).toContain('Evidence:'); + expect(text).toContain('Region'); + expect(text).toContain('US East'); + expect(text).toContain('Env'); + expect(text).toContain('Stage'); + expect(text).toContain('Window'); + expect(text).toContain('7d'); }); - it('shows degraded offline status when offline mode is active', () => { - offlineModeStub.isOffline.set(true); - offlineModeStub.offlineBannerMessage.set('Offline mode enabled'); + it('updates region selection through the shared context store', () => { + const fixture = TestBed.createComponent(ContextChipsComponent); + const component = fixture.componentInstance; + fixture.detectChanges(); + + component.onToggleRegion('eu-west'); + + expect(contextStore.setRegions).toHaveBeenCalledWith(['us-east', 'eu-west']); + }); + + it('renders inline context errors after store refresh', async () => { + const errorStore = createContextStore({ + error: () => 'Context temporarily unavailable', + }); + + await TestBed.configureTestingModule({ + imports: [ContextChipsComponent], + providers: [ + { provide: PlatformContextStore, useValue: errorStore }, + ], + }).compileComponents(); const fixture = TestBed.createComponent(ContextChipsComponent); fixture.detectChanges(); const text = (fixture.nativeElement as HTMLElement).textContent ?? ''; - expect(text).toContain('DEGRADED'); - }); - - it('links evidence chip to the canonical evidence trust-signing route', () => { - const fixture = TestBed.createComponent(ContextChipsComponent); - fixture.detectChanges(); - - const links = Array.from( - (fixture.nativeElement as HTMLElement).querySelectorAll('a') - ) as HTMLAnchorElement[]; - const evidenceLink = links.find((link) => - (link.textContent ?? '').includes('Evidence:') - ); - - expect(evidenceLink).toBeTruthy(); - expect(evidenceLink?.getAttribute('href')).toContain('/setup/trust-signing'); + expect(text).toContain('Context temporarily unavailable'); }); }); diff --git a/src/Web/StellaOps.Web/src/app/layout/context-chips/context-chips.component.ts b/src/Web/StellaOps.Web/src/app/layout/context-chips/context-chips.component.ts index 986c632ce..ac4ec3354 100644 --- a/src/Web/StellaOps.Web/src/app/layout/context-chips/context-chips.component.ts +++ b/src/Web/StellaOps.Web/src/app/layout/context-chips/context-chips.component.ts @@ -1,10 +1,5 @@ import { Component, ChangeDetectionStrategy, inject, signal, computed, ElementRef, HostListener } from '@angular/core'; -import { OfflineStatusChipComponent } from './offline-status-chip.component'; -import { FeedSnapshotChipComponent } from './feed-snapshot-chip.component'; -import { PolicyBaselineChipComponent } from './policy-baseline-chip.component'; -import { EvidenceModeChipComponent } from './evidence-mode-chip.component'; -import { LiveEventStreamChipComponent } from './live-event-stream-chip.component'; import { PlatformContextStore } from '../../core/context/platform-context.store'; interface DropdownOption { @@ -15,22 +10,16 @@ interface DropdownOption { /** * ContextChipsComponent - Container for global context chips in the topbar. * - * Displays compact dropdown selectors for Region/Environment/Window/Stage, - * followed by status indicator chips. All four selectors use a consistent - * custom dropdown control with filter input. + * Displays compact dropdown selectors for Region/Environment/Window/Stage. + * Status chips are rendered by the topbar so shell state and scope controls + * can be arranged independently on wider screens. */ @Component({ selector: 'app-context-chips', standalone: true, - imports: [ - OfflineStatusChipComponent, - FeedSnapshotChipComponent, - PolicyBaselineChipComponent, - EvidenceModeChipComponent, - LiveEventStreamChipComponent - ], + imports: [], template: ` -
+
@@ -177,16 +166,6 @@ interface DropdownOption {
-
- -
- - - - - -
- @if (context.error()) { {{ context.error() }} } @@ -359,24 +338,6 @@ interface DropdownOption { background: var(--color-brand-muted, rgba(245, 166, 35, 0.15)); } - /* ---- Separator ---- */ - - .ctx__sep { - width: 1px; - height: 18px; - background: var(--color-border-primary); - flex-shrink: 0; - } - - /* ---- Status chips row ---- */ - - .ctx__chips { - display: flex; - align-items: center; - gap: 0.375rem; - flex-wrap: nowrap; - } - .ctx__error { font-size: 0.625rem; color: var(--color-status-error-text); @@ -407,13 +368,6 @@ interface DropdownOption { font-size: 0.58rem; } - .ctx__sep { - display: none; - } - - .ctx__chips { - display: none; - } } `], changeDetection: ChangeDetectionStrategy.OnPush,