AI Implementation feature(880): Authenticated Shell, Quick Tabs and Change Password 1.04 #19
@@ -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 26–34).
|
|
||||||
|
|
||||||
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 10–88) 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 23–27)
|
|
||||||
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 26–34) 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.
|
|
||||||
@@ -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.
|
||||||
@@ -3,7 +3,7 @@ type: architecture
|
|||||||
title: Frontend Structure
|
title: Frontend Structure
|
||||||
description: Angular routes, components, services, guards, interceptors, and authenticated SSE transport.
|
description: Angular routes, components, services, guards, interceptors, and authenticated SSE transport.
|
||||||
tags: [architecture, frontend, angular, shell, sse]
|
tags: [architecture, frontend, angular, shell, sse]
|
||||||
timestamp: 2026-07-22T09:22:34Z
|
timestamp: 2026-07-22T09:35:00Z
|
||||||
---
|
---
|
||||||
|
|
||||||
# Routes
|
# 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. |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `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
|
# See also
|
||||||
|
|
||||||
- [Authenticated Shell](/guides/authenticated-shell.md)
|
- [Authenticated Shell](/guides/authenticated-shell.md)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ type: architecture
|
|||||||
title: Key Files Index
|
title: Key Files Index
|
||||||
description: One-line responsibility for important source files, including authenticated event streaming.
|
description: One-line responsibility for important source files, including authenticated event streaming.
|
||||||
tags: [architecture, index, key-files]
|
tags: [architecture, index, key-files]
|
||||||
timestamp: 2026-07-21T23:25:00Z
|
timestamp: 2026-07-22T09:35:00Z
|
||||||
---
|
---
|
||||||
|
|
||||||
# Backend
|
# 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/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/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.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/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, countdown, and user menu. |
|
| `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/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. |
|
| `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. |
|
| `tests/frontend/authenticated-event-source.spec.ts` | Tests SSE authorization and frame transport behavior. |
|
||||||
|
|||||||
@@ -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-22T09:22:34Z
|
timestamp: 2026-07-22T09:35:00Z
|
||||||
---
|
---
|
||||||
|
|
||||||
# When this view is available
|
# When this view is available
|
||||||
@@ -35,20 +35,29 @@ before the shell mounts.
|
|||||||
|
|
||||||
# Event LED colors
|
# 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 |
|
| Event state | CSS class | Color token (`[style.background-color]` value) | Approx. hex | Visual |
|
||||||
|-------------------|-----------------------|-----------------------|-------------|--------|
|
|-------------------|-----------------------|------------------------------------------------|-------------|--------|
|
||||||
| `countdown` | `led led-countdown` | `--color-warning` | `#f59e0b` | Yellow filled dot |
|
| `countdown` | `led led-countdown` | `var(--color-warning)` | `#f59e0b` | Yellow filled dot |
|
||||||
| `running` | `led led-running` | `--color-success` | `#10b981` | Green filled dot |
|
| `running` | `led led-running` | `var(--color-success)` | `#10b981` | Green filled dot |
|
||||||
| `stopped` | `led led-stopped` | `--color-danger` | `#ef4444` | Red filled dot |
|
| `stopped` | `led led-stopped` | `var(--color-danger)` | `#ef4444` | Red filled dot |
|
||||||
| `unconfigured` | `led led-unconfigured`| `--color-secondary` | `#64748b` | Neutral gray 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
|
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
|
is always the same size and shape — only the background color changes
|
||||||
between states. The element also carries
|
between states. The element also carries
|
||||||
`aria-label="Event status: <state>"` for assistive tech.
|
`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
|
## Quick tabs
|
||||||
|
|
||||||
The tab strip `[data-testid="quick-tabs"]` contains `Challenges`, `Scoreboard`, and
|
The tab strip `[data-testid="quick-tabs"]` contains `Challenges`, `Scoreboard`, and
|
||||||
|
|||||||
@@ -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
@@ -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-22T09:22:34Z.
|
they need. Last regenerated 2026-07-22T09:35:00Z.
|
||||||
|
|
||||||
# Architecture
|
# Architecture
|
||||||
|
|
||||||
@@ -75,5 +75,7 @@ they need. Last regenerated 2026-07-22T09:22:34Z.
|
|||||||
* [Theming](/guides/theming.md) - How themes are loaded and applied.
|
* [Theming](/guides/theming.md) - How themes are loaded and applied.
|
||||||
* [Event Window](/guides/event-window.md) - How the 4-state event
|
* [Event Window](/guides/event-window.md) - How the 4-state event
|
||||||
window and live countdown work, including the authenticated SSE stream.
|
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
|
* [Scoreboard Stream](/guides/scoreboard-stream.md) - How live solves are
|
||||||
broadcast to clients.
|
broadcast to clients.
|
||||||
|
|||||||
@@ -36,3 +36,14 @@ export function deriveCountdownText(
|
|||||||
}
|
}
|
||||||
return secondsToEnd == null ? '00:00:00' : formatDdHhMm(secondsToEnd);
|
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,
|
EventSourceLike,
|
||||||
EventState,
|
EventState,
|
||||||
EventStatePayload,
|
EventStatePayload,
|
||||||
|
LED_COLOR_BY_STATE,
|
||||||
|
ledBackgroundColor,
|
||||||
} from './event-status.pure';
|
} 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' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class EventStatusStore {
|
export class EventStatusStore {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { EventState } from '../../../core/services/event-status.store';
|
import { EventState, ledBackgroundColor } from '../../../core/services/event-status.store';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-shell-header',
|
selector: 'app-shell-header',
|
||||||
@@ -8,20 +8,14 @@ import { EventState } from '../../../core/services/event-status.store';
|
|||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
imports: [CommonModule],
|
imports: [CommonModule],
|
||||||
styles: [`
|
styles: [`
|
||||||
:host .led,
|
|
||||||
.led {
|
.led {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 10px;
|
width: 10px;
|
||||||
height: 10px;
|
height: 10px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 0;
|
border: 0;
|
||||||
background-color: transparent;
|
|
||||||
vertical-align: middle;
|
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: `
|
template: `
|
||||||
<header class="shell-header" data-testid="shell-header">
|
<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-countdown]="eventState() === 'countdown'"
|
||||||
[class.led-stopped]="eventState() === 'stopped'"
|
[class.led-stopped]="eventState() === 'stopped'"
|
||||||
[class.led-unconfigured]="eventState() === 'unconfigured'"
|
[class.led-unconfigured]="eventState() === 'unconfigured'"
|
||||||
|
[style.background-color]="ledColor()"
|
||||||
data-testid="shell-led"
|
data-testid="shell-led"
|
||||||
[attr.aria-label]="'Event status: ' + eventState()"
|
[attr.aria-label]="'Event status: ' + eventState()"
|
||||||
></span>
|
></span>
|
||||||
@@ -120,5 +115,5 @@ export class ShellHeaderComponent {
|
|||||||
readonly adminClick = output<void>();
|
readonly adminClick = output<void>();
|
||||||
readonly logoutClick = output<void>();
|
readonly logoutClick = output<void>();
|
||||||
|
|
||||||
readonly ledClass = computed(() => `led led-${this.eventState()}`);
|
readonly ledColor = computed(() => ledBackgroundColor(this.eventState()));
|
||||||
}
|
}
|
||||||
@@ -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%; }
|
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 { 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); }
|
|
||||||
@@ -1,54 +1,74 @@
|
|||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { resolve } from 'node:path';
|
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(
|
const sourcePath = resolve(
|
||||||
__dirname,
|
__dirname,
|
||||||
'../../frontend/src/app/features/shell/header/shell-header.component.ts',
|
'../../frontend/src/app/features/shell/header/shell-header.component.ts',
|
||||||
);
|
);
|
||||||
const source = readFileSync(sourcePath, 'utf8');
|
const source = readFileSync(sourcePath, 'utf8');
|
||||||
|
|
||||||
it('declares component-scoped styles with the LED base shape', () => {
|
it('does not declare a transparent background on the LED base', () => {
|
||||||
expect(source).toMatch(/styles:\s*\[/);
|
const baseBlock = source.match(/\.led\s*\{[^}]*\}/);
|
||||||
expect(source).toMatch(/display:\s*inline-block/);
|
expect(baseBlock).not.toBeNull();
|
||||||
expect(source).toMatch(/width:\s*10px/);
|
expect(baseBlock?.[0]).not.toMatch(/background-color\s*:\s*transparent/);
|
||||||
expect(source).toMatch(/height:\s*10px/);
|
|
||||||
expect(source).toMatch(/border-radius:\s*50%/);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it('keeps the component shape (size, radius, no border, inline-block, vertical-align)', () => {
|
||||||
['.led-running', '--color-success'],
|
const baseBlock = source.match(/\.led\s*\{[^}]*\}/);
|
||||||
['.led-countdown', '--color-warning'],
|
expect(baseBlock?.[0]).toMatch(/display:\s*inline-block/);
|
||||||
['.led-stopped', '--color-danger'],
|
expect(baseBlock?.[0]).toMatch(/width:\s*10px/);
|
||||||
['.led-unconfigured', '--color-secondary'],
|
expect(baseBlock?.[0]).toMatch(/height:\s*10px/);
|
||||||
] as const)(
|
expect(baseBlock?.[0]).toMatch(/border-radius:\s*50%/);
|
||||||
'scopes %s to the existing %s token',
|
expect(baseBlock?.[0]).toMatch(/border:\s*0/);
|
||||||
(selector, token) => {
|
expect(baseBlock?.[0]).toMatch(/vertical-align:\s*middle/);
|
||||||
const escaped = selector.replace('.', '\\.');
|
});
|
||||||
const re = new RegExp(`${escaped}\\s*\\{[^}]*var\\(${token}\\)`);
|
|
||||||
expect(source).toMatch(re);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it('keeps the existing state class bindings and aria-label in the template', () => {
|
it('binds the LED background color directly from the state signal', () => {
|
||||||
expect(source).toContain('class="led"');
|
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-running]="eventState() === \'running\'"');
|
||||||
expect(source).toContain('[class.led-countdown]="eventState() === \'countdown\'"');
|
expect(source).toContain('[class.led-countdown]="eventState() === \'countdown\'"');
|
||||||
expect(source).toContain('[class.led-stopped]="eventState() === \'stopped\'"');
|
expect(source).toContain('[class.led-stopped]="eventState() === \'stopped\'"');
|
||||||
expect(source).toContain('[class.led-unconfigured]="eventState() === \'unconfigured\'"');
|
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()\"");
|
expect(source).toContain("[attr.aria-label]=\"'Event status: ' + eventState()\"");
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
describe('EventState union covers all four LED classes', () => {
|
it('does not redeclare LED state colors in the component styles', () => {
|
||||||
it.each(['running', 'countdown', 'stopped', 'unconfigured'] as EventState[])(
|
expect(source).not.toMatch(/\.led-running\s*\{[^}]*background-color/);
|
||||||
'recognises %s',
|
expect(source).not.toMatch(/\.led-countdown\s*\{[^}]*background-color/);
|
||||||
(state) => {
|
expect(source).not.toMatch(/\.led-stopped\s*\{[^}]*background-color/);
|
||||||
const expected = `led led-${state}`;
|
expect(source).not.toMatch(/\.led-unconfigured\s*\{[^}]*background-color/);
|
||||||
expect(expected.startsWith('led ')).toBe(true);
|
});
|
||||||
expect(expected.endsWith(state)).toBe(true);
|
});
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user