AI Implementation feature(880): Authenticated Shell, Quick Tabs and Change Password 1.04 #19

Merged
m0rph3us1987 merged 2 commits from feature-880-1784712468074 into dev 2026-07-22 09:37:11 +00:00
12 changed files with 248 additions and 242 deletions
-178
View File
@@ -1,178 +0,0 @@
# Implementation Plan: Authenticated Shell — Component-Scoped LED Fix (Job 879, iteration 1.03)
## Status
The Job 879 reproduction shows that the LED `<span class="led led-running">`
renders with zero size and transparent background even though the global
`frontend/src/styles.css` declares the rules and the production bundle
contains them. To guarantee the rules apply regardless of cascade order,
external stylesheet loading, or specificity conflicts, this fix moves the
LED's visual styling from the global stylesheet into the
`ShellHeaderComponent` itself using Angular's
`styles: [...]` (component-scoped CSS).
The component is a standalone component (no `styleUrls`), so the fix is a
single, self-contained edit: add `styles: [\`...\`] to the `@Component`
decorator in
`frontend/src/app/features/shell/header/shell-header.component.ts`.
The existing template, state class bindings, and `aria-label` are kept
exactly as-is (lines 2634).
A minimal regression test is added in a dedicated
`tests/frontend/shell-led.spec.ts` that asserts the rendered `<span>`
carries `led` plus the correct `led-<state>` class for each
`EventState`, and that the bundled component CSS contains the four state
selectors.
## 1. Architectural Reconnaissance
- **Codebase style & conventions:**
- NestJS 10 backend + Angular 17 standalone components.
- Standalone components use `imports: [...]` and prefer `styles: [\`...\`]`
for small, co-located CSS. Existing examples in this repo:
`frontend/src/app/features/setup/setup-create-admin.component.ts`
uses `styleUrls: ['./setup-create-admin.component.css']`;
`frontend/src/app/features/landing/landing.component.ts` likewise.
For a small block of CSS that belongs to a single component, the
inline `styles: [\`...\`] form keeps the fix in one file (per the
user instruction: "Update the LED styling in
frontend/src/app/features/shell/header/shell-header.component.ts near
lines 25-34").
- Angular's default `ViewEncapsulation.Emulated` rewrites component CSS
selectors with attribute selectors (`[_ngcontent-xxx]`) and adds the
same attribute to the component's host + template nodes. Because the
LED `<span>` is rendered by `ShellHeaderComponent` (not by a child
component or via `innerHTML`), encapsulation correctly scopes the new
rules to that span, while still allowing the global `:root` CSS
custom-property tokens (`--color-success`, etc.) to be inherited.
- **Data Layer:** Not impacted. The LED color is driven by
`eventState()` from `EventStatusStore` (already wired).
- **Test Framework & Structure:**
- Jest 29 multi-project config (`tests/jest.config.js`).
- Frontend project uses `ts-jest` + jsdom +
`tests/frontend/jest.setup.ts` (Angular TestBed shims).
- Tests MUST live in `tests/frontend/` — never alongside source. They
MUST run via `npm test` (single root command). Tests MUST NOT
require a UI; this plan keeps them purely structural.
- **Required Tools & Dependencies:** None new. Existing
`@angular-devkit/build-angular:application` builder already supports
component-scoped `styles: [\`...\`] `, and `critters` (used for inline
above-the-fold CSS) is unaffected. `setup.sh` needs no changes.
## 2. Impacted Files
- **To Modify:**
- `frontend/src/app/features/shell/header/shell-header.component.ts`
— add a `styles: [\`...\`] block to the `@Component` decorator that
mirrors the LED rules from the global stylesheet, scoped to the
component. Keep the existing template (lines 1088) untouched.
Keep the `[class.led-<state>]` bindings and the dynamic `aria-label`.
- **To Create:**
- `tests/frontend/shell-led.spec.ts` — minimal jsdom-based test that
renders `ShellHeaderComponent` with each `EventState` value and
asserts (a) the rendered `[data-testid="shell-led"]` carries the
`led` class plus the matching `led-<state>` class, and (b) the
component's bundled CSS string contains the four required state
selectors (`led-running`, `led-countdown`, `led-stopped`,
`led-unconfigured`).
## 3. Proposed Changes
1. **Component-scoped CSS in `ShellHeaderComponent`**
In
`frontend/src/app/features/shell/header/shell-header.component.ts`,
add a `styles` property to the `@Component` decorator. Place it
immediately after the existing `imports: [CommonModule]` property
(around the current line 9 area) so the decorator remains in
canonical Angular order: `selector`, `standalone`, `changeDetection`,
`imports`, `template`, `styles`.
Exact CSS block to add inside the backtick literal:
```
:host .led,
.led {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
border: 0;
background-color: transparent;
vertical-align: middle;
}
.led-running { background-color: var(--color-success); }
.led-countdown { background-color: var(--color-warning); }
.led-stopped { background-color: var(--color-danger); }
.led-unconfigured { background-color: var(--color-secondary); }
```
Notes:
- The base rule is duplicated as `:host .led, .led` so it matches
both a projected host usage and the in-template span. The
`:host` selector is harmless under Emulated encapsulation (it
targets the component's own host element) and does not break the
existing template.
- State classes are written exactly as before — the template already
toggles `[class.led-running]`, `[class.led-countdown]`,
`[class.led-stopped]`, `[class.led-unconfigured]`.
- Color values use the existing `:root` CSS custom properties
(`--color-success`/`--color-warning`/`--color-danger`/
`--color-secondary`) defined in `frontend/src/styles.css`. These
tokens are inherited through the DOM, so the component-scoped
rules resolve them automatically — no per-state hex is needed in
TypeScript.
- The current global rules in `frontend/src/styles.css` (lines 2327)
are left untouched as a safety net; they remain correct and
harmless.
2. **No template or state-binding changes**
The `<span ... class="led" [class.led-running]="..." ...>`
block (lines 2634) and the dynamic `aria-label` are kept exactly
as the documentation in
`docs/guides/authenticated-shell.md` specifies.
3. **No backend / DB changes**
The bug is purely a frontend cascade issue.
## 4. Test Strategy
- **Target Unit Test File:** `tests/frontend/shell-led.spec.ts`
(new file, in the dedicated `tests/frontend/` folder per repo
convention; runs via `npm test` -> `jest --config tests/jest.config.js
--selectProjects frontend`).
- **Mocking Strategy:**
- Use Angular's `TestBed.configureTestingModule({ imports:
[ShellHeaderComponent] })` so the component renders standalone
in jsdom with no router / http mocking required (the LED span has
no dependencies).
- Provide minimal inputs via the component's `input.required<...>` /
`input<...>(...)` signals. Specifically, set `eventState()` to
each of `'running' | 'countdown' | 'stopped' | 'unconfigured'`
using `TestBed.runInInjectionContext` + `ComponentRef.setInput`
(or `fixture.componentRef.setInput`) and assert the DOM.
- No HTTP, no router, no SSE — keeping the test fast and isolated.
- Two compact assertions per case:
1. `expect(led.classList.contains('led')).toBe(true)` and the
matching `led-<state>` class.
2. Read the component's raw `styles` string (via the decorated
component's static `ɵcmp.styles` or by string-matching the
source file's `styles:` template literal in a separate small
test) to confirm all four state selectors are present.
Concretely: a single test that imports the component file as
text (via `fs.readFileSync` of the TS source — same pattern
other frontend tests already use for inspecting contracts) and
asserts it contains
`.led-running`, `.led-countdown`, `.led-stopped`,
`.led-unconfigured`, plus `width: 10px`, `height: 10px`,
`border-radius: 50%`, and `var(--color-success)`.
- **What is NOT tested (per instructions):** no visual / screenshot
confirmation, no jsdom layout reflow assertions, no large mock SSE
harness — only the structural class binding and the presence of
the component-scoped CSS rules. This matches the
"minimal, highly-focused tests" instruction.
+30
View File
@@ -0,0 +1,30 @@
# Implementation Plan: Authenticated Shell LED Visibility Fix
## 1. Architectural Reconnaissance
- **Codebase style & conventions:** Node.js monorepo with an Angular 17 standalone-component SPA and NestJS backend. The authenticated shell uses OnPush components, signal inputs/outputs, inline Angular templates/styles, and theme CSS custom properties. `HomeComponent` owns event-stream lifecycle and passes `EventStatusStore.state()` into the presentational `ShellHeaderComponent`. Quick tabs and change-password behavior are already implemented and are not implicated in this regression.
- **Data Layer:** TypeORM-backed backend settings provide `eventStartUtc` and `eventEndUtc`; no schema or persistence changes are required. The backend event-state calculation and authenticated SSE payload are already functioning: the shell receives the correct `running`, `countdown`, `stopped`, or `unconfigured` state and applies the matching class.
- **Test Framework & Structure:** Root Jest 29 multi-project configuration with `ts-jest`; frontend tests run in jsdom from the dedicated `tests/frontend/` directory. `npm test` runs all backend and frontend tests, while `npm run test:frontend` runs the frontend project only. Existing `tests/frontend/shell-led.spec.ts` is a source-structure assertion and does not instantiate the component or verify the rendered style binding.
- **Required Tools & Dependencies:** No new system tools, global CLIs, npm packages, persistent `/data` assets, or `setup.sh` changes are required. Existing Angular, Jest, jsdom, and TypeScript dependencies are sufficient.
## 2. Impacted Files
- **To Modify:**
- `frontend/src/app/features/shell/header/shell-header.component.ts` — remove the conflicting transparent base declaration and bind the state-specific background color directly on the LED element so the browser paints the themed color reliably.
- `tests/frontend/shell-led.spec.ts` — replace/extend brittle source-only assertions with a focused automated regression covering the style value produced for each event state and preserving the accessible state label.
- **To Create:** None.
## 3. Proposed Changes
1. **Database / Schema Migration:** None. Event timestamps, settings storage, REST/SSE contracts, and backend state computation remain unchanged.
2. **Backend Logic & APIs:** None. `/api/v1/events/status` already emits the correct state, and `EventStatusStore` already forwards it to the shell.
3. **Frontend UI Integration:**
1. In `ShellHeaderComponent`, define a typed state-to-theme-color mapping for all `EventState` values: `running``var(--color-success)`, `countdown``var(--color-warning)`, `stopped``var(--color-danger)`, and `unconfigured``var(--color-secondary)`.
2. Replace the unused `ledClass` computed value with a computed background-color value derived from `eventState()`; keep the existing state class bindings because they are useful selectors and preserve the current DOM contract.
3. Bind the computed value directly to the LED span's `style.background-color`. This gives the element an explicit non-transparent author value while continuing to resolve the active theme token at runtime, matching the reported proof that a direct targeted declaration makes the dot visible.
4. Remove `background-color: transparent` from the component base `.led` rule, and remove the redundant component state-color declarations if the inline binding becomes the single authoritative color source. Retain only the shape/layout styling (`inline-block`, 10×10 dimensions, circular radius, border, alignment).
5. Remove the global LED state rules from `frontend/src/styles.css` if they are no longer consumed, avoiding duplicate ownership and future cascade ambiguity; keep canonical theme token declarations untouched.
6. Preserve the existing `data-testid`, four state classes, and `aria-label="Event status: <state>"`, so status remains exposed textually rather than by color alone.
7. Do not modify `HomeComponent`, `QuickTabsComponent`, change-password components, event store, routes, SSE transport, or backend services because repository tracing shows those layers already deliver the correct state and shell behavior.
## 4. Test Strategy
- **Target Unit Test File:** `tests/frontend/shell-led.spec.ts`.
- **Mocking Strategy:** No backend, HTTP, SSE, timers, filesystem fixtures, browser automation, or persistent data. Exercise a small exported pure state-to-color helper (or equivalent component mapping) with table-driven assertions for the four states, and retain focused source/template assertions that the LED consumes that value through `[style.background-color]`, keeps each state class binding, and keeps the dynamic `aria-label`. This directly prevents a regression to a transparent base color while remaining fast and CLI-only under `npm test`.
- **Verification Commands for the implementer:** Run `npm run test:frontend`, `npm test`, and `npm run build` from `/repo`. The root build compiles both Angular and NestJS; no separate lint or typecheck script is defined.
+27 -2
View File
@@ -3,7 +3,7 @@ type: architecture
title: Frontend Structure
description: Angular routes, components, services, guards, interceptors, and authenticated SSE transport.
tags: [architecture, frontend, angular, shell, sse]
timestamp: 2026-07-22T09:22:34Z
timestamp: 2026-07-22T09:35:00Z
---
# Routes
@@ -27,7 +27,7 @@ Routes live in `frontend/src/app/app.routes.ts`:
| `HomeComponent` | `frontend/src/app/features/home/home.component.ts` | Smart shell; owns navigation signals, user hydration, change-password flow, and event-stream lifecycle. |
| `ShellHeaderComponent` | `frontend/src/app/features/shell/header/shell-header.component.ts` | Renders title, active section, event LED/countdown, and user menu; contains component-scoped LED styling. |
| `QuickTabsComponent` | `frontend/src/app/features/shell/tabs/quick-tabs.component.ts` | Emits navigation events for Challenges, Scoreboard, and Blog. |
| `EventStatusStore` | `frontend/src/app/core/services/event-status.store.ts` | Applies event state frames and computes countdown text. |
| `EventStatusStore` | `frontend/src/app/core/services/event-status.store.ts` | Applies event state frames and computes countdown text; re-exports the pure LED color mapping. |
| `AuthenticatedEventSourceService` | `frontend/src/app/core/services/authenticated-event-source.service.ts` | Opens authenticated SSE over `fetch`, parsing streamed frames into `EventSourceLike` events. |
| `AuthService` | `frontend/src/app/core/services/auth.service.ts` | Stores the access token used by the authenticated SSE transport. |
| `UserStore` | `frontend/src/app/core/services/user.store.ts` | Hydrates the signed-in user and rank data. |
@@ -59,6 +59,31 @@ and runs a one-second local timer. `HomeComponent` stops the store on destructio
| `frontend/src/app/core/services/event-status.pure.ts` | Defines the event payload and transport interface. |
| `tests/frontend/authenticated-event-source.spec.ts` | Regression coverage for authenticated SSE transport behavior. |
# Event-status pure helpers
`frontend/src/app/core/services/event-status.pure.ts` owns the small,
no-Angular types and pure functions consumed by the event status pipeline.
`EventStatusStore` re-exports them so application code can import from a
single path.
| Export | Purpose |
|---|---|
| `EventState` | Union `'running' \| 'countdown' \| 'stopped' \| 'unconfigured'`. |
| `EventStatePayload` | Server-pushed event window payload shape. |
| `EventSourceLike` | Minimal interface for any SSE transport the store can drive. |
| `formatDdHhMm(seconds)` | Pure `DD:HH:mm` formatter. |
| `deriveCountdownText(state, start, end)` | Pure mapping from event window state to countdown label. |
| `LED_COLOR_BY_STATE` | `Readonly<Record<EventState, string>>` mapping each state to its theme CSS variable (`var(--color-success)`, `--color-warning`, `--color-danger`, `--color-secondary`). |
| `ledBackgroundColor(state)` | Pure accessor returning the `background-color` value for the LED element. |
`ShellHeaderComponent` exposes a `computed` signal `ledColor()` that calls
`ledBackgroundColor(this.eventState())` and binds it to the LED via
`[style.background-color]`. The component's `.led` base rule is therefore
shape-only (size, radius, `inline-block`, vertical alignment); the visible
color always comes from `LED_COLOR_BY_STATE` and is guaranteed to be a
non-transparent theme token. The four `led-<state>` classes remain on the
element as selectors/test hooks but no longer own the color.
# See also
- [Authenticated Shell](/guides/authenticated-shell.md)
+3 -3
View File
@@ -3,7 +3,7 @@ type: architecture
title: Key Files Index
description: One-line responsibility for important source files, including authenticated event streaming.
tags: [architecture, index, key-files]
timestamp: 2026-07-21T23:25:00Z
timestamp: 2026-07-22T09:35:00Z
---
# Backend
@@ -34,8 +34,8 @@ timestamp: 2026-07-21T23:25:00Z
| `frontend/src/app/core/services/auth.service.ts` | Signal-backed access token and current-user state. |
| `frontend/src/app/core/services/authenticated-event-source.service.ts` | Fetch-based authenticated SSE transport with frame parsing and abort support. |
| `frontend/src/app/core/services/event-status.store.ts` | Event state signal store and one-second countdown timer. |
| `frontend/src/app/core/services/event-status.pure.ts` | Event payload types and pure countdown helpers. |
| `frontend/src/app/features/shell/header/shell-header.component.ts` | Shell title, status LED, countdown, and user menu. |
| `frontend/src/app/core/services/event-status.pure.ts` | Event payload types, pure countdown helpers, and the `LED_COLOR_BY_STATE` map used by the shell LED. |
| `frontend/src/app/features/shell/header/shell-header.component.ts` | Shell title, status LED (size/shape only — color comes from the pure map via `[style.background-color]`), countdown, and user menu. |
| `frontend/src/app/features/shell/tabs/quick-tabs.component.ts` | Main shell navigation tabs. |
| `frontend/src/app/features/shell/change-password/change-password-modal.component.ts` | Change-password form modal. |
| `tests/frontend/authenticated-event-source.spec.ts` | Tests SSE authorization and frame transport behavior. |
+17 -8
View File
@@ -3,7 +3,7 @@ type: guide
title: Authenticated Shell
description: How a signed-in user experiences the post-login shell, including authenticated event streaming.
tags: [guide, shell, header, sse, navigation, tester]
timestamp: 2026-07-22T09:22:34Z
timestamp: 2026-07-22T09:35:00Z
---
# When this view is available
@@ -35,20 +35,29 @@ before the shell mounts.
# Event LED colors
The LED is rendered by `ShellHeaderComponent` as a `<span class="led">` with one of four state classes attached via `[class.led-<state>]`. Its component-scoped styling is declared in `frontend/src/app/features/shell/header/shell-header.component.ts` and is driven entirely by the existing theme color tokens — there is no per-state color in TypeScript.
The LED is rendered by `ShellHeaderComponent` as a `<span class="led">` with one of four state classes attached via `[class.led-<state>]` and a `[style.background-color]` bound directly from a pure TypeScript helper.
| Event state | CSS class | Color token | Approx. hex | Visual |
|-------------------|-----------------------|-----------------------|-------------|--------|
| `countdown` | `led led-countdown` | `--color-warning` | `#f59e0b` | Yellow filled dot |
| `running` | `led led-running` | `--color-success` | `#10b981` | Green filled dot |
| `stopped` | `led led-stopped` | `--color-danger` | `#ef4444` | Red filled dot |
| `unconfigured` | `led led-unconfigured`| `--color-secondary` | `#64748b` | Neutral gray filled dot |
| Event state | CSS class | Color token (`[style.background-color]` value) | Approx. hex | Visual |
|-------------------|-----------------------|------------------------------------------------|-------------|--------|
| `countdown` | `led led-countdown` | `var(--color-warning)` | `#f59e0b` | Yellow filled dot |
| `running` | `led led-running` | `var(--color-success)` | `#10b981` | Green filled dot |
| `stopped` | `led led-stopped` | `var(--color-danger)` | `#ef4444` | Red filled dot |
| `unconfigured` | `led led-unconfigured`| `var(--color-secondary)` | `#64748b` | Neutral gray filled dot |
The mapping is defined in [`frontend/src/app/core/services/event-status.pure.ts`](/architecture/frontend-structure.md#event-status-pure-helpers) as the constant `LED_COLOR_BY_STATE` and exposed through the helper `ledBackgroundColor(state)`. `ShellHeaderComponent` exposes it on the DOM via a `computed` signal called `ledColor()`, so the active theme token resolves at runtime and the LED is never painted with `transparent`.
The base `.led` rule paints a 10×10 circular `inline-block`, so the LED
is always the same size and shape — only the background color changes
between states. The element also carries
`aria-label="Event status: <state>"` for assistive tech.
# What the tester should see
* The 10×10 LED is visible (not transparent, not invisible) in every state.
* The LED color matches the table above for the active `EventState`.
* Switching the event window (countdown → running → stopped → unconfigured) flips the dot color in real time.
* The `aria-label` updates to `Event status: <state>` and never relies on color alone.
## Quick tabs
The tab strip `[data-testid="quick-tabs"]` contains `Challenges`, `Scoreboard`, and
+86
View File
@@ -0,0 +1,86 @@
---
type: guide
title: Event LED Color Mapping
description: Single source of truth mapping each event window state to the themed background color painted on the authenticated shell's status LED.
tags: [guide, shell, led, theming, event-window, tester]
timestamp: 2026-07-22T09:35:00Z
---
# Purpose
The 10×10 status dot in the authenticated shell LED
(`[data-testid="shell-led"]` in
[`ShellHeaderComponent`](/architecture/frontend-structure.md)) reflects the
current event window state. Its background color is driven by a single pure
helper so the dot is **never transparent** and the active theme token always
resolves at runtime.
# Mapping
Defined in
`frontend/src/app/core/services/event-status.pure.ts` and re-exported via
`frontend/src/app/core/services/event-status.store.ts`:
| `EventState` | `ledBackgroundColor(state)` | Theme CSS variable |
|--------------|------------------------------|------------------------|
| `countdown` | `var(--color-warning)` | `--color-warning` |
| `running` | `var(--color-success)` | `--color-success` |
| `stopped` | `var(--color-danger)` | `--color-danger` |
| `unconfigured` | `var(--color-secondary)` | `--color-secondary` |
Backing constant: `LED_COLOR_BY_STATE: Readonly<Record<EventState, string>>`.
The accessor function `ledBackgroundColor(state)` returns the same string for
each state.
# How it reaches the DOM
1. `HomeComponent` forwards the `state` signal from
[`EventStatusStore`](/architecture/frontend-structure.md) to
`ShellHeaderComponent` via its `eventState` input.
2. `ShellHeaderComponent.ledColor` is a `computed` signal defined as
`computed(() => ledBackgroundColor(this.eventState()))`.
3. The template binds it directly:
`[style.background-color]="ledColor()"`.
4. The LED's base `.led` rule is shape-only (`inline-block`, 10×10, circular,
`vertical-align: middle`); no `transparent` fallback is applied, so the
author background color is always visible.
5. The legacy `.led-running`, `.led-countdown`, `.led-stopped`,
`.led-unconfigured` CSS selectors remain on the element and continue to be
useful hooks for selectors and tests, but they no longer declare the color.
# Examples
## Programmatic lookup
```ts
import { ledBackgroundColor, LED_COLOR_BY_STATE } from
'./frontend/src/app/core/services/event-status.pure';
ledBackgroundColor('running'); // 'var(--color-success)'
ledBackgroundColor('stopped'); // 'var(--color-danger)'
LED_COLOR_BY_STATE.unconfigured; // 'var(--color-secondary)'
```
## Verifying in the DOM
```ts
const dot = document.querySelector('[data-testid="shell-led"]') as HTMLElement;
dot.style.backgroundColor; // resolves to the current theme token, never empty
dot.getAttribute('aria-label'); // 'Event status: running'
```
# Tester checklist
* The LED is **visible** in every state (no transparent or missing dot).
* The LED color matches the mapping table when the event window is forced
into each state via the [event window settings](/guides/event-window.md).
* Switching states live updates both the dot color and the
`aria-label="Event status: <state>"` attribute.
* The four `led-<state>` classes are still applied to the element.
# See also
- [Authenticated Shell](/guides/authenticated-shell.md)
- [Event Window](/guides/event-window.md)
- [Frontend Structure](/architecture/frontend-structure.md)
- [Theming](/guides/theming.md)
+3 -1
View File
@@ -11,7 +11,7 @@ scoreboard, an event window with a public countdown, theming, and admin
controls.
The docs below are organized by purpose so agents can pull just the slice
they need. Last regenerated 2026-07-22T09:22:34Z.
they need. Last regenerated 2026-07-22T09:35:00Z.
# Architecture
@@ -75,5 +75,7 @@ they need. Last regenerated 2026-07-22T09:22:34Z.
* [Theming](/guides/theming.md) - How themes are loaded and applied.
* [Event Window](/guides/event-window.md) - How the 4-state event
window and live countdown work, including the authenticated SSE stream.
* [Event LED Color Mapping](/guides/event-led-colors.md) - Source of
truth for the shell status dot's color in each `EventState`.
* [Scoreboard Stream](/guides/scoreboard-stream.md) - How live solves are
broadcast to clients.
@@ -36,3 +36,14 @@ export function deriveCountdownText(
}
return secondsToEnd == null ? '00:00:00' : formatDdHhMm(secondsToEnd);
}
export const LED_COLOR_BY_STATE: Readonly<Record<EventState, string>> = {
running: 'var(--color-success)',
countdown: 'var(--color-warning)',
stopped: 'var(--color-danger)',
unconfigured: 'var(--color-secondary)',
};
export function ledBackgroundColor(state: EventState): string {
return LED_COLOR_BY_STATE[state];
}
@@ -4,9 +4,19 @@ import {
EventSourceLike,
EventState,
EventStatePayload,
LED_COLOR_BY_STATE,
ledBackgroundColor,
} from './event-status.pure';
export { EventState, EventStatePayload, EventSourceLike, deriveCountdownText, formatDdHhMm } from './event-status.pure';
export {
EventState,
EventStatePayload,
EventSourceLike,
deriveCountdownText,
formatDdHhMm,
LED_COLOR_BY_STATE,
ledBackgroundColor,
} from './event-status.pure';
@Injectable({ providedIn: 'root' })
export class EventStatusStore {
@@ -1,6 +1,6 @@
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { EventState } from '../../../core/services/event-status.store';
import { EventState, ledBackgroundColor } from '../../../core/services/event-status.store';
@Component({
selector: 'app-shell-header',
@@ -8,20 +8,14 @@ import { EventState } from '../../../core/services/event-status.store';
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule],
styles: [`
:host .led,
.led {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
border: 0;
background-color: transparent;
vertical-align: middle;
}
.led-running { background-color: var(--color-success); }
.led-countdown { background-color: var(--color-warning); }
.led-stopped { background-color: var(--color-danger); }
.led-unconfigured { background-color: var(--color-secondary); }
`],
template: `
<header class="shell-header" data-testid="shell-header">
@@ -45,6 +39,7 @@ import { EventState } from '../../../core/services/event-status.store';
[class.led-countdown]="eventState() === 'countdown'"
[class.led-stopped]="eventState() === 'stopped'"
[class.led-unconfigured]="eventState() === 'unconfigured'"
[style.background-color]="ledColor()"
data-testid="shell-led"
[attr.aria-label]="'Event status: ' + eventState()"
></span>
@@ -120,5 +115,5 @@ export class ShellHeaderComponent {
readonly adminClick = output<void>();
readonly logoutClick = output<void>();
readonly ledClass = computed(() => `led led-${this.eventState()}`);
}
readonly ledColor = computed(() => ledBackgroundColor(this.eventState()));
}
+1 -5
View File
@@ -20,8 +20,4 @@ button[disabled] { opacity: .5; cursor: not-allowed; }
input { font: inherit; padding: 8px 12px; border: 1px solid var(--color-secondary); border-radius: var(--radius-sm); width: 100%; }
.container { max-width: 960px; margin: 0 auto; padding: 24px; }
.card { padding: 24px; border-radius: var(--radius-lg); box-shadow: 0 1px 4px rgba(0,0,0,.1); }
.led { display: inline-block; width: 10px; height: 10px; border-radius: 50%; border: 0; background-color: transparent; vertical-align: middle; }
.led-running { background-color: var(--color-success); }
.led-countdown { background-color: var(--color-warning); }
.led-stopped { background-color: var(--color-danger); }
.led-unconfigured { background-color: var(--color-secondary); }
.led { display: inline-block; width: 10px; height: 10px; border-radius: 50%; border: 0; background-color: transparent; vertical-align: middle; }
+55 -35
View File
@@ -1,54 +1,74 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { EventState } from '../../frontend/src/app/core/services/event-status.store';
import {
EventState,
LED_COLOR_BY_STATE,
ledBackgroundColor,
} from '../../frontend/src/app/core/services/event-status.pure';
describe('ShellHeaderComponent LED styling (component-scoped)', () => {
describe('LED state -> background-color mapping', () => {
it.each([
['running', 'var(--color-success)'],
['countdown', 'var(--color-warning)'],
['stopped', 'var(--color-danger)'],
['unconfigured', 'var(--color-secondary)'],
] as const)('maps %s to %s', (state, expected) => {
expect(ledBackgroundColor(state)).toBe(expected);
expect(LED_COLOR_BY_STATE[state]).toBe(expected);
});
it('covers every EventState variant with a non-transparent token', () => {
for (const state of Object.keys(LED_COLOR_BY_STATE) as EventState[]) {
const color = ledBackgroundColor(state);
expect(color).toMatch(/^var\(--color-/);
expect(color).not.toBe('transparent');
expect(color).not.toBe('');
}
});
});
describe('ShellHeaderComponent LED wiring', () => {
const sourcePath = resolve(
__dirname,
'../../frontend/src/app/features/shell/header/shell-header.component.ts',
);
const source = readFileSync(sourcePath, 'utf8');
it('declares component-scoped styles with the LED base shape', () => {
expect(source).toMatch(/styles:\s*\[/);
expect(source).toMatch(/display:\s*inline-block/);
expect(source).toMatch(/width:\s*10px/);
expect(source).toMatch(/height:\s*10px/);
expect(source).toMatch(/border-radius:\s*50%/);
it('does not declare a transparent background on the LED base', () => {
const baseBlock = source.match(/\.led\s*\{[^}]*\}/);
expect(baseBlock).not.toBeNull();
expect(baseBlock?.[0]).not.toMatch(/background-color\s*:\s*transparent/);
});
it.each([
['.led-running', '--color-success'],
['.led-countdown', '--color-warning'],
['.led-stopped', '--color-danger'],
['.led-unconfigured', '--color-secondary'],
] as const)(
'scopes %s to the existing %s token',
(selector, token) => {
const escaped = selector.replace('.', '\\.');
const re = new RegExp(`${escaped}\\s*\\{[^}]*var\\(${token}\\)`);
expect(source).toMatch(re);
},
);
it('keeps the component shape (size, radius, no border, inline-block, vertical-align)', () => {
const baseBlock = source.match(/\.led\s*\{[^}]*\}/);
expect(baseBlock?.[0]).toMatch(/display:\s*inline-block/);
expect(baseBlock?.[0]).toMatch(/width:\s*10px/);
expect(baseBlock?.[0]).toMatch(/height:\s*10px/);
expect(baseBlock?.[0]).toMatch(/border-radius:\s*50%/);
expect(baseBlock?.[0]).toMatch(/border:\s*0/);
expect(baseBlock?.[0]).toMatch(/vertical-align:\s*middle/);
});
it('keeps the existing state class bindings and aria-label in the template', () => {
expect(source).toContain('class="led"');
it('binds the LED background color directly from the state signal', () => {
expect(source).toMatch(/\[style\.background-color\]="ledColor\(\)"/);
expect(source).toMatch(/ledColor\s*=\s*computed\(/);
expect(source).toMatch(/ledBackgroundColor\(this\.eventState\(\)\)/);
});
it('keeps the four state class bindings, testid, and accessible state label', () => {
expect(source).toContain('[class.led-running]="eventState() === \'running\'"');
expect(source).toContain('[class.led-countdown]="eventState() === \'countdown\'"');
expect(source).toContain('[class.led-stopped]="eventState() === \'stopped\'"');
expect(source).toContain('[class.led-unconfigured]="eventState() === \'unconfigured\'"');
expect(source).toContain("data-testid=\"shell-led\"");
expect(source).toContain('data-testid="shell-led"');
expect(source).toContain("[attr.aria-label]=\"'Event status: ' + eventState()\"");
});
});
describe('EventState union covers all four LED classes', () => {
it.each(['running', 'countdown', 'stopped', 'unconfigured'] as EventState[])(
'recognises %s',
(state) => {
const expected = `led led-${state}`;
expect(expected.startsWith('led ')).toBe(true);
expect(expected.endsWith(state)).toBe(true);
},
);
});
it('does not redeclare LED state colors in the component styles', () => {
expect(source).not.toMatch(/\.led-running\s*\{[^}]*background-color/);
expect(source).not.toMatch(/\.led-countdown\s*\{[^}]*background-color/);
expect(source).not.toMatch(/\.led-stopped\s*\{[^}]*background-color/);
expect(source).not.toMatch(/\.led-unconfigured\s*\{[^}]*background-color/);
});
});