From b55760fc7603d7ad0d387ae3a32addb64af796fe Mon Sep 17 00:00:00 2001
From: master <>
Date: Sun, 8 Mar 2026 23:49:23 +0200
Subject: [PATCH] fix(web): harden derived shared ui components
---
...E_page_header_context_header_derivation.md | 2 +
...E_metric_card_dashboard_card_derivation.md | 2 +
...timeline_list_audit_timeline_derivation.md | 2 +
.../context-header.component.ts | 2 +-
.../metric-card/metric-card.component.spec.ts | 22 ++++++++++
.../ui/metric-card/metric-card.component.ts | 26 ++++++------
.../page-header/page-header.component.spec.ts | 42 +++++++++++++++++++
.../timeline-list.component.spec.ts | 13 ++++++
.../timeline-list/timeline-list.component.ts | 14 ++++---
9 files changed, 106 insertions(+), 19 deletions(-)
create mode 100644 src/Web/StellaOps.Web/src/app/shared/ui/page-header/page-header.component.spec.ts
diff --git a/docs/implplan/SPRINT_20260308_027_FE_page_header_context_header_derivation.md b/docs/implplan/SPRINT_20260308_027_FE_page_header_context_header_derivation.md
index f73329d7a..32663f570 100644
--- a/docs/implplan/SPRINT_20260308_027_FE_page_header_context_header_derivation.md
+++ b/docs/implplan/SPRINT_20260308_027_FE_page_header_context_header_derivation.md
@@ -78,6 +78,7 @@ Completion criteria:
| 2026-03-08 | FE-PHD-002: Enhanced `ContextHeaderComponent` with configurable heading level (h1/h2/h3), testId, arrow in return button, ARIA labels on return button and chip list, JSDoc on all inputs. `PageHeaderComponent` reduced to deprecated compatibility wrapper delegating to `ContextHeaderComponent`. | Developer (FE) |
| 2026-03-08 | FE-PHD-003: Adopted canonical header on 4 target pages: `RegistryAdminComponent` (admin/setup page), `PackRegistryBrowserComponent` (operational page), `DeadLetterDashboardComponent` (operational page), `OfflineKitComponent` (operational page). Removed repeated ad-hoc header markup from all 4. Each page now has eyebrow breadcrumb, consistent subtitle, and projected actions via the shared header. | Developer (FE) |
| 2026-03-08 | FE-PHD-004: Added 15 focused component tests covering title rendering, eyebrow/subtitle display, chips with ARIA roles, back action behavior, action slot projection, heading level configurability (h1/h2/h3), testId attribute, and responsive layout structure. All 15 pass. Updated sprint and docs. Marked `PageHeaderComponent` as deprecated in the shared index. | Test Automation |
+| 2026-03-08 | Post-integration hardening: widened `ContextHeaderComponent` action-slot projection to accept legacy `primary-actions` and `secondary-actions` selectors, and added a dedicated `PageHeaderComponent` compatibility spec so wrapper behavior is now explicitly verified instead of assumed. | Developer (FE) |
## Decisions & Risks
- **Decision: Single canonical header.** `ContextHeaderComponent` is the sole canonical header primitive. `PageHeaderComponent` is deprecated to a thin compatibility wrapper.
@@ -85,6 +86,7 @@ Completion criteria:
- **Decision: Back button arrow.** Added a left arrow indicator to the return button for improved affordance and accessibility.
- **Decision: testId support.** Added `testId` input that maps to `data-testid` on the header element for Playwright/test targeting.
- **Decision: Adopted pages.** Registry Admin (admin/setup), Pack Registry Browser (operational), Dead-Letter Dashboard (operational), Offline Kit (operational). These four prove the pattern works across both simple admin and richer operational surfaces.
+- **Decision: Compatibility selectors remain supported.** `ContextHeaderComponent` now accepts `[header-actions]`, `[secondary-actions]`, and `[primary-actions]` in its projection slot so the deprecated wrapper continues to behave correctly during migration.
- Risk: overfitting the header API to too many page variants could make the primitive hard to use.
- Mitigation: validated the API on a bounded 4-page adoption set. Future rollout should proceed incrementally.
diff --git a/docs/implplan/SPRINT_20260308_028_FE_metric_card_dashboard_card_derivation.md b/docs/implplan/SPRINT_20260308_028_FE_metric_card_dashboard_card_derivation.md
index fed01498a..134488214 100644
--- a/docs/implplan/SPRINT_20260308_028_FE_metric_card_dashboard_card_derivation.md
+++ b/docs/implplan/SPRINT_20260308_028_FE_metric_card_dashboard_card_derivation.md
@@ -104,6 +104,7 @@ Completion criteria:
| 2026-03-08 | FE-MCD-002: Rewrote MetricCardComponent with full semantic model, ARIA labels, loading/empty/error states, severity accents, and responsive dense-grid support. Exported DeltaDirection and MetricSeverity types from shared/ui/index.ts. | Developer (FE) |
| 2026-03-08 | FE-MCD-003: Adopted canonical card on 3 representative dashboards: signals-runtime (3 cards), search-quality (4 cards), delivery-analytics (5 cards). 12 bespoke inline tiles replaced total. | Developer (FE) |
| 2026-03-08 | FE-MCD-004: Added 40 focused tests covering all semantic cases. Build verified clean. Docs updated. | Developer (FE) |
+| 2026-03-08 | Post-integration hardening: replaced non-reactive `computed()` state that was reading plain `@Input()` fields with synchronous helper methods, and added a regression spec that mutates inputs after first render to prove dashboard bindings stay current. | Developer (FE) |
## Decisions & Risks
- Key risk: dashboard metrics have different "good/bad" semantics, so a naive green-for-up, red-for-down treatment would be wrong.
@@ -111,6 +112,7 @@ Completion criteria:
- Decision: `deltaDirection` defaults to `'up-is-good'` for backward compatibility with existing callers.
- Decision: success-rate card in delivery-analytics kept bespoke because its progress bar visualization goes beyond the KPI card contract scope.
- Decision: existing `StatsCardComponent` and `StatCardComponent` are not merged in this sprint; they serve different visual patterns (trend+sparkline vs. KPI). Consolidation is a separate future sprint.
+- Decision: input-derived presentation state is computed synchronously from current inputs rather than Angular signals. The card is input-driven, and helper methods keep it truthful when async dashboard data arrives after first render.
## Next Checkpoints
- All tasks DONE. Sprint ready for archive after review.
diff --git a/docs/implplan/SPRINT_20260308_029_FE_timeline_list_audit_timeline_derivation.md b/docs/implplan/SPRINT_20260308_029_FE_timeline_list_audit_timeline_derivation.md
index 7cbdee00a..1ede20130 100644
--- a/docs/implplan/SPRINT_20260308_029_FE_timeline_list_audit_timeline_derivation.md
+++ b/docs/implplan/SPRINT_20260308_029_FE_timeline_list_audit_timeline_derivation.md
@@ -83,6 +83,7 @@ Completion criteria:
| 2026-03-08 | FE-TLD-002 DONE: Derived TimelineListComponent with vertical timeline, colored severity markers (info/success/warning/error/critical/neutral), deterministic UTC timestamps, expandable detail sections, actor/source metadata, date grouping, loading skeleton, empty state, accessibility (role="feed", aria-labels), and content projection. | Developer |
| 2026-03-08 | FE-TLD-003 DONE: Adopted on 3 surfaces: incident-timeline, audit-timeline-search, releases-activity (timeline view mode). Domain-specific context preserved via content projection. | Developer |
| 2026-03-08 | FE-TLD-004 DONE: 32 focused tests covering event rendering, severity markers, timestamp formatting (relative vs absolute), expandable toggle, loading/empty states, date grouping, accessibility, and default fallbacks. Build passes. | Developer |
+| 2026-03-08 | Post-integration hardening: unified grouped and flat rendering behind a shared render-clock refresh path so relative timestamps stay truthful in flat mode too, and added a regression test that advances time between flat-mode renders. | Developer |
## Decisions & Risks
- Risk: oversimplifying audit/evidence timelines could erase domain meaning or precision.
@@ -90,6 +91,7 @@ Completion criteria:
- Decision: Excluded witness/evidence hosts (sprint 031 territory), VEX timeline (domain-specific source-consensus visualization), pedigree timeline (horizontal ancestry lineage), observation timeline (SVG bar chart), and explainer timeline (process steps) from adoption because they are fundamentally different visualization patterns, not generic event streams.
- Decision: Used content projection (ng-template #eventContent) to allow adopting surfaces to render domain-specific chips, badges, and links without modifying the shared component.
- Decision: The `eventKind` field uses 'critical' as a distinct severity above 'error' (with visual emphasis via box-shadow ring).
+- Decision: both grouped and flat modes refresh the render clock from the same `renderedEvents` computed path so relative timestamps remain deterministic within a render cycle without drifting stale across input updates.
## Next Checkpoints
- Freeze the event model and time-display rules. -- DONE
diff --git a/src/Web/StellaOps.Web/src/app/shared/ui/context-header/context-header.component.ts b/src/Web/StellaOps.Web/src/app/shared/ui/context-header/context-header.component.ts
index 0d6902884..77a2cefe2 100644
--- a/src/Web/StellaOps.Web/src/app/shared/ui/context-header/context-header.component.ts
+++ b/src/Web/StellaOps.Web/src/app/shared/ui/context-header/context-header.component.ts
@@ -69,7 +69,7 @@ export type HeadingLevel = 1 | 2 | 3;
}
-
+
`,
diff --git a/src/Web/StellaOps.Web/src/app/shared/ui/metric-card/metric-card.component.spec.ts b/src/Web/StellaOps.Web/src/app/shared/ui/metric-card/metric-card.component.spec.ts
index 9bf05659f..cc8c808c4 100644
--- a/src/Web/StellaOps.Web/src/app/shared/ui/metric-card/metric-card.component.spec.ts
+++ b/src/Web/StellaOps.Web/src/app/shared/ui/metric-card/metric-card.component.spec.ts
@@ -83,6 +83,28 @@ describe('MetricCardComponent', () => {
expect(el.querySelector('.metric-card__subtitle')).toBeNull();
});
+
+ it('should recompute derived output when inputs change after first render', () => {
+ fixture.componentRef.setInput('label', 'Latency');
+ fixture.componentRef.setInput('value', 150);
+ fixture.componentRef.setInput('delta', 5);
+ fixture.componentRef.setInput('deltaDirection', 'up-is-bad');
+ fixture.detectChanges();
+
+ expect(el.querySelector('.metric-card__value')?.textContent?.trim()).toBe('150');
+ expect(el.querySelector('.metric-card__delta')?.textContent?.trim()).toContain('+5%');
+ expect(el.querySelector('.metric-card__delta')?.classList.contains('metric-card__delta--bad')).toBe(true);
+
+ fixture.componentRef.setInput('value', 95);
+ fixture.componentRef.setInput('delta', -3);
+ fixture.componentRef.setInput('deltaDirection', 'up-is-good');
+ fixture.detectChanges();
+
+ expect(el.querySelector('.metric-card__value')?.textContent?.trim()).toBe('95');
+ expect(el.querySelector('.metric-card__delta')?.textContent?.trim()).toContain('-3%');
+ expect(el.querySelector('.metric-card__delta')?.classList.contains('metric-card__delta--bad')).toBe(true);
+ expect(el.querySelector('.metric-card')?.getAttribute('aria-label')).toContain('Latency: 95, -3%');
+ });
});
// ------------------------------------------------------------------
diff --git a/src/Web/StellaOps.Web/src/app/shared/ui/metric-card/metric-card.component.ts b/src/Web/StellaOps.Web/src/app/shared/ui/metric-card/metric-card.component.ts
index 512b310df..c7d876416 100644
--- a/src/Web/StellaOps.Web/src/app/shared/ui/metric-card/metric-card.component.ts
+++ b/src/Web/StellaOps.Web/src/app/shared/ui/metric-card/metric-card.component.ts
@@ -11,7 +11,7 @@
* - ARIA accessibility
*/
-import { Component, Input, ChangeDetectionStrategy, computed, input } from '@angular/core';
+import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
/**
* Controls color semantics for delta values.
@@ -309,7 +309,7 @@ export class MetricCardComponent {
@Input() error?: string;
/** Formatted display value with locale-aware number formatting */
- readonly formattedValue = computed(() => {
+ formattedValue(): string {
const val = this.value;
if (typeof val === 'string') return val;
if (typeof val === 'number') {
@@ -319,25 +319,25 @@ export class MetricCardComponent {
return '--';
}
return String(val);
- });
+ }
/** Delta display text: "+12.3%" or "-5.1%" */
- readonly deltaDisplay = computed(() => {
+ deltaDisplay(): string {
if (this.delta === undefined || this.delta === null) return '';
const abs = Math.abs(this.delta);
const formatted = abs % 1 === 0 ? abs.toString() : abs.toFixed(1);
const sign = this.delta > 0 ? '+' : this.delta < 0 ? '-' : '';
return `${sign}${formatted}%`;
- });
+ }
/** Arrow direction based on delta sign */
- readonly deltaIcon = computed((): 'up' | 'down' | null => {
+ deltaIcon(): 'up' | 'down' | null {
if (this.delta === undefined || this.delta === null || this.delta === 0) return null;
return this.delta > 0 ? 'up' : 'down';
- });
+ }
/** CSS class for delta badge based on direction semantics */
- readonly deltaColorClass = computed((): string => {
+ deltaColorClass(): string {
const base = 'metric-card__delta';
if (this.delta === undefined || this.delta === null || this.delta === 0) {
return `${base} ${base}--neutral`;
@@ -353,10 +353,10 @@ export class MetricCardComponent {
(this.deltaDirection === 'up-is-bad' && !isPositive);
return `${base} ${isGood ? `${base}--good` : `${base}--bad`}`;
- });
+ }
/** Composite ARIA label for the entire card */
- readonly ariaLabel = computed(() => {
+ ariaLabel(): string {
if (this.loading) return `${this.label}: loading`;
if (this.error) return `${this.label}: error, ${this.error}`;
if (this.empty) return `${this.label}: no data`;
@@ -366,10 +366,10 @@ export class MetricCardComponent {
const deltaStr = this.delta != null ? `, ${this.deltaDisplay()}` : '';
const severityStr = this.severity ? `, ${this.severity}` : '';
return `${this.label}: ${val}${unitStr}${deltaStr}${severityStr}`;
- });
+ }
/** ARIA label specifically for the delta badge */
- readonly deltaAriaLabel = computed(() => {
+ deltaAriaLabel(): string {
if (this.delta === undefined || this.delta === null) return '';
const display = this.deltaDisplay();
if (this.deltaDirection === 'neutral') return `Change: ${display}`;
@@ -380,5 +380,5 @@ export class MetricCardComponent {
(this.deltaDirection === 'up-is-bad' && !isPositive);
return `Change: ${display}, ${isGood ? 'favorable' : 'unfavorable'}`;
- });
+ }
}
diff --git a/src/Web/StellaOps.Web/src/app/shared/ui/page-header/page-header.component.spec.ts b/src/Web/StellaOps.Web/src/app/shared/ui/page-header/page-header.component.spec.ts
new file mode 100644
index 000000000..ecd815506
--- /dev/null
+++ b/src/Web/StellaOps.Web/src/app/shared/ui/page-header/page-header.component.spec.ts
@@ -0,0 +1,42 @@
+import { Component } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { PageHeaderComponent } from './page-header.component';
+
+@Component({
+ standalone: true,
+ imports: [PageHeaderComponent],
+ template: `
+
+
+
+
+ `,
+})
+class PageHeaderHostComponent {}
+
+describe('PageHeaderComponent', () => {
+ let fixture: ComponentFixture;
+ let el: HTMLElement;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [PageHeaderHostComponent],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(PageHeaderHostComponent);
+ el = fixture.nativeElement;
+ fixture.detectChanges();
+ });
+
+ it('forwards legacy action slots into the context-header action area', () => {
+ const actions = el.querySelector('.context-header__actions');
+ expect(actions?.querySelector('[data-testid="secondary-action"]')?.textContent?.trim()).toBe('Secondary');
+ expect(actions?.querySelector('[data-testid="primary-action"]')?.textContent?.trim()).toBe('Primary');
+ });
+
+ it('forwards title and subtitle to the canonical context header', () => {
+ expect(el.querySelector('.context-header__title')?.textContent?.trim()).toBe('Compatibility Title');
+ expect(el.querySelector('.context-header__subtitle')?.textContent?.trim()).toBe('Compatibility Subtitle');
+ });
+});
diff --git a/src/Web/StellaOps.Web/src/app/shared/ui/timeline-list/timeline-list.component.spec.ts b/src/Web/StellaOps.Web/src/app/shared/ui/timeline-list/timeline-list.component.spec.ts
index cd0eb5825..f4facf38b 100644
--- a/src/Web/StellaOps.Web/src/app/shared/ui/timeline-list/timeline-list.component.spec.ts
+++ b/src/Web/StellaOps.Web/src/app/shared/ui/timeline-list/timeline-list.component.spec.ts
@@ -201,6 +201,19 @@ describe('TimelineListComponent', () => {
expect(timeText).toMatch(/\d{4}-\d{2}-\d{2}/);
});
+ it('should refresh relative time when flat-mode events change', () => {
+ host.groupByDate = false;
+ host.events = [{ id: 't-flat', timestamp: minutesAgo(5), title: 'Flat event' }];
+ fixture.detectChanges();
+ expect(el.querySelector('.timeline__time')!.textContent).toContain('5m ago');
+
+ vi.setSystemTime(new Date(NOW.getTime() + 10 * 60_000));
+ host.events = [{ ...host.events[0] }];
+ fixture.detectChanges();
+
+ expect(el.querySelector('.timeline__time')!.textContent).toContain('15m ago');
+ });
+
it('should show full ISO timestamp in title attribute (tooltip)', () => {
host.events = [{ id: 't-tip', timestamp: minutesAgo(10), title: 'Tooltip event' }];
fixture.detectChanges();
diff --git a/src/Web/StellaOps.Web/src/app/shared/ui/timeline-list/timeline-list.component.ts b/src/Web/StellaOps.Web/src/app/shared/ui/timeline-list/timeline-list.component.ts
index 69f00e0dc..fd957ad9c 100644
--- a/src/Web/StellaOps.Web/src/app/shared/ui/timeline-list/timeline-list.component.ts
+++ b/src/Web/StellaOps.Web/src/app/shared/ui/timeline-list/timeline-list.component.ts
@@ -183,7 +183,7 @@ function toDateKey(iso: string): string {
}
} @else {
- @for (event of events(); track event.id; let last = $last) {
+ @for (event of renderedEvents(); track event.id; let last = $last) {
@@ -621,14 +621,18 @@ export class TimelineListComponent {
/** Cached "now" for consistent relative time within a render cycle. */
private renderNow = new Date();
+ /** Flat and grouped modes share the same render clock refresh path. */
+ readonly renderedEvents = computed(() => {
+ const evts = this.events();
+ this.renderNow = new Date();
+ return evts;
+ });
+
/** Computed date groups for grouped display. */
readonly dateGroups = computed(() => {
- const evts = this.events();
+ const evts = this.renderedEvents();
if (!evts.length) return [];
- // Refresh "now" each time events change
- this.renderNow = new Date();
-
const groups = new Map();
for (const evt of evts) {
const key = toDateKey(evt.timestamp);