From a1ec3e22def968ef8e2f3fe402f8c743e26068b5 Mon Sep 17 00:00:00 2001 From: wyattb Date: Mon, 13 Jul 2026 08:12:03 -0700 Subject: [PATCH 1/5] #705 - add LV Battery voltage tile to eFuses page --- .../lv-battery-card.component.html | 9 +++ .../lv-battery-card.component.spec.ts | 59 ++++++++++++++++ .../lv-battery-card.component.ts | 68 +++++++++++++++++++ .../efuses-page/efuses-page.component.html | 3 + .../efuses-page/efuses-page.component.ts | 3 +- .../pages/efuses-page/efuses-page.topics.ts | 7 ++ 6 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 angular-client/src/pages/efuses-page/components/lv-battery-card/lv-battery-card.component.html create mode 100644 angular-client/src/pages/efuses-page/components/lv-battery-card/lv-battery-card.component.spec.ts create mode 100644 angular-client/src/pages/efuses-page/components/lv-battery-card/lv-battery-card.component.ts diff --git a/angular-client/src/pages/efuses-page/components/lv-battery-card/lv-battery-card.component.html b/angular-client/src/pages/efuses-page/components/lv-battery-card/lv-battery-card.component.html new file mode 100644 index 000000000..feeab1d25 --- /dev/null +++ b/angular-client/src/pages/efuses-page/components/lv-battery-card/lv-battery-card.component.html @@ -0,0 +1,9 @@ + + + diff --git a/angular-client/src/pages/efuses-page/components/lv-battery-card/lv-battery-card.component.spec.ts b/angular-client/src/pages/efuses-page/components/lv-battery-card/lv-battery-card.component.spec.ts new file mode 100644 index 000000000..d613b3ef5 --- /dev/null +++ b/angular-client/src/pages/efuses-page/components/lv-battery-card/lv-battery-card.component.spec.ts @@ -0,0 +1,59 @@ +import { provideExperimentalZonelessChangeDetection } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import Storage from 'src/services/storage.service'; +import { DataValue } from 'src/utils/socket.utils'; +import { EFUSE_TOPICS } from '../../efuses-page.topics'; +import LvBatteryCardComponent, { LV_FAULT_COLOR, LV_NORMAL_COLOR } from './lv-battery-card.component'; + +const push = (storage: Storage, key: string, value: string): void => { + storage.addValue(key, { values: [value], time: '0', unit: '' } as DataValue); +}; + +describe('LvBatteryCardComponent', () => { + let fixture: ComponentFixture; + let component: LvBatteryCardComponent; + let storage: Storage; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [LvBatteryCardComponent], + providers: [provideExperimentalZonelessChangeDetection(), Storage] + }).compileComponents(); + + storage = TestBed.inject(Storage); + fixture = TestBed.createComponent(LvBatteryCardComponent); + component = fixture.componentInstance; + fixture.detectChanges(); // ngOnInit → storage subscriptions + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('shows the LV battery voltage published to VCU/LV/voltage', () => { + // Storage delivers synchronously on push, so component state updates without a + // further detectChanges; a per-push template re-render raises a spurious NG0100 + // under zoneless. The real DOM render is covered by the Playwright visual test. + push(storage, EFUSE_TOPICS.VCU.LV.Voltage, '24.53'); + + expect(component.voltage).toBeCloseTo(24.53); + }); + + it('turns the warning color when the LV low-voltage fault activates and clears when it resolves', () => { + expect(component.getStatusColor()).toBe(LV_NORMAL_COLOR); + + push(storage, EFUSE_TOPICS.VCU.LV.LowVoltageFault, '1'); + expect(component.isFaulted).toBeTrue(); + expect(component.getStatusColor()).toBe(LV_FAULT_COLOR); + + push(storage, EFUSE_TOPICS.VCU.LV.LowVoltageFault, '0'); + expect(component.isFaulted).toBeFalse(); + expect(component.getStatusColor()).toBe(LV_NORMAL_COLOR); + }); + + it('drives the warning from the fault flag, not a hardcoded voltage threshold', () => { + push(storage, EFUSE_TOPICS.VCU.LV.Voltage, '1.0'); // implausibly low, but no fault flag set + + expect(component.getStatusColor()).toBe(LV_NORMAL_COLOR); + }); +}); diff --git a/angular-client/src/pages/efuses-page/components/lv-battery-card/lv-battery-card.component.ts b/angular-client/src/pages/efuses-page/components/lv-battery-card/lv-battery-card.component.ts new file mode 100644 index 000000000..91ee3ceb9 --- /dev/null +++ b/angular-client/src/pages/efuses-page/components/lv-battery-card/lv-battery-card.component.ts @@ -0,0 +1,68 @@ +import { Component, inject, OnDestroy, OnInit } from '@angular/core'; +import { Subscription } from 'rxjs'; +import { InfoBackgroundComponent } from 'src/components/info-background/info-background.component'; +import { + ConnectionDotConfig, + InfoValueDisplayComponent +} from 'src/components/info-value-dispaly/info-value-display.component'; +import Storage from 'src/services/storage.service'; +import { EFUSE_TOPICS } from '../../efuses-page.topics'; + +/** Dot color shown while the LV low-voltage fault is active. */ +export const LV_FAULT_COLOR = '#ef4444'; +/** Dot color shown while no LV low-voltage fault is active. */ +export const LV_NORMAL_COLOR = '#22c55e'; + +/** + * Low-voltage battery voltage tile for the eFuses page. + * + * Reads the LV battery voltage (VCU/LV/voltage) and turns a warning color when + * the firmware's LV low-voltage fault flag is active. The threshold is owned by + * firmware, so the warning is driven purely by the fault flag — never a hardcoded + * voltage cutoff. This is the physical LV battery, distinct from the LV eFuse card, + * which reads VCU/eFuses/LV/Voltage. + */ +@Component({ + selector: 'lv-battery-card', + templateUrl: './lv-battery-card.component.html', + standalone: true, + imports: [InfoBackgroundComponent, InfoValueDisplayComponent] +}) +export default class LvBatteryCardComponent implements OnInit, OnDestroy { + private storage = inject(Storage); + private subscriptions: Subscription[] = []; + + readonly topics = EFUSE_TOPICS.VCU.LV; + + voltage: number | undefined = undefined; + isFaulted = false; + + ngOnInit(): void { + this.subscriptions.push( + this.storage.get(this.topics.Voltage).subscribe((value) => { + this.voltage = parseFloat(value.values[0]); + }), + this.storage.get(this.topics.LowVoltageFault).subscribe((value) => { + this.isFaulted = Number(value.values[0]) === 1; + }) + ); + } + + getStatusColor = (): string => { + return this.isFaulted ? LV_FAULT_COLOR : LV_NORMAL_COLOR; + }; + + getStatusMessage = (): string => { + return this.isFaulted ? 'LV low-voltage fault' : 'Nominal'; + }; + + connectionDotConfig: ConnectionDotConfig = { + type: 'connection-dot-config', + getStatusColor: this.getStatusColor, + getStatusMessage: this.getStatusMessage + }; + + ngOnDestroy(): void { + this.subscriptions.forEach((sub) => sub.unsubscribe()); + } +} 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..39b727ec0 100644 --- a/angular-client/src/pages/efuses-page/efuses-page.component.html +++ b/angular-client/src/pages/efuses-page/efuses-page.component.html @@ -117,6 +117,9 @@ [lockMode]="EfuseLockMode.UnlockToEdit" /> + + + Date: Wed, 15 Jul 2026 01:39:02 -0700 Subject: [PATCH 2/5] #705 - LV Battery status bar (Quick view pop-out) on eFuses page --- .../lv-status-bar-prototype/NOTES.md | 46 ++++ .../lv-chips.prototype.ts | 171 +++++++++++++ .../prototype-switcher.prototype.ts | 91 +++++++ .../status-bar.prototype.ts | 225 ++++++++++++++++++ .../efuses-page/efuses-page.component.css | 7 + .../efuses-page/efuses-page.component.html | 8 +- .../efuses-page/efuses-page.component.ts | 33 +-- 7 files changed, 563 insertions(+), 18 deletions(-) create mode 100644 angular-client/src/pages/efuses-page/components/lv-status-bar-prototype/NOTES.md create mode 100644 angular-client/src/pages/efuses-page/components/lv-status-bar-prototype/lv-chips.prototype.ts create mode 100644 angular-client/src/pages/efuses-page/components/lv-status-bar-prototype/prototype-switcher.prototype.ts create mode 100644 angular-client/src/pages/efuses-page/components/lv-status-bar-prototype/status-bar.prototype.ts diff --git a/angular-client/src/pages/efuses-page/components/lv-status-bar-prototype/NOTES.md b/angular-client/src/pages/efuses-page/components/lv-status-bar-prototype/NOTES.md new file mode 100644 index 000000000..5225437fc --- /dev/null +++ b/angular-client/src/pages/efuses-page/components/lv-status-bar-prototype/NOTES.md @@ -0,0 +1,46 @@ +# PROTOTYPE — LV Battery status bar (ticket #705, foundation for #709) + +**Throwaway.** Delete this whole folder once the answer is captured; rewrite the winner as a +real `src/components/status-bar` + chip. Mounted on the real eFuses page (sub-shape A) behind +`?variant=`, dev-only floating switcher. + +## Question + +What should the LV Battery presentation look like as a reusable **status bar** — out of the +command zones, holding a small read-only chip, extendable to more pinned topics (#709)? + +Settled so far: + +- **Chip = variant C** (stacked: big value over a small "LV BATTERY" subtitle + status dot, + green ok / red fault). Content-sized card, capped `max-width: 25vw`, never stretched. +- The **expansion is content-sized** — the bar only grows far enough to fit its chip(s); it is + NOT a full-width strip. One chip (LV) sits on the right; more (#709) line up later. + +Open: which **container shape** wins — top line drop-down vs side pull-out. + +## Variants (container shape) + +- **`line`** — full-width hairline pinned to the top (always present, flush at `top: 0`) with a + drop-down tab on the right. Expanding drops a cohesive card (chip + collapse chevron) that + hangs from the bar at the top-right. Sticky while scrolling. +- **`side`** — a right-edge sidebar pop-out, TOP-aligned: a handle at the top-right edge; opening + slides out (animated) a sidebar flush to the edge holding the chip(s), close chevron top-right. + Sticky while scrolling (pins to the top-right corner). + +Rejected side attempts: a vertically-centred content-hugging drawer ("middle and weird"), and a +top-centre floating **island** capsule (that was a misread of "new style" — user wanted the side +pop-out fixed, not replaced). + +Flip: `/efuses?variant=line|side` or the bottom switcher / ← → keys. + +## Verdict + +**`side` + Quick View won** (chip C locked). The status bar is a right-edge sidebar pop-out, +top-aligned, with a tiny "Quick view" header; it auto-opens and pins to the top-right corner +while scrolling. Wired live on the eFuses page. + +Variant switching is retired: the page renders the one locked shape (no `?variant=`, no +switcher, no `line` branch). `prototype-switcher.prototype.ts`, the `line`/`island` shapes, and +`StatusBarItemComponent` + `LvChipA`/`LvChipB` are now unused — kept in the folder for now. +Fold the locked shape into a real `src/components/status-bar` + LV chip when #709 builds out +multi-chip pinning, then remove this folder. diff --git a/angular-client/src/pages/efuses-page/components/lv-status-bar-prototype/lv-chips.prototype.ts b/angular-client/src/pages/efuses-page/components/lv-status-bar-prototype/lv-chips.prototype.ts new file mode 100644 index 000000000..b2fcffcc3 --- /dev/null +++ b/angular-client/src/pages/efuses-page/components/lv-status-bar-prototype/lv-chips.prototype.ts @@ -0,0 +1,171 @@ +import { Component, input } from '@angular/core'; +import { MatIcon } from '@angular/material/icon'; +import { StatusBarItemComponent } from './status-bar.prototype'; + +/** + * PROTOTYPE — throwaway. Three structurally different LV-battery chips, all pure + * presentational (voltage + firmware LV-fault flag come in as inputs from the host). + * Flip between them with ?variant= to settle the chip anatomy, then keep one. + */ + +const RED = 'var(--color-battery-low)'; +const GREEN = 'var(--color-battery-high)'; +const fmt = (v?: number): string => (v != null && isFinite(v) ? v.toFixed(2) : '–'); + +/** Variant A — reuses the status-bar-item shell: icon + "LV Battery" label + value + unit + status dot. */ +@Component({ + selector: 'proto-lv-chip-a', + standalone: true, + imports: [StatusBarItemComponent], + template: ` + + ` +}) +export class LvChipAComponent { + voltage = input(); + faulted = input(false); + red = RED; + green = GREEN; + value = (): string => fmt(this.voltage()); +} + +/** Variant B — compact rounded pill, value-first, no label, no dot: the whole pill turns red on fault. */ +@Component({ + selector: 'proto-lv-chip-b', + standalone: true, + imports: [MatIcon], + template: ` +
+
+ `, + styles: [ + ` + .pill { + display: inline-flex; + align-items: center; + gap: 6px; + max-width: 25vw; + padding: 6px 12px; + border-radius: 999px; + background: var(--color-background-page); + border: 1px solid var(--color-divider); + white-space: nowrap; + } + .pill.fault { + border-color: var(--color-battery-low); + } + .pill.fault .v, + .pill.fault .u, + .pill.fault .i { + color: var(--color-battery-low); + } + .i { + width: 18px; + height: 18px; + color: #cfcfcf; + } + .v { + font-size: var(--font-size-md, 15px); + font-weight: 700; + color: #ffffff; + } + .u { + font-size: var(--font-size-sm, 12px); + color: #9a9a9a; + } + ` + ] +}) +export class LvChipBComponent { + voltage = input(); + faulted = input(false); + value = (): string => fmt(this.voltage()); +} + +/** Variant C — richer card: big value over a "LV Battery" subtitle, icon left, status dot right. */ +@Component({ + selector: 'proto-lv-chip-c', + standalone: true, + imports: [MatIcon], + template: ` +
+
+ `, + styles: [ + ` + .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); + } + .ic { + width: 26px; + height: 26px; + flex: 0 0 auto; + color: #cfcfcf; + } + .col { + display: flex; + flex-direction: column; + line-height: 1.1; + } + .top { + display: flex; + align-items: baseline; + gap: 3px; + } + .val { + font-size: var(--font-size-lg, 20px); + font-weight: 700; + color: #ffffff; + } + .un { + font-size: var(--font-size-sm, 12px); + color: #9a9a9a; + } + .sub { + font-size: var(--font-size-sm, 11px); + color: #8f8f8f; + text-transform: uppercase; + letter-spacing: 0.04em; + } + .dot { + width: 10px; + height: 10px; + border-radius: 50%; + flex: 0 0 auto; + margin-left: 4px; + } + ` + ] +}) +export class LvChipCComponent { + voltage = input(); + faulted = input(false); + red = RED; + green = GREEN; + value = (): string => fmt(this.voltage()); +} diff --git a/angular-client/src/pages/efuses-page/components/lv-status-bar-prototype/prototype-switcher.prototype.ts b/angular-client/src/pages/efuses-page/components/lv-status-bar-prototype/prototype-switcher.prototype.ts new file mode 100644 index 000000000..6c1d5b3bf --- /dev/null +++ b/angular-client/src/pages/efuses-page/components/lv-status-bar-prototype/prototype-switcher.prototype.ts @@ -0,0 +1,91 @@ +import { Component, HostListener, inject, input, isDevMode } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { ActivatedRoute, Router } from '@angular/router'; +import { map } from 'rxjs'; + +/** + * PROTOTYPE — dev-only floating variant switcher. Cycles the ?variant= query param + * (shareable + reload-stable) via arrows or ← / → keys. Hidden in production builds. + * Delete together with the variant chips once the prototype is resolved. + */ +@Component({ + selector: 'proto-prototype-switcher', + standalone: true, + template: ` + @if (isDev) { +
+ + {{ current() }}{{ name() }} + +
+ } + `, + styles: [ + ` + .switcher { + position: fixed; + bottom: 16px; + left: 50%; + transform: translateX(-50%); + z-index: 1000; + display: flex; + align-items: center; + gap: 10px; + padding: 6px 10px; + border-radius: 999px; + background: #f5f5f5; + color: #111111; + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.5); + font-family: monospace; + font-size: 14px; + } + .switcher button { + border: none; + background: #111111; + color: #ffffff; + width: 26px; + height: 26px; + border-radius: 50%; + cursor: pointer; + font-size: 16px; + line-height: 1; + } + .lbl { + min-width: 160px; + text-align: center; + font-weight: 700; + } + ` + ] +}) +export class PrototypeSwitcherComponent { + variants = input(['A']); + names = input>({}); + isDev = isDevMode(); + + private route = inject(ActivatedRoute); + private router = inject(Router); + private param = toSignal(this.route.queryParamMap.pipe(map((p) => p.get('variant')))); + + current = (): string => this.param() ?? this.variants()[0]; + + name = (): string => { + const n = this.names()[this.current()]; + return n ? ` — ${n}` : ''; + }; + + cycle = (dir: number): void => { + const vs = this.variants(); + const i = vs.indexOf(this.current()); + const next = vs[(i + dir + vs.length) % vs.length]; + this.router.navigate([], { queryParams: { variant: next }, queryParamsHandling: 'merge' }); + }; + + @HostListener('document:keydown', ['$event']) + onKey(e: KeyboardEvent): void { + const t = e.target as HTMLElement | null; + if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return; + if (e.key === 'ArrowLeft') this.cycle(-1); + else if (e.key === 'ArrowRight') this.cycle(1); + } +} diff --git a/angular-client/src/pages/efuses-page/components/lv-status-bar-prototype/status-bar.prototype.ts b/angular-client/src/pages/efuses-page/components/lv-status-bar-prototype/status-bar.prototype.ts new file mode 100644 index 000000000..ab00417e1 --- /dev/null +++ b/angular-client/src/pages/efuses-page/components/lv-status-bar-prototype/status-bar.prototype.ts @@ -0,0 +1,225 @@ +import { Component, input, signal } from '@angular/core'; +import { MatIcon } from '@angular/material/icon'; + +/** + * PROTOTYPE (#705, foundation for #709). Reusable "quick view" status bar: a right-edge + * sidebar pop-out, top-aligned, that surfaces pinned chips (projected via ). + * Auto-opens; a handle at the top-right edge re-opens it after collapse. Sticky while + * scrolling — pins to the top-right corner. Design is locked (side + Quick View header); + * fold into a real src/components/status-bar when #709 builds out multi-chip pinning. + */ +@Component({ + selector: 'proto-status-bar', + standalone: true, + imports: [MatIcon], + template: ` + @if (collapsed()) { + + } @else { +
+ + +
+ } + `, + styles: [ + ` + /* 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: #ffffff; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + } + .side-handle:hover { + background: #383838; + } + .side-handle mat-icon { + width: 26px; + height: 26px; + font-size: 26px; + line-height: 26px; + } + + /* Open sidebar: 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); + animation: sidebar-in 0.2s ease; + } + + /* Header: tiny "Quick view" title on the left, close chevron on the right. */ + .sidebar-header { + align-self: stretch; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + } + .sidebar-title { + font-size: var(--font-size-sm, 12px); + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #9a9a9a; + padding-left: 2px; + } + + .panel-collapse { + flex: 0 0 auto; + width: 28px; + height: 28px; + padding: 0; + border: none; + border-radius: 6px; + background: transparent; + color: #cfcfcf; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + } + .panel-collapse:hover { + background: rgba(255, 255, 255, 0.08); + } + .panel-collapse mat-icon { + width: 22px; + height: 22px; + font-size: 22px; + line-height: 22px; + } + ` + ] +}) +export class StatusBarComponent { + collapsed = signal(false); + toggle = (): void => this.collapsed.update((c) => !c); +} + +/** + * PROTOTYPE — reusable small-card chip shell that sits on the status bar. Content-sized, + * capped at 1/4 of the viewport, never stretched. icon + label + value/unit + optional dot. + */ +@Component({ + selector: 'proto-status-bar-item', + standalone: true, + imports: [MatIcon], + template: ` +
+ @if (icon()) { +
+ `, + styles: [ + ` + .chip { + display: inline-flex; + align-items: center; + gap: 8px; + max-width: 25vw; + box-sizing: border-box; + padding: 8px 14px; + border-radius: 8px; + background: var(--color-background-page); + border: 1px solid var(--color-divider); + white-space: nowrap; + overflow: hidden; + } + .icon { + width: 20px; + height: 20px; + flex: 0 0 auto; + color: #cfcfcf; + } + .label { + font-size: var(--font-size-md, 14px); + color: #b7b7b7; + overflow: hidden; + text-overflow: ellipsis; + } + .value { + font-size: var(--font-size-md, 15px); + font-weight: 600; + color: #ffffff; + } + .unit { + font-size: var(--font-size-sm, 12px); + color: #9a9a9a; + margin-left: -2px; + } + .dot { + width: 10px; + height: 10px; + border-radius: 50%; + flex: 0 0 auto; + margin-left: 2px; + } + ` + ] +}) +export class StatusBarItemComponent { + icon = input(); + label = input(); + value = input(''); + unit = input(); + dotColor = input(); +} 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..3e1cccc43 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 */ +/* PROTOTYPE (#705 status-bar) — 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 39b727ec0..e5bdbb9b8 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
@@ -117,9 +122,6 @@ [lockMode]="EfuseLockMode.UnlockToEdit" /> - - - 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 } + ); } From 5ca98dafa4b1031d851b9c363af60f22fb8690fb Mon Sep 17 00:00:00 2001 From: wyattb Date: Sat, 18 Jul 2026 17:27:36 -0700 Subject: [PATCH 3/5] #705 - fix eqeqeq lint (!= to !==) in LV chip prototype --- .../components/lv-status-bar-prototype/lv-chips.prototype.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/angular-client/src/pages/efuses-page/components/lv-status-bar-prototype/lv-chips.prototype.ts b/angular-client/src/pages/efuses-page/components/lv-status-bar-prototype/lv-chips.prototype.ts index b2fcffcc3..a854d06fb 100644 --- a/angular-client/src/pages/efuses-page/components/lv-status-bar-prototype/lv-chips.prototype.ts +++ b/angular-client/src/pages/efuses-page/components/lv-status-bar-prototype/lv-chips.prototype.ts @@ -10,7 +10,7 @@ import { StatusBarItemComponent } from './status-bar.prototype'; const RED = 'var(--color-battery-low)'; const GREEN = 'var(--color-battery-high)'; -const fmt = (v?: number): string => (v != null && isFinite(v) ? v.toFixed(2) : '–'); +const fmt = (v?: number): string => (v !== undefined && isFinite(v) ? v.toFixed(2) : '–'); /** Variant A — reuses the status-bar-item shell: icon + "LV Battery" label + value + unit + status dot. */ @Component({ From 84696b834d4d5e511caaf46516675bfc4966041e Mon Sep 17 00:00:00 2001 From: wyattb Date: Sat, 18 Jul 2026 21:47:48 -0700 Subject: [PATCH 4/5] #705 - promote status bar + LV chip to production components; drop prototype --- .../status-bar/status-bar.component.css | 105 ++++++++ .../status-bar/status-bar.component.html | 15 ++ .../status-bar/status-bar.component.ts | 26 ++ .../lv-battery-chip.component.css | 48 ++++ .../lv-battery-chip.component.html | 11 + .../lv-battery-chip.component.ts | 36 +++ .../lv-status-bar-prototype/NOTES.md | 46 ---- .../lv-chips.prototype.ts | 171 ------------- .../prototype-switcher.prototype.ts | 91 ------- .../status-bar.prototype.ts | 225 ------------------ .../efuses-page/efuses-page.component.css | 6 +- .../efuses-page/efuses-page.component.html | 6 +- .../efuses-page/efuses-page.component.ts | 7 +- 13 files changed, 250 insertions(+), 543 deletions(-) create mode 100644 angular-client/src/components/status-bar/status-bar.component.css create mode 100644 angular-client/src/components/status-bar/status-bar.component.html create mode 100644 angular-client/src/components/status-bar/status-bar.component.ts create mode 100644 angular-client/src/pages/efuses-page/components/lv-battery-chip/lv-battery-chip.component.css create mode 100644 angular-client/src/pages/efuses-page/components/lv-battery-chip/lv-battery-chip.component.html create mode 100644 angular-client/src/pages/efuses-page/components/lv-battery-chip/lv-battery-chip.component.ts delete mode 100644 angular-client/src/pages/efuses-page/components/lv-status-bar-prototype/NOTES.md delete mode 100644 angular-client/src/pages/efuses-page/components/lv-status-bar-prototype/lv-chips.prototype.ts delete mode 100644 angular-client/src/pages/efuses-page/components/lv-status-bar-prototype/prototype-switcher.prototype.ts delete mode 100644 angular-client/src/pages/efuses-page/components/lv-status-bar-prototype/status-bar.prototype.ts 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..04f7c7a43 --- /dev/null +++ b/angular-client/src/components/status-bar/status-bar.component.css @@ -0,0 +1,105 @@ +/* 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: #ffffff; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; +} +.side-handle:hover { + background: #383838; +} +.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); + 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: #9a9a9a; + padding-left: 2px; +} + +.panel-collapse { + flex: 0 0 auto; + width: 28px; + height: 28px; + padding: 0; + border: none; + border-radius: 6px; + background: transparent; + color: #cfcfcf; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; +} +.panel-collapse:hover { + background: rgba(255, 255, 255, 0.08); +} +.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..e156909d1 --- /dev/null +++ b/angular-client/src/components/status-bar/status-bar.component.html @@ -0,0 +1,15 @@ +@if (collapsed()) { + +} @else { +
+
+ {{ title() }} + +
+ +
+} 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..2fa5b6060 --- /dev/null +++ b/angular-client/src/components/status-bar/status-bar.component.ts @@ -0,0 +1,26 @@ +import { ChangeDetectionStrategy, Component, input, signal } 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 { + /** Small header label shown above the pinned chips. */ + readonly title = input('Quick view'); + + protected readonly collapsed = signal(false); + + protected toggle(): void { + this.collapsed.update((collapsed) => !collapsed); + } +} 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..c9aa16921 --- /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: #cfcfcf; +} +.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: #ffffff; +} +.unit { + font-size: var(--font-size-sm, 12px); + color: #9a9a9a; +} +.subtitle { + font-size: var(--font-size-sm, 11px); + color: #8f8f8f; + 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..268477816 --- /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.ts b/angular-client/src/pages/efuses-page/components/lv-battery-chip/lv-battery-chip.component.ts new file mode 100644 index 000000000..49b21e360 --- /dev/null +++ b/angular-client/src/pages/efuses-page/components/lv-battery-chip/lv-battery-chip.component.ts @@ -0,0 +1,36 @@ +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)); +} diff --git a/angular-client/src/pages/efuses-page/components/lv-status-bar-prototype/NOTES.md b/angular-client/src/pages/efuses-page/components/lv-status-bar-prototype/NOTES.md deleted file mode 100644 index 5225437fc..000000000 --- a/angular-client/src/pages/efuses-page/components/lv-status-bar-prototype/NOTES.md +++ /dev/null @@ -1,46 +0,0 @@ -# PROTOTYPE — LV Battery status bar (ticket #705, foundation for #709) - -**Throwaway.** Delete this whole folder once the answer is captured; rewrite the winner as a -real `src/components/status-bar` + chip. Mounted on the real eFuses page (sub-shape A) behind -`?variant=`, dev-only floating switcher. - -## Question - -What should the LV Battery presentation look like as a reusable **status bar** — out of the -command zones, holding a small read-only chip, extendable to more pinned topics (#709)? - -Settled so far: - -- **Chip = variant C** (stacked: big value over a small "LV BATTERY" subtitle + status dot, - green ok / red fault). Content-sized card, capped `max-width: 25vw`, never stretched. -- The **expansion is content-sized** — the bar only grows far enough to fit its chip(s); it is - NOT a full-width strip. One chip (LV) sits on the right; more (#709) line up later. - -Open: which **container shape** wins — top line drop-down vs side pull-out. - -## Variants (container shape) - -- **`line`** — full-width hairline pinned to the top (always present, flush at `top: 0`) with a - drop-down tab on the right. Expanding drops a cohesive card (chip + collapse chevron) that - hangs from the bar at the top-right. Sticky while scrolling. -- **`side`** — a right-edge sidebar pop-out, TOP-aligned: a handle at the top-right edge; opening - slides out (animated) a sidebar flush to the edge holding the chip(s), close chevron top-right. - Sticky while scrolling (pins to the top-right corner). - -Rejected side attempts: a vertically-centred content-hugging drawer ("middle and weird"), and a -top-centre floating **island** capsule (that was a misread of "new style" — user wanted the side -pop-out fixed, not replaced). - -Flip: `/efuses?variant=line|side` or the bottom switcher / ← → keys. - -## Verdict - -**`side` + Quick View won** (chip C locked). The status bar is a right-edge sidebar pop-out, -top-aligned, with a tiny "Quick view" header; it auto-opens and pins to the top-right corner -while scrolling. Wired live on the eFuses page. - -Variant switching is retired: the page renders the one locked shape (no `?variant=`, no -switcher, no `line` branch). `prototype-switcher.prototype.ts`, the `line`/`island` shapes, and -`StatusBarItemComponent` + `LvChipA`/`LvChipB` are now unused — kept in the folder for now. -Fold the locked shape into a real `src/components/status-bar` + LV chip when #709 builds out -multi-chip pinning, then remove this folder. diff --git a/angular-client/src/pages/efuses-page/components/lv-status-bar-prototype/lv-chips.prototype.ts b/angular-client/src/pages/efuses-page/components/lv-status-bar-prototype/lv-chips.prototype.ts deleted file mode 100644 index a854d06fb..000000000 --- a/angular-client/src/pages/efuses-page/components/lv-status-bar-prototype/lv-chips.prototype.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { Component, input } from '@angular/core'; -import { MatIcon } from '@angular/material/icon'; -import { StatusBarItemComponent } from './status-bar.prototype'; - -/** - * PROTOTYPE — throwaway. Three structurally different LV-battery chips, all pure - * presentational (voltage + firmware LV-fault flag come in as inputs from the host). - * Flip between them with ?variant= to settle the chip anatomy, then keep one. - */ - -const RED = 'var(--color-battery-low)'; -const GREEN = 'var(--color-battery-high)'; -const fmt = (v?: number): string => (v !== undefined && isFinite(v) ? v.toFixed(2) : '–'); - -/** Variant A — reuses the status-bar-item shell: icon + "LV Battery" label + value + unit + status dot. */ -@Component({ - selector: 'proto-lv-chip-a', - standalone: true, - imports: [StatusBarItemComponent], - template: ` - - ` -}) -export class LvChipAComponent { - voltage = input(); - faulted = input(false); - red = RED; - green = GREEN; - value = (): string => fmt(this.voltage()); -} - -/** Variant B — compact rounded pill, value-first, no label, no dot: the whole pill turns red on fault. */ -@Component({ - selector: 'proto-lv-chip-b', - standalone: true, - imports: [MatIcon], - template: ` -
-
- `, - styles: [ - ` - .pill { - display: inline-flex; - align-items: center; - gap: 6px; - max-width: 25vw; - padding: 6px 12px; - border-radius: 999px; - background: var(--color-background-page); - border: 1px solid var(--color-divider); - white-space: nowrap; - } - .pill.fault { - border-color: var(--color-battery-low); - } - .pill.fault .v, - .pill.fault .u, - .pill.fault .i { - color: var(--color-battery-low); - } - .i { - width: 18px; - height: 18px; - color: #cfcfcf; - } - .v { - font-size: var(--font-size-md, 15px); - font-weight: 700; - color: #ffffff; - } - .u { - font-size: var(--font-size-sm, 12px); - color: #9a9a9a; - } - ` - ] -}) -export class LvChipBComponent { - voltage = input(); - faulted = input(false); - value = (): string => fmt(this.voltage()); -} - -/** Variant C — richer card: big value over a "LV Battery" subtitle, icon left, status dot right. */ -@Component({ - selector: 'proto-lv-chip-c', - standalone: true, - imports: [MatIcon], - template: ` -
-
- `, - styles: [ - ` - .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); - } - .ic { - width: 26px; - height: 26px; - flex: 0 0 auto; - color: #cfcfcf; - } - .col { - display: flex; - flex-direction: column; - line-height: 1.1; - } - .top { - display: flex; - align-items: baseline; - gap: 3px; - } - .val { - font-size: var(--font-size-lg, 20px); - font-weight: 700; - color: #ffffff; - } - .un { - font-size: var(--font-size-sm, 12px); - color: #9a9a9a; - } - .sub { - font-size: var(--font-size-sm, 11px); - color: #8f8f8f; - text-transform: uppercase; - letter-spacing: 0.04em; - } - .dot { - width: 10px; - height: 10px; - border-radius: 50%; - flex: 0 0 auto; - margin-left: 4px; - } - ` - ] -}) -export class LvChipCComponent { - voltage = input(); - faulted = input(false); - red = RED; - green = GREEN; - value = (): string => fmt(this.voltage()); -} diff --git a/angular-client/src/pages/efuses-page/components/lv-status-bar-prototype/prototype-switcher.prototype.ts b/angular-client/src/pages/efuses-page/components/lv-status-bar-prototype/prototype-switcher.prototype.ts deleted file mode 100644 index 6c1d5b3bf..000000000 --- a/angular-client/src/pages/efuses-page/components/lv-status-bar-prototype/prototype-switcher.prototype.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { Component, HostListener, inject, input, isDevMode } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; -import { ActivatedRoute, Router } from '@angular/router'; -import { map } from 'rxjs'; - -/** - * PROTOTYPE — dev-only floating variant switcher. Cycles the ?variant= query param - * (shareable + reload-stable) via arrows or ← / → keys. Hidden in production builds. - * Delete together with the variant chips once the prototype is resolved. - */ -@Component({ - selector: 'proto-prototype-switcher', - standalone: true, - template: ` - @if (isDev) { -
- - {{ current() }}{{ name() }} - -
- } - `, - styles: [ - ` - .switcher { - position: fixed; - bottom: 16px; - left: 50%; - transform: translateX(-50%); - z-index: 1000; - display: flex; - align-items: center; - gap: 10px; - padding: 6px 10px; - border-radius: 999px; - background: #f5f5f5; - color: #111111; - box-shadow: 0 6px 20px rgba(0, 0, 0, 0.5); - font-family: monospace; - font-size: 14px; - } - .switcher button { - border: none; - background: #111111; - color: #ffffff; - width: 26px; - height: 26px; - border-radius: 50%; - cursor: pointer; - font-size: 16px; - line-height: 1; - } - .lbl { - min-width: 160px; - text-align: center; - font-weight: 700; - } - ` - ] -}) -export class PrototypeSwitcherComponent { - variants = input(['A']); - names = input>({}); - isDev = isDevMode(); - - private route = inject(ActivatedRoute); - private router = inject(Router); - private param = toSignal(this.route.queryParamMap.pipe(map((p) => p.get('variant')))); - - current = (): string => this.param() ?? this.variants()[0]; - - name = (): string => { - const n = this.names()[this.current()]; - return n ? ` — ${n}` : ''; - }; - - cycle = (dir: number): void => { - const vs = this.variants(); - const i = vs.indexOf(this.current()); - const next = vs[(i + dir + vs.length) % vs.length]; - this.router.navigate([], { queryParams: { variant: next }, queryParamsHandling: 'merge' }); - }; - - @HostListener('document:keydown', ['$event']) - onKey(e: KeyboardEvent): void { - const t = e.target as HTMLElement | null; - if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return; - if (e.key === 'ArrowLeft') this.cycle(-1); - else if (e.key === 'ArrowRight') this.cycle(1); - } -} diff --git a/angular-client/src/pages/efuses-page/components/lv-status-bar-prototype/status-bar.prototype.ts b/angular-client/src/pages/efuses-page/components/lv-status-bar-prototype/status-bar.prototype.ts deleted file mode 100644 index ab00417e1..000000000 --- a/angular-client/src/pages/efuses-page/components/lv-status-bar-prototype/status-bar.prototype.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { Component, input, signal } from '@angular/core'; -import { MatIcon } from '@angular/material/icon'; - -/** - * PROTOTYPE (#705, foundation for #709). Reusable "quick view" status bar: a right-edge - * sidebar pop-out, top-aligned, that surfaces pinned chips (projected via ). - * Auto-opens; a handle at the top-right edge re-opens it after collapse. Sticky while - * scrolling — pins to the top-right corner. Design is locked (side + Quick View header); - * fold into a real src/components/status-bar when #709 builds out multi-chip pinning. - */ -@Component({ - selector: 'proto-status-bar', - standalone: true, - imports: [MatIcon], - template: ` - @if (collapsed()) { - - } @else { -
- - -
- } - `, - styles: [ - ` - /* 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: #ffffff; - cursor: pointer; - display: inline-flex; - align-items: center; - justify-content: center; - } - .side-handle:hover { - background: #383838; - } - .side-handle mat-icon { - width: 26px; - height: 26px; - font-size: 26px; - line-height: 26px; - } - - /* Open sidebar: 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); - animation: sidebar-in 0.2s ease; - } - - /* Header: tiny "Quick view" title on the left, close chevron on the right. */ - .sidebar-header { - align-self: stretch; - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - } - .sidebar-title { - font-size: var(--font-size-sm, 12px); - font-weight: 600; - letter-spacing: 0.04em; - text-transform: uppercase; - color: #9a9a9a; - padding-left: 2px; - } - - .panel-collapse { - flex: 0 0 auto; - width: 28px; - height: 28px; - padding: 0; - border: none; - border-radius: 6px; - background: transparent; - color: #cfcfcf; - cursor: pointer; - display: inline-flex; - align-items: center; - justify-content: center; - } - .panel-collapse:hover { - background: rgba(255, 255, 255, 0.08); - } - .panel-collapse mat-icon { - width: 22px; - height: 22px; - font-size: 22px; - line-height: 22px; - } - ` - ] -}) -export class StatusBarComponent { - collapsed = signal(false); - toggle = (): void => this.collapsed.update((c) => !c); -} - -/** - * PROTOTYPE — reusable small-card chip shell that sits on the status bar. Content-sized, - * capped at 1/4 of the viewport, never stretched. icon + label + value/unit + optional dot. - */ -@Component({ - selector: 'proto-status-bar-item', - standalone: true, - imports: [MatIcon], - template: ` -
- @if (icon()) { -
- `, - styles: [ - ` - .chip { - display: inline-flex; - align-items: center; - gap: 8px; - max-width: 25vw; - box-sizing: border-box; - padding: 8px 14px; - border-radius: 8px; - background: var(--color-background-page); - border: 1px solid var(--color-divider); - white-space: nowrap; - overflow: hidden; - } - .icon { - width: 20px; - height: 20px; - flex: 0 0 auto; - color: #cfcfcf; - } - .label { - font-size: var(--font-size-md, 14px); - color: #b7b7b7; - overflow: hidden; - text-overflow: ellipsis; - } - .value { - font-size: var(--font-size-md, 15px); - font-weight: 600; - color: #ffffff; - } - .unit { - font-size: var(--font-size-sm, 12px); - color: #9a9a9a; - margin-left: -2px; - } - .dot { - width: 10px; - height: 10px; - border-radius: 50%; - flex: 0 0 auto; - margin-left: 2px; - } - ` - ] -}) -export class StatusBarItemComponent { - icon = input(); - label = input(); - value = input(''); - unit = input(); - dotColor = input(); -} 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 3e1cccc43..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,8 +1,8 @@ /* eFuses Page Styles */ -/* PROTOTYPE (#705 status-bar) — 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. */ +/* 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; } 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 e5bdbb9b8..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,7 +1,7 @@ - - - + + +
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 2345e3777..98ab951b4 100644 --- a/angular-client/src/pages/efuses-page/efuses-page.component.ts +++ b/angular-client/src/pages/efuses-page/efuses-page.component.ts @@ -7,9 +7,8 @@ import EfuseCardComponent, { EfuseLockMode } from './components/efuse-card/efuse 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'; -// #705 LV Battery status bar (prototype, foundation for #709) — lives in ./components/lv-status-bar-prototype. -import { StatusBarComponent } from './components/lv-status-bar-prototype/status-bar.prototype'; -import { LvChipCComponent } from './components/lv-status-bar-prototype/lv-chips.prototype'; +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. @@ -19,7 +18,7 @@ import { LvChipCComponent } from './components/lv-status-bar-prototype/lv-chips. styleUrls: ['./efuses-page.component.css'], templateUrl: './efuses-page.component.html', standalone: true, - imports: [GridLayoutComponent, EfuseCardComponent, RtdsDebugCardComponent, StatusBarComponent, LvChipCComponent] + imports: [GridLayoutComponent, EfuseCardComponent, RtdsDebugCardComponent, StatusBarComponent, LvBatteryChipComponent] }) export default class EfusesPageComponent { private storage = inject(Storage); From b97b3097ea1bbdf3a538a01f80da6271c8158eee Mon Sep 17 00:00:00 2001 From: wyattb Date: Sun, 19 Jul 2026 11:19:10 -0700 Subject: [PATCH 5/5] #705 - address PR review: tokens/contrast, dot a11y, focus-on-toggle, specs; drop orphaned card --- .../status-bar/status-bar.component.css | 22 ++++-- .../status-bar/status-bar.component.html | 11 ++- .../status-bar/status-bar.component.spec.ts | 70 +++++++++++++++++++ .../status-bar/status-bar.component.ts | 26 ++++++- .../lv-battery-card.component.html | 9 --- .../lv-battery-card.component.spec.ts | 59 ---------------- .../lv-battery-card.component.ts | 68 ------------------ .../lv-battery-chip.component.css | 10 +-- .../lv-battery-chip.component.html | 2 +- .../lv-battery-chip.component.spec.ts | 55 +++++++++++++++ .../lv-battery-chip.component.ts | 3 + 11 files changed, 186 insertions(+), 149 deletions(-) create mode 100644 angular-client/src/components/status-bar/status-bar.component.spec.ts delete mode 100644 angular-client/src/pages/efuses-page/components/lv-battery-card/lv-battery-card.component.html delete mode 100644 angular-client/src/pages/efuses-page/components/lv-battery-card/lv-battery-card.component.spec.ts delete mode 100644 angular-client/src/pages/efuses-page/components/lv-battery-card/lv-battery-card.component.ts create mode 100644 angular-client/src/pages/efuses-page/components/lv-battery-chip/lv-battery-chip.component.spec.ts diff --git a/angular-client/src/components/status-bar/status-bar.component.css b/angular-client/src/components/status-bar/status-bar.component.css index 04f7c7a43..59c3a06af 100644 --- a/angular-client/src/components/status-bar/status-bar.component.css +++ b/angular-client/src/components/status-bar/status-bar.component.css @@ -29,7 +29,7 @@ border-right: none; border-radius: 10px 0 0 10px; background: var(--color-background-info); - color: #ffffff; + color: var(--color-text-primary); cursor: pointer; display: inline-flex; align-items: center; @@ -38,6 +38,10 @@ .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; @@ -60,7 +64,13 @@ border-right: none; border-radius: 12px 0 0 12px; box-shadow: -6px 6px 18px rgba(0, 0, 0, 0.45); - animation: sidebar-in 0.2s ease; +} + +/* 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. */ @@ -76,7 +86,7 @@ font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; - color: #9a9a9a; + color: var(--color-text-subtitle); padding-left: 2px; } @@ -88,7 +98,7 @@ border: none; border-radius: 6px; background: transparent; - color: #cfcfcf; + color: var(--color-text-subtitle); cursor: pointer; display: inline-flex; align-items: center; @@ -97,6 +107,10 @@ .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; diff --git a/angular-client/src/components/status-bar/status-bar.component.html b/angular-client/src/components/status-bar/status-bar.component.html index e156909d1..a5e114c18 100644 --- a/angular-client/src/components/status-bar/status-bar.component.html +++ b/angular-client/src/components/status-bar/status-bar.component.html @@ -1,12 +1,19 @@ @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 index 2fa5b6060..1e588144b 100644 --- a/angular-client/src/components/status-bar/status-bar.component.ts +++ b/angular-client/src/components/status-bar/status-bar.component.ts @@ -1,4 +1,14 @@ -import { ChangeDetectionStrategy, Component, input, signal } from '@angular/core'; +import { + afterNextRender, + ChangeDetectionStrategy, + Component, + ElementRef, + inject, + Injector, + input, + signal, + viewChild +} from '@angular/core'; import { MatIcon } from '@angular/material/icon'; /** @@ -15,12 +25,26 @@ import { MatIcon } from '@angular/material/icon'; 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-card/lv-battery-card.component.html b/angular-client/src/pages/efuses-page/components/lv-battery-card/lv-battery-card.component.html deleted file mode 100644 index feeab1d25..000000000 --- a/angular-client/src/pages/efuses-page/components/lv-battery-card/lv-battery-card.component.html +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/angular-client/src/pages/efuses-page/components/lv-battery-card/lv-battery-card.component.spec.ts b/angular-client/src/pages/efuses-page/components/lv-battery-card/lv-battery-card.component.spec.ts deleted file mode 100644 index d613b3ef5..000000000 --- a/angular-client/src/pages/efuses-page/components/lv-battery-card/lv-battery-card.component.spec.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { provideExperimentalZonelessChangeDetection } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import Storage from 'src/services/storage.service'; -import { DataValue } from 'src/utils/socket.utils'; -import { EFUSE_TOPICS } from '../../efuses-page.topics'; -import LvBatteryCardComponent, { LV_FAULT_COLOR, LV_NORMAL_COLOR } from './lv-battery-card.component'; - -const push = (storage: Storage, key: string, value: string): void => { - storage.addValue(key, { values: [value], time: '0', unit: '' } as DataValue); -}; - -describe('LvBatteryCardComponent', () => { - let fixture: ComponentFixture; - let component: LvBatteryCardComponent; - let storage: Storage; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [LvBatteryCardComponent], - providers: [provideExperimentalZonelessChangeDetection(), Storage] - }).compileComponents(); - - storage = TestBed.inject(Storage); - fixture = TestBed.createComponent(LvBatteryCardComponent); - component = fixture.componentInstance; - fixture.detectChanges(); // ngOnInit → storage subscriptions - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('shows the LV battery voltage published to VCU/LV/voltage', () => { - // Storage delivers synchronously on push, so component state updates without a - // further detectChanges; a per-push template re-render raises a spurious NG0100 - // under zoneless. The real DOM render is covered by the Playwright visual test. - push(storage, EFUSE_TOPICS.VCU.LV.Voltage, '24.53'); - - expect(component.voltage).toBeCloseTo(24.53); - }); - - it('turns the warning color when the LV low-voltage fault activates and clears when it resolves', () => { - expect(component.getStatusColor()).toBe(LV_NORMAL_COLOR); - - push(storage, EFUSE_TOPICS.VCU.LV.LowVoltageFault, '1'); - expect(component.isFaulted).toBeTrue(); - expect(component.getStatusColor()).toBe(LV_FAULT_COLOR); - - push(storage, EFUSE_TOPICS.VCU.LV.LowVoltageFault, '0'); - expect(component.isFaulted).toBeFalse(); - expect(component.getStatusColor()).toBe(LV_NORMAL_COLOR); - }); - - it('drives the warning from the fault flag, not a hardcoded voltage threshold', () => { - push(storage, EFUSE_TOPICS.VCU.LV.Voltage, '1.0'); // implausibly low, but no fault flag set - - expect(component.getStatusColor()).toBe(LV_NORMAL_COLOR); - }); -}); diff --git a/angular-client/src/pages/efuses-page/components/lv-battery-card/lv-battery-card.component.ts b/angular-client/src/pages/efuses-page/components/lv-battery-card/lv-battery-card.component.ts deleted file mode 100644 index 91ee3ceb9..000000000 --- a/angular-client/src/pages/efuses-page/components/lv-battery-card/lv-battery-card.component.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { Component, inject, OnDestroy, OnInit } from '@angular/core'; -import { Subscription } from 'rxjs'; -import { InfoBackgroundComponent } from 'src/components/info-background/info-background.component'; -import { - ConnectionDotConfig, - InfoValueDisplayComponent -} from 'src/components/info-value-dispaly/info-value-display.component'; -import Storage from 'src/services/storage.service'; -import { EFUSE_TOPICS } from '../../efuses-page.topics'; - -/** Dot color shown while the LV low-voltage fault is active. */ -export const LV_FAULT_COLOR = '#ef4444'; -/** Dot color shown while no LV low-voltage fault is active. */ -export const LV_NORMAL_COLOR = '#22c55e'; - -/** - * Low-voltage battery voltage tile for the eFuses page. - * - * Reads the LV battery voltage (VCU/LV/voltage) and turns a warning color when - * the firmware's LV low-voltage fault flag is active. The threshold is owned by - * firmware, so the warning is driven purely by the fault flag — never a hardcoded - * voltage cutoff. This is the physical LV battery, distinct from the LV eFuse card, - * which reads VCU/eFuses/LV/Voltage. - */ -@Component({ - selector: 'lv-battery-card', - templateUrl: './lv-battery-card.component.html', - standalone: true, - imports: [InfoBackgroundComponent, InfoValueDisplayComponent] -}) -export default class LvBatteryCardComponent implements OnInit, OnDestroy { - private storage = inject(Storage); - private subscriptions: Subscription[] = []; - - readonly topics = EFUSE_TOPICS.VCU.LV; - - voltage: number | undefined = undefined; - isFaulted = false; - - ngOnInit(): void { - this.subscriptions.push( - this.storage.get(this.topics.Voltage).subscribe((value) => { - this.voltage = parseFloat(value.values[0]); - }), - this.storage.get(this.topics.LowVoltageFault).subscribe((value) => { - this.isFaulted = Number(value.values[0]) === 1; - }) - ); - } - - getStatusColor = (): string => { - return this.isFaulted ? LV_FAULT_COLOR : LV_NORMAL_COLOR; - }; - - getStatusMessage = (): string => { - return this.isFaulted ? 'LV low-voltage fault' : 'Nominal'; - }; - - connectionDotConfig: ConnectionDotConfig = { - type: 'connection-dot-config', - getStatusColor: this.getStatusColor, - getStatusMessage: this.getStatusMessage - }; - - ngOnDestroy(): void { - this.subscriptions.forEach((sub) => sub.unsubscribe()); - } -} 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 index c9aa16921..2533df055 100644 --- 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 @@ -12,7 +12,7 @@ width: 26px; height: 26px; flex: 0 0 auto; - color: #cfcfcf; + color: var(--color-text-subtitle); } .col { display: flex; @@ -27,15 +27,15 @@ .value { font-size: var(--font-size-lg, 20px); font-weight: 700; - color: #ffffff; + color: var(--color-text-primary); } .unit { font-size: var(--font-size-sm, 12px); - color: #9a9a9a; + color: var(--color-text-subtitle); } .subtitle { - font-size: var(--font-size-sm, 11px); - color: #8f8f8f; + font-size: var(--font-size-sm, 12px); + color: var(--color-text-subtitle); text-transform: uppercase; letter-spacing: 0.04em; } 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 index 268477816..7df41336d 100644 --- 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 @@ -7,5 +7,5 @@
LV Battery
- +
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 index 49b21e360..82a08e156 100644 --- 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 @@ -33,4 +33,7 @@ export default class LvBatteryChipComponent { }); 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')); }