From 90e570c43cc8d9bfe1794c36ae214ea4bf105bd3 Mon Sep 17 00:00:00 2001 From: m0rph3us1987 Date: Wed, 22 Jul 2026 09:37:10 +0000 Subject: [PATCH] AI Implementation feature(880): Authenticated Shell, Quick Tabs and Change Password 1.04 (#19) --- .kilo/plans/879.md | 178 ------------------ .kilo/plans/880.md | 30 +++ docs/architecture/frontend-structure.md | 29 ++- docs/architecture/key-files.md | 6 +- docs/guides/authenticated-shell.md | 25 ++- docs/guides/event-led-colors.md | 86 +++++++++ docs/index.md | 4 +- .../app/core/services/event-status.pure.ts | 11 ++ .../app/core/services/event-status.store.ts | 12 +- .../shell/header/shell-header.component.ts | 13 +- frontend/src/styles.css | 6 +- tests/frontend/shell-led.spec.ts | 90 +++++---- 12 files changed, 248 insertions(+), 242 deletions(-) delete mode 100644 .kilo/plans/879.md create mode 100644 .kilo/plans/880.md create mode 100644 docs/guides/event-led-colors.md diff --git a/.kilo/plans/879.md b/.kilo/plans/879.md deleted file mode 100644 index b24bd24..0000000 --- a/.kilo/plans/879.md +++ /dev/null @@ -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 `` -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 `` -carries `led` plus the correct `led-` 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 `` 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-]` 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-` 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 `` - 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-` 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. diff --git a/.kilo/plans/880.md b/.kilo/plans/880.md new file mode 100644 index 0000000..b5d78b0 --- /dev/null +++ b/.kilo/plans/880.md @@ -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: "`, 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. diff --git a/docs/architecture/frontend-structure.md b/docs/architecture/frontend-structure.md index 48f6a5c..fe086d4 100644 --- a/docs/architecture/frontend-structure.md +++ b/docs/architecture/frontend-structure.md @@ -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>` 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-` classes remain on the +element as selectors/test hooks but no longer own the color. + # See also - [Authenticated Shell](/guides/authenticated-shell.md) diff --git a/docs/architecture/key-files.md b/docs/architecture/key-files.md index de069ab..33ab169 100644 --- a/docs/architecture/key-files.md +++ b/docs/architecture/key-files.md @@ -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. | diff --git a/docs/guides/authenticated-shell.md b/docs/guides/authenticated-shell.md index 81e4d8f..00f0bae 100644 --- a/docs/guides/authenticated-shell.md +++ b/docs/guides/authenticated-shell.md @@ -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 `` with one of four state classes attached via `[class.led-]`. 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 `` with one of four state classes attached via `[class.led-]` 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: "` 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: ` and never relies on color alone. + ## Quick tabs The tab strip `[data-testid="quick-tabs"]` contains `Challenges`, `Scoreboard`, and diff --git a/docs/guides/event-led-colors.md b/docs/guides/event-led-colors.md new file mode 100644 index 0000000..ea1b477 --- /dev/null +++ b/docs/guides/event-led-colors.md @@ -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>`. +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: "` attribute. +* The four `led-` 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) diff --git a/docs/index.md b/docs/index.md index b689d13..9efd50f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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. diff --git a/frontend/src/app/core/services/event-status.pure.ts b/frontend/src/app/core/services/event-status.pure.ts index 78d627e..a3cb986 100644 --- a/frontend/src/app/core/services/event-status.pure.ts +++ b/frontend/src/app/core/services/event-status.pure.ts @@ -36,3 +36,14 @@ export function deriveCountdownText( } return secondsToEnd == null ? '00:00:00' : formatDdHhMm(secondsToEnd); } + +export const LED_COLOR_BY_STATE: Readonly> = { + 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]; +} diff --git a/frontend/src/app/core/services/event-status.store.ts b/frontend/src/app/core/services/event-status.store.ts index bbe9687..379e59a 100644 --- a/frontend/src/app/core/services/event-status.store.ts +++ b/frontend/src/app/core/services/event-status.store.ts @@ -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 { diff --git a/frontend/src/app/features/shell/header/shell-header.component.ts b/frontend/src/app/features/shell/header/shell-header.component.ts index 2e6a06a..9c26f05 100644 --- a/frontend/src/app/features/shell/header/shell-header.component.ts +++ b/frontend/src/app/features/shell/header/shell-header.component.ts @@ -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: `
@@ -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()" > @@ -120,5 +115,5 @@ export class ShellHeaderComponent { readonly adminClick = output(); readonly logoutClick = output(); - readonly ledClass = computed(() => `led led-${this.eventState()}`); -} + readonly ledColor = computed(() => ledBackgroundColor(this.eventState())); +} \ No newline at end of file diff --git a/frontend/src/styles.css b/frontend/src/styles.css index d656dc2..8995e3d 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -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); } \ No newline at end of file +.led { display: inline-block; width: 10px; height: 10px; border-radius: 50%; border: 0; background-color: transparent; vertical-align: middle; } \ No newline at end of file diff --git a/tests/frontend/shell-led.spec.ts b/tests/frontend/shell-led.spec.ts index a6b5f2d..8220f31 100644 --- a/tests/frontend/shell-led.spec.ts +++ b/tests/frontend/shell-led.spec.ts @@ -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/); + }); +}); \ No newline at end of file