Finalize topbar status chip ownership split

This commit is contained in:
master
2026-03-10 13:20:17 +02:00
parent 0e764da736
commit 1fe3f489f1
5 changed files with 269 additions and 191 deletions

View File

@@ -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.

View File

@@ -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');
});
});

View File

@@ -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: `
<header class="topbar" role="banner">
@@ -121,79 +132,101 @@ import { I18nService, UserLocalePreferenceService } from '../../core/i18n';
</div>
</div>
<!-- Row 2: Tenant (if multi) + Context chips + Locale -->
<!-- Row 2: Tenant + Status chips + Context controls -->
<div
id="topbar-context-row"
class="topbar__row topbar__row--secondary"
[class.topbar__row--secondary-open]="mobileContextOpen()"
>
<!-- Tenant selector (only shown when multiple tenants) -->
@if (showTenantSelector()) {
<!-- Tenant (always shown when authenticated) -->
@if (isAuthenticated()) {
<div class="topbar__tenant">
<button
type="button"
class="topbar__tenant-btn"
[class.topbar__tenant-btn--busy]="tenantSwitchInFlight()"
[attr.aria-expanded]="tenantPanelOpen()"
aria-haspopup="listbox"
aria-controls="topbar-tenant-listbox"
[attr.aria-label]="'Tenant selector. Current tenant: ' + activeTenantDisplayName()"
(click)="toggleTenantPanel()"
(keydown)="onTenantTriggerKeydown($event)"
>
<span class="topbar__tenant-label">{{ activeTenantDisplayName() }}</span>
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
<polyline points="6 9 12 15 18 9" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
</button>
@if (tenants().length > 1) {
<!-- Multi-tenant: dropdown -->
<button
type="button"
class="topbar__tenant-btn"
[class.topbar__tenant-btn--busy]="tenantSwitchInFlight()"
[attr.aria-expanded]="tenantPanelOpen()"
aria-haspopup="listbox"
aria-controls="topbar-tenant-listbox"
[attr.aria-label]="'Tenant selector. Current tenant: ' + activeTenantDisplayName()"
(click)="toggleTenantPanel()"
(keydown)="onTenantTriggerKeydown($event)"
>
<span class="topbar__tenant-key">Tenant</span>
<span class="topbar__tenant-label">{{ activeTenantDisplayName() }}</span>
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
<polyline points="6 9 12 15 18 9" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
</button>
@if (tenantPanelOpen()) {
<div class="topbar__tenant-panel" role="dialog" aria-label="Tenant selection">
@if (tenantLoading()) {
<p class="topbar__tenant-state">Loading tenant catalog...</p>
} @else if (tenantError(); as errorMessage) {
<div class="topbar__tenant-state topbar__tenant-state--error">
<p>{{ errorMessage }}</p>
<button type="button" class="topbar__tenant-retry" (click)="refreshTenantCatalog()">Retry</button>
</div>
} @else if (tenants().length === 0) {
<p class="topbar__tenant-state">No tenant assignments available.</p>
} @else {
<ul
id="topbar-tenant-listbox"
class="topbar__tenant-list"
role="listbox"
[attr.aria-activedescendant]="'topbar-tenant-option-' + (activeTenant() ?? '')"
(keydown)="onTenantListKeydown($event)"
>
@for (tenant of tenants(); track tenant.id; let tenantIndex = $index) {
<li>
<button
type="button"
class="topbar__tenant-option"
[id]="'topbar-tenant-option-' + tenant.id"
[class.topbar__tenant-option--active]="tenant.id === activeTenant()"
[disabled]="tenantSwitchInFlight()"
role="option"
[attr.data-tenant-option-index]="tenantIndex"
[attr.aria-selected]="tenant.id === activeTenant()"
(click)="onTenantSelected(tenant.id)"
>
<span class="topbar__tenant-option-name">{{ tenant.displayName }}</span>
<span class="topbar__tenant-option-id">{{ tenant.id }}</span>
</button>
</li>
}
</ul>
}
</div>
@if (tenantPanelOpen()) {
<div class="topbar__tenant-panel" role="dialog" aria-label="Tenant selection">
@if (tenantLoading()) {
<p class="topbar__tenant-state">Loading tenant catalog...</p>
} @else if (tenantError(); as errorMessage) {
<div class="topbar__tenant-state topbar__tenant-state--error">
<p>{{ errorMessage }}</p>
<button type="button" class="topbar__tenant-retry" (click)="refreshTenantCatalog()">Retry</button>
</div>
} @else if (tenants().length === 0) {
<p class="topbar__tenant-state">No tenant assignments available.</p>
} @else {
<ul
id="topbar-tenant-listbox"
class="topbar__tenant-list"
role="listbox"
[attr.aria-activedescendant]="'topbar-tenant-option-' + (activeTenant() ?? '')"
(keydown)="onTenantListKeydown($event)"
>
@for (tenant of tenants(); track tenant.id; let tenantIndex = $index) {
<li>
<button
type="button"
class="topbar__tenant-option"
[id]="'topbar-tenant-option-' + tenant.id"
[class.topbar__tenant-option--active]="tenant.id === activeTenant()"
[disabled]="tenantSwitchInFlight()"
role="option"
[attr.data-tenant-option-index]="tenantIndex"
[attr.aria-selected]="tenant.id === activeTenant()"
(click)="onTenantSelected(tenant.id)"
>
<span class="topbar__tenant-option-name">{{ tenant.displayName }}</span>
<span class="topbar__tenant-option-id">{{ tenant.id }}</span>
</button>
</li>
}
</ul>
}
</div>
}
} @else {
<!-- Single-tenant: static badge -->
<span class="topbar__tenant-badge">
<span class="topbar__tenant-key">Tenant</span>
<span class="topbar__tenant-label">{{ activeTenantDisplayName() }}</span>
</span>
}
</div>
<div class="topbar__row-sep"></div>
}
<app-context-chips></app-context-chips>
@if (isAuthenticated()) {
<div class="topbar__row-sep"></div>
<!-- Status indicator chips -->
<div class="topbar__status-chips">
<app-live-event-stream-chip></app-live-event-stream-chip>
<app-policy-baseline-chip></app-policy-baseline-chip>
<app-evidence-mode-chip></app-evidence-mode-chip>
<app-feed-snapshot-chip></app-feed-snapshot-chip>
<app-offline-status-chip></app-offline-status-chip>
</div>
}
</div>
</header>
`,
@@ -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);

View File

@@ -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<string | null>(null);
}
class PolicyPackStoreStub {
getPacks() {
return of([
function createContextStore(overrides: Partial<Record<string, unknown>> = {}) {
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<typeof createContextStore>;
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');
});
});

View File

@@ -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: `
<div class="ctx" role="status" aria-label="Global context controls and system status indicators">
<div class="ctx" role="status" aria-label="Global context controls">
<div class="ctx__controls">
<!-- Region dropdown (multi-select) -->
<div class="ctx__dropdown">
@@ -177,16 +166,6 @@ interface DropdownOption {
</div>
</div>
<div class="ctx__sep"></div>
<div class="ctx__chips">
<app-offline-status-chip></app-offline-status-chip>
<app-feed-snapshot-chip></app-feed-snapshot-chip>
<app-policy-baseline-chip></app-policy-baseline-chip>
<app-evidence-mode-chip></app-evidence-mode-chip>
<app-live-event-stream-chip></app-live-event-stream-chip>
</div>
@if (context.error()) {
<span class="ctx__error">{{ context.error() }}</span>
}
@@ -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,