diff --git a/angular-client/src/components/status-bar/status-bar.component.css b/angular-client/src/components/status-bar/status-bar.component.css new file mode 100644 index 000000000..59c3a06af --- /dev/null +++ b/angular-client/src/components/status-bar/status-bar.component.css @@ -0,0 +1,119 @@ +/* Block host so the sticky container's containing block spans the whole page. */ +:host { + display: block; + position: sticky; + top: 0; + z-index: 20; +} + +@keyframes sidebar-in { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +/* Collapsed affordance: a handle docked flush to the top-right edge. */ +.side-handle { + position: absolute; + top: 0; + right: 0; + width: 30px; + height: 56px; + padding: 0; + border: 1px solid var(--color-divider); + border-right: none; + border-radius: 10px 0 0 10px; + background: var(--color-background-info); + color: var(--color-text-primary); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; +} +.side-handle:hover { + background: #383838; +} +.side-handle:focus-visible { + outline: 2px solid var(--color-text-primary); + outline-offset: -2px; +} +.side-handle mat-icon { + width: 26px; + height: 26px; + font-size: 26px; + line-height: 26px; +} + +/* Open panel: docked flush to the right edge, top-aligned, hugs its chip(s). */ +.panel { + position: absolute; + top: 0; + right: 0; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 10px; + padding: 10px 12px 14px; + background: var(--color-background-info); + border: 1px solid var(--color-divider); + border-right: none; + border-radius: 12px 0 0 12px; + box-shadow: -6px 6px 18px rgba(0, 0, 0, 0.45); +} + +/* Respect reduced-motion: only slide the panel in when motion is welcome. */ +@media (prefers-reduced-motion: no-preference) { + .panel { + animation: sidebar-in 0.2s ease; + } +} + +/* Header: small title on the left, close chevron on the right. */ +.panel-header { + align-self: stretch; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} +.panel-title { + font-size: var(--font-size-sm, 12px); + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--color-text-subtitle); + padding-left: 2px; +} + +.panel-collapse { + flex: 0 0 auto; + width: 28px; + height: 28px; + padding: 0; + border: none; + border-radius: 6px; + background: transparent; + color: var(--color-text-subtitle); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; +} +.panel-collapse:hover { + background: rgba(255, 255, 255, 0.08); +} +.panel-collapse:focus-visible { + outline: 2px solid var(--color-text-primary); + outline-offset: 2px; +} +.panel-collapse mat-icon { + width: 22px; + height: 22px; + font-size: 22px; + line-height: 22px; +} diff --git a/angular-client/src/components/status-bar/status-bar.component.html b/angular-client/src/components/status-bar/status-bar.component.html new file mode 100644 index 000000000..a5e114c18 --- /dev/null +++ b/angular-client/src/components/status-bar/status-bar.component.html @@ -0,0 +1,22 @@ +@if (collapsed()) { + +} @else { +
+
+ {{ title() }} + +
+ +
+} diff --git a/angular-client/src/components/status-bar/status-bar.component.spec.ts b/angular-client/src/components/status-bar/status-bar.component.spec.ts new file mode 100644 index 000000000..0b8189da3 --- /dev/null +++ b/angular-client/src/components/status-bar/status-bar.component.spec.ts @@ -0,0 +1,70 @@ +import { Component, signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import StatusBarComponent from './status-bar.component'; + +// A host exercises projection and the title input exactly as a consumer would. +// title is a signal so updates integrate with zoneless change detection. +// Zoneless change detection is registered globally in src/test-setup.ts. +@Component({ + template: `chip`, + imports: [StatusBarComponent] +}) +class HostComponent { + title = signal('Quick view'); +} + +describe('StatusBarComponent', () => { + let fixture: ComponentFixture; + let host: HTMLElement; + + const q = (selector: string): HTMLElement | null => host.querySelector(selector); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [HostComponent] + }).compileComponents(); + + fixture = TestBed.createComponent(HostComponent); + host = fixture.nativeElement as HTMLElement; + fixture.detectChanges(); + }); + + it('auto-opens with the panel and projected chips, and no edge handle', () => { + expect(q('.panel')).toBeTruthy(); + expect(q('.side-handle')).toBeFalsy(); + expect(q('.projected')?.textContent).toContain('chip'); + }); + + it('renders the title input in the header', () => { + expect(q('.panel-title')?.textContent?.trim()).toBe('Quick view'); + + fixture.componentInstance.title.set('Pinned'); + fixture.detectChanges(); + + expect(q('.panel-title')?.textContent?.trim()).toBe('Pinned'); + }); + + it('collapses to a handle and re-expands on toggle', () => { + (q('.panel-collapse') as HTMLButtonElement).click(); + fixture.detectChanges(); + expect(q('.panel')).toBeFalsy(); + expect(q('.side-handle')).toBeTruthy(); + + (q('.side-handle') as HTMLButtonElement).click(); + fixture.detectChanges(); + expect(q('.panel')).toBeTruthy(); + expect(q('.side-handle')).toBeFalsy(); + }); + + it('moves keyboard focus to the successor control on toggle', async () => { + (q('.panel-collapse') as HTMLButtonElement).click(); + fixture.detectChanges(); + await fixture.whenStable(); + expect(document.activeElement).toBe(q('.side-handle')); + + (q('.side-handle') as HTMLButtonElement).click(); + fixture.detectChanges(); + await fixture.whenStable(); + expect(document.activeElement).toBe(q('.panel-collapse')); + }); +}); diff --git a/angular-client/src/components/status-bar/status-bar.component.ts b/angular-client/src/components/status-bar/status-bar.component.ts new file mode 100644 index 000000000..1e588144b --- /dev/null +++ b/angular-client/src/components/status-bar/status-bar.component.ts @@ -0,0 +1,50 @@ +import { + afterNextRender, + ChangeDetectionStrategy, + Component, + ElementRef, + inject, + Injector, + input, + signal, + viewChild +} from '@angular/core'; +import { MatIcon } from '@angular/material/icon'; + +/** + * Reusable read-only "quick view" status bar: a right-edge sidebar pop-out, top-aligned, + * that surfaces pinned chips (projected via ``). It auto-opens; a handle at the + * top-right edge re-opens it after collapse, and it stays sticky while the page scrolls — + * pinning to the top-right corner. Foundation for user-pinned, multi-chip status (see #709). + */ +@Component({ + selector: 'status-bar', + templateUrl: './status-bar.component.html', + styleUrl: './status-bar.component.css', + imports: [MatIcon], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export default class StatusBarComponent { + private readonly injector = inject(Injector); + + /** Small header label shown above the pinned chips. */ + readonly title = input('Quick view'); + + protected readonly collapsed = signal(false); + + private readonly handle = viewChild>('handle'); + private readonly collapseButton = viewChild>('collapseButton'); + + protected toggle(): void { + this.collapsed.update((collapsed) => !collapsed); + // The @if/@else swaps which control is on screen, so move keyboard focus to the + // successor — otherwise focus falls back to and the focus-visible ring is lost. + afterNextRender( + () => { + const target = this.collapsed() ? this.handle() : this.collapseButton(); + target?.nativeElement.focus(); + }, + { injector: this.injector } + ); + } +} diff --git a/angular-client/src/pages/efuses-page/components/lv-battery-chip/lv-battery-chip.component.css b/angular-client/src/pages/efuses-page/components/lv-battery-chip/lv-battery-chip.component.css new file mode 100644 index 000000000..2533df055 --- /dev/null +++ b/angular-client/src/pages/efuses-page/components/lv-battery-chip/lv-battery-chip.component.css @@ -0,0 +1,48 @@ +.card { + display: inline-flex; + align-items: center; + gap: 10px; + max-width: 25vw; + padding: 6px 14px; + border-radius: 8px; + background: var(--color-background-page); + border: 1px solid var(--color-divider); +} +.icon { + width: 26px; + height: 26px; + flex: 0 0 auto; + color: var(--color-text-subtitle); +} +.col { + display: flex; + flex-direction: column; + line-height: 1.1; +} +.top { + display: flex; + align-items: baseline; + gap: 3px; +} +.value { + font-size: var(--font-size-lg, 20px); + font-weight: 700; + color: var(--color-text-primary); +} +.unit { + font-size: var(--font-size-sm, 12px); + color: var(--color-text-subtitle); +} +.subtitle { + font-size: var(--font-size-sm, 12px); + color: var(--color-text-subtitle); + text-transform: uppercase; + letter-spacing: 0.04em; +} +.dot { + width: 10px; + height: 10px; + border-radius: 50%; + flex: 0 0 auto; + margin-left: 4px; +} diff --git a/angular-client/src/pages/efuses-page/components/lv-battery-chip/lv-battery-chip.component.html b/angular-client/src/pages/efuses-page/components/lv-battery-chip/lv-battery-chip.component.html new file mode 100644 index 000000000..7df41336d --- /dev/null +++ b/angular-client/src/pages/efuses-page/components/lv-battery-chip/lv-battery-chip.component.html @@ -0,0 +1,11 @@ +
+
diff --git a/angular-client/src/pages/efuses-page/components/lv-battery-chip/lv-battery-chip.component.spec.ts b/angular-client/src/pages/efuses-page/components/lv-battery-chip/lv-battery-chip.component.spec.ts new file mode 100644 index 000000000..69d66e65d --- /dev/null +++ b/angular-client/src/pages/efuses-page/components/lv-battery-chip/lv-battery-chip.component.spec.ts @@ -0,0 +1,55 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import LvBatteryChipComponent from './lv-battery-chip.component'; + +// Presentational component: drive it through its inputs and assert the rendered DOM, +// so the tests cover external behavior rather than internal signals. Zoneless change +// detection is registered globally in src/test-setup.ts. +describe('LvBatteryChipComponent', () => { + let fixture: ComponentFixture; + + const text = (selector: string): string => + (fixture.nativeElement as HTMLElement).querySelector(selector)?.textContent?.trim() ?? ''; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [LvBatteryChipComponent] + }).compileComponents(); + + fixture = TestBed.createComponent(LvBatteryChipComponent); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(fixture.componentInstance).toBeTruthy(); + }); + + it('shows a placeholder until a voltage arrives', () => { + // voltage input defaults to undefined + expect(text('.value')).toBe('–'); + }); + + it('formats the voltage to two decimals', () => { + fixture.componentRef.setInput('voltage', 24.5); + fixture.detectChanges(); + + expect(text('.value')).toBe('24.50'); + }); + + it('shows the placeholder for a non-finite voltage', () => { + fixture.componentRef.setInput('voltage', NaN); + fixture.detectChanges(); + + expect(text('.value')).toBe('–'); + }); + + it('labels the status dot for accessibility, driven by the fault flag', () => { + const dot = (): HTMLElement => (fixture.nativeElement as HTMLElement).querySelector('.dot')!; + + expect(dot().getAttribute('aria-label')).toBe('LV battery nominal'); + + fixture.componentRef.setInput('faulted', true); + fixture.detectChanges(); + + expect(dot().getAttribute('aria-label')).toBe('LV battery fault'); + }); +}); diff --git a/angular-client/src/pages/efuses-page/components/lv-battery-chip/lv-battery-chip.component.ts b/angular-client/src/pages/efuses-page/components/lv-battery-chip/lv-battery-chip.component.ts new file mode 100644 index 000000000..82a08e156 --- /dev/null +++ b/angular-client/src/pages/efuses-page/components/lv-battery-chip/lv-battery-chip.component.ts @@ -0,0 +1,39 @@ +import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; +import { MatIcon } from '@angular/material/icon'; + +/** Dot color while the LV low-voltage fault is active (shared battery-low token). */ +const LV_FAULT_COLOR = 'var(--color-battery-low)'; +/** Dot color while no LV low-voltage fault is active (shared battery-high token). */ +const LV_NORMAL_COLOR = 'var(--color-battery-high)'; + +/** + * LV Battery chip for the eFuses status bar: the live voltage over a "LV Battery" subtitle, + * with a status dot that is green normally and red on fault. + * + * Presentational — the host supplies the live voltage and the firmware low-voltage fault flag. + * The warning is driven purely by the fault flag, never a hardcoded voltage cutoff. This is the + * physical LV battery (VCU/LV/voltage), distinct from the LV eFuse card (VCU/eFuses/LV/Voltage). + */ +@Component({ + selector: 'lv-battery-chip', + templateUrl: './lv-battery-chip.component.html', + styleUrl: './lv-battery-chip.component.css', + imports: [MatIcon], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export default class LvBatteryChipComponent { + /** Live LV battery voltage (V); undefined until the first reading arrives. */ + readonly voltage = input(); + /** Whether the firmware LV low-voltage fault flag is active. */ + readonly faulted = input(false); + + protected readonly value = computed(() => { + const voltage = this.voltage(); + return voltage !== undefined && isFinite(voltage) ? voltage.toFixed(2) : '–'; + }); + + protected readonly dotColor = computed(() => (this.faulted() ? LV_FAULT_COLOR : LV_NORMAL_COLOR)); + + /** Accessible label so the fault state is not conveyed by color alone (WCAG 1.4.1). */ + protected readonly dotLabel = computed(() => (this.faulted() ? 'LV battery fault' : 'LV battery nominal')); +} diff --git a/angular-client/src/pages/efuses-page/efuses-page.component.css b/angular-client/src/pages/efuses-page/efuses-page.component.css index fe66de20b..1dbe28d5f 100644 --- a/angular-client/src/pages/efuses-page/efuses-page.component.css +++ b/angular-client/src/pages/efuses-page/efuses-page.component.css @@ -1,5 +1,12 @@ /* eFuses Page Styles */ +/* The page host must be a block so the sticky status bar's containing block spans the full + page; the default (inline) collapses it to one viewport, making the pinned bar drop off + partway down the scroll. */ +:host { + display: block; +} + .efuse-card { background: var(--background-color); border-radius: 8px; diff --git a/angular-client/src/pages/efuses-page/efuses-page.component.html b/angular-client/src/pages/efuses-page/efuses-page.component.html index ad5d6460d..617e022c8 100644 --- a/angular-client/src/pages/efuses-page/efuses-page.component.html +++ b/angular-client/src/pages/efuses-page/efuses-page.component.html @@ -1,3 +1,8 @@ + + + + +
Normal eFuses
diff --git a/angular-client/src/pages/efuses-page/efuses-page.component.ts b/angular-client/src/pages/efuses-page/efuses-page.component.ts index 8ce02b7dd..98ab951b4 100644 --- a/angular-client/src/pages/efuses-page/efuses-page.component.ts +++ b/angular-client/src/pages/efuses-page/efuses-page.component.ts @@ -1,11 +1,14 @@ -import { Component, OnDestroy, OnInit } from '@angular/core'; -import { Subscription } from 'rxjs'; +import { Component, inject } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { map } from 'rxjs'; import { EFUSE_TOPICS } from './efuses-page.topics'; -import TypographyComponent from 'src/components/typography/typography.component'; import EfuseCardComponent, { EfuseLockMode } from './components/efuse-card/efuse-card.component'; import RtdsDebugCardComponent from './components/rtds-debug-card/rtds-debug-card.component'; import GridLayoutComponent from 'src/components/grid-layout/grid-layout.component'; +import Storage from 'src/services/storage.service'; +import StatusBarComponent from 'src/components/status-bar/status-bar.component'; +import LvBatteryChipComponent from './components/lv-battery-chip/lv-battery-chip.component'; /** * Container for the eFuses page, displays eFuse status and controls. @@ -15,20 +18,20 @@ import GridLayoutComponent from 'src/components/grid-layout/grid-layout.componen styleUrls: ['./efuses-page.component.css'], templateUrl: './efuses-page.component.html', standalone: true, - imports: [GridLayoutComponent, TypographyComponent, EfuseCardComponent, RtdsDebugCardComponent] + imports: [GridLayoutComponent, EfuseCardComponent, RtdsDebugCardComponent, StatusBarComponent, LvBatteryChipComponent] }) -export default class EfusesPageComponent implements OnInit, OnDestroy { - private subscriptions: Subscription[] = []; +export default class EfusesPageComponent { + private storage = inject(Storage); readonly EfuseLockMode = EfuseLockMode; readonly topics = EFUSE_TOPICS; - ngOnInit() { - // Page initialization - console.log('eFuses page initialized'); - } - - ngOnDestroy() { - this.subscriptions.forEach((sub) => sub.unsubscribe()); - } + // LV Battery chip data for the status bar. Warning is driven purely by the firmware + // low-voltage fault flag (never a hardcoded voltage cutoff); this is the physical LV + // battery (VCU/LV/voltage), distinct from the LV eFuse card (VCU/eFuses/LV/Voltage). + readonly voltage = toSignal(this.storage.get(EFUSE_TOPICS.VCU.LV.Voltage).pipe(map((v) => parseFloat(v.values[0])))); + readonly faulted = toSignal( + this.storage.get(EFUSE_TOPICS.VCU.LV.LowVoltageFault).pipe(map((v) => Number(v.values[0]) === 1)), + { initialValue: false } + ); } diff --git a/angular-client/src/pages/efuses-page/efuses-page.topics.ts b/angular-client/src/pages/efuses-page/efuses-page.topics.ts index 134accbce..3d02f0af2 100644 --- a/angular-client/src/pages/efuses-page/efuses-page.topics.ts +++ b/angular-client/src/pages/efuses-page/efuses-page.topics.ts @@ -42,6 +42,13 @@ export const EFUSE_TOPICS = { Spare: _FORMAT_EFUSE_TOPICS('Spare'), Battbox: _FORMAT_EFUSE_TOPICS('Battbox'), MC: _FORMAT_EFUSE_TOPICS('MC') + }, + + // Low-voltage battery — the physical LV battery pack. + // Distinct from the LV eFuse (VCU/eFuses/LV/...) above. + LV: { + Voltage: 'VCU/LV/voltage', + LowVoltageFault: 'VCU/Faults/Non-Critical/LV_LOW_VOLTAGE_FAULT' } },