AI Implementation feature(878): Authenticated Shell, Quick Tabs and Change Password 1.02 #17

Merged
m0rph3us1987 merged 2 commits from feature-878-1784710631146 into dev 2026-07-22 09:04:46 +00:00
6 changed files with 256 additions and 61 deletions
-55
View File
@@ -1,55 +0,0 @@
# Implementation Plan: Authenticated Shell, Quick Tabs and Change Password 1.01
## 1. Architectural Reconnaissance
- **Status:** The requested shell, quick tabs, change-password flow, and authenticated SSE endpoint are already present, but Job 876 is **not** fully implemented because the authenticated browser SSE request is rejected with 401 and the LED remains `led-unconfigured`. Existing implementation is concentrated in `frontend/src/app/features/home/home.component.ts:127-135`, `frontend/src/app/core/services/event-status.store.ts:51-76`, `frontend/src/app/features/shell/header/shell-header.component.ts:25-35`, and `backend/src/modules/system/system.controller.ts:77-113`.
- **Codebase style & conventions:** TypeScript monorepo with NestJS 10 backend and Angular 17 standalone frontend. Angular uses OnPush components, signal inputs/outputs, signal-backed services, and a smart `HomeComponent` container with dumb shell children. NestJS uses a global JWT guard, `@Public()` for public routes, typed controller methods, RxJS Observables for SSE, and TypeORM over SQLite.
- **Data Layer:** better-sqlite3 through TypeORM, with event window values stored in `setting` rows (`eventStartUtc` and `eventEndUtc`). No schema change is indicated. `EventStatusService.getState()` is the authoritative four-state event state machine.
- **Authentication / transport:** REST requests receive `Authorization: Bearer <accessToken>` from `authInterceptor` (`frontend/src/app/core/interceptors/auth.interceptor.ts:5-11`). The browser-native `EventSource` created by `HomeComponent` only sends cookies via `{ withCredentials: true }`; it does not send the interceptor's bearer header. The backend's global `JwtAuthGuard` validates bearer JWTs, and the plural `/api/v1/events/status` endpoint is intentionally authenticated SSE.
- **Test Framework & Structure:** Jest 29 with ts-jest, split into `tests/backend` (Node/Supertest integration tests) and `tests/frontend` (jsdom/pure frontend tests); root `npm test` runs both projects via `tests/jest.config.js`. Existing SSE coverage verifies unauthenticated 401 and authenticated initial frames in `tests/backend/events-status-sse.spec.ts`; existing event-status frontend coverage is currently pure-helper focused in `tests/frontend/event-status.store.spec.ts`.
- **Required Tools & Dependencies:** No new dependency should be required. The implementation should use existing Angular, NestJS, RxJS, JWT, and Jest packages. No database migration or `/data` fixture is needed. `setup.sh` already installs dependencies and builds both workspaces; retain that behavior unless the chosen authentication transport requires an existing package already present in the lockfile.
## 2. Impacted Files
- **To Modify:**
- `frontend/src/app/features/home/home.component.ts` — change the authenticated SSE connection strategy so the request carries the access token accepted by the backend, while preserving shell lifecycle cleanup.
- `frontend/src/app/core/services/event-status.store.ts` and/or `frontend/src/app/core/services/event-status.pure.ts` — only if needed to support the selected authenticated event-source abstraction or validate the incoming frame contract without moving state logic into the component.
- `frontend/src/app/core/interceptors/auth.interceptor.ts` — only if the final design introduces a reusable authenticated streaming transport through Angular HTTP; otherwise leave unchanged because native `EventSource` bypasses interceptors.
- `backend/src/modules/system/system.controller.ts` — only if the selected design intentionally changes the stream authentication contract (for example, a narrowly scoped query-token fallback); avoid weakening the existing JWT protection unless required and explicitly constrained.
- `tests/frontend/event-status.store.spec.ts` or a new focused file under `tests/frontend/` — add a minimal regression test proving the shell status store receives and applies an authenticated stream frame through the chosen abstraction.
- `tests/backend/events-status-sse.spec.ts` — retain the existing unauthenticated rejection and authenticated initial-frame tests; extend only if the backend contract changes.
- `docs/api/system.md` and `docs/guides/event-window.md` — update the documented browser authentication mechanism if the implementation changes the current native `EventSource` contract.
- **To Create:**
- Prefer no new production file unless a small dedicated authenticated SSE client service is required by the selected approach.
- If extraction is needed, create `frontend/src/app/core/services/authenticated-event-source.service.ts` and place its focused unit test under `tests/frontend/`, not beside source code.
## 3. Proposed Changes
Explain the exact implementation logic step-by-step:
1. **Database / Schema Migration:**
- No migration. Continue reading `eventStartUtc` and `eventEndUtc` from the existing `setting` table through `EventStatusService`.
- Confirm the fix preserves the current public endpoints (`GET /api/v1/event/status` and `GET /api/v1/settings/event`) and the authenticated SSE payload shape (`state`, timestamps, and countdown fields).
2. **Backend Logic & APIs:**
- Treat the observed 401 as an authentication-transport mismatch, not an event-state calculation problem: `HomeComponent` uses native `EventSource`, while the backend JWT strategy extracts only an Authorization bearer token.
- Prefer a frontend-only transport fix that keeps `/api/v1/events/status` JWT-protected. The cleanest option is to replace the native `EventSource` connection with an existing-API-compatible authenticated streaming client that can issue a GET carrying the current bearer token and parse SSE `message` frames, then expose the same `EventSourceLike` lifecycle (`addEventListener`/`close`) consumed by `EventStatusStore.start()`.
- Keep authentication centralized: obtain the current access token from `AuthService`, send it as `Authorization: Bearer <token>`, include credentials where refresh-cookie behavior requires it, and ensure the stream is closed when the shell is destroyed or the session is cleared.
- Do not make `/api/v1/events/status` public and do not accept unrestricted unauthenticated query tokens. If a query-token fallback is considered unavoidable because of browser-native EventSource limitations, document and constrain it, avoid logging the token, and add explicit backend tests for valid/invalid/expired token behavior; the default plan is to avoid this security regression.
- Preserve the existing RxJS SSE implementation: it should continue to emit the initial state immediately, tick every 60 seconds, merge hub updates, and suppress identical payloads. No changes to event-state computation are needed.
3. **Frontend UI Integration:**
- In `HomeComponent.ngOnInit()`, start the stream only after the authenticated shell is mounted and the current access token is available; pass the authenticated source factory into `EventStatusStore.start()`.
- Keep `EventStatusStore` responsible for parsing frames, applying `EventStatePayload`, maintaining the server-clock anchor, and ticking local countdown signals. Do not duplicate status logic in `HomeComponent` or `ShellHeaderComponent`.
- Keep `ShellHeaderComponent`'s state-class bindings and ARIA label intact. Once the first authenticated frame arrives, `eventStatus.state()` must become `running` for the reported active window, resulting in `led-running` rather than the initial `led-unconfigured` class.
- Preserve quick-tab routing and the existing change-password flow; repository inspection shows those features already exist and are unrelated to the 401 root cause. Avoid reworking them.
- Ensure stream errors do not leave an uncancelled reader, timer, or subscription. Reconnect behavior should be limited to the existing lifecycle unless a focused retry is necessary; do not create an aggressive reconnect loop that can amplify authentication failures.
- Update the relevant API/event-window documentation to state how the authenticated browser stream transmits JWT credentials after the fix.
## 4. Test Strategy
- **Target Unit Test File:**
- Add the smallest frontend regression coverage under `tests/frontend/`, preferably in `tests/frontend/event-status.store.spec.ts` if the abstraction remains store-compatible, or in a new `tests/frontend/authenticated-event-source.spec.ts` if a dedicated client is extracted.
- Arrange a fake authenticated stream boundary and a valid `EventStatePayload` with a running event window; act by starting the store/client; assert that the payload is applied and the resulting state is `running`. Also assert the request boundary receives the bearer token if the new client owns request creation.
- Keep the existing backend tests in `tests/backend/events-status-sse.spec.ts`: the 401 test is essential evidence that the endpoint remains protected, and the authenticated initial-frame test verifies the server contract. If no backend route contract changes, do not add broad backend scenarios.
- **Mocking Strategy:**
- Mock only external boundaries: the browser stream/request implementation, `AuthService` token lookup where needed, timers/Date only if required for deterministic countdown behavior, and network responses. Do not mock `EventStatusService` in the existing backend integration test; it is fast internal logic and the current test already uses the real module and isolated in-memory database.
- Use the repository's Jest configuration and dedicated `tests/` folders. Tests must run with the single root command `npm test`, must not require a browser or visual assertion, and must not introduce persistent fixtures. If any fixture is needed, use `/data` and generate it when absent, but this job should need none.
- Follow Arrange-Act-Assert and keep assertions focused on the authentication header/stream frame and LED-driving state contract. Avoid visual tests, large mock environments, or extensive edge-case matrices.
- **Verification:** After implementation, run the repository's available root test command plus the workspace build/type validation commands used by the project. There is no declared lint or typecheck npm script in `package.json`; implementers should at minimum run `npm test` and `npm run build`, and use the workspace TypeScript/build commands if separate validation is required.
+221
View File
@@ -0,0 +1,221 @@
# Implementation Plan: Authenticated Shell — LED Color Rendering (Job 878, 1.02)
## 0. Already-Implemented Check
After investigating the repository:
- `frontend/src/app/features/shell/header/shell-header.component.ts` already
renders `<span class="led" [class.led-running]="..." [class.led-countdown]="..." [class.led-stopped]="..." [class.led-unconfigured]="..." data-testid="shell-led">`
with the correct `aria-label`.
- The state derivation (`running` / `countdown` / `stopped` / `unconfigured`)
is already correct (verified by the Job description: `running` between
Start and End is being assigned).
- `frontend/src/styles.css` defines the color tokens
`--color-success`, `--color-warning`, `--color-danger`, but contains **no**
rules for `.led`, `.led-running`, `.led-countdown`, `.led-stopped`, or
`.led-unconfigured`.
The bug is therefore isolated to missing CSS. The fix is purely a stylesheet
addition. No TypeScript / Angular code changes are required; no backend, no
state logic, and no template changes are required.
This means most sections of this plan are intentionally short — there is
nothing to migrate, no APIs to add, and no component logic to author.
## 1. Architectural Reconnaissance
- **Codebase style & conventions:** TypeScript, Angular 17+ standalone
components (`ShellHeaderComponent` is `standalone: true`,
`ChangeDetectionStrategy.OnPush`). Global styles live in
`frontend/src/styles.css` and are the single source of design tokens
(`--color-*`, `--radius-*`, `--font-family`). All Angular component
templates and styles are inlined per-component; nothing for the LED is
defined at component scope.
- **Data Layer:** Not applicable — this Job is a pure CSS fix. The event state
itself already flows correctly via `EventStatusStore`
`HomeComponent``ShellHeaderComponent.eventState` input.
- **Test Framework & Structure:** Jest via `tests/jest.config.js` with two
projects (`backend`, `frontend`). Frontend tests use `jsdom` + `ts-jest` and
`tests/frontend/jest.setup.ts` (provides `matchMedia` shim). Frontend specs
live under `tests/frontend/*.spec.ts` and import directly from
`frontend/src/...`. All tests run from the repo root with a single command:
`npm test`.
- **Required Tools & Dependencies:** None new. Existing stack (Node, Angular
CLI / `npm --workspace frontend run build`, Jest 29, jsdom 29) is
sufficient.
## 2. Impacted Files
- **To Modify:**
- `frontend/src/styles.css` — add the four `.led-{state}` rules plus the
base `.led` rule, using the already-defined color tokens. One-line
reason: the LED element currently has no dimensions and no color, so it
renders as invisible content; the bug description confirms this
exactly.
- **To Create:**
- `tests/frontend/led-class-mapping.spec.ts` — minimal regression test
that asserts `ShellHeaderComponent.ledClass()` produces the expected
`led led-<state>` string for each `EventState` value. This verifies the
class wiring that the new CSS rules depend on (small, pure, runnable
without a UI).
## 3. Proposed Changes
### 3.1 Stylesheet (`frontend/src/styles.css`)
Append the following rules to the bottom of `frontend/src/styles.css`,
preserving the existing two-space indentation used in the file:
```css
.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); }
```
Design rationale (each decision is required to satisfy the Job):
- `display: inline-block` + `width/height: 10px` + `border-radius: 50%` are
the minimum declarations needed to paint a visible circular dot and to
override the browser's default `display: inline` on the `<span>` (which
the bug report explicitly calls out as `width: auto; height: auto;
background-color: rgba(0,0,0,0)`).
- The four state classes map directly to the four token colors already
declared in `:root`, which is exactly the mapping the Job specifies
(Green / Yellow / Red / secondary-gray for unconfigured, since
`--color-secondary` was the only remaining neutral token available).
- No `::before` / `::after` content is used, so no `content` property is
needed (the bug report ruled these out as a fix path).
- No changes to the template or component logic — the `[class.led-running]`
etc. bindings in `shell-header.component.ts` already drive the right
state class.
### 3.2 Regression test (`tests/frontend/led-class-mapping.spec.ts`)
This test is purely defensive: it locks in the contract that
`ShellHeaderComponent.ledClass` produces the class string the CSS rules are
keyed on, so a future template refactor cannot silently break the visual
fix. It uses the existing `jsdom` Jest project and the same import style as
`tests/frontend/shell-active-section.spec.ts`.
```ts
import { ShellHeaderComponent } from
'../../frontend/src/app/features/shell/header/shell-header.component';
import { EventState } from
'../../frontend/src/app/core/services/event-status.store';
describe('ShellHeaderComponent LED class mapping', () => {
function build() {
const c = new ShellHeaderComponent();
return c;
}
it.each([
['running', 'led led-running'],
['countdown', 'led led-countdown'],
['stopped', 'led led-stopped'],
['unconfigured', 'led led-unconfigured'],
] as [EventState, string][])(
'maps event state %s to class string %s',
(state, expected) => {
const c = build();
c.eventState.set(state as any); // input signal set is a noop — handled below
},
);
});
```
Because `ShellHeaderComponent` exposes `eventState` as an Angular **input
signal** (`input<EventState>('unconfigured')`), and test-driving Angular
signals without `TestBed` is fragile, the cleanest minimal test — and the
one most consistent with the existing convention used by
`shell-active-section.spec.ts` (which tests a pure helper) — is to mirror
that pattern. We will instead add **one lightweight pure helper** export
from `shell-header.component.ts`:
```ts
// pure helper, exported for tests
export function ledClassForState(state: EventState): string {
return `led led-${state}`;
}
```
And have `ledClass` delegate to it:
```ts
readonly ledClass = computed(() => ledClassForState(this.eventState()));
```
This keeps the test pure-function-style (matches the
`deriveActiveSectionFromUrl` precedent in `home.shell.ts`) and avoids any
`TestBed` / `ChangeDetection` setup. The test then becomes:
```ts
import { ledClassForState } from
'../../frontend/src/app/features/shell/header/shell-header.component';
describe('ledClassForState', () => {
it('returns the base + state class for each EventState', () => {
expect(ledClassForState('running'))
.toBe('led led-running');
expect(ledClassForState('countdown'))
.toBe('led led-countdown');
expect(ledClassForState('stopped'))
.toBe('led led-stopped');
expect(ledClassForState('unconfigured'))
.toBe('led led-unconfigured');
});
});
```
A single `describe` / single `it` keeps the suite minimal and instantly
runnable, satisfying the "minimal, highly-focused tests" rule.
If the implementer prefers to avoid touching `shell-header.component.ts`
entirely, the alternative is to skip this test. The plan therefore lists
this test as **optional / lowest priority**, and the Plan Implementer may
omit it without affecting the bug fix. The bug fix does **not** require
any code change to `shell-header.component.ts`.
## 4. Test Strategy
- **Target Unit Test File:** `tests/frontend/led-class-mapping.spec.ts`
(optional; see §3.2). Run via `npm test` from the repo root, which the
project's package.json already wires to
`jest --config tests/jest.config.js`.
- **Mocking Strategy:** None required. The test, if added, exercises a
pure exported function with no I/O, no Angular DI, and no DOM mutation,
so no mocks, fakes, or `TestBed` configuration are needed. The existing
`tests/frontend/jest.setup.ts` already shims `matchMedia` for any
incidental DOM usage.
- **What we deliberately do NOT add:**
- No visual regression test (the Job explicitly forbids tests that
require visual confirmation).
- No `TestBed` rendering test for `ShellHeaderComponent` (would add
infrastructure cost for no functional value, since the existing
template + class bindings are unchanged).
- No backend or integration test — the bug is purely CSS.
## 5. Acceptance Criteria
The Job is considered fixed when, after rebuilding the Angular bundle:
1. The `<span data-testid="shell-led">` element is rendered as a 10×10
filled circle in the authenticated shell header.
2. Its background color matches the live event state:
- `countdown` (now → startUtc) → `var(--color-warning)` (#f59e0b / yellow)
- `running` (startUtc → endUtc) → `var(--color-success)` (#10b981 / green)
- `stopped` (after endUtc) → `var(--color-danger)` (#ef4444 / red)
- `unconfigured` (no Start/End) → `var(--color-secondary)` neutral
3. No new HTTP traffic, no new backend code, no new Angular component
file, and no change to the shell template.
4. `npm test` continues to pass.
+23 -2
View File
@@ -3,7 +3,7 @@ type: guide
title: Authenticated Shell title: Authenticated Shell
description: How a signed-in user experiences the post-login shell, including authenticated event streaming. description: How a signed-in user experiences the post-login shell, including authenticated event streaming.
tags: [guide, shell, header, sse, navigation, tester] tags: [guide, shell, header, sse, navigation, tester]
timestamp: 2026-07-21T23:25:00Z timestamp: 2026-07-22T09:03:53Z
--- ---
# When this view is available # When this view is available
@@ -29,10 +29,30 @@ before the shell mounts.
|---|---|---| |---|---|---|
| Page title | `[data-testid="shell-title"]` | Shows the bootstrap `pageTitle`; clicking navigates to `/challenges`. | | Page title | `[data-testid="shell-title"]` | Shows the bootstrap `pageTitle`; clicking navigates to `/challenges`. |
| Active section | `[data-testid="shell-active-section"]` | Shows `Challenges`, `Scoreboard`, `Blog`, or `Admin` based on the URL. | | Active section | `[data-testid="shell-active-section"]` | Shows `Challenges`, `Scoreboard`, `Blog`, or `Admin` based on the URL. |
| Event LED | `[data-testid="shell-led"]` | Uses `led-running`, `led-countdown`, `led-stopped`, or `led-unconfigured`. | | Event LED | `[data-testid="shell-led"]` | A 10×10 circular dot whose color reflects the current event state. See [Event LED colors](#event-led-colors) below. |
| Countdown | `[data-testid="shell-countdown"]` | Displays `DD:HH:mm`, `Event ended`, or empty for an unconfigured event. | | Countdown | `[data-testid="shell-countdown"]` | Displays `DD:HH:mm`, `Event ended`, or empty for an unconfigured event. |
| User menu | `[data-testid="user-menu-trigger"]` | Toggles rank, change-password, optional admin, and logout entries. | | User menu | `[data-testid="user-menu-trigger"]` | Toggles rank, change-password, optional admin, and logout entries. |
# 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>]`. The
visual styling lives in `frontend/src/styles.css` and is driven entirely
by the existing theme color tokens — there is no per-state color in
TypeScript.
| 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 |
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.
## Quick tabs ## Quick tabs
The tab strip `[data-testid="quick-tabs"]` contains `Challenges`, `Scoreboard`, and The tab strip `[data-testid="quick-tabs"]` contains `Challenges`, `Scoreboard`, and
@@ -62,6 +82,7 @@ error event. The shell retains the last event state until a later connection suc
| `frontend/src/app/core/services/authenticated-event-source.service.ts` | Fetch-based authenticated SSE transport with abort and frame parsing. | | `frontend/src/app/core/services/authenticated-event-source.service.ts` | Fetch-based authenticated SSE transport with abort and frame parsing. |
| `frontend/src/app/core/services/event-status.store.ts` | Applies event frames and derives the live countdown. | | `frontend/src/app/core/services/event-status.store.ts` | Applies event frames and derives the live countdown. |
| `frontend/src/app/features/shell/header/shell-header.component.ts` | Renders event LED, countdown, navigation, and user menu. | | `frontend/src/app/features/shell/header/shell-header.component.ts` | Renders event LED, countdown, navigation, and user menu. |
| `frontend/src/styles.css` | Defines the `.led` base shape plus the four `.led-{state}` background-color rules keyed on theme tokens. |
| `frontend/src/app/features/shell/tabs/quick-tabs.component.ts` | Renders the main navigation tabs. | | `frontend/src/app/features/shell/tabs/quick-tabs.component.ts` | Renders the main navigation tabs. |
| `tests/frontend/authenticated-event-source.spec.ts` | Verifies authorization headers, SSE frame delivery, and anonymous header omission. | | `tests/frontend/authenticated-event-source.spec.ts` | Verifies authorization headers, SSE frame delivery, and anonymous header omission. |
+5 -2
View File
@@ -3,7 +3,7 @@ type: guide
title: Event Window title: Event Window
description: How the event window state machine is computed and surfaced via REST + SSE. description: How the event window state machine is computed and surfaced via REST + SSE.
tags: [guide, event, countdown, sse, state-machine] tags: [guide, event, countdown, sse, state-machine]
timestamp: 2026-07-21T22:19:08Z timestamp: 2026-07-22T09:03:53Z
--- ---
# Configuration # Configuration
@@ -78,7 +78,10 @@ The store:
The header (`ShellHeaderComponent`) renders an LED with the The header (`ShellHeaderComponent`) renders an LED with the
`led-{state}` class so the colour reflects the current state (the `led-{state}` class so the colour reflects the current state (the
exact theme colours are wired in `styles.css` against the existing exact theme colours are wired in `styles.css` against the existing
`--color-success` / `--color-warning` / `--color-danger` tokens). `--color-success` / `--color-warning` / `--color-danger` tokens;
`unconfigured` uses `--color-secondary`). See the
[Event LED colors](/guides/authenticated-shell.md#event-led-colors)
table in the Authenticated Shell guide for the full mapping.
`HomeComponent.ngOnDestroy()` (and the `DestroyRef` hook in the store `HomeComponent.ngOnDestroy()` (and the `DestroyRef` hook in the store
itself) close the SSE source and clear the tick interval, so leaving itself) close the SSE source and clear the tick interval, so leaving
+1 -1
View File
@@ -11,7 +11,7 @@ scoreboard, an event window with a public countdown, theming, and admin
controls. controls.
The docs below are organized by purpose so agents can pull just the slice The docs below are organized by purpose so agents can pull just the slice
they need. Last regenerated 2026-07-21T23:25:00Z. they need. Last regenerated 2026-07-22T09:03:53Z.
# Architecture # Architecture
+5
View File
@@ -20,3 +20,8 @@ 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%; } 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; } .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); } .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); }