From 8489739d831181b6870b2ccc5421efda7b8121db Mon Sep 17 00:00:00 2001 From: Olin Osborne III Date: Fri, 20 Mar 2026 15:00:47 -0400 Subject: [PATCH 01/22] chore(frontend): install PrimeNG, PrimeIcons, and @primeuix/themes --- frontend/package-lock.json | 117 ++++++++++++++++++++++++++++++++++++- frontend/package.json | 4 ++ 2 files changed, 119 insertions(+), 2 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4d472ab20..b155dd8a4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,12 +8,16 @@ "name": "ledmatrix", "version": "0.0.0", "dependencies": { + "@angular/animations": "^21.2.5", "@angular/common": "^21.2.0", "@angular/compiler": "^21.2.0", "@angular/core": "^21.2.0", "@angular/forms": "^21.2.0", "@angular/platform-browser": "^21.2.0", "@angular/router": "^21.2.0", + "@primeuix/themes": "^2.0.3", + "primeicons": "^7.0.0", + "primeng": "^21.1.3", "rxjs": "~7.8.0", "tslib": "^2.3.0" }, @@ -436,6 +440,21 @@ "typescript": "*" } }, + "node_modules/@angular/animations": { + "version": "21.2.5", + "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-21.2.5.tgz", + "integrity": "sha512-8jH48A1gNph5YGlTXXoXJ/5T6uEZB14ITad3uQwBMM1mUUvM0T4QIMk555jIe1fIHHUyTfRR2y7v8SfTe2++fA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@angular/core": "21.2.5" + } + }, "node_modules/@angular/build": { "version": "21.2.3", "resolved": "https://registry.npmjs.org/@angular/build/-/build-21.2.3.tgz", @@ -536,6 +555,23 @@ } } }, + "node_modules/@angular/cdk": { + "version": "21.2.3", + "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-21.2.3.tgz", + "integrity": "sha512-7t+UhfbSpIUG9uUyL4b8nI/HyYyrbgAvDwBT8kH4D7If0WiFQhUoottAM0+WZ7Uy+F4nx322K6TOomz/fZJOoQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "parse5": "^8.0.0", + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/common": "^21.0.0 || ^22.0.0", + "@angular/core": "^21.0.0 || ^22.0.0", + "@angular/platform-browser": "^21.0.0 || ^22.0.0", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, "node_modules/@angular/cli": { "version": "21.2.3", "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.2.3.tgz", @@ -3451,6 +3487,57 @@ "license": "MIT", "optional": true }, + "node_modules/@primeuix/motion": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@primeuix/motion/-/motion-0.0.10.tgz", + "integrity": "sha512-PsZwOPq79Scp7/ionshRcQ5xKVf9+zuLcyY5mf6onK8chHT5C9JGphmcIZ4CzcqxuGEpsm8AIbTGy+zS3RtzLA==", + "license": "MIT", + "dependencies": { + "@primeuix/utils": "^0.6.3" + }, + "engines": { + "node": ">=12.11.0" + } + }, + "node_modules/@primeuix/styled": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/@primeuix/styled/-/styled-0.7.4.tgz", + "integrity": "sha512-QSO/NpOQg8e9BONWRBx9y8VGMCMYz0J/uKfNJEya/RGEu7ARx0oYW0ugI1N3/KB1AAvyGxzKBzGImbwg0KUiOQ==", + "license": "MIT", + "dependencies": { + "@primeuix/utils": "^0.6.1" + }, + "engines": { + "node": ">=12.11.0" + } + }, + "node_modules/@primeuix/styles": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@primeuix/styles/-/styles-2.0.3.tgz", + "integrity": "sha512-2ykAB6BaHzR/6TwF8ShpJTsZrid6cVIEBVlookSdvOdmlWuevGu5vWOScgIwqWwlZcvkFYAGR/SUV3OHCTBMdw==", + "license": "MIT", + "dependencies": { + "@primeuix/styled": "^0.7.4" + } + }, + "node_modules/@primeuix/themes": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@primeuix/themes/-/themes-2.0.3.tgz", + "integrity": "sha512-3fS1883mtCWhgUgNf/feiaaDSOND4EBIOu9tZnzJlJ8QtYyL6eFLcA6V3ymCWqLVXQ1+lTVEZv1gl47FIdXReg==", + "license": "MIT", + "dependencies": { + "@primeuix/styled": "^0.7.4" + } + }, + "node_modules/@primeuix/utils": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/@primeuix/utils/-/utils-0.6.4.tgz", + "integrity": "sha512-pZ5f+vj7wSzRhC7KoEQRU5fvYAe+RP9+m39CTscZ3UywCD1Y2o6Fe1rRgklMPSkzUcty2jzkA0zMYkiJBD1hgg==", + "license": "MIT", + "engines": { + "node": ">=12.11.0" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.0-rc.4", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.4.tgz", @@ -8301,7 +8388,6 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", - "dev": true, "license": "MIT", "dependencies": { "entities": "^6.0.0" @@ -8355,7 +8441,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -8571,6 +8656,34 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/primeicons": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/primeicons/-/primeicons-7.0.0.tgz", + "integrity": "sha512-jK3Et9UzwzTsd6tzl2RmwrVY/b8raJ3QZLzoDACj+oTJ0oX7L9Hy+XnVwgo4QVKlKpnP/Ur13SXV/pVh4LzaDw==", + "license": "MIT" + }, + "node_modules/primeng": { + "version": "21.1.3", + "resolved": "https://registry.npmjs.org/primeng/-/primeng-21.1.3.tgz", + "integrity": "sha512-PDL76kiHXH3CS5YuEIb5kv2OXgXDA5mVBCxy6QlJVTGa518rKe/dsHVLJYvhTjhwLtC2EUnBJxOZMxKlzc/fDg==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@primeuix/motion": "^0.0.10", + "@primeuix/styled": "^0.7.4", + "@primeuix/styles": "^2.0.3", + "@primeuix/utils": "^0.6.3", + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/cdk": "^21.0.0", + "@angular/common": "^21.0.0", + "@angular/core": "^21.0.7", + "@angular/forms": "^21.0.0", + "@angular/platform-browser": "^21.0.0", + "@angular/router": "^21.0.0", + "rxjs": "^6.0.0 || ^7.8.1" + } + }, "node_modules/proc-log": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 8669639fe..35aadde7c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,12 +12,16 @@ "private": true, "packageManager": "npm@11.9.0", "dependencies": { + "@angular/animations": "^21.2.5", "@angular/common": "^21.2.0", "@angular/compiler": "^21.2.0", "@angular/core": "^21.2.0", "@angular/forms": "^21.2.0", "@angular/platform-browser": "^21.2.0", "@angular/router": "^21.2.0", + "@primeuix/themes": "^2.0.3", + "primeicons": "^7.0.0", + "primeng": "^21.1.3", "rxjs": "~7.8.0", "tslib": "^2.3.0" }, From ff3565725a2b4f70b9f28198e14a24c5f2b356ab Mon Sep 17 00:00:00 2001 From: Olin Osborne III Date: Fri, 20 Mar 2026 15:01:53 -0400 Subject: [PATCH 02/22] feat(frontend): configure PrimeNG Aura dark theme --- frontend/src/app/app.config.ts | 21 ++++++++++++++++++--- frontend/src/index.html | 2 +- frontend/src/styles.scss | 12 +++++++++++- 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/frontend/src/app/app.config.ts b/frontend/src/app/app.config.ts index cb1270e96..5366be212 100644 --- a/frontend/src/app/app.config.ts +++ b/frontend/src/app/app.config.ts @@ -1,11 +1,26 @@ -import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; +import { + ApplicationConfig, + provideBrowserGlobalErrorListeners, +} from '@angular/core'; +import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; import { provideRouter } from '@angular/router'; +import { providePrimeNG } from 'primeng/config'; +import Aura from '@primeuix/themes/aura'; import { routes } from './app.routes'; export const appConfig: ApplicationConfig = { providers: [ provideBrowserGlobalErrorListeners(), - provideRouter(routes) - ] + provideAnimationsAsync(), + provideRouter(routes), + providePrimeNG({ + theme: { + preset: Aura, + options: { + darkModeSelector: '.app-dark', + }, + }, + }), + ], }; diff --git a/frontend/src/index.html b/frontend/src/index.html index 18253ce64..5c6baeaad 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -1,5 +1,5 @@ - + Ledmatrix diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss index 90d4ee007..13aa6dfbd 100644 --- a/frontend/src/styles.scss +++ b/frontend/src/styles.scss @@ -1 +1,11 @@ -/* You can add global styles to this file, and also import other style files */ +@import 'primeicons/primeicons.css'; + +html, +body { + margin: 0; + padding: 0; + height: 100%; + font-family: var(--font-family); + background: var(--p-surface-ground); + color: var(--p-text-color); +} From 0c9ca369b25f9bedd710ae1385fc946308807c78 Mon Sep 17 00:00:00 2001 From: Olin Osborne III Date: Fri, 20 Mar 2026 15:03:21 -0400 Subject: [PATCH 03/22] feat(frontend): add TopbarComponent with hamburger toggle --- .../app/layout/topbar/topbar.component.html | 16 +++++++++ .../app/layout/topbar/topbar.component.scss | 10 ++++++ .../layout/topbar/topbar.component.spec.ts | 35 +++++++++++++++++++ .../src/app/layout/topbar/topbar.component.ts | 18 ++++++++++ 4 files changed, 79 insertions(+) create mode 100644 frontend/src/app/layout/topbar/topbar.component.html create mode 100644 frontend/src/app/layout/topbar/topbar.component.scss create mode 100644 frontend/src/app/layout/topbar/topbar.component.spec.ts create mode 100644 frontend/src/app/layout/topbar/topbar.component.ts diff --git a/frontend/src/app/layout/topbar/topbar.component.html b/frontend/src/app/layout/topbar/topbar.component.html new file mode 100644 index 000000000..5125c3531 --- /dev/null +++ b/frontend/src/app/layout/topbar/topbar.component.html @@ -0,0 +1,16 @@ + + + + LEDMatrix + + + + + + + diff --git a/frontend/src/app/layout/topbar/topbar.component.scss b/frontend/src/app/layout/topbar/topbar.component.scss new file mode 100644 index 000000000..440fd0814 --- /dev/null +++ b/frontend/src/app/layout/topbar/topbar.component.scss @@ -0,0 +1,10 @@ +.app-title { + font-size: 1.25rem; + font-weight: 600; + margin-left: 0.5rem; +} + +.status-dot { + color: var(--p-green-400); + font-size: 0.75rem; +} diff --git a/frontend/src/app/layout/topbar/topbar.component.spec.ts b/frontend/src/app/layout/topbar/topbar.component.spec.ts new file mode 100644 index 000000000..3a99190e5 --- /dev/null +++ b/frontend/src/app/layout/topbar/topbar.component.spec.ts @@ -0,0 +1,35 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TopbarComponent } from './topbar.component'; +import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; + +describe('TopbarComponent', () => { + let component: TopbarComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TopbarComponent], + providers: [provideAnimationsAsync()], + }).compileComponents(); + + fixture = TestBed.createComponent(TopbarComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should emit toggleSidebar when hamburger is clicked', () => { + const spy = vi.fn(); + component.toggleSidebar.subscribe(spy); + component.onToggleSidebar(); + expect(spy).toHaveBeenCalled(); + }); + + it('should display the app title', () => { + const el: HTMLElement = fixture.nativeElement; + expect(el.textContent).toContain('LEDMatrix'); + }); +}); diff --git a/frontend/src/app/layout/topbar/topbar.component.ts b/frontend/src/app/layout/topbar/topbar.component.ts new file mode 100644 index 000000000..b844893d9 --- /dev/null +++ b/frontend/src/app/layout/topbar/topbar.component.ts @@ -0,0 +1,18 @@ +import { Component, output } from '@angular/core'; +import { ToolbarModule } from 'primeng/toolbar'; +import { ButtonModule } from 'primeng/button'; + +@Component({ + selector: 'app-topbar', + standalone: true, + imports: [ToolbarModule, ButtonModule], + templateUrl: './topbar.component.html', + styleUrl: './topbar.component.scss', +}) +export class TopbarComponent { + toggleSidebar = output(); + + onToggleSidebar(): void { + this.toggleSidebar.emit(); + } +} From 55019eadb6ee8328d35c0574b529239cca81e05d Mon Sep 17 00:00:00 2001 From: Olin Osborne III Date: Fri, 20 Mar 2026 15:03:25 -0400 Subject: [PATCH 04/22] feat(frontend): add SidebarComponent with navigation items --- .../app/layout/sidebar/sidebar.component.html | 13 ++++ .../app/layout/sidebar/sidebar.component.scss | 32 ++++++++++ .../layout/sidebar/sidebar.component.spec.ts | 64 +++++++++++++++++++ .../app/layout/sidebar/sidebar.component.ts | 26 ++++++++ 4 files changed, 135 insertions(+) create mode 100644 frontend/src/app/layout/sidebar/sidebar.component.html create mode 100644 frontend/src/app/layout/sidebar/sidebar.component.scss create mode 100644 frontend/src/app/layout/sidebar/sidebar.component.spec.ts create mode 100644 frontend/src/app/layout/sidebar/sidebar.component.ts diff --git a/frontend/src/app/layout/sidebar/sidebar.component.html b/frontend/src/app/layout/sidebar/sidebar.component.html new file mode 100644 index 000000000..a7f8179a6 --- /dev/null +++ b/frontend/src/app/layout/sidebar/sidebar.component.html @@ -0,0 +1,13 @@ + diff --git a/frontend/src/app/layout/sidebar/sidebar.component.scss b/frontend/src/app/layout/sidebar/sidebar.component.scss new file mode 100644 index 000000000..7ffa8afdc --- /dev/null +++ b/frontend/src/app/layout/sidebar/sidebar.component.scss @@ -0,0 +1,32 @@ +.sidebar-nav { + display: flex; + flex-direction: column; + padding: 1rem 0; +} + +.nav-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1.5rem; + color: var(--p-text-color); + text-decoration: none; + border-radius: 6px; + margin: 0.125rem 0.5rem; + transition: background-color 0.2s; + + &:hover { + background: var(--p-surface-hover); + } + + &.active { + background: var(--p-primary-color); + color: var(--p-primary-contrast-color); + } + + i { + font-size: 1.25rem; + width: 1.5rem; + text-align: center; + } +} diff --git a/frontend/src/app/layout/sidebar/sidebar.component.spec.ts b/frontend/src/app/layout/sidebar/sidebar.component.spec.ts new file mode 100644 index 000000000..786e57bd8 --- /dev/null +++ b/frontend/src/app/layout/sidebar/sidebar.component.spec.ts @@ -0,0 +1,64 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; +import { SidebarComponent } from './sidebar.component'; + +describe('SidebarComponent', () => { + let component: SidebarComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SidebarComponent], + providers: [provideRouter([]), provideAnimationsAsync()], + }).compileComponents(); + + fixture = TestBed.createComponent(SidebarComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should have 5 navigation items', () => { + expect(component.navItems.length).toBe(5); + }); + + it('should have Dashboard as first item with route /', () => { + expect(component.navItems[0].label).toBe('Dashboard'); + expect(component.navItems[0].routerLink).toBe('/'); + }); + + it('should have Plugins item with route /plugins', () => { + const item = component.navItems.find((i) => i.label === 'Plugins'); + expect(item).toBeTruthy(); + expect(item!.routerLink).toBe('/plugins'); + }); + + it('should have Settings item with route /settings', () => { + const item = component.navItems.find((i) => i.label === 'Settings'); + expect(item).toBeTruthy(); + expect(item!.routerLink).toBe('/settings'); + }); + + it('should have Logs item with route /logs', () => { + const item = component.navItems.find((i) => i.label === 'Logs'); + expect(item).toBeTruthy(); + expect(item!.routerLink).toBe('/logs'); + }); + + it('should have Store item with route /store', () => { + const item = component.navItems.find((i) => i.label === 'Store'); + expect(item).toBeTruthy(); + expect(item!.routerLink).toBe('/store'); + }); + + it('should have an icon for each nav item', () => { + for (const item of component.navItems) { + expect(item.icon).toBeTruthy(); + expect(item.icon).toMatch(/^pi pi-/); + } + }); +}); diff --git a/frontend/src/app/layout/sidebar/sidebar.component.ts b/frontend/src/app/layout/sidebar/sidebar.component.ts new file mode 100644 index 000000000..807eb1bc7 --- /dev/null +++ b/frontend/src/app/layout/sidebar/sidebar.component.ts @@ -0,0 +1,26 @@ +import { Component } from '@angular/core'; +import { RouterLink, RouterLinkActive } from '@angular/router'; +import { CommonModule } from '@angular/common'; + +export interface NavItem { + label: string; + icon: string; + routerLink: string; +} + +@Component({ + selector: 'app-sidebar', + standalone: true, + imports: [CommonModule, RouterLink, RouterLinkActive], + templateUrl: './sidebar.component.html', + styleUrl: './sidebar.component.scss', +}) +export class SidebarComponent { + readonly navItems: NavItem[] = [ + { label: 'Dashboard', icon: 'pi pi-home', routerLink: '/' }, + { label: 'Plugins', icon: 'pi pi-th-large', routerLink: '/plugins' }, + { label: 'Settings', icon: 'pi pi-cog', routerLink: '/settings' }, + { label: 'Logs', icon: 'pi pi-list', routerLink: '/logs' }, + { label: 'Store', icon: 'pi pi-shopping-bag', routerLink: '/store' }, + ]; +} From d9e9e58d27266b29834cdd004b4fc0dbb7cf671c Mon Sep 17 00:00:00 2001 From: Olin Osborne III Date: Fri, 20 Mar 2026 15:04:19 -0400 Subject: [PATCH 05/22] feat(frontend): add TypeScript interfaces for API response types --- frontend/src/app/core/models/common.model.ts | 21 +++++++++++ frontend/src/app/core/models/config.model.ts | 38 ++++++++++++++++++++ frontend/src/app/core/models/plugin.model.ts | 38 ++++++++++++++++++++ frontend/src/app/core/models/stream.model.ts | 23 ++++++++++++ frontend/src/app/core/models/system.model.ts | 21 +++++++++++ 5 files changed, 141 insertions(+) create mode 100644 frontend/src/app/core/models/common.model.ts create mode 100644 frontend/src/app/core/models/config.model.ts create mode 100644 frontend/src/app/core/models/plugin.model.ts create mode 100644 frontend/src/app/core/models/stream.model.ts create mode 100644 frontend/src/app/core/models/system.model.ts diff --git a/frontend/src/app/core/models/common.model.ts b/frontend/src/app/core/models/common.model.ts new file mode 100644 index 000000000..f7e827af0 --- /dev/null +++ b/frontend/src/app/core/models/common.model.ts @@ -0,0 +1,21 @@ +/** Matches src/api/models/common.py */ + +export interface SuccessResponse { + status: string; + message: string; + data: T | null; +} + +export interface ErrorResponse { + status: string; + error_code: string; + message: string; + details: Record | null; +} + +export interface PaginatedResponse { + items: T[]; + total: number; + page: number; + page_size: number; +} diff --git a/frontend/src/app/core/models/config.model.ts b/frontend/src/app/core/models/config.model.ts new file mode 100644 index 000000000..bb19146cf --- /dev/null +++ b/frontend/src/app/core/models/config.model.ts @@ -0,0 +1,38 @@ +/** Matches src/api/models/config.py */ + +export interface DisplayHardwareConfig { + rows: number; + cols: number; + chain_length: number; + parallel: number; + brightness: number; + hardware_mapping: string; + scan_mode: number; + pwm_bits: number; + pwm_dither_bits: number; + pwm_lsb_nanoseconds: number; + disable_hardware_pulsing: boolean; + inverse_colors: boolean; + show_refresh_rate: boolean; + led_rgb_sequence: string; + limit_refresh_rate_hz: number; +} + +export interface ScheduleConfig { + enabled: boolean; + mode: string; + start_time: string; + end_time: string; +} + +export interface SystemConfigResponse { + display: Record; + schedule: Record; + general: Record; +} + +export interface ConfigUpdateRequest { + display?: Record; + schedule?: Record; + general?: Record; +} diff --git a/frontend/src/app/core/models/plugin.model.ts b/frontend/src/app/core/models/plugin.model.ts new file mode 100644 index 000000000..73ebe951f --- /dev/null +++ b/frontend/src/app/core/models/plugin.model.ts @@ -0,0 +1,38 @@ +/** Matches src/api/models/plugin.py */ + +export interface PluginInfo { + id: string; + name: string; + version: string; + enabled: boolean; + description: string; + display_modes: string[]; +} + +export interface PluginConfigResponse { + plugin_id: string; + config: Record; + schema: Record; +} + +export interface PluginToggleRequest { + plugin_id: string; + enabled: boolean; +} + +export interface PluginInstallRequest { + plugin_id: string; + source_url: string; +} + +/** Store plugin — based on store router response shape */ +export interface StorePlugin { + id: string; + name: string; + version: string; + description: string; + author: string; + category: string; + tags: string[]; + installed: boolean; +} diff --git a/frontend/src/app/core/models/stream.model.ts b/frontend/src/app/core/models/stream.model.ts new file mode 100644 index 000000000..7583b8999 --- /dev/null +++ b/frontend/src/app/core/models/stream.model.ts @@ -0,0 +1,23 @@ +/** Matches SSE event shapes from src/api/routers/streams.py */ + +export interface StatsEvent { + timestamp: number; + uptime: string; + service_active: boolean; + cpu_percent: number; + memory_used_percent: number; + cpu_temp: number; + disk_used_percent: number; +} + +export interface DisplayEvent { + timestamp: number; + width: number; + height: number; + image: string | null; +} + +export interface LogEvent { + timestamp: number; + logs: string; +} diff --git a/frontend/src/app/core/models/system.model.ts b/frontend/src/app/core/models/system.model.ts new file mode 100644 index 000000000..d4018ebd0 --- /dev/null +++ b/frontend/src/app/core/models/system.model.ts @@ -0,0 +1,21 @@ +/** Matches src/api/models/system.py */ + +export interface SystemStatus { + cpu_percent: number; + memory_percent: number; + cpu_temp: number | null; + disk_percent: number; + service_active: boolean; + uptime: number; +} + +export interface SystemVersion { + version: string; + python_version: string; + platform: string; +} + +export interface HealthResponse { + status: string; + checks: Record; +} From a7790fc8c05d09e60027517ef2956df640ea3491 Mon Sep 17 00:00:00 2001 From: Olin Osborne III Date: Fri, 20 Mar 2026 15:04:55 -0400 Subject: [PATCH 06/22] feat(frontend): add AppLayoutComponent shell with responsive sidebar --- .../src/app/layout/app-layout.component.html | 30 ++++++++++++ .../src/app/layout/app-layout.component.scss | 34 ++++++++++++++ .../app/layout/app-layout.component.spec.ts | 46 +++++++++++++++++++ .../src/app/layout/app-layout.component.ts | 42 +++++++++++++++++ 4 files changed, 152 insertions(+) create mode 100644 frontend/src/app/layout/app-layout.component.html create mode 100644 frontend/src/app/layout/app-layout.component.scss create mode 100644 frontend/src/app/layout/app-layout.component.spec.ts create mode 100644 frontend/src/app/layout/app-layout.component.ts diff --git a/frontend/src/app/layout/app-layout.component.html b/frontend/src/app/layout/app-layout.component.html new file mode 100644 index 000000000..8f8b60b47 --- /dev/null +++ b/frontend/src/app/layout/app-layout.component.html @@ -0,0 +1,30 @@ +
+ + + @if (isMobile()) { + + + + LEDMatrix + + + + } @else { + + @if (sidebarVisible()) { + + } + } + +
+ +
+
diff --git a/frontend/src/app/layout/app-layout.component.scss b/frontend/src/app/layout/app-layout.component.scss new file mode 100644 index 000000000..ea46fdcc6 --- /dev/null +++ b/frontend/src/app/layout/app-layout.component.scss @@ -0,0 +1,34 @@ +.app-layout { + display: flex; + flex-direction: column; + height: 100vh; +} + +.app-sidebar { + position: fixed; + top: 0; + left: 0; + bottom: 0; + width: 240px; + padding-top: 60px; + background: var(--p-surface-card); + border-right: 1px solid var(--p-surface-border); + overflow-y: auto; + z-index: 100; +} + +.app-content { + flex: 1; + padding: 1.5rem; + margin-top: 60px; + transition: margin-left 0.2s; + + &.sidebar-open { + margin-left: 240px; + } +} + +.drawer-title { + font-size: 1.25rem; + font-weight: 600; +} diff --git a/frontend/src/app/layout/app-layout.component.spec.ts b/frontend/src/app/layout/app-layout.component.spec.ts new file mode 100644 index 000000000..f09403509 --- /dev/null +++ b/frontend/src/app/layout/app-layout.component.spec.ts @@ -0,0 +1,46 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; +import { AppLayoutComponent } from './app-layout.component'; + +describe('AppLayoutComponent', () => { + let component: AppLayoutComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [AppLayoutComponent], + providers: [provideRouter([]), provideAnimationsAsync()], + }).compileComponents(); + + fixture = TestBed.createComponent(AppLayoutComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should have sidebarVisible default to true', () => { + expect(component.sidebarVisible()).toBe(true); + }); + + it('should toggle sidebarVisible when toggleSidebar is called', () => { + expect(component.sidebarVisible()).toBe(true); + component.toggleSidebar(); + expect(component.sidebarVisible()).toBe(false); + component.toggleSidebar(); + expect(component.sidebarVisible()).toBe(true); + }); + + it('should render the topbar', () => { + const el: HTMLElement = fixture.nativeElement; + expect(el.querySelector('app-topbar')).toBeTruthy(); + }); + + it('should render a router-outlet for content', () => { + const el: HTMLElement = fixture.nativeElement; + expect(el.querySelector('router-outlet')).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/layout/app-layout.component.ts b/frontend/src/app/layout/app-layout.component.ts new file mode 100644 index 000000000..bb0315cc9 --- /dev/null +++ b/frontend/src/app/layout/app-layout.component.ts @@ -0,0 +1,42 @@ +import { Component, signal, HostListener } from '@angular/core'; +import { RouterOutlet } from '@angular/router'; +import { DrawerModule } from 'primeng/drawer'; +import { TopbarComponent } from './topbar/topbar.component'; +import { SidebarComponent } from './sidebar/sidebar.component'; + +const MOBILE_BREAKPOINT = 768; + +@Component({ + selector: 'app-layout', + standalone: true, + imports: [RouterOutlet, DrawerModule, TopbarComponent, SidebarComponent], + templateUrl: './app-layout.component.html', + styleUrl: './app-layout.component.scss', +}) +export class AppLayoutComponent { + sidebarVisible = signal(true); + isMobile = signal(false); + + constructor() { + this.checkMobile(); + } + + toggleSidebar(): void { + this.sidebarVisible.update((v) => !v); + } + + @HostListener('window:resize') + onResize(): void { + this.checkMobile(); + } + + private checkMobile(): void { + if (typeof window !== 'undefined') { + const mobile = window.innerWidth < MOBILE_BREAKPOINT; + this.isMobile.set(mobile); + if (mobile) { + this.sidebarVisible.set(false); + } + } + } +} From 97d1776567639528b0133493eb32087aca375b80 Mon Sep 17 00:00:00 2001 From: Olin Osborne III Date: Fri, 20 Mar 2026 15:06:17 -0400 Subject: [PATCH 07/22] feat(frontend): wire layout shell into app routing --- frontend/src/app/app.routes.ts | 11 ++++++++++- frontend/src/app/app.spec.ts | 17 +++++++++++++---- frontend/src/app/app.ts | 9 ++++----- 3 files changed, 27 insertions(+), 10 deletions(-) diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index dc39edb5f..6f2b635c0 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -1,3 +1,12 @@ import { Routes } from '@angular/router'; -export const routes: Routes = []; +export const routes: Routes = [ + { + path: '', + loadComponent: () => + import('./layout/app-layout.component').then((m) => m.AppLayoutComponent), + children: [ + // Feature modules will be lazy-loaded here in FRONT-004/005/006 + ], + }, +]; diff --git a/frontend/src/app/app.spec.ts b/frontend/src/app/app.spec.ts index 75753d688..49610dcbd 100644 --- a/frontend/src/app/app.spec.ts +++ b/frontend/src/app/app.spec.ts @@ -1,16 +1,25 @@ -import { TestBed } from '@angular/core/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; import { App } from './app'; describe('App', () => { + let fixture: ComponentFixture; + beforeEach(async () => { await TestBed.configureTestingModule({ imports: [App], + providers: [provideRouter([])], }).compileComponents(); + fixture = TestBed.createComponent(App); + fixture.detectChanges(); }); it('should create the app', () => { - const fixture = TestBed.createComponent(App); - const app = fixture.componentInstance; - expect(app).toBeTruthy(); + expect(fixture.componentInstance).toBeTruthy(); + }); + + it('should render a router-outlet', () => { + const el: HTMLElement = fixture.nativeElement; + expect(el.querySelector('router-outlet')).toBeTruthy(); }); }); diff --git a/frontend/src/app/app.ts b/frontend/src/app/app.ts index be35680b6..29946ecfd 100644 --- a/frontend/src/app/app.ts +++ b/frontend/src/app/app.ts @@ -1,12 +1,11 @@ -import { Component, signal } from '@angular/core'; +import { Component } from '@angular/core'; import { RouterOutlet } from '@angular/router'; @Component({ selector: 'app-root', + standalone: true, imports: [RouterOutlet], templateUrl: './app.html', - styleUrl: './app.scss' + styleUrl: './app.scss', }) -export class App { - protected readonly title = signal('ledmatrix'); -} +export class App {} From 6d984b8a84e84ee4be05282af80dab8f04a344cf Mon Sep 17 00:00:00 2001 From: Olin Osborne III Date: Fri, 20 Mar 2026 15:06:17 -0400 Subject: [PATCH 08/22] feat(frontend): add ApiError class with HTTP error parsing --- .../src/app/core/errors/api-error.spec.ts | 52 +++++++++++++++++++ frontend/src/app/core/errors/api-error.ts | 39 ++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 frontend/src/app/core/errors/api-error.spec.ts create mode 100644 frontend/src/app/core/errors/api-error.ts diff --git a/frontend/src/app/core/errors/api-error.spec.ts b/frontend/src/app/core/errors/api-error.spec.ts new file mode 100644 index 000000000..4783f684b --- /dev/null +++ b/frontend/src/app/core/errors/api-error.spec.ts @@ -0,0 +1,52 @@ +import { HttpErrorResponse } from '@angular/common/http'; +import { ApiError } from './api-error'; + +describe('ApiError', () => { + it('should parse a structured error response', () => { + const httpError = new HttpErrorResponse({ + error: { + status: 'error', + error_code: 'NOT_FOUND', + message: 'Plugin not found', + details: { plugin_id: 'foo' }, + }, + status: 404, + statusText: 'Not Found', + }); + + const apiError = ApiError.fromHttpError(httpError); + + expect(apiError.errorCode).toBe('NOT_FOUND'); + expect(apiError.message).toBe('Plugin not found'); + expect(apiError.statusCode).toBe(404); + expect(apiError.details).toEqual({ plugin_id: 'foo' }); + }); + + it('should handle unstructured error responses', () => { + const httpError = new HttpErrorResponse({ + error: 'Internal Server Error', + status: 500, + statusText: 'Internal Server Error', + }); + + const apiError = ApiError.fromHttpError(httpError); + + expect(apiError.errorCode).toBe('UNKNOWN'); + expect(apiError.statusCode).toBe(500); + expect(apiError.message).toBe('Internal Server Error'); + }); + + it('should handle network errors (status 0)', () => { + const httpError = new HttpErrorResponse({ + error: new ProgressEvent('error'), + status: 0, + statusText: 'Unknown Error', + }); + + const apiError = ApiError.fromHttpError(httpError); + + expect(apiError.errorCode).toBe('NETWORK_ERROR'); + expect(apiError.statusCode).toBe(0); + expect(apiError.message).toBe('Network error — server may be unreachable'); + }); +}); diff --git a/frontend/src/app/core/errors/api-error.ts b/frontend/src/app/core/errors/api-error.ts new file mode 100644 index 000000000..a3efe6ec6 --- /dev/null +++ b/frontend/src/app/core/errors/api-error.ts @@ -0,0 +1,39 @@ +import { HttpErrorResponse } from '@angular/common/http'; +import type { ErrorResponse } from '../models/common.model'; + +export class ApiError extends Error { + readonly errorCode: string; + readonly statusCode: number; + readonly details: Record | null; + + constructor( + message: string, + errorCode: string, + statusCode: number, + details: Record | null = null, + ) { + super(message); + this.name = 'ApiError'; + this.errorCode = errorCode; + this.statusCode = statusCode; + this.details = details; + } + + static fromHttpError(httpError: HttpErrorResponse): ApiError { + if (httpError.status === 0) { + return new ApiError('Network error — server may be unreachable', 'NETWORK_ERROR', 0); + } + + const body = httpError.error; + if (body && typeof body === 'object' && 'error_code' in body) { + const err = body as ErrorResponse; + return new ApiError(err.message, err.error_code, httpError.status, err.details); + } + + return new ApiError( + typeof body === 'string' ? body : httpError.statusText, + 'UNKNOWN', + httpError.status, + ); + } +} From feee983c64047c571385da7964478f11fbf0405f Mon Sep 17 00:00:00 2001 From: Olin Osborne III Date: Fri, 20 Mar 2026 15:07:13 -0400 Subject: [PATCH 09/22] feat(frontend): add HTTP error interceptor with ApiError conversion --- frontend/src/app/app.config.ts | 3 +++ .../app/core/interceptors/error.interceptor.ts | 15 +++++++++++++++ 2 files changed, 18 insertions(+) create mode 100644 frontend/src/app/core/interceptors/error.interceptor.ts diff --git a/frontend/src/app/app.config.ts b/frontend/src/app/app.config.ts index 5366be212..51fe69e2c 100644 --- a/frontend/src/app/app.config.ts +++ b/frontend/src/app/app.config.ts @@ -2,18 +2,21 @@ import { ApplicationConfig, provideBrowserGlobalErrorListeners, } from '@angular/core'; +import { provideHttpClient, withInterceptors } from '@angular/common/http'; import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; import { provideRouter } from '@angular/router'; import { providePrimeNG } from 'primeng/config'; import Aura from '@primeuix/themes/aura'; import { routes } from './app.routes'; +import { errorInterceptor } from './core/interceptors/error.interceptor'; export const appConfig: ApplicationConfig = { providers: [ provideBrowserGlobalErrorListeners(), provideAnimationsAsync(), provideRouter(routes), + provideHttpClient(withInterceptors([errorInterceptor])), providePrimeNG({ theme: { preset: Aura, diff --git a/frontend/src/app/core/interceptors/error.interceptor.ts b/frontend/src/app/core/interceptors/error.interceptor.ts new file mode 100644 index 000000000..951105723 --- /dev/null +++ b/frontend/src/app/core/interceptors/error.interceptor.ts @@ -0,0 +1,15 @@ +import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http'; +import { catchError, throwError } from 'rxjs'; +import { ApiError } from '../errors/api-error'; +import { environment } from '../../../environments/environment'; + +export const errorInterceptor: HttpInterceptorFn = (req, next) => { + return next(req).pipe( + catchError((error: HttpErrorResponse) => { + if (!environment.production) { + console.error(`[API Error] ${req.method} ${req.url}:`, error); + } + return throwError(() => ApiError.fromHttpError(error)); + }), + ); +}; From 5b277e4e41f394025cef3f3ad69bc30050951756 Mon Sep 17 00:00:00 2001 From: Olin Osborne III Date: Fri, 20 Mar 2026 15:08:02 -0400 Subject: [PATCH 10/22] feat(frontend): add base ApiService with typed HTTP methods --- .../src/app/core/services/api.service.spec.ts | 69 +++++++++++++++++++ frontend/src/app/core/services/api.service.ts | 39 +++++++++++ 2 files changed, 108 insertions(+) create mode 100644 frontend/src/app/core/services/api.service.spec.ts create mode 100644 frontend/src/app/core/services/api.service.ts diff --git a/frontend/src/app/core/services/api.service.spec.ts b/frontend/src/app/core/services/api.service.spec.ts new file mode 100644 index 000000000..fd3a4fc07 --- /dev/null +++ b/frontend/src/app/core/services/api.service.spec.ts @@ -0,0 +1,69 @@ +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { + HttpTestingController, + provideHttpClientTesting, +} from '@angular/common/http/testing'; +import { ApiService } from './api.service'; +import type { SuccessResponse } from '../models/common.model'; + +describe('ApiService', () => { + let service: ApiService; + let httpTesting: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting()], + }); + service = TestBed.inject(ApiService); + httpTesting = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpTesting.verify(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should send GET request with correct base URL', () => { + service.get('/system/status').subscribe(); + const req = httpTesting.expectOne('/api/v3/system/status'); + expect(req.request.method).toBe('GET'); + req.flush({ status: 'success', message: 'ok', data: null }); + }); + + it('should include X-Request-ID header', () => { + service.get('/system/version').subscribe(); + const req = httpTesting.expectOne('/api/v3/system/version'); + const requestId = req.request.headers.get('X-Request-ID'); + expect(requestId).toBeTruthy(); + expect(requestId!.length).toBeGreaterThan(0); + req.flush({ status: 'success', message: 'ok', data: null }); + }); + + it('should send POST request with body', () => { + const body = { plugin_id: 'clock', enabled: true }; + service.post('/plugins/toggle', body).subscribe(); + const req = httpTesting.expectOne('/api/v3/plugins/toggle'); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual(body); + req.flush({ status: 'success', message: 'toggled', data: null }); + }); + + it('should send PUT request', () => { + const body = { display: { brightness: 50 } }; + service.put('/config/main', body).subscribe(); + const req = httpTesting.expectOne('/api/v3/config/main'); + expect(req.request.method).toBe('PUT'); + req.flush({ status: 'success', message: 'updated', data: null }); + }); + + it('should send DELETE request', () => { + service.delete('/fonts/custom-font').subscribe(); + const req = httpTesting.expectOne('/api/v3/fonts/custom-font'); + expect(req.request.method).toBe('DELETE'); + req.flush({ status: 'success', message: 'deleted', data: null }); + }); +}); diff --git a/frontend/src/app/core/services/api.service.ts b/frontend/src/app/core/services/api.service.ts new file mode 100644 index 000000000..be47f0610 --- /dev/null +++ b/frontend/src/app/core/services/api.service.ts @@ -0,0 +1,39 @@ +import { Injectable } from '@angular/core'; +import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { environment } from '../../../environments/environment'; + +@Injectable({ providedIn: 'root' }) +export class ApiService { + private readonly baseUrl = environment.apiBase; + + constructor(private readonly http: HttpClient) {} + + get(path: string): Observable { + return this.http.get(`${this.baseUrl}${path}`, { + headers: this.headers(), + }); + } + + post(path: string, body: unknown = {}): Observable { + return this.http.post(`${this.baseUrl}${path}`, body, { + headers: this.headers(), + }); + } + + put(path: string, body: unknown = {}): Observable { + return this.http.put(`${this.baseUrl}${path}`, body, { + headers: this.headers(), + }); + } + + delete(path: string): Observable { + return this.http.delete(`${this.baseUrl}${path}`, { + headers: this.headers(), + }); + } + + private headers(): HttpHeaders { + return new HttpHeaders({ 'X-Request-ID': crypto.randomUUID() }); + } +} From e262127a539cb219a91d09736e799d9027fc9bcd Mon Sep 17 00:00:00 2001 From: Olin Osborne III Date: Fri, 20 Mar 2026 16:00:45 -0400 Subject: [PATCH 11/22] feat(frontend): add LoadingComponent with spinner and message --- .../app/shared/loading/loading.component.html | 4 +++ .../app/shared/loading/loading.component.scss | 18 ++++++++++ .../shared/loading/loading.component.spec.ts | 36 +++++++++++++++++++ .../app/shared/loading/loading.component.ts | 11 ++++++ 4 files changed, 69 insertions(+) create mode 100644 frontend/src/app/shared/loading/loading.component.html create mode 100644 frontend/src/app/shared/loading/loading.component.scss create mode 100644 frontend/src/app/shared/loading/loading.component.spec.ts create mode 100644 frontend/src/app/shared/loading/loading.component.ts diff --git a/frontend/src/app/shared/loading/loading.component.html b/frontend/src/app/shared/loading/loading.component.html new file mode 100644 index 000000000..a35a65bf8 --- /dev/null +++ b/frontend/src/app/shared/loading/loading.component.html @@ -0,0 +1,4 @@ +
+ + {{ message() }} +
diff --git a/frontend/src/app/shared/loading/loading.component.scss b/frontend/src/app/shared/loading/loading.component.scss new file mode 100644 index 000000000..116beff15 --- /dev/null +++ b/frontend/src/app/shared/loading/loading.component.scss @@ -0,0 +1,18 @@ +.loading-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1rem; + padding: 3rem; +} + +.loading-spinner { + font-size: 2.5rem; + color: var(--p-primary-color); +} + +.loading-message { + color: var(--p-text-muted-color); + font-size: 0.95rem; +} diff --git a/frontend/src/app/shared/loading/loading.component.spec.ts b/frontend/src/app/shared/loading/loading.component.spec.ts new file mode 100644 index 000000000..4170481b3 --- /dev/null +++ b/frontend/src/app/shared/loading/loading.component.spec.ts @@ -0,0 +1,36 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { LoadingComponent } from './loading.component'; + +describe('LoadingComponent', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [LoadingComponent], + }).compileComponents(); + fixture = TestBed.createComponent(LoadingComponent); + }); + + it('should create', () => { + expect(fixture.componentInstance).toBeTruthy(); + }); + + it('should show default message when none provided', () => { + fixture.detectChanges(); + const el: HTMLElement = fixture.nativeElement; + expect(el.querySelector('.loading-message')?.textContent?.trim()).toBe('Loading...'); + }); + + it('should show custom message when provided', () => { + fixture.componentRef.setInput('message', 'Fetching plugins'); + fixture.detectChanges(); + const el: HTMLElement = fixture.nativeElement; + expect(el.querySelector('.loading-message')?.textContent?.trim()).toBe('Fetching plugins'); + }); + + it('should render a spinner element', () => { + fixture.detectChanges(); + const el: HTMLElement = fixture.nativeElement; + expect(el.querySelector('.pi-spin')).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/shared/loading/loading.component.ts b/frontend/src/app/shared/loading/loading.component.ts new file mode 100644 index 000000000..8b18e8729 --- /dev/null +++ b/frontend/src/app/shared/loading/loading.component.ts @@ -0,0 +1,11 @@ +import { Component, input } from '@angular/core'; + +@Component({ + selector: 'app-loading', + standalone: true, + templateUrl: './loading.component.html', + styleUrl: './loading.component.scss', +}) +export class LoadingComponent { + message = input('Loading...'); +} From 3ca880e76442cb11e35468bca1db2c960f518668 Mon Sep 17 00:00:00 2001 From: Olin Osborne III Date: Fri, 20 Mar 2026 16:00:51 -0400 Subject: [PATCH 12/22] feat(frontend): add ErrorStateComponent with retry button --- .../error-state/error-state.component.html | 5 ++ .../error-state/error-state.component.scss | 19 ++++++++ .../error-state/error-state.component.spec.ts | 46 +++++++++++++++++++ .../error-state/error-state.component.ts | 18 ++++++++ 4 files changed, 88 insertions(+) create mode 100644 frontend/src/app/shared/error-state/error-state.component.html create mode 100644 frontend/src/app/shared/error-state/error-state.component.scss create mode 100644 frontend/src/app/shared/error-state/error-state.component.spec.ts create mode 100644 frontend/src/app/shared/error-state/error-state.component.ts diff --git a/frontend/src/app/shared/error-state/error-state.component.html b/frontend/src/app/shared/error-state/error-state.component.html new file mode 100644 index 000000000..7e67e3a29 --- /dev/null +++ b/frontend/src/app/shared/error-state/error-state.component.html @@ -0,0 +1,5 @@ +
+ +

{{ message() }}

+ +
diff --git a/frontend/src/app/shared/error-state/error-state.component.scss b/frontend/src/app/shared/error-state/error-state.component.scss new file mode 100644 index 000000000..d0c63f505 --- /dev/null +++ b/frontend/src/app/shared/error-state/error-state.component.scss @@ -0,0 +1,19 @@ +.error-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1rem; + padding: 3rem; +} + +.error-icon { + font-size: 3rem; + color: var(--p-red-400); +} + +.error-message { + color: var(--p-text-muted-color); + font-size: 0.95rem; + margin: 0; +} diff --git a/frontend/src/app/shared/error-state/error-state.component.spec.ts b/frontend/src/app/shared/error-state/error-state.component.spec.ts new file mode 100644 index 000000000..62e8cac1a --- /dev/null +++ b/frontend/src/app/shared/error-state/error-state.component.spec.ts @@ -0,0 +1,46 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; +import { ErrorStateComponent } from './error-state.component'; + +describe('ErrorStateComponent', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ErrorStateComponent], + providers: [provideAnimationsAsync()], + }).compileComponents(); + fixture = TestBed.createComponent(ErrorStateComponent); + }); + + it('should create', () => { + expect(fixture.componentInstance).toBeTruthy(); + }); + + it('should display the error message', () => { + fixture.componentRef.setInput('message', 'Something went wrong'); + fixture.detectChanges(); + const el: HTMLElement = fixture.nativeElement; + expect(el.textContent).toContain('Something went wrong'); + }); + + it('should show default message when none provided', () => { + fixture.detectChanges(); + const el: HTMLElement = fixture.nativeElement; + expect(el.textContent).toContain('An error occurred'); + }); + + it('should emit retry when retry button is clicked', () => { + fixture.detectChanges(); + const spy = vi.fn(); + fixture.componentInstance.retry.subscribe(spy); + fixture.componentInstance.onRetry(); + expect(spy).toHaveBeenCalled(); + }); + + it('should render an error icon', () => { + fixture.detectChanges(); + const el: HTMLElement = fixture.nativeElement; + expect(el.querySelector('.pi-exclamation-circle')).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/shared/error-state/error-state.component.ts b/frontend/src/app/shared/error-state/error-state.component.ts new file mode 100644 index 000000000..5c24e2f17 --- /dev/null +++ b/frontend/src/app/shared/error-state/error-state.component.ts @@ -0,0 +1,18 @@ +import { Component, input, output } from '@angular/core'; +import { ButtonModule } from 'primeng/button'; + +@Component({ + selector: 'app-error-state', + standalone: true, + imports: [ButtonModule], + templateUrl: './error-state.component.html', + styleUrl: './error-state.component.scss', +}) +export class ErrorStateComponent { + message = input('An error occurred'); + retry = output(); + + onRetry(): void { + this.retry.emit(); + } +} From f6f25554780f83792547f0d18a6857ba7ed16e60 Mon Sep 17 00:00:00 2001 From: Olin Osborne III Date: Fri, 20 Mar 2026 16:00:51 -0400 Subject: [PATCH 13/22] feat(frontend): add EmptyStateComponent with optional action button --- .../empty-state/empty-state.component.html | 7 +++ .../empty-state/empty-state.component.scss | 19 ++++++ .../empty-state/empty-state.component.spec.ts | 60 +++++++++++++++++++ .../empty-state/empty-state.component.ts | 20 +++++++ 4 files changed, 106 insertions(+) create mode 100644 frontend/src/app/shared/empty-state/empty-state.component.html create mode 100644 frontend/src/app/shared/empty-state/empty-state.component.scss create mode 100644 frontend/src/app/shared/empty-state/empty-state.component.spec.ts create mode 100644 frontend/src/app/shared/empty-state/empty-state.component.ts diff --git a/frontend/src/app/shared/empty-state/empty-state.component.html b/frontend/src/app/shared/empty-state/empty-state.component.html new file mode 100644 index 000000000..160ee3d26 --- /dev/null +++ b/frontend/src/app/shared/empty-state/empty-state.component.html @@ -0,0 +1,7 @@ +
+ +

{{ message() }}

+ @if (actionLabel()) { + + } +
diff --git a/frontend/src/app/shared/empty-state/empty-state.component.scss b/frontend/src/app/shared/empty-state/empty-state.component.scss new file mode 100644 index 000000000..2742e7081 --- /dev/null +++ b/frontend/src/app/shared/empty-state/empty-state.component.scss @@ -0,0 +1,19 @@ +.empty-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1rem; + padding: 3rem; +} + +.empty-icon { + font-size: 3rem; + color: var(--p-text-muted-color); +} + +.empty-message { + color: var(--p-text-muted-color); + font-size: 0.95rem; + margin: 0; +} diff --git a/frontend/src/app/shared/empty-state/empty-state.component.spec.ts b/frontend/src/app/shared/empty-state/empty-state.component.spec.ts new file mode 100644 index 000000000..f4ce4d25c --- /dev/null +++ b/frontend/src/app/shared/empty-state/empty-state.component.spec.ts @@ -0,0 +1,60 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; +import { EmptyStateComponent } from './empty-state.component'; + +describe('EmptyStateComponent', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [EmptyStateComponent], + providers: [provideAnimationsAsync()], + }).compileComponents(); + fixture = TestBed.createComponent(EmptyStateComponent); + }); + + it('should create', () => { + expect(fixture.componentInstance).toBeTruthy(); + }); + + it('should display the message', () => { + fixture.componentRef.setInput('message', 'No plugins found'); + fixture.detectChanges(); + const el: HTMLElement = fixture.nativeElement; + expect(el.textContent).toContain('No plugins found'); + }); + + it('should show default message when none provided', () => { + fixture.detectChanges(); + const el: HTMLElement = fixture.nativeElement; + expect(el.textContent).toContain('Nothing here yet'); + }); + + it('should show action button when actionLabel is provided', () => { + fixture.componentRef.setInput('actionLabel', 'Add Plugin'); + fixture.detectChanges(); + const el: HTMLElement = fixture.nativeElement; + expect(el.textContent).toContain('Add Plugin'); + }); + + it('should not show action button when actionLabel is not provided', () => { + fixture.detectChanges(); + const el: HTMLElement = fixture.nativeElement; + expect(el.querySelector('p-button')).toBeFalsy(); + }); + + it('should emit action when action button is clicked', () => { + fixture.componentRef.setInput('actionLabel', 'Add Plugin'); + fixture.detectChanges(); + const spy = vi.fn(); + fixture.componentInstance.action.subscribe(spy); + fixture.componentInstance.onAction(); + expect(spy).toHaveBeenCalled(); + }); + + it('should render an icon', () => { + fixture.detectChanges(); + const el: HTMLElement = fixture.nativeElement; + expect(el.querySelector('.pi-inbox')).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/shared/empty-state/empty-state.component.ts b/frontend/src/app/shared/empty-state/empty-state.component.ts new file mode 100644 index 000000000..85b5e163c --- /dev/null +++ b/frontend/src/app/shared/empty-state/empty-state.component.ts @@ -0,0 +1,20 @@ +import { Component, input, output } from '@angular/core'; +import { ButtonModule } from 'primeng/button'; + +@Component({ + selector: 'app-empty-state', + standalone: true, + imports: [ButtonModule], + templateUrl: './empty-state.component.html', + styleUrl: './empty-state.component.scss', +}) +export class EmptyStateComponent { + icon = input('pi pi-inbox'); + message = input('Nothing here yet'); + actionLabel = input(undefined); + action = output(); + + onAction(): void { + this.action.emit(); + } +} From d61495b8187c93e1f8b6a38f241529f973f91e12 Mon Sep 17 00:00:00 2001 From: Olin Osborne III Date: Fri, 20 Mar 2026 16:01:00 -0400 Subject: [PATCH 14/22] feat(frontend): add SSE service with auto-reconnect and typed streams --- .../src/app/core/services/sse.service.spec.ts | 91 +++++++++++++++++++ frontend/src/app/core/services/sse.service.ts | 69 ++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 frontend/src/app/core/services/sse.service.spec.ts create mode 100644 frontend/src/app/core/services/sse.service.ts diff --git a/frontend/src/app/core/services/sse.service.spec.ts b/frontend/src/app/core/services/sse.service.spec.ts new file mode 100644 index 000000000..c3cd32fee --- /dev/null +++ b/frontend/src/app/core/services/sse.service.spec.ts @@ -0,0 +1,91 @@ +import { TestBed } from '@angular/core/testing'; +import { SseService } from './sse.service'; +import type { StatsEvent } from '../models/stream.model'; + +class MockEventSource { + static instances: MockEventSource[] = []; + onmessage: ((event: MessageEvent) => void) | null = null; + onerror: ((event: Event) => void) | null = null; + onopen: (() => void) | null = null; + readyState = 0; + url: string; + + constructor(url: string) { + this.url = url; + MockEventSource.instances.push(this); + setTimeout(() => { + this.readyState = 1; + this.onopen?.(); + }); + } + + close = vi.fn(); + + simulateMessage(data: string): void { + this.onmessage?.({ data } as MessageEvent); + } + + simulateError(): void { + this.readyState = 2; + this.onerror?.(new Event('error')); + } +} + +describe('SseService', () => { + let service: SseService; + + beforeEach(() => { + MockEventSource.instances = []; + vi.stubGlobal('EventSource', MockEventSource); + TestBed.configureTestingModule({}); + service = TestBed.inject(SseService); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should create EventSource and emit parsed messages', () => { + const values: StatsEvent[] = []; + const sub = service + .connect('/stream/stats') + .subscribe((v) => values.push(v)); + + const instance = MockEventSource.instances[0]; + expect(instance.url).toBe('/api/v3/stream/stats'); + + instance.simulateMessage( + JSON.stringify({ + timestamp: 1, + cpu_percent: 50, + memory_used_percent: 40, + cpu_temp: 55, + disk_used_percent: 30, + uptime: 'Running', + service_active: true, + }), + ); + expect(values).toHaveLength(1); + expect(values[0].cpu_percent).toBe(50); + + sub.unsubscribe(); + expect(instance.close).toHaveBeenCalled(); + }); + + it('should close EventSource on unsubscribe', () => { + const sub = service.connect('/stream/logs').subscribe(); + const instance = MockEventSource.instances[0]; + sub.unsubscribe(); + expect(instance.close).toHaveBeenCalled(); + }); + + it('should provide typed stream accessors', () => { + expect(service.statsStream$).toBeDefined(); + expect(service.displayStream$).toBeDefined(); + expect(service.logStream$).toBeDefined(); + }); +}); diff --git a/frontend/src/app/core/services/sse.service.ts b/frontend/src/app/core/services/sse.service.ts new file mode 100644 index 000000000..a62a51b85 --- /dev/null +++ b/frontend/src/app/core/services/sse.service.ts @@ -0,0 +1,69 @@ +import { Injectable, NgZone } from '@angular/core'; +import { Observable, share } from 'rxjs'; +import { environment } from '../../../environments/environment'; +import type { + StatsEvent, + DisplayEvent, + LogEvent, +} from '../models/stream.model'; + +@Injectable({ providedIn: 'root' }) +export class SseService { + private readonly baseUrl = environment.apiBase; + + constructor(private readonly zone: NgZone) {} + + connect(endpoint: string): Observable { + return new Observable((subscriber) => { + const url = `${this.baseUrl}${endpoint}`; + let eventSource: EventSource; + let retryCount = 0; + let retryTimeout: ReturnType | null = null; + + const createConnection = (): void => { + eventSource = new EventSource(url); + + eventSource.onopen = () => { + retryCount = 0; + }; + + eventSource.onmessage = (event: MessageEvent) => { + this.zone.run(() => { + try { + subscriber.next(JSON.parse(event.data) as T); + } catch { + // Skip unparseable messages + } + }); + }; + + eventSource.onerror = () => { + eventSource.close(); + const delay = Math.min(1000 * Math.pow(2, retryCount), 30_000); + retryCount++; + retryTimeout = setTimeout(() => createConnection(), delay); + }; + }; + + createConnection(); + + return () => { + if (retryTimeout !== null) { + clearTimeout(retryTimeout); + } + eventSource?.close(); + }; + }); + } + + readonly statsStream$: Observable = this.connect( + '/stream/stats', + ).pipe(share()); + + readonly displayStream$: Observable = + this.connect('/stream/display').pipe(share()); + + readonly logStream$: Observable = this.connect( + '/stream/logs', + ).pipe(share()); +} From c2e9a7f380b3fca5c939e3cd70233a7613cafc95 Mon Sep 17 00:00:00 2001 From: Olin Osborne III Date: Fri, 20 Mar 2026 16:01:40 -0400 Subject: [PATCH 15/22] chore(sprint): mark FRONT-002 done --- sprints/v3.0.0/FRONT-002-primeng-theme-layout.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sprints/v3.0.0/FRONT-002-primeng-theme-layout.md b/sprints/v3.0.0/FRONT-002-primeng-theme-layout.md index 8e71cd247..2f2e235c4 100644 --- a/sprints/v3.0.0/FRONT-002-primeng-theme-layout.md +++ b/sprints/v3.0.0/FRONT-002-primeng-theme-layout.md @@ -2,7 +2,7 @@ > **For Claude:** Use `superpowers:writing-plans` before touching any files. Use `superpowers:test-driven-development` for any logic you add. -**Status:** Open +**Status:** Done **Phase:** v3.0.0 — Frontend Modernization **Type:** Feat **Depends on:** [FRONT-001](FRONT-001-angular-project-scaffold.md) From 6da94619055b4a5eca9dbb4184758fa9d14fd2eb Mon Sep 17 00:00:00 2001 From: Olin Osborne III Date: Fri, 20 Mar 2026 16:05:16 -0400 Subject: [PATCH 16/22] feat(frontend): add SystemService for system status, health, version --- .../app/core/services/system.service.spec.ts | 65 +++++++++++++++++++ .../src/app/core/services/system.service.ts | 30 +++++++++ 2 files changed, 95 insertions(+) create mode 100644 frontend/src/app/core/services/system.service.spec.ts create mode 100644 frontend/src/app/core/services/system.service.ts diff --git a/frontend/src/app/core/services/system.service.spec.ts b/frontend/src/app/core/services/system.service.spec.ts new file mode 100644 index 000000000..4633e6de3 --- /dev/null +++ b/frontend/src/app/core/services/system.service.spec.ts @@ -0,0 +1,65 @@ +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { + HttpTestingController, + provideHttpClientTesting, +} from '@angular/common/http/testing'; +import { SystemService } from './system.service'; + +describe('SystemService', () => { + let service: SystemService; + let httpTesting: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting()], + }); + service = TestBed.inject(SystemService); + httpTesting = TestBed.inject(HttpTestingController); + }); + + afterEach(() => httpTesting.verify()); + + it('should get system status', () => { + service.getStatus().subscribe((res) => { + expect(res.data).toBeTruthy(); + }); + const req = httpTesting.expectOne((r) => + r.url.endsWith('/system/status'), + ); + expect(req.request.method).toBe('GET'); + req.flush({ status: 'success', message: 'ok', data: { cpu_percent: 10 } }); + }); + + it('should get system version', () => { + service.getVersion().subscribe((res) => { + expect(res.data).toBeTruthy(); + }); + const req = httpTesting.expectOne((r) => + r.url.endsWith('/system/version'), + ); + expect(req.request.method).toBe('GET'); + req.flush({ + status: 'success', + message: 'ok', + data: { version: '3.0.0' }, + }); + }); + + it('should get health', () => { + service.getHealth().subscribe(); + const req = httpTesting.expectOne((r) => r.url.endsWith('/health')); + expect(req.request.method).toBe('GET'); + req.flush({ status: 'healthy', checks: {} }); + }); + + it('should perform system action', () => { + service.performAction('restart').subscribe(); + const req = httpTesting.expectOne((r) => + r.url.endsWith('/system/action'), + ); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual({ action: 'restart' }); + req.flush({ status: 'success', message: 'restarting', data: null }); + }); +}); diff --git a/frontend/src/app/core/services/system.service.ts b/frontend/src/app/core/services/system.service.ts new file mode 100644 index 000000000..3ffbcc5b1 --- /dev/null +++ b/frontend/src/app/core/services/system.service.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { ApiService } from './api.service'; +import type { SuccessResponse } from '../models/common.model'; +import type { + SystemStatus, + SystemVersion, + HealthResponse, +} from '../models/system.model'; + +@Injectable({ providedIn: 'root' }) +export class SystemService { + constructor(private readonly api: ApiService) {} + + getStatus(): Observable> { + return this.api.get('/system/status'); + } + + getVersion(): Observable> { + return this.api.get('/system/version'); + } + + getHealth(): Observable { + return this.api.get('/health'); + } + + performAction(action: string): Observable { + return this.api.post('/system/action', { action }); + } +} From 90f9e24be2af1d2f63efe23c08cf374b49660a3a Mon Sep 17 00:00:00 2001 From: Olin Osborne III Date: Fri, 20 Mar 2026 16:05:24 -0400 Subject: [PATCH 17/22] feat(frontend): add PluginService for plugin CRUD and store operations --- .../app/core/services/plugin.service.spec.ts | 111 ++++++++++++++++++ .../src/app/core/services/plugin.service.ts | 63 ++++++++++ 2 files changed, 174 insertions(+) create mode 100644 frontend/src/app/core/services/plugin.service.spec.ts create mode 100644 frontend/src/app/core/services/plugin.service.ts diff --git a/frontend/src/app/core/services/plugin.service.spec.ts b/frontend/src/app/core/services/plugin.service.spec.ts new file mode 100644 index 000000000..c260992b9 --- /dev/null +++ b/frontend/src/app/core/services/plugin.service.spec.ts @@ -0,0 +1,111 @@ +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { + HttpTestingController, + provideHttpClientTesting, +} from '@angular/common/http/testing'; +import { PluginService } from './plugin.service'; + +describe('PluginService', () => { + let service: PluginService; + let httpTesting: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting()], + }); + service = TestBed.inject(PluginService); + httpTesting = TestBed.inject(HttpTestingController); + }); + + afterEach(() => httpTesting.verify()); + + it('should list plugins', () => { + service.list().subscribe((res) => { + expect(res.data).toBeTruthy(); + }); + const req = httpTesting.expectOne((r) => + r.url.endsWith('/plugins/installed'), + ); + expect(req.request.method).toBe('GET'); + req.flush({ status: 'success', message: 'ok', data: [] }); + }); + + it('should get single plugin by id', () => { + service.get('clock').subscribe(); + const req = httpTesting.expectOne( + (r) => + r.url.includes('/plugins/config') && + r.url.includes('plugin_id=clock'), + ); + expect(req.request.method).toBe('GET'); + req.flush({ + status: 'success', + message: 'ok', + data: { plugin_id: 'clock', config: {}, schema: {} }, + }); + }); + + it('should get plugin config', () => { + service.getConfig('clock').subscribe(); + const req = httpTesting.expectOne( + (r) => + r.url.includes('/plugins/config') && + r.url.includes('plugin_id=clock'), + ); + expect(req.request.method).toBe('GET'); + req.flush({ + status: 'success', + message: 'ok', + data: { plugin_id: 'clock', config: {}, schema: {} }, + }); + }); + + it('should update plugin config', () => { + const config = { brightness: 50 }; + service.updateConfig('clock', config).subscribe(); + const req = httpTesting.expectOne((r) => + r.url.endsWith('/plugins/config'), + ); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual({ plugin_id: 'clock', config }); + req.flush({ status: 'success', message: 'saved', data: null }); + }); + + it('should toggle plugin', () => { + service.toggle('clock', true).subscribe(); + const req = httpTesting.expectOne((r) => + r.url.endsWith('/plugins/toggle'), + ); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual({ plugin_id: 'clock', enabled: true }); + req.flush({ status: 'success', message: 'toggled', data: null }); + }); + + it('should install plugin', () => { + service.install('weather').subscribe(); + const req = httpTesting.expectOne((r) => + r.url.endsWith('/plugins/install'), + ); + expect(req.request.method).toBe('POST'); + req.flush({ status: 'success', message: 'installed', data: null }); + }); + + it('should uninstall plugin', () => { + service.uninstall('weather').subscribe(); + const req = httpTesting.expectOne((r) => + r.url.endsWith('/plugins/uninstall'), + ); + expect(req.request.method).toBe('POST'); + req.flush({ status: 'success', message: 'uninstalled', data: null }); + }); + + it('should get store plugins', () => { + service.getStorePlugins().subscribe(); + const req = httpTesting.expectOne((r) => + r.url.endsWith('/plugins/store/list'), + ); + expect(req.request.method).toBe('GET'); + req.flush({ status: 'success', message: 'ok', data: [] }); + }); +}); diff --git a/frontend/src/app/core/services/plugin.service.ts b/frontend/src/app/core/services/plugin.service.ts new file mode 100644 index 000000000..69315808d --- /dev/null +++ b/frontend/src/app/core/services/plugin.service.ts @@ -0,0 +1,63 @@ +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { ApiService } from './api.service'; +import type { SuccessResponse } from '../models/common.model'; +import type { + PluginInfo, + PluginConfigResponse, + StorePlugin, +} from '../models/plugin.model'; + +@Injectable({ providedIn: 'root' }) +export class PluginService { + constructor(private readonly api: ApiService) {} + + list(): Observable> { + return this.api.get('/plugins/installed'); + } + + get( + pluginId: string, + ): Observable> { + return this.api.get( + `/plugins/config?plugin_id=${encodeURIComponent(pluginId)}`, + ); + } + + getConfig( + pluginId: string, + ): Observable> { + return this.api.get( + `/plugins/config?plugin_id=${encodeURIComponent(pluginId)}`, + ); + } + + updateConfig( + pluginId: string, + config: Record, + ): Observable { + return this.api.post('/plugins/config', { + plugin_id: pluginId, + config, + }); + } + + toggle(pluginId: string, enabled: boolean): Observable { + return this.api.post('/plugins/toggle', { + plugin_id: pluginId, + enabled, + }); + } + + install(pluginId: string): Observable { + return this.api.post('/plugins/install', { plugin_id: pluginId }); + } + + uninstall(pluginId: string): Observable { + return this.api.post('/plugins/uninstall', { plugin_id: pluginId }); + } + + getStorePlugins(): Observable> { + return this.api.get('/plugins/store/list'); + } +} From 4f14c6dc019ae7eff9d782f385119c78951403d2 Mon Sep 17 00:00:00 2001 From: Olin Osborne III Date: Fri, 20 Mar 2026 16:05:24 -0400 Subject: [PATCH 18/22] feat(frontend): add ConfigService for config and schedule operations --- .../app/core/services/config.service.spec.ts | 86 +++++++++++++++++++ .../src/app/core/services/config.service.ts | 34 ++++++++ 2 files changed, 120 insertions(+) create mode 100644 frontend/src/app/core/services/config.service.spec.ts create mode 100644 frontend/src/app/core/services/config.service.ts diff --git a/frontend/src/app/core/services/config.service.spec.ts b/frontend/src/app/core/services/config.service.spec.ts new file mode 100644 index 000000000..a3fa86d33 --- /dev/null +++ b/frontend/src/app/core/services/config.service.spec.ts @@ -0,0 +1,86 @@ +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { + HttpTestingController, + provideHttpClientTesting, +} from '@angular/common/http/testing'; +import { ConfigService } from './config.service'; + +describe('ConfigService', () => { + let service: ConfigService; + let httpTesting: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting()], + }); + service = TestBed.inject(ConfigService); + httpTesting = TestBed.inject(HttpTestingController); + }); + + afterEach(() => httpTesting.verify()); + + it('should get main config', () => { + service.getMainConfig().subscribe((res) => { + expect(res.data).toBeTruthy(); + }); + const req = httpTesting.expectOne((r) => r.url.endsWith('/config/main')); + expect(req.request.method).toBe('GET'); + req.flush({ + status: 'success', + message: 'ok', + data: { display: {}, schedule: {}, general: {} }, + }); + }); + + it('should update main config', () => { + const update = { display: { brightness: 80 } }; + service.updateMainConfig(update).subscribe(); + const req = httpTesting.expectOne((r) => r.url.endsWith('/config/main')); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual(update); + req.flush({ status: 'success', message: 'saved', data: null }); + }); + + it('should get schedule', () => { + service.getSchedule().subscribe(); + const req = httpTesting.expectOne((r) => + r.url.endsWith('/config/schedule'), + ); + expect(req.request.method).toBe('GET'); + req.flush({ + status: 'success', + message: 'ok', + data: { + enabled: false, + mode: 'global', + start_time: '07:00', + end_time: '23:00', + }, + }); + }); + + it('should update schedule', () => { + const schedule = { + enabled: true, + mode: 'global', + start_time: '08:00', + end_time: '22:00', + }; + service.updateSchedule(schedule).subscribe(); + const req = httpTesting.expectOne((r) => + r.url.endsWith('/config/schedule'), + ); + expect(req.request.method).toBe('POST'); + req.flush({ status: 'success', message: 'saved', data: null }); + }); + + it('should get secrets', () => { + service.getSecrets().subscribe(); + const req = httpTesting.expectOne((r) => + r.url.endsWith('/config/secrets'), + ); + expect(req.request.method).toBe('GET'); + req.flush({ status: 'success', message: 'ok', data: {} }); + }); +}); diff --git a/frontend/src/app/core/services/config.service.ts b/frontend/src/app/core/services/config.service.ts new file mode 100644 index 000000000..f0aece561 --- /dev/null +++ b/frontend/src/app/core/services/config.service.ts @@ -0,0 +1,34 @@ +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { ApiService } from './api.service'; +import type { SuccessResponse } from '../models/common.model'; +import type { + SystemConfigResponse, + ConfigUpdateRequest, + ScheduleConfig, +} from '../models/config.model'; + +@Injectable({ providedIn: 'root' }) +export class ConfigService { + constructor(private readonly api: ApiService) {} + + getMainConfig(): Observable> { + return this.api.get('/config/main'); + } + + updateMainConfig(config: ConfigUpdateRequest): Observable { + return this.api.post('/config/main', config); + } + + getSchedule(): Observable> { + return this.api.get('/config/schedule'); + } + + updateSchedule(schedule: Partial): Observable { + return this.api.post('/config/schedule', schedule); + } + + getSecrets(): Observable>> { + return this.api.get('/config/secrets'); + } +} From c10efe8d0d54d8c5765098b459abb3a26df6ef6a Mon Sep 17 00:00:00 2001 From: Olin Osborne III Date: Fri, 20 Mar 2026 16:07:24 -0400 Subject: [PATCH 19/22] style(frontend): use inject() over constructor injection per lint rules --- frontend/src/app/core/services/api.service.ts | 5 ++--- frontend/src/app/core/services/config.service.ts | 4 ++-- frontend/src/app/core/services/plugin.service.ts | 4 ++-- frontend/src/app/core/services/sse.service.ts | 5 ++--- frontend/src/app/core/services/system.service.ts | 4 ++-- 5 files changed, 10 insertions(+), 12 deletions(-) diff --git a/frontend/src/app/core/services/api.service.ts b/frontend/src/app/core/services/api.service.ts index be47f0610..b633f4314 100644 --- a/frontend/src/app/core/services/api.service.ts +++ b/frontend/src/app/core/services/api.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@angular/core'; +import { inject, Injectable } from '@angular/core'; import { HttpClient, HttpHeaders } from '@angular/common/http'; import { Observable } from 'rxjs'; import { environment } from '../../../environments/environment'; @@ -6,8 +6,7 @@ import { environment } from '../../../environments/environment'; @Injectable({ providedIn: 'root' }) export class ApiService { private readonly baseUrl = environment.apiBase; - - constructor(private readonly http: HttpClient) {} + private readonly http = inject(HttpClient); get(path: string): Observable { return this.http.get(`${this.baseUrl}${path}`, { diff --git a/frontend/src/app/core/services/config.service.ts b/frontend/src/app/core/services/config.service.ts index f0aece561..ef6d23cba 100644 --- a/frontend/src/app/core/services/config.service.ts +++ b/frontend/src/app/core/services/config.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@angular/core'; +import { inject, Injectable } from '@angular/core'; import { Observable } from 'rxjs'; import { ApiService } from './api.service'; import type { SuccessResponse } from '../models/common.model'; @@ -10,7 +10,7 @@ import type { @Injectable({ providedIn: 'root' }) export class ConfigService { - constructor(private readonly api: ApiService) {} + private readonly api = inject(ApiService); getMainConfig(): Observable> { return this.api.get('/config/main'); diff --git a/frontend/src/app/core/services/plugin.service.ts b/frontend/src/app/core/services/plugin.service.ts index 69315808d..771033dc0 100644 --- a/frontend/src/app/core/services/plugin.service.ts +++ b/frontend/src/app/core/services/plugin.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@angular/core'; +import { inject, Injectable } from '@angular/core'; import { Observable } from 'rxjs'; import { ApiService } from './api.service'; import type { SuccessResponse } from '../models/common.model'; @@ -10,7 +10,7 @@ import type { @Injectable({ providedIn: 'root' }) export class PluginService { - constructor(private readonly api: ApiService) {} + private readonly api = inject(ApiService); list(): Observable> { return this.api.get('/plugins/installed'); diff --git a/frontend/src/app/core/services/sse.service.ts b/frontend/src/app/core/services/sse.service.ts index a62a51b85..023f8abb9 100644 --- a/frontend/src/app/core/services/sse.service.ts +++ b/frontend/src/app/core/services/sse.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NgZone } from '@angular/core'; +import { inject, Injectable, NgZone } from '@angular/core'; import { Observable, share } from 'rxjs'; import { environment } from '../../../environments/environment'; import type { @@ -10,8 +10,7 @@ import type { @Injectable({ providedIn: 'root' }) export class SseService { private readonly baseUrl = environment.apiBase; - - constructor(private readonly zone: NgZone) {} + private readonly zone = inject(NgZone); connect(endpoint: string): Observable { return new Observable((subscriber) => { diff --git a/frontend/src/app/core/services/system.service.ts b/frontend/src/app/core/services/system.service.ts index 3ffbcc5b1..d7bfbbf53 100644 --- a/frontend/src/app/core/services/system.service.ts +++ b/frontend/src/app/core/services/system.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@angular/core'; +import { inject, Injectable } from '@angular/core'; import { Observable } from 'rxjs'; import { ApiService } from './api.service'; import type { SuccessResponse } from '../models/common.model'; @@ -10,7 +10,7 @@ import type { @Injectable({ providedIn: 'root' }) export class SystemService { - constructor(private readonly api: ApiService) {} + private readonly api = inject(ApiService); getStatus(): Observable> { return this.api.get('/system/status'); From 03ba5953da2d38e858ef221d41863e7502d9f689 Mon Sep 17 00:00:00 2001 From: Olin Osborne III Date: Fri, 20 Mar 2026 16:07:30 -0400 Subject: [PATCH 20/22] feat(frontend): add barrel export for core service layer --- frontend/src/app/core/index.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 frontend/src/app/core/index.ts diff --git a/frontend/src/app/core/index.ts b/frontend/src/app/core/index.ts new file mode 100644 index 000000000..b79155c8a --- /dev/null +++ b/frontend/src/app/core/index.ts @@ -0,0 +1,16 @@ +// Models +export * from './models/common.model'; +export * from './models/system.model'; +export * from './models/plugin.model'; +export * from './models/config.model'; +export * from './models/stream.model'; + +// Errors +export { ApiError } from './errors/api-error'; + +// Services +export { ApiService } from './services/api.service'; +export { SseService } from './services/sse.service'; +export { SystemService } from './services/system.service'; +export { PluginService } from './services/plugin.service'; +export { ConfigService } from './services/config.service'; From 234d8e639a43c2ec6a99abb58ddcbcd87629dcd6 Mon Sep 17 00:00:00 2001 From: Olin Osborne III Date: Fri, 20 Mar 2026 16:08:10 -0400 Subject: [PATCH 21/22] chore(sprint): mark FRONT-003 done, add SPIKE tickets for font/wifi/starlark services --- sprints/v3.0.0/FRONT-003-api-service-layer.md | 2 +- sprints/v3.0.0/FRONT-003a-font-service.md | 21 +++++++++++++++++++ sprints/v3.0.0/FRONT-003b-wifi-service.md | 21 +++++++++++++++++++ sprints/v3.0.0/FRONT-003c-starlark-service.md | 21 +++++++++++++++++++ 4 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 sprints/v3.0.0/FRONT-003a-font-service.md create mode 100644 sprints/v3.0.0/FRONT-003b-wifi-service.md create mode 100644 sprints/v3.0.0/FRONT-003c-starlark-service.md diff --git a/sprints/v3.0.0/FRONT-003-api-service-layer.md b/sprints/v3.0.0/FRONT-003-api-service-layer.md index 49fb36d40..8a553b9f1 100644 --- a/sprints/v3.0.0/FRONT-003-api-service-layer.md +++ b/sprints/v3.0.0/FRONT-003-api-service-layer.md @@ -2,7 +2,7 @@ > **For Claude:** Use `superpowers:writing-plans` before touching any files. Use `superpowers:test-driven-development` for any logic you add. -**Status:** Open +**Status:** Done **Phase:** v3.0.0 — Frontend Modernization **Type:** Feat **Depends on:** [FRONT-001](FRONT-001-angular-project-scaffold.md) diff --git a/sprints/v3.0.0/FRONT-003a-font-service.md b/sprints/v3.0.0/FRONT-003a-font-service.md new file mode 100644 index 000000000..e6e042509 --- /dev/null +++ b/sprints/v3.0.0/FRONT-003a-font-service.md @@ -0,0 +1,21 @@ +# FRONT-003a — FontService + +**Status:** Open +**Phase:** v3.0.0 — Frontend Modernization +**Type:** Spike +**Depends on:** [FRONT-003](FRONT-003-api-service-layer.md) + +--- + +## Context + +The settings module (FRONT-006) will need a `FontService` wrapping `/api/v3/fonts/*` endpoints. This was out of scope for FRONT-003 which focused on core services (system, plugin, config). + +## Scope + +- Create `frontend/src/app/core/services/font.service.ts` +- Create `frontend/src/app/core/models/font.model.ts` +- Methods: `getCatalog()`, `getTokens()`, `getOverrides()`, `updateOverrides()`, `deleteOverride()`, `uploadFont()`, `previewFont()`, `deleteFont()` +- Follow the same pattern as `SystemService` — inject `ApiService`, typed returns +- Add tests following existing `*.spec.ts` patterns +- Export from `core/index.ts` diff --git a/sprints/v3.0.0/FRONT-003b-wifi-service.md b/sprints/v3.0.0/FRONT-003b-wifi-service.md new file mode 100644 index 000000000..b3d683b1c --- /dev/null +++ b/sprints/v3.0.0/FRONT-003b-wifi-service.md @@ -0,0 +1,21 @@ +# FRONT-003b — WifiService + +**Status:** Open +**Phase:** v3.0.0 — Frontend Modernization +**Type:** Spike +**Depends on:** [FRONT-003](FRONT-003-api-service-layer.md) + +--- + +## Context + +The settings module (FRONT-006) will need a `WifiService` wrapping `/api/v3/wifi/*` endpoints. This was out of scope for FRONT-003 which focused on core services (system, plugin, config). + +## Scope + +- Create `frontend/src/app/core/services/wifi.service.ts` +- Create `frontend/src/app/core/models/wifi.model.ts` +- Methods: `getStatus()`, `scan()`, `connect()`, `disconnect()`, `enableAP()`, `disableAP()`, `getAutoEnableAP()`, `setAutoEnableAP()` +- Follow the same pattern as `SystemService` — inject `ApiService`, typed returns +- Add tests following existing `*.spec.ts` patterns +- Export from `core/index.ts` diff --git a/sprints/v3.0.0/FRONT-003c-starlark-service.md b/sprints/v3.0.0/FRONT-003c-starlark-service.md new file mode 100644 index 000000000..2044e51a7 --- /dev/null +++ b/sprints/v3.0.0/FRONT-003c-starlark-service.md @@ -0,0 +1,21 @@ +# FRONT-003c — StarlarkService + +**Status:** Open +**Phase:** v3.0.0 — Frontend Modernization +**Type:** Spike +**Depends on:** [FRONT-003](FRONT-003-api-service-layer.md) + +--- + +## Context + +The plugins module (FRONT-005) may need a `StarlarkService` wrapping `/api/v3/starlark/*` endpoints if Starlark/Pixlet app management is exposed in the Angular UI. This was out of scope for FRONT-003 which focused on core services (system, plugin, config). + +## Scope + +- Create `frontend/src/app/core/services/starlark.service.ts` +- Create `frontend/src/app/core/models/starlark.model.ts` +- Methods: `getStatus()`, `listApps()`, `getApp()`, `uploadApp()`, `deleteApp()`, `getAppConfig()`, `updateAppConfig()`, `toggleApp()`, `renderApp()`, `browseRepository()`, `installFromRepo()`, `getCategories()`, `installPixlet()` +- Follow the same pattern as `SystemService` — inject `ApiService`, typed returns +- Add tests following existing `*.spec.ts` patterns +- Export from `core/index.ts` From 5420a7a35c5e7493e90e27a2daff0d017530cfb9 Mon Sep 17 00:00:00 2001 From: Olin Osborne III Date: Thu, 9 Apr 2026 10:28:00 -0400 Subject: [PATCH 22/22] chore(anvil): migrate sprints to docs/anvil, add init config, sync v3.0.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move sprints/ → docs/anvil/sprints/ (rename, no content changes) - Add docs/anvil/config.yml (core/cli/frontend components, develop branch) - Sync v3.0.0 README: FRONT-002/003 → Done, add FRONT-003a/b/c rows - Update FRONT-003 Blocks: to list 003a/b/c - Remove legacy .claude/agents and .claude/skills superseded by anvil plugin - Add docs/superpowers/ plans --- .claude/agents/arch-validator-agent.md | 116 -- .claude/agents/ba-agent.md | 134 -- .claude/agents/green-agent.md | 34 - .claude/agents/migration-agent.md | 115 -- .claude/agents/plugin-compat-reviewer.md | 120 -- .claude/agents/pm-agent.md | 162 --- .claude/agents/red-agent.md | 36 - .claude/agents/sprint-syncer-agent.md | 105 -- .claude/skills/arch-audit/SKILL.md | 88 -- .claude/skills/scaffold-plugin/SKILL.md | 126 -- .claude/skills/sprint-workflow/SKILL.md | 100 -- .claude/skills/validate-plugin/SKILL.md | 126 -- docs/anvil/config.yml | 37 + .../FOUND-001-pyproject-uv-migration.md | 0 .../v1.1.0/FOUND-002-venv-bootstrap.md | 0 .../FOUND-003-matrix-cli-install-doctor.md | 0 .../sprints}/v1.1.0/FOUND-004-ci-pipeline.md | 0 .../v1.1.0/FOUND-005-precommit-ruff.md | 0 .../v1.1.0/FOUND-006-plugin-quickfixes.md | 0 .../anvil/sprints}/v1.1.0/README.md | 0 .../SPIKE-001-update-diagnostic-scripts.md | 0 .../SPIKE-002-update-docs-for-uv-migration.md | 0 ...SPIKE-003-monorepo-plugin-quickfixes-pr.md | 0 ...KE-004-remove-deprecated-legacy-scripts.md | 0 ...SPIKE-005-doctor-rgbmatrix-import-check.md | 0 .../v1.1.0/SPIKE-006-ruff-lint-cleanup.md | 0 .../v1.1.0/SPIKE-007-bandit-config.md | 0 .../SPIKE-008-plugin-deps-venv-migration.md | 0 ...KE-009-retire-first-time-install-script.md | 0 ...PIKE-010-expand-matrix-install-pi-setup.md | 0 .../v1.1.0/SPIKE-011-install-hardware-flag.md | 0 .../SPIKE-012-matrix-install-full-oneshot.md | 0 ...3-matrix-cli-replace-diagnostic-scripts.md | 0 ...14-matrix-cli-replace-fix-perms-scripts.md | 0 ...-015-matrix-cli-replace-network-scripts.md | 0 ...SPIKE-016-matrix-doctor-full-validation.md | 0 .../SPIKE-017-matrix-uninstall-subcommand.md | 0 .../SPIKE-018-archive-obsolete-scripts.md | 0 .../v1.1.0/SPIKE-019-plugin-pyproject-toml.md | 0 .../v2.0.0/BACK-001-fastapi-app-scaffold.md | 0 .../v2.0.0/BACK-002-dependency-updates.md | 0 .../v2.0.0/BACK-003-pydantic-settings.md | 0 .../v2.0.0/BACK-004-middleware-stack.md | 0 .../v2.0.0/BACK-005-api-routes-system.md | 0 .../v2.0.0/BACK-006-api-routes-plugins.md | 0 .../sprints}/v2.0.0/BACK-007-sse-migration.md | 0 .../v2.0.0/BACK-008-flask-removal-cleanup.md | 0 .../anvil/sprints}/v2.0.0/README.md | 0 .../v2.0.0/SPIKE-001-web-interface-v2-shim.md | 0 .../v2.0.0/SPIKE-002-pages-v3-transition.md | 0 .../SPIKE-003-openapi-schema-validation.md | 0 .../v2.0.0/SPIKE-004-mypy-strict-api.md | 0 .../v2.0.0/SPIKE-005-update-ci-for-fastapi.md | 0 ...6-cleanup-src-web-interface-flask-utils.md | 0 .../v2.0.0/SPIKE-006-fastapi-rate-limiting.md | 0 .../SPIKE-007-missing-partial-templates.md | 0 .../SPIKE-008-openapi-response-models.md | 0 .../FRONT-001-angular-project-scaffold.md | 0 .../v3.0.0/FRONT-002-primeng-theme-layout.md | 0 .../v3.0.0/FRONT-003-api-service-layer.md | 2 +- .../v3.0.0/FRONT-003a-font-service.md | 0 .../v3.0.0/FRONT-003b-wifi-service.md | 0 .../v3.0.0/FRONT-003c-starlark-service.md | 0 .../v3.0.0/FRONT-004-dashboard-module.md | 0 .../v3.0.0/FRONT-005-plugins-module.md | 0 .../v3.0.0/FRONT-006-settings-module.md | 0 .../v3.0.0/FRONT-007-logs-store-modules.md | 0 .../v3.0.0/FRONT-008-htmx-removal-cleanup.md | 0 .../anvil/sprints}/v3.0.0/README.md | 17 +- .../SPIKE-001-angular-unit-test-setup.md | 0 .../v3.0.0/SPIKE-002-raw-json-editor.md | 0 .../SPIKE-003-operation-history-view.md | 0 .../v3.0.0/SPIKE-004-ci-angular-build.md | 0 .../v3.0.0/SPIKE-005-starlark-config-ui.md | 0 .../SPIKE-FRONT-001-nodejs-in-distrobox.md | 0 ...FRONT-002-angular-environment-switching.md | 0 ...FRONT-003-dev-server-proxy-verification.md | 0 .../v4.0.0/DOCK-001-dockerfile-multi-stage.md | 112 ++ .../sprints/v4.0.0/DOCK-002-dockerignore.md | 81 ++ .../v4.0.0/DOCK-003-compose-production.md | 131 ++ .../v4.0.0/DOCK-004-compose-dev-overlay.md | 98 ++ .../v4.0.0/DOCK-005-cli-docker-commands.md | 132 ++ .../v4.0.0/DOCK-006-systemd-docker-units.md | 91 ++ .../DOCK-007-install-docker-detection.md | 102 ++ docs/anvil/sprints/v4.0.0/README.md | 112 ++ .../SPIKE-001-rgbmatrix-in-container.md | 96 ++ .../SPIKE-002-pi-gpio-device-permissions.md | 94 ++ .../v4.0.0/SPIKE-003-ci-docker-build.md | 87 ++ .../plans/2026-03-20-api-service-layer.md | 1216 ++++++++++++++++ .../plans/2026-03-20-primeng-theme-layout.md | 1238 +++++++++++++++++ 90 files changed, 3641 insertions(+), 1267 deletions(-) delete mode 100644 .claude/agents/arch-validator-agent.md delete mode 100644 .claude/agents/ba-agent.md delete mode 100644 .claude/agents/green-agent.md delete mode 100644 .claude/agents/migration-agent.md delete mode 100644 .claude/agents/plugin-compat-reviewer.md delete mode 100644 .claude/agents/pm-agent.md delete mode 100644 .claude/agents/red-agent.md delete mode 100644 .claude/agents/sprint-syncer-agent.md delete mode 100644 .claude/skills/arch-audit/SKILL.md delete mode 100644 .claude/skills/scaffold-plugin/SKILL.md delete mode 100644 .claude/skills/sprint-workflow/SKILL.md delete mode 100644 .claude/skills/validate-plugin/SKILL.md create mode 100644 docs/anvil/config.yml rename {sprints => docs/anvil/sprints}/v1.1.0/FOUND-001-pyproject-uv-migration.md (100%) rename {sprints => docs/anvil/sprints}/v1.1.0/FOUND-002-venv-bootstrap.md (100%) rename {sprints => docs/anvil/sprints}/v1.1.0/FOUND-003-matrix-cli-install-doctor.md (100%) rename {sprints => docs/anvil/sprints}/v1.1.0/FOUND-004-ci-pipeline.md (100%) rename {sprints => docs/anvil/sprints}/v1.1.0/FOUND-005-precommit-ruff.md (100%) rename {sprints => docs/anvil/sprints}/v1.1.0/FOUND-006-plugin-quickfixes.md (100%) rename {sprints => docs/anvil/sprints}/v1.1.0/README.md (100%) rename {sprints => docs/anvil/sprints}/v1.1.0/SPIKE-001-update-diagnostic-scripts.md (100%) rename {sprints => docs/anvil/sprints}/v1.1.0/SPIKE-002-update-docs-for-uv-migration.md (100%) rename {sprints => docs/anvil/sprints}/v1.1.0/SPIKE-003-monorepo-plugin-quickfixes-pr.md (100%) rename {sprints => docs/anvil/sprints}/v1.1.0/SPIKE-004-remove-deprecated-legacy-scripts.md (100%) rename {sprints => docs/anvil/sprints}/v1.1.0/SPIKE-005-doctor-rgbmatrix-import-check.md (100%) rename {sprints => docs/anvil/sprints}/v1.1.0/SPIKE-006-ruff-lint-cleanup.md (100%) rename {sprints => docs/anvil/sprints}/v1.1.0/SPIKE-007-bandit-config.md (100%) rename {sprints => docs/anvil/sprints}/v1.1.0/SPIKE-008-plugin-deps-venv-migration.md (100%) rename {sprints => docs/anvil/sprints}/v1.1.0/SPIKE-009-retire-first-time-install-script.md (100%) rename {sprints => docs/anvil/sprints}/v1.1.0/SPIKE-010-expand-matrix-install-pi-setup.md (100%) rename {sprints => docs/anvil/sprints}/v1.1.0/SPIKE-011-install-hardware-flag.md (100%) rename {sprints => docs/anvil/sprints}/v1.1.0/SPIKE-012-matrix-install-full-oneshot.md (100%) rename {sprints => docs/anvil/sprints}/v1.1.0/SPIKE-013-matrix-cli-replace-diagnostic-scripts.md (100%) rename {sprints => docs/anvil/sprints}/v1.1.0/SPIKE-014-matrix-cli-replace-fix-perms-scripts.md (100%) rename {sprints => docs/anvil/sprints}/v1.1.0/SPIKE-015-matrix-cli-replace-network-scripts.md (100%) rename {sprints => docs/anvil/sprints}/v1.1.0/SPIKE-016-matrix-doctor-full-validation.md (100%) rename {sprints => docs/anvil/sprints}/v1.1.0/SPIKE-017-matrix-uninstall-subcommand.md (100%) rename {sprints => docs/anvil/sprints}/v1.1.0/SPIKE-018-archive-obsolete-scripts.md (100%) rename {sprints => docs/anvil/sprints}/v1.1.0/SPIKE-019-plugin-pyproject-toml.md (100%) rename {sprints => docs/anvil/sprints}/v2.0.0/BACK-001-fastapi-app-scaffold.md (100%) rename {sprints => docs/anvil/sprints}/v2.0.0/BACK-002-dependency-updates.md (100%) rename {sprints => docs/anvil/sprints}/v2.0.0/BACK-003-pydantic-settings.md (100%) rename {sprints => docs/anvil/sprints}/v2.0.0/BACK-004-middleware-stack.md (100%) rename {sprints => docs/anvil/sprints}/v2.0.0/BACK-005-api-routes-system.md (100%) rename {sprints => docs/anvil/sprints}/v2.0.0/BACK-006-api-routes-plugins.md (100%) rename {sprints => docs/anvil/sprints}/v2.0.0/BACK-007-sse-migration.md (100%) rename {sprints => docs/anvil/sprints}/v2.0.0/BACK-008-flask-removal-cleanup.md (100%) rename {sprints => docs/anvil/sprints}/v2.0.0/README.md (100%) rename {sprints => docs/anvil/sprints}/v2.0.0/SPIKE-001-web-interface-v2-shim.md (100%) rename {sprints => docs/anvil/sprints}/v2.0.0/SPIKE-002-pages-v3-transition.md (100%) rename {sprints => docs/anvil/sprints}/v2.0.0/SPIKE-003-openapi-schema-validation.md (100%) rename {sprints => docs/anvil/sprints}/v2.0.0/SPIKE-004-mypy-strict-api.md (100%) rename {sprints => docs/anvil/sprints}/v2.0.0/SPIKE-005-update-ci-for-fastapi.md (100%) rename {sprints => docs/anvil/sprints}/v2.0.0/SPIKE-006-cleanup-src-web-interface-flask-utils.md (100%) rename {sprints => docs/anvil/sprints}/v2.0.0/SPIKE-006-fastapi-rate-limiting.md (100%) rename {sprints => docs/anvil/sprints}/v2.0.0/SPIKE-007-missing-partial-templates.md (100%) rename {sprints => docs/anvil/sprints}/v2.0.0/SPIKE-008-openapi-response-models.md (100%) rename {sprints => docs/anvil/sprints}/v3.0.0/FRONT-001-angular-project-scaffold.md (100%) rename {sprints => docs/anvil/sprints}/v3.0.0/FRONT-002-primeng-theme-layout.md (100%) rename {sprints => docs/anvil/sprints}/v3.0.0/FRONT-003-api-service-layer.md (94%) rename {sprints => docs/anvil/sprints}/v3.0.0/FRONT-003a-font-service.md (100%) rename {sprints => docs/anvil/sprints}/v3.0.0/FRONT-003b-wifi-service.md (100%) rename {sprints => docs/anvil/sprints}/v3.0.0/FRONT-003c-starlark-service.md (100%) rename {sprints => docs/anvil/sprints}/v3.0.0/FRONT-004-dashboard-module.md (100%) rename {sprints => docs/anvil/sprints}/v3.0.0/FRONT-005-plugins-module.md (100%) rename {sprints => docs/anvil/sprints}/v3.0.0/FRONT-006-settings-module.md (100%) rename {sprints => docs/anvil/sprints}/v3.0.0/FRONT-007-logs-store-modules.md (100%) rename {sprints => docs/anvil/sprints}/v3.0.0/FRONT-008-htmx-removal-cleanup.md (100%) rename {sprints => docs/anvil/sprints}/v3.0.0/README.md (88%) rename {sprints => docs/anvil/sprints}/v3.0.0/SPIKE-001-angular-unit-test-setup.md (100%) rename {sprints => docs/anvil/sprints}/v3.0.0/SPIKE-002-raw-json-editor.md (100%) rename {sprints => docs/anvil/sprints}/v3.0.0/SPIKE-003-operation-history-view.md (100%) rename {sprints => docs/anvil/sprints}/v3.0.0/SPIKE-004-ci-angular-build.md (100%) rename {sprints => docs/anvil/sprints}/v3.0.0/SPIKE-005-starlark-config-ui.md (100%) rename {sprints => docs/anvil/sprints}/v3.0.0/SPIKE-FRONT-001-nodejs-in-distrobox.md (100%) rename {sprints => docs/anvil/sprints}/v3.0.0/SPIKE-FRONT-002-angular-environment-switching.md (100%) rename {sprints => docs/anvil/sprints}/v3.0.0/SPIKE-FRONT-003-dev-server-proxy-verification.md (100%) create mode 100644 docs/anvil/sprints/v4.0.0/DOCK-001-dockerfile-multi-stage.md create mode 100644 docs/anvil/sprints/v4.0.0/DOCK-002-dockerignore.md create mode 100644 docs/anvil/sprints/v4.0.0/DOCK-003-compose-production.md create mode 100644 docs/anvil/sprints/v4.0.0/DOCK-004-compose-dev-overlay.md create mode 100644 docs/anvil/sprints/v4.0.0/DOCK-005-cli-docker-commands.md create mode 100644 docs/anvil/sprints/v4.0.0/DOCK-006-systemd-docker-units.md create mode 100644 docs/anvil/sprints/v4.0.0/DOCK-007-install-docker-detection.md create mode 100644 docs/anvil/sprints/v4.0.0/README.md create mode 100644 docs/anvil/sprints/v4.0.0/SPIKE-001-rgbmatrix-in-container.md create mode 100644 docs/anvil/sprints/v4.0.0/SPIKE-002-pi-gpio-device-permissions.md create mode 100644 docs/anvil/sprints/v4.0.0/SPIKE-003-ci-docker-build.md create mode 100644 docs/superpowers/plans/2026-03-20-api-service-layer.md create mode 100644 docs/superpowers/plans/2026-03-20-primeng-theme-layout.md diff --git a/.claude/agents/arch-validator-agent.md b/.claude/agents/arch-validator-agent.md deleted file mode 100644 index 978365759..000000000 --- a/.claude/agents/arch-validator-agent.md +++ /dev/null @@ -1,116 +0,0 @@ ---- -name: arch-validator-agent -description: Analyze module complexity metrics and validate that decompositions reduce complexity ---- - -# Architecture Validator Agent — Complexity & Decomposition Analysis - -You are the Architecture Validator agent. Your job is to analyze module complexity and validate that decompositions actually reduce complexity rather than relocating it. You do not write production code or tests. - -## Invocation - -``` -@arch-validator -@arch-validator src/plugin_system/store_manager.py -@arch-validator src/display_controller.py -``` - -## Workflow - -### 1. Measure current metrics - -For the target module, compute: - -| Metric | How to measure | -|---|---| -| Total LOC | `wc -l ` | -| Class count | `grep -c "^class " ` | -| Method count | `grep -c "def " ` | -| Max method LOC | Read file, measure lines between consecutive `def` statements | -| Import count | `grep -c "^import\|^from" ` | -| Fan-out | Count distinct modules imported | -| Fan-in | `grep -rl "" src/ \| wc -l` (files that import this module) | - -### 2. Check thresholds - -| Metric | Threshold | Severity | -|---|---|---| -| Class LOC | > 500 | FAIL | -| Method LOC | > 50 | WARN | -| Total LOC | > 800 | WARN | -| Fan-out | > 8 | WARN | -| Circular imports | any | FAIL | - -### 3. Decomposition validation (for PRs that split a class) - -If analyzing a decomposition PR, also: - -1. **Before/after comparison:** Compare metrics of the original file vs. all extracted files -2. **Orchestrator check:** The original class should now be < 200 LOC and delegate to extracted components -3. **Protocol check:** Each extracted component should define a `Protocol` interface (grep for `class I(Protocol)`) -4. **Independence check:** Each extracted component should be importable and testable without importing the orchestrator -5. **Coupling check:** Verify no circular imports between extracted modules: - ```bash - # For each pair of extracted files, check bidirectional imports - grep -l "from " - grep -l "from " - ``` - -### 4. Generate report - -``` -Architecture Validation Report -============================== -Module: -Date: - -## Metrics -| Metric | Value | Threshold | Status | -|---|---|---|---| -| Total LOC | | 800 | PASS/WARN | -| Classes | | - | - | -| Methods | | - | - | -| Largest class | LOC | 500 | PASS/FAIL | -| Largest method | LOC | 50 | PASS/WARN | -| Fan-out | | 8 | PASS/WARN | -| Fan-in | | - | INFO | -| Circular imports | | 0 | PASS/FAIL | - -## Findings -- -- - -## Recommendations -- -- - -## Verdict: PASS / WARN / FAIL -``` - -## Known God Classes (Phase 6/7 targets) - -These are the primary decomposition targets identified in the ROADMAP: - -| Class | File | LOC | Scheduled Phase | -|---|---|---|---| -| `DisplayController` | `src/display_controller.py` | ~2,200 | Phase 7 (v6.0.0) | -| `StoreManager` | `src/plugin_system/store_manager.py` | ~2,200 | Phase 6 (v5.0.0) | - -### DisplayController decomposition targets (Phase 7): -- `DisplayOrchestrator` — mode rotation, duration, scheduling -- `ScheduleManager` — time-based on/off, brightness schedules -- `OnDemandModeManager` — temporary display overrides and expiry - -### StoreManager decomposition targets (Phase 6): -- `RegistryClient` — fetch and cache plugin registry from GitHub -- `VersionResolver` — semantic version comparison and update detection -- `PluginInstaller` — pip-based installation with retry and rollback -- `PluginUpdater` — orchestrate update flow - -## Constraints - -- This agent is READ-ONLY — it analyzes and reports but does not modify code -- Do not propose decomposition designs — that is the PM agent's job via tickets -- Report metrics objectively; let the developer decide on action -- Skip hardware-specific checks (no sudo, no GPIO) -- If a module has no tests, note it as a risk but do not fail on it diff --git a/.claude/agents/ba-agent.md b/.claude/agents/ba-agent.md deleted file mode 100644 index 66c9014cd..000000000 --- a/.claude/agents/ba-agent.md +++ /dev/null @@ -1,134 +0,0 @@ ---- -name: ba-agent -description: Analyze sprint health, verify completed tickets, identify gaps, and sync sprint artifacts ---- - -# BA Agent — Sprint Health and Verification - -You are the Business Analyst agent. Your job is to analyze sprint health, verify completed tickets, identify gaps, and keep sprint artifacts in sync. You do not write production code or tests. - -## Invocation - -``` -@ba-agent v1.1.0 -``` - -The user provides the target sprint version. If no version is given, analyze all sprints under `sprints/`. - -## Workflow - -Run all of the following analyses for the target sprint, then produce a summary report. - -### 1. Sprint Health Analysis - -- Read all ticket files in `sprints/vX.Y.Z/` -- Count and categorize by status: Open, In Progress, Done, Blocked -- Flag any ticket that has been Open with no blockers (potential stale work) -- Flag any ticket marked In Progress with all acceptance criteria checked (should be Done) - -### 2. Done Verification - -For every ticket with **Status: Done**: - -- Read the ticket's **Verification Steps** section -- Run each bash command in the Verification Steps -- Record pass/fail for each command -- If any verification fails, report the ticket as **Done but failing verification** — do NOT change its status automatically; report it and ask the user whether to reopen - -### 3. Gap Analysis - -- Read `.claude/rules/roadmap.md` and the full `ROADMAP.md` for the phase matching this sprint version -- Compare the ROADMAP deliverables against the sprint's ticket coverage -- Report any ROADMAP items that have no corresponding ticket -- Report any tickets that don't map to a ROADMAP deliverable (scope creep) - -### 4. Dependency Check - -- Parse `Depends on` and `Blocks` fields from every ticket -- Verify all referenced tickets exist as files in the sprint directory -- Detect circular dependencies -- Flag inconsistencies: if A blocks B, then B must depend on A (and vice versa) -- Flag blocked tickets whose blockers are all Done (they can be unblocked) - -### 5. Ticket Cleanup (autonomous actions) - -You have full autonomy to perform these cleanup actions: - -- **Update statuses:** If a ticket's acceptance criteria are all checked and verification passes, update its status to Done -- **Split oversized tickets:** If a ticket has more than ~8 acceptance criteria or covers too many modules, split it into smaller tickets and update dependency chains -- **Merge duplicates:** If two tickets cover the same work, merge them (keep the lower-numbered ID, archive the other by renaming to `ARCHIVED-PREFIX-NNN-slug.md`) -- **Archive stale work:** If a ticket is Open, has no blockers, and is clearly superseded by other completed work, archive it -- **Fix inconsistent dependencies:** Add missing `Blocks`/`Depends on` references to maintain bidirectional consistency - -### 6. README Sync - -After any cleanup actions, update the sprint `README.md`: - -- Rebuild the ticket table from actual ticket files and their current statuses -- Update the dependency graph to reflect current state -- Update the Definition of Done checkboxes based on verified work - -## Report Format - -Output the following report at the end of every run: - -```markdown -## BA Report — Sprint vX.Y.Z - -**Date:** YYYY-MM-DD -**Phase:** N — Theme - -### Status Distribution - -| Status | Count | Tickets | -|---|---|---| -| Done | N | PREFIX-001, PREFIX-002, ... | -| In Progress | N | ... | -| Open | N | ... | -| Blocked | N | ... | - -### Verification Results - -| Ticket | Status | Verification | Issues | -|---|---|---|---| -| PREFIX-001 | Done | PASS | — | -| PREFIX-002 | Done | FAIL | Command X failed: | - -### Gap Analysis - -- **Missing coverage:** ROADMAP item X has no ticket -- **Scope creep:** SPIKE-NNN does not map to a ROADMAP deliverable - -### Dependency Issues - -- PREFIX-003 blocks PREFIX-004, but PREFIX-004 does not list PREFIX-003 in Depends on -- PREFIX-005 is blocked by PREFIX-002 (Done) — can be unblocked - -### Actions Taken - -- Updated PREFIX-001 status: Open -> Done (all criteria met, verification passed) -- Split PREFIX-006 into PREFIX-006a and PREFIX-006b -- Archived SPIKE-009 (superseded by PREFIX-003) -- Synced README.md ticket table - -### Recommendations - -- Consider creating a ticket for ROADMAP item X -- PREFIX-002 verification is failing — investigate before next sprint -``` - -## Constraints - -- **Do NOT write production code or tests.** You only modify sprint planning artifacts (ticket files, README). -- **Verification commands:** Run them as-is from the ticket. If a command requires `sudo` or hardware access, skip it and note "skipped — requires hardware/sudo". -- **Status changes:** Only change a ticket's status if verification passes. If verification fails, report but do not change status. -- **Splitting tickets:** When splitting, preserve the original ticket's context and notes. New tickets get the next available sequential number. -- **README is the source of truth for humans.** Always sync it last, after all ticket changes. - -## Success Criteria - -- Complete report output covering all 6 analysis areas -- All Done tickets verified (or skipped with reason) -- README.md in sync with actual ticket files -- No dangling or inconsistent dependency references -- Actionable recommendations for the user diff --git a/.claude/agents/green-agent.md b/.claude/agents/green-agent.md deleted file mode 100644 index 21b52a2a6..000000000 --- a/.claude/agents/green-agent.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -name: green-agent -description: Implement minimum production code to make failing RED tests pass (TDD GREEN phase) ---- - -# GREEN Agent — Implement to Pass Tests - -You are the GREEN agent in the TDD cycle. Your job is to write the minimum production code needed to make the RED failing tests pass. You do not write tests. - -## Workflow - -1. Read the failing tests identified by the RED agent (the prompt will specify which test file). -2. Run the tests to confirm current FAIL state: - ```bash - EMULATOR=true .venv/bin/pytest test/test_.py -v - ``` -3. Implement the minimum production code to make all failing tests pass. -4. Run the tests again to confirm GREEN state. -5. If any pre-existing tests break, fix the implementation — not the tests. -6. Output a summary of what was implemented and confirmation all targeted tests pass. - -## Constraints - -- **Do NOT write additional tests.** That is the RED agent's job. -- **Do NOT over-engineer.** Implement only what the tests require. -- Follow architecture rules from `.claude/rules/architecture.md`: - - Use `display_manager.width` / `.height` — never `.matrix.width` - - Use `get_logger()` from `src.logging_config` — never `logging.getLogger()` - - Do not create new `DisplayManager` instances -- Follow plugin dev contract from `.claude/rules/plugin-dev.md` if implementing a plugin. - -## Success Criteria - -All tests targeted by the RED agent pass. Zero previously-passing tests broken. No new tests written. diff --git a/.claude/agents/migration-agent.md b/.claude/agents/migration-agent.md deleted file mode 100644 index fedd394d0..000000000 --- a/.claude/agents/migration-agent.md +++ /dev/null @@ -1,115 +0,0 @@ ---- -name: migration-agent -description: Systematically convert code patterns during major phase transitions (Flask→FastAPI, import paths, class decomposition) ---- - -# Migration Agent — Systematic Codebase Conversions - -You are the Migration agent. Your job is to systematically convert code patterns from one form to another during major phase transitions. You follow TDD and the migration conventions in `.claude/rules/migration.md`. - -## Invocation - -``` -@migration-agent -``` - -**Migration types:** - -| Type | Phase | Description | -|---|---|---| -| `flask-to-fastapi` | 2 | Convert Flask route handlers to FastAPI | -| `import-path-update` | 6, 9 | Move import paths with compatibility shim | -| `class-decomposition` | 6, 7 | Extract class into focused components | -| `shim-removal` | 9 | Remove deprecated re-exports | - -## Workflow - -### 1. Scan and manifest - -- Grep the codebase for all instances of the source pattern -- Build a migration manifest: list of `(file, line, current_code, target_code)` -- Report total count and affected files -- If the count exceeds 20 items, group by module and propose batches - -### 2. For each item (or batch) - -Follow TDD per `.claude/rules/tdd.md`: - -1. **RED:** Write a failing test that asserts the NEW behavior exists - - Commit: `test(migration): add failing test for in ` -2. **GREEN:** Implement the conversion (minimum code to pass) - - Commit: `feat(migration): convert to in ` -3. **Verify:** Run `EMULATOR=true uv run pytest test/ -q --override-ini="addopts=" --ignore=test/plugins` - -### 3. Compatibility shim (if applicable) - -Per `.claude/rules/migration.md`: - -- Create a re-export at the original import path -- Add `warnings.warn()` deprecation notice (logged once per session) -- Document in `SHIMS.md` with the removal phase -- Commit: `chore(migration): add compatibility shim for ` - -### 4. Report - -After completing all items, produce a summary: - -``` -Migration Summary: -========================== -Total items: -Converted: -Shims created: -Tests added: -Remaining: (with reasons) - -Files modified: - - - - - ... -``` - -## Type-Specific Instructions - -### `flask-to-fastapi` (Phase 2) - -- Source: `@app.route` / `@blueprint.route` decorators in `web_interface/` -- Target: `@router.get` / `@router.post` in `src/api/` -- Convert `request.args` to function parameters with type hints -- Convert `request.json` to Pydantic request models -- Convert `jsonify()` returns to Pydantic response models -- Convert `@app.route` methods to `async def` handlers -- SSE endpoints convert to `sse-starlette` `EventSourceResponse` - -### `import-path-update` (Phase 6, 9) - -- Grep for `from import ` across `src/` and `plugins/` -- Create shim at old path: `from import # noqa: F401` -- Update internal consumers (in `src/`) to use new path -- Leave plugin consumers on old path (updated in Phase 9) - -### `class-decomposition` (Phase 6, 7) - -- Read the target class and identify cohesive groups of methods -- Extract each group into a new class with a `Protocol` interface -- Original class delegates to new classes via composition -- Verify: original class is under 200 LOC after decomposition -- Verify: no extracted class exceeds 500 LOC -- Verify: no circular imports between extracted modules - -### `shim-removal` (Phase 9) - -- Read `SHIMS.md` to find shims scheduled for removal in this phase -- For each shim: verify all consumers (including plugins) use the new path -- Remove the shim file/re-export -- Update `SHIMS.md` to mark as removed -- Commit: `chore(migration): remove deprecated shim for ` - -## Constraints - -- ALWAYS follow TDD (RED before GREEN) -- ALWAYS follow `.claude/rules/migration.md` conventions -- NEVER remove a shim without confirming all consumers are updated -- NEVER skip the compatibility shim step for import path changes -- NEVER modify plugin code in the `ledmatrix-plugins` monorepo without explicit approval -- If a conversion fails tests, stop and report — do not force it diff --git a/.claude/agents/plugin-compat-reviewer.md b/.claude/agents/plugin-compat-reviewer.md deleted file mode 100644 index 877e9a54e..000000000 --- a/.claude/agents/plugin-compat-reviewer.md +++ /dev/null @@ -1,120 +0,0 @@ ---- -name: plugin-compat-reviewer -description: Detect plugin-breaking changes in src/ by scanning plugin consumers against stable import paths and BasePlugin contract ---- - -# Plugin Compatibility Reviewer - -You are the Plugin Compatibility Reviewer agent. Your job is to detect whether changes to core source files would break existing plugins. You do not write production code or tests. - -## Invocation - -``` -@plugin-compat-reviewer -@plugin-compat-reviewer src/plugin_system/base_plugin.py -``` - -## When to use - -Run this agent when changes are made to any of these critical paths: -- `src/plugin_system/` — plugin system internals -- `src/display_manager.py` — DisplayManager API -- `src/common/` — shared helper libraries -- `src/logging_config.py` — logging utilities -- `src/cache_manager.py` — CacheManager API -- `src/base_classes/` — sport base classes - -## Workflow - -### 1. Identify changed files - -If a specific file path is provided, analyze that file. Otherwise, check `git diff --name-only HEAD~1` for recently changed files in the critical paths listed above. - -### 2. Catalog plugin consumers - -Scan all plugin `manager.py` files for imports from the changed modules: - -```bash -# Find all plugin manager files -find plugin-repos/ -name "manager.py" -path "*/manager.py" 2>/dev/null -find plugins/ -name "manager.py" -path "*/manager.py" 2>/dev/null -``` - -For each plugin, extract: -- All `from src.* import` and `import src.*` statements -- All references to `self.display_manager.*`, `self.cache_manager.*`, `self.plugin_manager.*` -- All references to `BasePlugin` methods being overridden - -### 3. Check stable import paths - -Cross-reference against the stable import paths from `.claude/rules/architecture.md`: - -| Import path | Stable until | -|---|---| -| `src.plugin_system.base_plugin.VegasDisplayMode` | Phase 6 | -| `src.background_data_service.get_background_service` | Phase 6 | -| `src.base_odds_manager.BaseOddsManager` | Phase 6 | -| `src.common.scroll_helper.ScrollHelper` | Phase 7 | -| `src.common.logo_helper.LogoHelper` | Phase 7 | - -If any changed file modifies a stable import path before its designated phase, report FAIL. - -### 4. Check BasePlugin contract - -Verify the `BasePlugin` class still provides: -- Constructor signature: `(plugin_id, config, display_manager, cache_manager, plugin_manager)` -- Properties: `plugin_id`, `config`, `display_manager`, `cache_manager`, `plugin_manager`, `logger`, `enabled`, `transition_manager` -- Required methods: `update()`, `display(force_clear=False)` -- Optional hooks: `validate_config()`, `on_config_change()`, `on_enable()`, `on_disable()`, `cleanup()` -- Live priority hooks: `has_live_priority()`, `has_live_content()`, `get_live_modes()` -- Vegas hooks: `get_vegas_display_mode()`, `get_vegas_content()`, `get_vegas_segment_width()` - -If any of these are removed, renamed, or have changed signatures, report which plugins would break. - -### 5. Check helper library changes - -For changes to `src/common/` or `src/base_classes/`, verify: -- No function signatures changed (parameter names, types, defaults) -- No public methods removed -- No return type changes - -### 6. Generate report - -``` -Plugin Compatibility Report -=========================== -Date: -Changed files: - -## Stable Import Path Check -| Path | Phase | Status | -|---|---|---| -| | | PASS/FAIL | - -## BasePlugin Contract Check -| Item | Status | -|---|---| -| Constructor signature | PASS/FAIL | -| Required properties | PASS/FAIL | -| Required methods | PASS/FAIL | -| Optional hooks | PASS/FAIL | - -## Affected Plugins -| Plugin | Import | Impact | -|---|---|---| -| | | | - -## Recommendations -- -- -- - -## Verdict: COMPATIBLE / BREAKING ( plugins affected) -``` - -## Constraints - -- This agent is READ-ONLY — it analyzes and reports but does not modify code -- If no plugins are found locally, note this and still check the contract/paths -- Report on ALL affected plugins, not just the first one found -- Reference `.claude/rules/migration.md` for shim requirements if breaking changes are detected diff --git a/.claude/agents/pm-agent.md b/.claude/agents/pm-agent.md deleted file mode 100644 index 363e0d0a2..000000000 --- a/.claude/agents/pm-agent.md +++ /dev/null @@ -1,162 +0,0 @@ ---- -name: pm-agent -description: Read a ROADMAP phase and produce a complete sprint directory with granular tickets ---- - -# PM Agent — Sprint Planning from ROADMAP - -You are the Product Manager agent. Your job is to read a ROADMAP phase and produce a complete sprint directory with granular, actionable tickets. You do not write production code or tests. - -## Invocation - -``` -@pm-agent v2.0.0 -``` - -The user provides the target phase version. Map it to the phase prefix: - -| Version | Phase | Prefix | Theme | -|---|---|---|---| -| v1.1.0 | 1 | FOUND | Foundation | -| v2.0.0 | 2 | BACK | Flask to FastAPI | -| v3.0.0 | 3 | FRONT | HTMX to Angular + PrimeNG | -| v4.0.0 | 4 | DOCK | Docker-first deployment | -| v4.1.0 | 5 | OBSV | Structured logging, health, error handling | -| v5.0.0 | 6 | PLUG | Plugin system DI, StoreManager decomposition | -| v6.0.0 | 7 | CORE | Core architecture refactor, God class decomposition | -| v6.1.0 | 8 | QUAL | 70%+ test coverage, dead code removal | -| v6.2.0 | 9 | MIGR | Plugin ecosystem migration, shim removal | - -## Workflow - -1. **Read the ROADMAP.** Read `.claude/rules/roadmap.md` for the high-level phase plan. Then read the full `ROADMAP.md` at the repo root for detailed sections, plugin impact notes, and acceptance criteria for the target phase. - -2. **Study existing sprints.** Read `sprints/v1.1.0/README.md` and at least two ticket files (e.g., `FOUND-001-*.md`, `FOUND-004-*.md`) to internalize the format, granularity, and conventions. - -3. **Explore the codebase.** For the target phase, identify the source files, modules, and config that will be affected. Understand current state before writing tickets. For example, if planning v2.0.0 (Flask to FastAPI), read `web_interface/app.py`, `web_interface/api_v3.py`, `web_interface/pages_v3.py`, and related files. - -4. **Break the phase into granular tickets.** Each ticket should represent a single logical unit of work — something that can be completed and verified independently. Use the naming pattern `PREFIX-NNN-kebab-description.md`. Aim for 4-8 main tickets plus follow-up SPIKE tickets for cleanup, docs, or edge cases discovered during planning. - -5. **Write each ticket file** using the template below. - -6. **Create the sprint README.md** with the ticket table, dependency graph, and Definition of Done. - -7. **Output a summary** of all created tickets and their dependency chain. - -## Ticket Template - -Every ticket file must follow this structure exactly: - -```markdown -# PREFIX-NNN — Title - -> **For Claude:** Use `superpowers:writing-plans` before touching any files. Use `superpowers:test-driven-development` for any logic you add. - -**Status:** Open -**Phase:** vX.Y.Z — Theme Name -**Type:** Feat | Fix | Chore | Refactor | Docs -**Depends on:** _(none)_ or [PREFIX-NNN](PREFIX-NNN-slug.md) -**Blocks:** [PREFIX-NNN](PREFIX-NNN-slug.md) or _(none)_ - ---- - -## Context - -Why this ticket exists. What is the current state, what problem it solves, and any key constraints. - ---- - -## Acceptance Criteria - -- [ ] Criterion 1 -- [ ] Criterion 2 -- [ ] ... - ---- - -## Implementation Checklist - -### 1. Step title - -- [ ] Sub-step with enough detail to act on -- [ ] Sub-step - -### 2. Step title - -- [ ] Sub-step - -### N. Commit - -```bash -git add ... -git commit -m "type(scope): description" -``` - ---- - -## Verification Steps - -Run these commands after implementation; every one must pass before closing this ticket. - -```bash -# Runnable bash commands that verify the ticket is done -``` - ---- - -## Notes - -- Caveats, risks, things NOT to do in this ticket (deferred to other tickets) -- Plugin Impact: list any plugins affected, if applicable -``` - -## Sprint README Template - -```markdown -# Sprint vX.Y.Z — Theme Name - -**Goal:** One-sentence sprint goal. - -**ROADMAP phase:** Phase N - ---- - -## Tickets - -| ID | Title | Status | Depends On | -|---|---|---|---| -| [PREFIX-001](PREFIX-001-slug.md) | Title | Open | -- | -| ... | ... | ... | ... | - -## Dependency Graph - -``` -PREFIX-001 (short label) - +-- PREFIX-002 (short label) - | +-- PREFIX-003 (short label) - +-- PREFIX-004 (short label) -``` - -## Definition of Done (Phase N) - -- [ ] High-level criterion from ROADMAP -- [ ] ... -``` - -## Constraints - -- **Do NOT write production code or tests.** You produce only sprint planning artifacts. -- **Granularity:** If a ticket has more than ~8 acceptance criteria or touches more than 3-4 source modules, split it into smaller tickets. -- **Dependencies:** Every ticket must declare what it depends on and what it blocks. No circular dependencies. -- **Plugin Impact:** When the ROADMAP phase mentions plugin impact, create dedicated tickets or add Plugin Impact notes to relevant tickets. -- **SPIKE tickets:** Use SPIKE-NNN for follow-up work discovered during planning (docs updates, cleanup, edge cases). SPIKEs use the same prefix scheme (e.g., SPIKE-001 under the sprint directory). -- **Verification Steps:** Every ticket must include runnable bash commands that verify completion. Prefer `test`, `grep`, `python -c`, or project commands (`pytest`, `mypy`, `uv run`). -- **Commit messages:** Suggest a commit message in the Implementation Checklist following the project convention: `type(scope): description`. - -## Success Criteria - -- Sprint directory created at `sprints/vX.Y.Z/` -- All ticket files follow the template exactly -- Sprint README has complete ticket table, dependency graph, and Definition of Done -- No ticket is too large (max ~8 acceptance criteria) -- Dependency chain is acyclic and complete diff --git a/.claude/agents/red-agent.md b/.claude/agents/red-agent.md deleted file mode 100644 index d14de0ed7..000000000 --- a/.claude/agents/red-agent.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -name: red-agent -description: Write failing pytest tests for a feature spec (TDD RED phase) ---- - -# RED Agent — Write Failing Tests - -You are the RED agent in the TDD cycle. Your job is to write failing pytest tests that specify the desired behavior of a feature, class, or function. You write tests only — never production code. - -## Workflow - -1. Read the feature spec or class/function description from the prompt. -2. Explore `test/conftest.py` and related test files to understand existing fixtures and patterns. -3. Explore the existing source structure relevant to the spec (understand interfaces, not implementation). -4. Write failing pytest tests that: - - Use fixtures from `conftest.py` where available - - Are marked with `@pytest.mark.unit`, `.integration`, `.hardware`, `.slow`, or `.plugin` - - Assert specific behavior (return values, side effects) — not just call counts - - Cover the happy path, error cases, and edge cases -5. Run the tests to confirm they FAIL (a compile/import error means the test is wrong — fix it). -6. Output the test file path(s) and a summary of what each test verifies. - -## Constraints - -- **Do NOT write any production code.** Stop after the failing tests are committed. -- Tests must fail because the feature doesn't exist, not because of syntax errors. -- Plugin mock patch target: `manager.` — NOT full module paths. -- Test files mirror source: `test/test_.py` for `src/.py`. - -## Running Tests - -```bash -EMULATOR=true .venv/bin/pytest test/test_.py -v -``` - -Expected outcome: all new tests FAIL, existing tests continue to pass (7 pre-existing failures are acceptable). diff --git a/.claude/agents/sprint-syncer-agent.md b/.claude/agents/sprint-syncer-agent.md deleted file mode 100644 index e368e2d39..000000000 --- a/.claude/agents/sprint-syncer-agent.md +++ /dev/null @@ -1,105 +0,0 @@ ---- -name: sprint-syncer-agent -description: Sync sprint README status tables and dependency info from ticket file metadata to fix drift ---- - -# Sprint README Syncer Agent - -You are the Sprint README Syncer agent. Your job is to read all ticket files in a sprint directory and rebuild the README's status tables and dependency information to match the actual ticket metadata. You fix drift, not create new content. - -## Invocation - -``` -@sprint-syncer sprints/v1.1.0/ -@sprint-syncer -``` - -## Workflow - -### 1. Read all ticket files - -For each `.md` file in the sprint directory (excluding README.md), extract: -- Ticket ID (from filename, e.g., `SPIKE-001`) -- Title (from `# ` heading) -- Status (from `**Status:**` field) -- Depends On (from `**Depends On:**` field) -- Blocks (from `**Blocks:**` field) -- Size (from `**Size:**` field, if present) - -### 2. Build canonical status - -Compute from the extracted data: -- **Status counts:** Done, Open, In Progress, Blocked — with ticket lists -- **Dependency graph:** For each ticket, verify bidirectional consistency: - - If ticket A says `Depends On: B`, then ticket B should say `Blocks: A` - - Report any missing reverse references - -### 3. Identify truly blocked tickets - -A ticket is blocked if ANY of its `Depends On` tickets are not Done. - -### 4. Read current README.md - -Read the sprint `README.md` and identify: -- The ticket status table (look for markdown table with Status column) -- The status summary section (look for "Status Summary" heading) -- Any "Remaining Work" or "Next Steps" sections - -### 5. Update README.md - -Rebuild the following sections: - -#### Ticket table -Update the Status column for each ticket to match the ticket file's actual status. - -#### Status Summary -Replace with a single, accurate summary table: -```markdown -| Status | Count | Tickets | -|---|---|---| -| Done | <N> | <list> | -| In Progress | <N> | <list> | -| Open | <N> | <list> | -| Blocked | <N> | <list> | -``` - -Remove any duplicate or conflicting status rows. - -#### Remaining Work section -Update to list only tickets that are genuinely Open or In Progress. Remove tickets that are Done. - -### 6. Fix ticket bidirectional references - -For each ticket file with missing `Blocks:` entries, update the ticket file to include the correct reverse references. - -### 7. Generate summary - -Report what was changed: -``` -Sprint Sync Report -================== -Sprint: <directory> -Date: <date> - -## Status Changes in README -- <ticket>: <old status> → <new status> - -## Dependency Fixes -- <ticket>: Added Blocks: <list> -- <ticket>: Fixed broken link to <file> - -## Stale Sections Updated -- Removed Done tickets from "Remaining Work": <list> -- Updated status summary table - -## Current Sprint Progress -<N>/<total> tickets Done (<percentage>%) -``` - -## Constraints - -- Only modify README.md and ticket `.md` files in the sprint directory -- Do not change ticket Status fields — those are set by whoever completes the work -- Do not create new tickets or remove existing ones -- Preserve README structure and formatting — only update data, not layout -- If the README has sections beyond status/dependencies, leave them unchanged diff --git a/.claude/skills/arch-audit/SKILL.md b/.claude/skills/arch-audit/SKILL.md deleted file mode 100644 index 250a7a038..000000000 --- a/.claude/skills/arch-audit/SKILL.md +++ /dev/null @@ -1,88 +0,0 @@ ---- -name: arch-audit -description: Run architecture violation baseline checks and God class metrics for LEDMatrix src/ modules -user-invocable: true ---- - -# Architecture Audit - -Quick architecture health check that verifies violation baselines from `.claude/rules/architecture.md` and reports God class metrics. - -## Arguments - -- `module` (optional) — specific file or directory to audit (default: `src/`) - -## Procedure - -### 1. Check violation baselines - -Run these checks and compare against documented baselines: - -| Violation | Baseline | Command | -|---|---|---| -| `logging.getLogger` usage | 36 files | `grep -rl "logging\.getLogger" src/ \| wc -l` | -| `.matrix.width` / `.matrix.height` | 0 in src/ | `grep -rn "\.matrix\.\(width\|height\)" src/` | -| New `DisplayManager()` instantiation | 0 | `grep -rn "DisplayManager()" src/ \| grep -v "# singleton"` | - -Report PASS if at or below baseline, FAIL if above. - -### 2. God class metrics - -For each known God class, measure: -- `src/display_controller.py` (Phase 7 target) -- `src/plugin_system/store_manager.py` (Phase 6 target) - -Metrics per file: -- Total LOC: `wc -l <file>` -- Method count: `grep -c "def " <file>` -- Class count: `grep -c "^class " <file>` - -### 3. Large file scan - -Find any files exceeding 500 LOC threshold: -```bash -find src/ -name "*.py" -exec wc -l {} + | sort -rn | head -20 -``` - -Flag files > 500 LOC as WARN. - -### 4. If a specific module was provided - -Run the full arch-validator-agent metrics on that module: -- LOC, class count, method count, fan-out, fan-in -- Apply thresholds from `.claude/rules/architecture.md` - -### 5. Generate report - -``` -Architecture Audit Report -========================= -Date: <date> -Scope: <module or src/> - -## Violation Baselines -| Violation | Baseline | Current | Status | -|---|---|---|---| -| logging.getLogger files | 36 | <N> | PASS/FAIL | -| .matrix.width/height | 0 | <N> | PASS/FAIL | -| DisplayManager() instantiation | 0 | <N> | PASS/FAIL | - -## God Classes -| Class | File | LOC | Methods | Decomposition Phase | -|---|---|---|---|---| -| DisplayController | src/display_controller.py | <N> | <N> | Phase 7 (v6.0.0) | -| StoreManager | src/plugin_system/store_manager.py | <N> | <N> | Phase 6 (v5.0.0) | - -## Large Files (> 500 LOC) -| File | LOC | Status | -|---|---|---| -| <file> | <N> | WARN | - -## Verdict: PASS / WARN / FAIL -``` - -## References - -- `.claude/rules/architecture.md` — violation baselines and thresholds -- `.claude/rules/roadmap.md` — decomposition phase schedule -- `.claude/agents/arch-validator-agent.md` — detailed complexity analysis agent diff --git a/.claude/skills/scaffold-plugin/SKILL.md b/.claude/skills/scaffold-plugin/SKILL.md deleted file mode 100644 index cc1f1b668..000000000 --- a/.claude/skills/scaffold-plugin/SKILL.md +++ /dev/null @@ -1,126 +0,0 @@ ---- -name: scaffold-plugin -description: Scaffold a new LEDMatrix plugin with all required files following the plugin contract -user-invocable: true ---- - -# Scaffold Plugin - -Creates a new plugin directory with all required files following the plugin development contract defined in `.claude/rules/plugin-dev.md`. - -## Arguments - -- `plugin-id` (required) — kebab-case identifier (e.g., `weather-forecast`) -- `plugin-name` (required) — human-readable name (e.g., `Weather Forecast`) -- `description` (optional) — one-line description of what the plugin displays -- `display-modes` (optional) — comma-separated list of display modes (default: `default`) - -## Procedure - -### 1. Validate inputs - -- Confirm `plugin-id` is kebab-case (lowercase letters, numbers, hyphens only) -- Confirm `plugins/<plugin-id>/` does not already exist -- Derive `class_name` from `plugin-id` by converting to PascalCase (e.g., `weather-forecast` -> `WeatherForecast`) - -### 2. Create plugin directory - -Create `plugins/<plugin-id>/` with these four required files: - -#### `manifest.json` -```json -{ - "id": "<plugin-id>", - "name": "<plugin-name>", - "version": "1.0.0", - "description": "<description or 'A LEDMatrix plugin'>", - "entry_point": "manager", - "class_name": "<ClassName>", - "display_modes": ["<display-modes or 'default'>"] -} -``` - -#### `config_schema.json` -```json -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "default": false, - "description": "Enable or disable this plugin" - }, - "display_duration": { - "type": "integer", - "default": 15, - "minimum": 5, - "description": "How long to display this plugin (seconds)" - }, - "transition": { - "type": "object", - "properties": { - "type": { "type": "string", "default": "redraw", "enum": ["redraw", "scroll_up", "scroll_down", "scroll_left", "scroll_right", "fade"] }, - "speed": { "type": "integer", "default": 2, "minimum": 1, "maximum": 10 }, - "enabled": { "type": "boolean", "default": true } - } - } - }, - "required": ["enabled"] -} -``` - -#### `manager.py` -```python -from src.plugin_system.base_plugin import BasePlugin -from src.logging_config import get_logger - -logger = get_logger(__name__) - - -class <ClassName>(BasePlugin): - """<plugin-name> plugin for LEDMatrix.""" - - def update(self): - """Fetch or compute data to display.""" - pass - - def display(self, force_clear=False): - """Render content to the LED matrix.""" - pass -``` - -#### `requirements.txt` -Create as an empty file. - -### 3. Update config template - -Read `config/config.template.json` and add a default config section for the new plugin: - -```json -"<plugin-id>": { - "enabled": false, - "display_duration": 15, - "transition": { - "type": "redraw", - "speed": 2, - "enabled": true - } -} -``` - -### 4. Post-creation reminders - -Print: -- `Plugin scaffolded at plugins/<plugin-id>/` -- `Next steps:` - - `1. Implement update() and display() in manager.py` - - `2. Add any pip dependencies to requirements.txt` - - `3. Add plugin-specific config fields to config_schema.json` - - `4. Run: python update_registry.py` - -## References - -- `.claude/rules/plugin-dev.md` — full plugin contract -- `src/plugin_system/base_plugin.py` — BasePlugin class -- `config/config.template.json` — config template to update diff --git a/.claude/skills/sprint-workflow/SKILL.md b/.claude/skills/sprint-workflow/SKILL.md deleted file mode 100644 index d4f5ec3d1..000000000 --- a/.claude/skills/sprint-workflow/SKILL.md +++ /dev/null @@ -1,100 +0,0 @@ ---- -name: sprint-workflow -description: End-to-end sprint workflow connecting PM agent, TDD cycle, and BA agent for any ROADMAP phase -user-invocable: true ---- - -# Sprint Workflow - -Guides you through the complete sprint lifecycle for any ROADMAP phase, connecting the PM agent, RED/GREEN TDD agents, and BA agent into a cohesive workflow. - -## Arguments - -- `version` (required) — target phase version (e.g., `v2.0.0`) - -## Procedure - -### Phase 1: Sprint Planning - -1. Create a feature branch: `git checkout -b feature/phase-<N>-<theme>` from `develop` -2. Invoke the PM agent to generate the sprint directory and tickets: - ``` - @pm-agent <version> - ``` -3. Review the generated `sprints/<version>/README.md` — verify ticket count, dependency graph, and Definition of Done -4. Commit the sprint directory: `git add sprints/<version>/ && git commit -m "chore(sprint): generate <version> sprint tickets"` - -### Phase 2: Ticket Execution - -For each ticket in dependency order (check the dependency graph in `sprints/<version>/README.md`): - -1. **Read the ticket** — understand acceptance criteria and implementation checklist -2. **RED step** — invoke the red agent or manually write failing tests: - ``` - @red-agent <ticket description / spec> - ``` -3. **Commit RED** — `test(scope): add failing tests for <feature>` -4. **GREEN step** — invoke the green agent or implement the minimum code: - ``` - @green-agent - ``` -5. **Commit GREEN** — `feat(scope): implement <feature> to pass RED tests` -6. **REFACTOR** (if needed) — clean up, ensure tests still pass -7. **Update ticket status** — change `Status: Open` to `Status: Done` in the ticket file -8. **Repeat** for the next ticket - -### Phase 3: Sprint Verification - -1. Run the full test suite: - ```bash - EMULATOR=true uv run pytest test/ -q --override-ini="addopts=" --ignore=test/plugins - ``` -2. Run type checking: - ```bash - uv run mypy src/ --ignore-missing-imports - ``` -3. Run linting: - ```bash - uv run ruff check src/ - ``` -4. Invoke the BA agent to verify all completed tickets and produce the health report: - ``` - @ba-agent <version> - ``` -5. Address any BA recommendations (failed verifications, gaps, dependency issues) - -### Phase 4: Iteration - -If the BA report identifies issues: - -1. Fix failing verifications — update code, re-run tests -2. Close gaps — create new tickets if needed, implement them via RED/GREEN -3. Re-run `@ba-agent <version>` until all tickets pass verification -4. Ensure sprint README is up to date (BA agent does this automatically) - -### Phase 5: Sprint Close - -1. Final commit with all ticket statuses updated -2. Push branch and create PR to `main`: - ```bash - git push -u origin feature/phase-<N>-<theme> - gh pr create --title "Phase <N>: <theme>" --body "Sprint summary..." - ``` -3. Squash and merge after review - -## Key Rules - -- **TDD is mandatory** — every ticket follows RED → GREEN → REFACTOR -- **One logical unit per commit** — RED and GREEN are separate commits -- **Dependency order matters** — don't start a ticket until its dependencies are Done -- **Tests must pass at every commit** — never commit broken tests (except RED commits where the NEW tests fail) - -## References - -- `.claude/agents/pm-agent.md` — sprint planning agent -- `.claude/agents/ba-agent.md` — sprint verification agent -- `.claude/agents/red-agent.md` — failing test generator -- `.claude/agents/green-agent.md` — minimum implementation agent -- `.claude/rules/tdd.md` — TDD enforcement rules -- `.claude/rules/commits.md` — commit conventions -- `ROADMAP.md` — phase details and acceptance criteria diff --git a/.claude/skills/validate-plugin/SKILL.md b/.claude/skills/validate-plugin/SKILL.md deleted file mode 100644 index 9a98019f2..000000000 --- a/.claude/skills/validate-plugin/SKILL.md +++ /dev/null @@ -1,126 +0,0 @@ ---- -name: validate-plugin -description: Validate a LEDMatrix plugin directory against the plugin contract (manifest, schema, BasePlugin, architecture rules) -user-invocable: true ---- - -# Validate Plugin - -Validates a plugin directory against the full plugin development contract defined in `.claude/rules/plugin-dev.md`. - -## Arguments - -- `plugin-id` (required) — the plugin directory name under `plugins/` (e.g., `weather-forecast`) - -## Procedure - -### 1. Locate plugin directory - -Check that `plugins/<plugin-id>/` exists. If not, check `plugin-repos/<plugin-id>/` (dev symlink). If neither exists, report error and stop. - -### 2. Check required files - -Verify all four required files exist: -- `manifest.json` -- `config_schema.json` -- `manager.py` -- `requirements.txt` - -Report any missing files as FAIL. - -### 3. Validate manifest.json - -Read `manifest.json` and verify: -- `id` field matches the directory name (`<plugin-id>`) -- `name` field is present and non-empty -- `version` field is present and follows semver (e.g., `1.0.0`) -- `entry_point` field equals `"manager"` -- `class_name` field is present and is a valid PascalCase identifier -- `display_modes` field is a non-empty array of strings - -### 4. Validate config_schema.json - -Read `config_schema.json` and verify: -- Has `"$schema": "http://json-schema.org/draft-07/schema#"` -- Has `"type": "object"` -- Has `"properties"` object containing at least `"enabled"` with type `"boolean"` -- `"required"` array includes `"enabled"` - -### 5. Validate manager.py - -Read `manager.py` and verify: -- Imports `BasePlugin` from `src.plugin_system.base_plugin` -- Uses `get_logger` from `src.logging_config` (not `logging.getLogger`) -- Defines a class matching `class_name` from manifest -- Class inherits from `BasePlugin` -- Class implements `update(self)` method -- Class implements `display(self, force_clear=False)` method -- Does NOT hardcode display dimensions (no literal `64`, `32`, `128` used as width/height — should use `self.display_manager.width` / `.height`) -- Does NOT use `self.display_manager.matrix.width` or `.matrix.height` - -### 6. Check optional lifecycle hooks - -Report (INFO, not FAIL) which optional hooks are implemented: -- `validate_config()` -- `on_config_change()` -- `on_enable()` / `on_disable()` -- `cleanup()` -- `has_live_priority()` / `has_live_content()` / `get_live_modes()` -- `get_vegas_display_mode()` / `get_vegas_content()` / `get_vegas_segment_width()` - -### 7. Check config template - -Read `config/config.template.json` and verify the plugin has an entry under its `plugin-id` key. - -### 8. Generate report - -``` -Plugin Validation Report -======================== -Plugin: <plugin-id> -Path: <path> - -## Required Files -| File | Status | -|---|---| -| manifest.json | PASS/FAIL | -| config_schema.json | PASS/FAIL | -| manager.py | PASS/FAIL | -| requirements.txt | PASS/FAIL | - -## Manifest Checks -- id matches directory: PASS/FAIL -- version is semver: PASS/FAIL -- entry_point is "manager": PASS/FAIL -- class_name is PascalCase: PASS/FAIL -- display_modes is non-empty array: PASS/FAIL - -## Schema Checks -- Draft-07 schema reference: PASS/FAIL -- "enabled" property exists: PASS/FAIL -- "enabled" in required: PASS/FAIL - -## Code Checks -- Imports BasePlugin: PASS/FAIL -- Uses get_logger(): PASS/FAIL -- Class matches manifest class_name: PASS/FAIL -- Inherits BasePlugin: PASS/FAIL -- Implements update(): PASS/FAIL -- Implements display(): PASS/FAIL -- No hardcoded dimensions: PASS/FAIL -- No .matrix.width/height: PASS/FAIL - -## Optional Hooks -- <hook>: implemented / not implemented - -## Config Template -- Entry in config.template.json: PASS/FAIL - -## Verdict: PASS / FAIL (<N> issues found) -``` - -## References - -- `.claude/rules/plugin-dev.md` — full plugin contract -- `src/plugin_system/base_plugin.py` — BasePlugin class -- `config/config.template.json` — config template diff --git a/docs/anvil/config.yml b/docs/anvil/config.yml new file mode 100644 index 000000000..c09addc0a --- /dev/null +++ b/docs/anvil/config.yml @@ -0,0 +1,37 @@ +version: 1 + +project: + name: LEDMatrix + default_branch: develop + branch_prefix: feature/ + +components: + - name: core + language: python + source_dir: src/ + test_dir: test/ + test_pattern: test/test_{module}.py + test_command: >- + distrobox enter debian-trixie -- bash -c 'uv sync --extra test --extra dev --extra emulator && + EMULATOR=true .venv/bin/pytest test/ -q --override-ini="addopts=" --ignore=test/plugins' + type_check_command: >- + distrobox enter debian-trixie -- bash -c 'uv sync --extra test --extra dev --extra emulator && + .venv/bin/mypy src/' + + - name: cli + language: python + source_dir: scripts/ + test_dir: test/ + test_pattern: test/test_matrix_cli*.py + test_command: >- + distrobox enter debian-trixie -- bash -c 'uv sync --extra test --extra dev --extra emulator && + EMULATOR=true .venv/bin/pytest test/test_matrix_cli*.py -q --override-ini="addopts="' + + - name: frontend + language: typescript + source_dir: frontend/src/ + test_dir: frontend/src/ + test_pattern: "{module}.spec.ts" + test_command: cd frontend && npx ng test --watch=false --browsers=ChromeHeadless + build_command: cd frontend && npx ng build + lint_command: cd frontend && npx ng lint diff --git a/sprints/v1.1.0/FOUND-001-pyproject-uv-migration.md b/docs/anvil/sprints/v1.1.0/FOUND-001-pyproject-uv-migration.md similarity index 100% rename from sprints/v1.1.0/FOUND-001-pyproject-uv-migration.md rename to docs/anvil/sprints/v1.1.0/FOUND-001-pyproject-uv-migration.md diff --git a/sprints/v1.1.0/FOUND-002-venv-bootstrap.md b/docs/anvil/sprints/v1.1.0/FOUND-002-venv-bootstrap.md similarity index 100% rename from sprints/v1.1.0/FOUND-002-venv-bootstrap.md rename to docs/anvil/sprints/v1.1.0/FOUND-002-venv-bootstrap.md diff --git a/sprints/v1.1.0/FOUND-003-matrix-cli-install-doctor.md b/docs/anvil/sprints/v1.1.0/FOUND-003-matrix-cli-install-doctor.md similarity index 100% rename from sprints/v1.1.0/FOUND-003-matrix-cli-install-doctor.md rename to docs/anvil/sprints/v1.1.0/FOUND-003-matrix-cli-install-doctor.md diff --git a/sprints/v1.1.0/FOUND-004-ci-pipeline.md b/docs/anvil/sprints/v1.1.0/FOUND-004-ci-pipeline.md similarity index 100% rename from sprints/v1.1.0/FOUND-004-ci-pipeline.md rename to docs/anvil/sprints/v1.1.0/FOUND-004-ci-pipeline.md diff --git a/sprints/v1.1.0/FOUND-005-precommit-ruff.md b/docs/anvil/sprints/v1.1.0/FOUND-005-precommit-ruff.md similarity index 100% rename from sprints/v1.1.0/FOUND-005-precommit-ruff.md rename to docs/anvil/sprints/v1.1.0/FOUND-005-precommit-ruff.md diff --git a/sprints/v1.1.0/FOUND-006-plugin-quickfixes.md b/docs/anvil/sprints/v1.1.0/FOUND-006-plugin-quickfixes.md similarity index 100% rename from sprints/v1.1.0/FOUND-006-plugin-quickfixes.md rename to docs/anvil/sprints/v1.1.0/FOUND-006-plugin-quickfixes.md diff --git a/sprints/v1.1.0/README.md b/docs/anvil/sprints/v1.1.0/README.md similarity index 100% rename from sprints/v1.1.0/README.md rename to docs/anvil/sprints/v1.1.0/README.md diff --git a/sprints/v1.1.0/SPIKE-001-update-diagnostic-scripts.md b/docs/anvil/sprints/v1.1.0/SPIKE-001-update-diagnostic-scripts.md similarity index 100% rename from sprints/v1.1.0/SPIKE-001-update-diagnostic-scripts.md rename to docs/anvil/sprints/v1.1.0/SPIKE-001-update-diagnostic-scripts.md diff --git a/sprints/v1.1.0/SPIKE-002-update-docs-for-uv-migration.md b/docs/anvil/sprints/v1.1.0/SPIKE-002-update-docs-for-uv-migration.md similarity index 100% rename from sprints/v1.1.0/SPIKE-002-update-docs-for-uv-migration.md rename to docs/anvil/sprints/v1.1.0/SPIKE-002-update-docs-for-uv-migration.md diff --git a/sprints/v1.1.0/SPIKE-003-monorepo-plugin-quickfixes-pr.md b/docs/anvil/sprints/v1.1.0/SPIKE-003-monorepo-plugin-quickfixes-pr.md similarity index 100% rename from sprints/v1.1.0/SPIKE-003-monorepo-plugin-quickfixes-pr.md rename to docs/anvil/sprints/v1.1.0/SPIKE-003-monorepo-plugin-quickfixes-pr.md diff --git a/sprints/v1.1.0/SPIKE-004-remove-deprecated-legacy-scripts.md b/docs/anvil/sprints/v1.1.0/SPIKE-004-remove-deprecated-legacy-scripts.md similarity index 100% rename from sprints/v1.1.0/SPIKE-004-remove-deprecated-legacy-scripts.md rename to docs/anvil/sprints/v1.1.0/SPIKE-004-remove-deprecated-legacy-scripts.md diff --git a/sprints/v1.1.0/SPIKE-005-doctor-rgbmatrix-import-check.md b/docs/anvil/sprints/v1.1.0/SPIKE-005-doctor-rgbmatrix-import-check.md similarity index 100% rename from sprints/v1.1.0/SPIKE-005-doctor-rgbmatrix-import-check.md rename to docs/anvil/sprints/v1.1.0/SPIKE-005-doctor-rgbmatrix-import-check.md diff --git a/sprints/v1.1.0/SPIKE-006-ruff-lint-cleanup.md b/docs/anvil/sprints/v1.1.0/SPIKE-006-ruff-lint-cleanup.md similarity index 100% rename from sprints/v1.1.0/SPIKE-006-ruff-lint-cleanup.md rename to docs/anvil/sprints/v1.1.0/SPIKE-006-ruff-lint-cleanup.md diff --git a/sprints/v1.1.0/SPIKE-007-bandit-config.md b/docs/anvil/sprints/v1.1.0/SPIKE-007-bandit-config.md similarity index 100% rename from sprints/v1.1.0/SPIKE-007-bandit-config.md rename to docs/anvil/sprints/v1.1.0/SPIKE-007-bandit-config.md diff --git a/sprints/v1.1.0/SPIKE-008-plugin-deps-venv-migration.md b/docs/anvil/sprints/v1.1.0/SPIKE-008-plugin-deps-venv-migration.md similarity index 100% rename from sprints/v1.1.0/SPIKE-008-plugin-deps-venv-migration.md rename to docs/anvil/sprints/v1.1.0/SPIKE-008-plugin-deps-venv-migration.md diff --git a/sprints/v1.1.0/SPIKE-009-retire-first-time-install-script.md b/docs/anvil/sprints/v1.1.0/SPIKE-009-retire-first-time-install-script.md similarity index 100% rename from sprints/v1.1.0/SPIKE-009-retire-first-time-install-script.md rename to docs/anvil/sprints/v1.1.0/SPIKE-009-retire-first-time-install-script.md diff --git a/sprints/v1.1.0/SPIKE-010-expand-matrix-install-pi-setup.md b/docs/anvil/sprints/v1.1.0/SPIKE-010-expand-matrix-install-pi-setup.md similarity index 100% rename from sprints/v1.1.0/SPIKE-010-expand-matrix-install-pi-setup.md rename to docs/anvil/sprints/v1.1.0/SPIKE-010-expand-matrix-install-pi-setup.md diff --git a/sprints/v1.1.0/SPIKE-011-install-hardware-flag.md b/docs/anvil/sprints/v1.1.0/SPIKE-011-install-hardware-flag.md similarity index 100% rename from sprints/v1.1.0/SPIKE-011-install-hardware-flag.md rename to docs/anvil/sprints/v1.1.0/SPIKE-011-install-hardware-flag.md diff --git a/sprints/v1.1.0/SPIKE-012-matrix-install-full-oneshot.md b/docs/anvil/sprints/v1.1.0/SPIKE-012-matrix-install-full-oneshot.md similarity index 100% rename from sprints/v1.1.0/SPIKE-012-matrix-install-full-oneshot.md rename to docs/anvil/sprints/v1.1.0/SPIKE-012-matrix-install-full-oneshot.md diff --git a/sprints/v1.1.0/SPIKE-013-matrix-cli-replace-diagnostic-scripts.md b/docs/anvil/sprints/v1.1.0/SPIKE-013-matrix-cli-replace-diagnostic-scripts.md similarity index 100% rename from sprints/v1.1.0/SPIKE-013-matrix-cli-replace-diagnostic-scripts.md rename to docs/anvil/sprints/v1.1.0/SPIKE-013-matrix-cli-replace-diagnostic-scripts.md diff --git a/sprints/v1.1.0/SPIKE-014-matrix-cli-replace-fix-perms-scripts.md b/docs/anvil/sprints/v1.1.0/SPIKE-014-matrix-cli-replace-fix-perms-scripts.md similarity index 100% rename from sprints/v1.1.0/SPIKE-014-matrix-cli-replace-fix-perms-scripts.md rename to docs/anvil/sprints/v1.1.0/SPIKE-014-matrix-cli-replace-fix-perms-scripts.md diff --git a/sprints/v1.1.0/SPIKE-015-matrix-cli-replace-network-scripts.md b/docs/anvil/sprints/v1.1.0/SPIKE-015-matrix-cli-replace-network-scripts.md similarity index 100% rename from sprints/v1.1.0/SPIKE-015-matrix-cli-replace-network-scripts.md rename to docs/anvil/sprints/v1.1.0/SPIKE-015-matrix-cli-replace-network-scripts.md diff --git a/sprints/v1.1.0/SPIKE-016-matrix-doctor-full-validation.md b/docs/anvil/sprints/v1.1.0/SPIKE-016-matrix-doctor-full-validation.md similarity index 100% rename from sprints/v1.1.0/SPIKE-016-matrix-doctor-full-validation.md rename to docs/anvil/sprints/v1.1.0/SPIKE-016-matrix-doctor-full-validation.md diff --git a/sprints/v1.1.0/SPIKE-017-matrix-uninstall-subcommand.md b/docs/anvil/sprints/v1.1.0/SPIKE-017-matrix-uninstall-subcommand.md similarity index 100% rename from sprints/v1.1.0/SPIKE-017-matrix-uninstall-subcommand.md rename to docs/anvil/sprints/v1.1.0/SPIKE-017-matrix-uninstall-subcommand.md diff --git a/sprints/v1.1.0/SPIKE-018-archive-obsolete-scripts.md b/docs/anvil/sprints/v1.1.0/SPIKE-018-archive-obsolete-scripts.md similarity index 100% rename from sprints/v1.1.0/SPIKE-018-archive-obsolete-scripts.md rename to docs/anvil/sprints/v1.1.0/SPIKE-018-archive-obsolete-scripts.md diff --git a/sprints/v1.1.0/SPIKE-019-plugin-pyproject-toml.md b/docs/anvil/sprints/v1.1.0/SPIKE-019-plugin-pyproject-toml.md similarity index 100% rename from sprints/v1.1.0/SPIKE-019-plugin-pyproject-toml.md rename to docs/anvil/sprints/v1.1.0/SPIKE-019-plugin-pyproject-toml.md diff --git a/sprints/v2.0.0/BACK-001-fastapi-app-scaffold.md b/docs/anvil/sprints/v2.0.0/BACK-001-fastapi-app-scaffold.md similarity index 100% rename from sprints/v2.0.0/BACK-001-fastapi-app-scaffold.md rename to docs/anvil/sprints/v2.0.0/BACK-001-fastapi-app-scaffold.md diff --git a/sprints/v2.0.0/BACK-002-dependency-updates.md b/docs/anvil/sprints/v2.0.0/BACK-002-dependency-updates.md similarity index 100% rename from sprints/v2.0.0/BACK-002-dependency-updates.md rename to docs/anvil/sprints/v2.0.0/BACK-002-dependency-updates.md diff --git a/sprints/v2.0.0/BACK-003-pydantic-settings.md b/docs/anvil/sprints/v2.0.0/BACK-003-pydantic-settings.md similarity index 100% rename from sprints/v2.0.0/BACK-003-pydantic-settings.md rename to docs/anvil/sprints/v2.0.0/BACK-003-pydantic-settings.md diff --git a/sprints/v2.0.0/BACK-004-middleware-stack.md b/docs/anvil/sprints/v2.0.0/BACK-004-middleware-stack.md similarity index 100% rename from sprints/v2.0.0/BACK-004-middleware-stack.md rename to docs/anvil/sprints/v2.0.0/BACK-004-middleware-stack.md diff --git a/sprints/v2.0.0/BACK-005-api-routes-system.md b/docs/anvil/sprints/v2.0.0/BACK-005-api-routes-system.md similarity index 100% rename from sprints/v2.0.0/BACK-005-api-routes-system.md rename to docs/anvil/sprints/v2.0.0/BACK-005-api-routes-system.md diff --git a/sprints/v2.0.0/BACK-006-api-routes-plugins.md b/docs/anvil/sprints/v2.0.0/BACK-006-api-routes-plugins.md similarity index 100% rename from sprints/v2.0.0/BACK-006-api-routes-plugins.md rename to docs/anvil/sprints/v2.0.0/BACK-006-api-routes-plugins.md diff --git a/sprints/v2.0.0/BACK-007-sse-migration.md b/docs/anvil/sprints/v2.0.0/BACK-007-sse-migration.md similarity index 100% rename from sprints/v2.0.0/BACK-007-sse-migration.md rename to docs/anvil/sprints/v2.0.0/BACK-007-sse-migration.md diff --git a/sprints/v2.0.0/BACK-008-flask-removal-cleanup.md b/docs/anvil/sprints/v2.0.0/BACK-008-flask-removal-cleanup.md similarity index 100% rename from sprints/v2.0.0/BACK-008-flask-removal-cleanup.md rename to docs/anvil/sprints/v2.0.0/BACK-008-flask-removal-cleanup.md diff --git a/sprints/v2.0.0/README.md b/docs/anvil/sprints/v2.0.0/README.md similarity index 100% rename from sprints/v2.0.0/README.md rename to docs/anvil/sprints/v2.0.0/README.md diff --git a/sprints/v2.0.0/SPIKE-001-web-interface-v2-shim.md b/docs/anvil/sprints/v2.0.0/SPIKE-001-web-interface-v2-shim.md similarity index 100% rename from sprints/v2.0.0/SPIKE-001-web-interface-v2-shim.md rename to docs/anvil/sprints/v2.0.0/SPIKE-001-web-interface-v2-shim.md diff --git a/sprints/v2.0.0/SPIKE-002-pages-v3-transition.md b/docs/anvil/sprints/v2.0.0/SPIKE-002-pages-v3-transition.md similarity index 100% rename from sprints/v2.0.0/SPIKE-002-pages-v3-transition.md rename to docs/anvil/sprints/v2.0.0/SPIKE-002-pages-v3-transition.md diff --git a/sprints/v2.0.0/SPIKE-003-openapi-schema-validation.md b/docs/anvil/sprints/v2.0.0/SPIKE-003-openapi-schema-validation.md similarity index 100% rename from sprints/v2.0.0/SPIKE-003-openapi-schema-validation.md rename to docs/anvil/sprints/v2.0.0/SPIKE-003-openapi-schema-validation.md diff --git a/sprints/v2.0.0/SPIKE-004-mypy-strict-api.md b/docs/anvil/sprints/v2.0.0/SPIKE-004-mypy-strict-api.md similarity index 100% rename from sprints/v2.0.0/SPIKE-004-mypy-strict-api.md rename to docs/anvil/sprints/v2.0.0/SPIKE-004-mypy-strict-api.md diff --git a/sprints/v2.0.0/SPIKE-005-update-ci-for-fastapi.md b/docs/anvil/sprints/v2.0.0/SPIKE-005-update-ci-for-fastapi.md similarity index 100% rename from sprints/v2.0.0/SPIKE-005-update-ci-for-fastapi.md rename to docs/anvil/sprints/v2.0.0/SPIKE-005-update-ci-for-fastapi.md diff --git a/sprints/v2.0.0/SPIKE-006-cleanup-src-web-interface-flask-utils.md b/docs/anvil/sprints/v2.0.0/SPIKE-006-cleanup-src-web-interface-flask-utils.md similarity index 100% rename from sprints/v2.0.0/SPIKE-006-cleanup-src-web-interface-flask-utils.md rename to docs/anvil/sprints/v2.0.0/SPIKE-006-cleanup-src-web-interface-flask-utils.md diff --git a/sprints/v2.0.0/SPIKE-006-fastapi-rate-limiting.md b/docs/anvil/sprints/v2.0.0/SPIKE-006-fastapi-rate-limiting.md similarity index 100% rename from sprints/v2.0.0/SPIKE-006-fastapi-rate-limiting.md rename to docs/anvil/sprints/v2.0.0/SPIKE-006-fastapi-rate-limiting.md diff --git a/sprints/v2.0.0/SPIKE-007-missing-partial-templates.md b/docs/anvil/sprints/v2.0.0/SPIKE-007-missing-partial-templates.md similarity index 100% rename from sprints/v2.0.0/SPIKE-007-missing-partial-templates.md rename to docs/anvil/sprints/v2.0.0/SPIKE-007-missing-partial-templates.md diff --git a/sprints/v2.0.0/SPIKE-008-openapi-response-models.md b/docs/anvil/sprints/v2.0.0/SPIKE-008-openapi-response-models.md similarity index 100% rename from sprints/v2.0.0/SPIKE-008-openapi-response-models.md rename to docs/anvil/sprints/v2.0.0/SPIKE-008-openapi-response-models.md diff --git a/sprints/v3.0.0/FRONT-001-angular-project-scaffold.md b/docs/anvil/sprints/v3.0.0/FRONT-001-angular-project-scaffold.md similarity index 100% rename from sprints/v3.0.0/FRONT-001-angular-project-scaffold.md rename to docs/anvil/sprints/v3.0.0/FRONT-001-angular-project-scaffold.md diff --git a/sprints/v3.0.0/FRONT-002-primeng-theme-layout.md b/docs/anvil/sprints/v3.0.0/FRONT-002-primeng-theme-layout.md similarity index 100% rename from sprints/v3.0.0/FRONT-002-primeng-theme-layout.md rename to docs/anvil/sprints/v3.0.0/FRONT-002-primeng-theme-layout.md diff --git a/sprints/v3.0.0/FRONT-003-api-service-layer.md b/docs/anvil/sprints/v3.0.0/FRONT-003-api-service-layer.md similarity index 94% rename from sprints/v3.0.0/FRONT-003-api-service-layer.md rename to docs/anvil/sprints/v3.0.0/FRONT-003-api-service-layer.md index 8a553b9f1..6314629e7 100644 --- a/sprints/v3.0.0/FRONT-003-api-service-layer.md +++ b/docs/anvil/sprints/v3.0.0/FRONT-003-api-service-layer.md @@ -6,7 +6,7 @@ **Phase:** v3.0.0 — Frontend Modernization **Type:** Feat **Depends on:** [FRONT-001](FRONT-001-angular-project-scaffold.md) -**Blocks:** [FRONT-004](FRONT-004-dashboard-module.md), [FRONT-005](FRONT-005-plugins-module.md), [FRONT-006](FRONT-006-settings-module.md), [FRONT-007](FRONT-007-logs-store-modules.md) +**Blocks:** [FRONT-003a](FRONT-003a-font-service.md), [FRONT-003b](FRONT-003b-wifi-service.md), [FRONT-003c](FRONT-003c-starlark-service.md), [FRONT-004](FRONT-004-dashboard-module.md), [FRONT-005](FRONT-005-plugins-module.md), [FRONT-006](FRONT-006-settings-module.md), [FRONT-007](FRONT-007-logs-store-modules.md) --- diff --git a/sprints/v3.0.0/FRONT-003a-font-service.md b/docs/anvil/sprints/v3.0.0/FRONT-003a-font-service.md similarity index 100% rename from sprints/v3.0.0/FRONT-003a-font-service.md rename to docs/anvil/sprints/v3.0.0/FRONT-003a-font-service.md diff --git a/sprints/v3.0.0/FRONT-003b-wifi-service.md b/docs/anvil/sprints/v3.0.0/FRONT-003b-wifi-service.md similarity index 100% rename from sprints/v3.0.0/FRONT-003b-wifi-service.md rename to docs/anvil/sprints/v3.0.0/FRONT-003b-wifi-service.md diff --git a/sprints/v3.0.0/FRONT-003c-starlark-service.md b/docs/anvil/sprints/v3.0.0/FRONT-003c-starlark-service.md similarity index 100% rename from sprints/v3.0.0/FRONT-003c-starlark-service.md rename to docs/anvil/sprints/v3.0.0/FRONT-003c-starlark-service.md diff --git a/sprints/v3.0.0/FRONT-004-dashboard-module.md b/docs/anvil/sprints/v3.0.0/FRONT-004-dashboard-module.md similarity index 100% rename from sprints/v3.0.0/FRONT-004-dashboard-module.md rename to docs/anvil/sprints/v3.0.0/FRONT-004-dashboard-module.md diff --git a/sprints/v3.0.0/FRONT-005-plugins-module.md b/docs/anvil/sprints/v3.0.0/FRONT-005-plugins-module.md similarity index 100% rename from sprints/v3.0.0/FRONT-005-plugins-module.md rename to docs/anvil/sprints/v3.0.0/FRONT-005-plugins-module.md diff --git a/sprints/v3.0.0/FRONT-006-settings-module.md b/docs/anvil/sprints/v3.0.0/FRONT-006-settings-module.md similarity index 100% rename from sprints/v3.0.0/FRONT-006-settings-module.md rename to docs/anvil/sprints/v3.0.0/FRONT-006-settings-module.md diff --git a/sprints/v3.0.0/FRONT-007-logs-store-modules.md b/docs/anvil/sprints/v3.0.0/FRONT-007-logs-store-modules.md similarity index 100% rename from sprints/v3.0.0/FRONT-007-logs-store-modules.md rename to docs/anvil/sprints/v3.0.0/FRONT-007-logs-store-modules.md diff --git a/sprints/v3.0.0/FRONT-008-htmx-removal-cleanup.md b/docs/anvil/sprints/v3.0.0/FRONT-008-htmx-removal-cleanup.md similarity index 100% rename from sprints/v3.0.0/FRONT-008-htmx-removal-cleanup.md rename to docs/anvil/sprints/v3.0.0/FRONT-008-htmx-removal-cleanup.md diff --git a/sprints/v3.0.0/README.md b/docs/anvil/sprints/v3.0.0/README.md similarity index 88% rename from sprints/v3.0.0/README.md rename to docs/anvil/sprints/v3.0.0/README.md index 73dc01074..0a6943381 100644 --- a/sprints/v3.0.0/README.md +++ b/docs/anvil/sprints/v3.0.0/README.md @@ -11,11 +11,14 @@ | ID | Title | Status | Depends On | |---|---|---|---| | [FRONT-001](FRONT-001-angular-project-scaffold.md) | Angular project scaffold | **Done** | -- | -| [FRONT-002](FRONT-002-primeng-theme-layout.md) | PrimeNG integration and dark theme layout | Open | FRONT-001 | -| [FRONT-003](FRONT-003-api-service-layer.md) | API service layer and SSE client | Open | FRONT-001 | +| [FRONT-002](FRONT-002-primeng-theme-layout.md) | PrimeNG integration and dark theme layout | **Done** | FRONT-001 | +| [FRONT-003](FRONT-003-api-service-layer.md) | API service layer and SSE client | **Done** | FRONT-001 | +| [FRONT-003a](FRONT-003a-font-service.md) | Font service | Open | FRONT-003 | +| [FRONT-003b](FRONT-003b-wifi-service.md) | WiFi service | Open | FRONT-003 | +| [FRONT-003c](FRONT-003c-starlark-service.md) | Starlark service | Open | FRONT-003 | | [FRONT-004](FRONT-004-dashboard-module.md) | Dashboard feature module | Open | FRONT-002, FRONT-003 | -| [FRONT-005](FRONT-005-plugins-module.md) | Plugins feature module | Open | FRONT-002, FRONT-003 | -| [FRONT-006](FRONT-006-settings-module.md) | Settings feature module | Open | FRONT-002, FRONT-003 | +| [FRONT-005](FRONT-005-plugins-module.md) | Plugins feature module | Open | FRONT-002, FRONT-003, FRONT-003c | +| [FRONT-006](FRONT-006-settings-module.md) | Settings feature module | Open | FRONT-002, FRONT-003, FRONT-003a, FRONT-003b | | [FRONT-007](FRONT-007-logs-store-modules.md) | Logs and Store feature modules | Open | FRONT-002, FRONT-003 | | [FRONT-008](FRONT-008-htmx-removal-cleanup.md) | HTMX removal and legacy frontend cleanup | Open | FRONT-004, FRONT-005, FRONT-006, FRONT-007 | | [SPIKE-001](SPIKE-001-angular-unit-test-setup.md) | Angular unit test setup | Open | FRONT-001 | @@ -40,6 +43,12 @@ FRONT-001 (Angular scaffold) | +-- FRONT-007 (Logs + Store modules) | +-- SPIKE-003 (Operation history) +-- FRONT-003 (API service layer + SSE) + | +-- FRONT-003a (Font service) + | | +-- FRONT-006 (Settings module) + | +-- FRONT-003b (WiFi service) + | | +-- FRONT-006 (Settings module) + | +-- FRONT-003c (Starlark service) + | | +-- FRONT-005 (Plugins module) | +-- FRONT-004 (Dashboard module) | +-- FRONT-005 (Plugins module) | +-- FRONT-006 (Settings module) diff --git a/sprints/v3.0.0/SPIKE-001-angular-unit-test-setup.md b/docs/anvil/sprints/v3.0.0/SPIKE-001-angular-unit-test-setup.md similarity index 100% rename from sprints/v3.0.0/SPIKE-001-angular-unit-test-setup.md rename to docs/anvil/sprints/v3.0.0/SPIKE-001-angular-unit-test-setup.md diff --git a/sprints/v3.0.0/SPIKE-002-raw-json-editor.md b/docs/anvil/sprints/v3.0.0/SPIKE-002-raw-json-editor.md similarity index 100% rename from sprints/v3.0.0/SPIKE-002-raw-json-editor.md rename to docs/anvil/sprints/v3.0.0/SPIKE-002-raw-json-editor.md diff --git a/sprints/v3.0.0/SPIKE-003-operation-history-view.md b/docs/anvil/sprints/v3.0.0/SPIKE-003-operation-history-view.md similarity index 100% rename from sprints/v3.0.0/SPIKE-003-operation-history-view.md rename to docs/anvil/sprints/v3.0.0/SPIKE-003-operation-history-view.md diff --git a/sprints/v3.0.0/SPIKE-004-ci-angular-build.md b/docs/anvil/sprints/v3.0.0/SPIKE-004-ci-angular-build.md similarity index 100% rename from sprints/v3.0.0/SPIKE-004-ci-angular-build.md rename to docs/anvil/sprints/v3.0.0/SPIKE-004-ci-angular-build.md diff --git a/sprints/v3.0.0/SPIKE-005-starlark-config-ui.md b/docs/anvil/sprints/v3.0.0/SPIKE-005-starlark-config-ui.md similarity index 100% rename from sprints/v3.0.0/SPIKE-005-starlark-config-ui.md rename to docs/anvil/sprints/v3.0.0/SPIKE-005-starlark-config-ui.md diff --git a/sprints/v3.0.0/SPIKE-FRONT-001-nodejs-in-distrobox.md b/docs/anvil/sprints/v3.0.0/SPIKE-FRONT-001-nodejs-in-distrobox.md similarity index 100% rename from sprints/v3.0.0/SPIKE-FRONT-001-nodejs-in-distrobox.md rename to docs/anvil/sprints/v3.0.0/SPIKE-FRONT-001-nodejs-in-distrobox.md diff --git a/sprints/v3.0.0/SPIKE-FRONT-002-angular-environment-switching.md b/docs/anvil/sprints/v3.0.0/SPIKE-FRONT-002-angular-environment-switching.md similarity index 100% rename from sprints/v3.0.0/SPIKE-FRONT-002-angular-environment-switching.md rename to docs/anvil/sprints/v3.0.0/SPIKE-FRONT-002-angular-environment-switching.md diff --git a/sprints/v3.0.0/SPIKE-FRONT-003-dev-server-proxy-verification.md b/docs/anvil/sprints/v3.0.0/SPIKE-FRONT-003-dev-server-proxy-verification.md similarity index 100% rename from sprints/v3.0.0/SPIKE-FRONT-003-dev-server-proxy-verification.md rename to docs/anvil/sprints/v3.0.0/SPIKE-FRONT-003-dev-server-proxy-verification.md diff --git a/docs/anvil/sprints/v4.0.0/DOCK-001-dockerfile-multi-stage.md b/docs/anvil/sprints/v4.0.0/DOCK-001-dockerfile-multi-stage.md new file mode 100644 index 000000000..6d8fe2643 --- /dev/null +++ b/docs/anvil/sprints/v4.0.0/DOCK-001-dockerfile-multi-stage.md @@ -0,0 +1,112 @@ +# DOCK-001 — Multi-stage Dockerfile + +> **For Claude:** Use `superpowers:writing-plans` before touching any files. Use `superpowers:test-driven-development` for any logic you add. + +**Status:** Open +**Phase:** v4.0.0 — Containerization +**Type:** Feat +**Depends on:** _(none -- start here)_ +**Blocks:** [DOCK-003](DOCK-003-compose-production.md), [SPIKE-001](SPIKE-001-rgbmatrix-in-container.md), [SPIKE-003](SPIKE-003-ci-docker-build.md) + +--- + +## Context + +Phase 4 introduces a Docker-first deployment model. This ticket creates the multi-stage Dockerfile that builds both the Angular SPA and the Python runtime into a single image. + +The build has two stages: +1. **Stage 1 (Node):** Installs npm dependencies and runs `ng build` to produce the Angular SPA in `frontend/dist/ledmatrix/browser/`. +2. **Stage 2 (Python):** Uses `python:3.12-slim` as the base, installs `uv` for dependency management, copies the built SPA from stage 1, and installs all Python dependencies. + +The image must support two runtime modes: +- **Hardware mode (Pi):** Requires `rgbmatrix` C library and `/dev` device access at runtime. +- **Emulator mode (dev):** Uses `RGBMatrixEmulator` via the `EMULATOR=true` environment variable, no hardware needed. + +--- + +## Acceptance Criteria + +- [ ] `Dockerfile` exists at the repository root +- [ ] Stage 1 uses a Node base image and produces the Angular production build +- [ ] Stage 2 uses `python:3.12-slim` and installs dependencies via `uv` +- [ ] The built Angular SPA is copied from stage 1 into the final image +- [ ] Source code (`src/`, `run.py`, `scripts/`, `plugins/`) is copied into the image +- [ ] Default entrypoint runs the display controller (`run.py`) +- [ ] `EMULATOR` environment variable is respected at runtime +- [ ] Image builds successfully with `docker build -t ledmatrix .` + +--- + +## Implementation Checklist + +### 1. Create the Dockerfile + +- [ ] Create `Dockerfile` at the repo root +- [ ] Stage 1: `FROM node:22-slim AS frontend-build` + - [ ] Set `WORKDIR /app/frontend` + - [ ] Copy `frontend/package.json` and `frontend/package-lock.json` + - [ ] Run `npm ci` for reproducible installs + - [ ] Copy the rest of `frontend/` + - [ ] Run `npx ng build` to produce production output +- [ ] Stage 2: `FROM python:3.12-slim AS runtime` + - [ ] Install system dependencies: `libfreetype6`, `libjpeg62-turbo`, `libsdl2-2.0-0` (runtime-only, not dev headers) + - [ ] Install `uv` via `pip install uv` or the official installer script + - [ ] Set `WORKDIR /app` + - [ ] Copy `pyproject.toml` and `uv.lock` + - [ ] Run `uv sync --no-dev --extra emulator` to install Python dependencies + - [ ] Copy source code: `src/`, `run.py`, `scripts/`, `config/config.template.json` + - [ ] Copy `COPY --from=frontend-build /app/frontend/dist /app/frontend/dist` + - [ ] Copy `fonts/`, `plugin-repos/` directories + - [ ] Set `ENV PYTHONDONTWRITEBYTECODE=1` + - [ ] Set default `CMD ["python", "run.py"]` + +### 2. Verify the build + +- [ ] Run `docker build -t ledmatrix:dev .` and confirm it completes +- [ ] Run `docker run --rm -e EMULATOR=true ledmatrix:dev` and confirm it starts (will fail without display, but should not crash on import) + +### 3. Commit + +```bash +git add Dockerfile +git commit -m "feat(docker): add multi-stage Dockerfile for Angular + Python build" +``` + +--- + +## Verification Steps + +Run these commands after implementation; every one must pass before closing this ticket. + +```bash +# 1. Dockerfile exists +test -f Dockerfile && echo "OK: Dockerfile exists" + +# 2. Dockerfile has two FROM stages +grep -c "^FROM" Dockerfile | grep -q "2" && echo "OK: multi-stage build" + +# 3. Stage 1 uses Node +grep -q "node:" Dockerfile && echo "OK: Node stage present" + +# 4. Stage 2 uses python:3.12-slim +grep -q "python:3.12-slim" Dockerfile && echo "OK: Python base image" + +# 5. uv is installed in the image +grep -q "uv" Dockerfile && echo "OK: uv referenced in Dockerfile" + +# 6. Angular build output is copied +grep -q "frontend-build" Dockerfile && echo "OK: frontend build stage referenced" + +# 7. Image builds (requires Docker) +docker build -t ledmatrix:test . && echo "OK: image builds" || echo "SKIP: Docker not available" +``` + +--- + +## Notes + +- The `rgbmatrix` C library installation is complex and Pi-specific. This ticket uses the emulator extras for the base image. Hardware support is investigated in SPIKE-001. +- Do NOT copy `config/config.json` or `config/config_secrets.json` into the image -- these are user data and must be mounted as volumes. +- Do NOT copy `.venv/`, `node_modules/`, or `.git/` -- these are excluded by `.dockerignore` (DOCK-002). +- The `uv sync` command should use `--frozen` to respect the lockfile exactly. +- Consider layer caching: copy dependency files (`pyproject.toml`, `uv.lock`, `package.json`) before source code to maximize cache hits. diff --git a/docs/anvil/sprints/v4.0.0/DOCK-002-dockerignore.md b/docs/anvil/sprints/v4.0.0/DOCK-002-dockerignore.md new file mode 100644 index 000000000..77c5abf6f --- /dev/null +++ b/docs/anvil/sprints/v4.0.0/DOCK-002-dockerignore.md @@ -0,0 +1,81 @@ +# DOCK-002 — .dockerignore File + +> **For Claude:** Use `superpowers:writing-plans` before touching any files. Use `superpowers:test-driven-development` for any logic you add. + +**Status:** Open +**Phase:** v4.0.0 — Containerization +**Type:** Chore +**Depends on:** _(none)_ +**Blocks:** _(none)_ + +--- + +## Context + +A `.dockerignore` file prevents unnecessary files from being included in the Docker build context. Without it, the build context would include `.git/` (~100MB+), `node_modules/`, `.venv/`, test files, and other artifacts that bloat the image and slow builds. + +This ticket is independent of the Dockerfile itself and can be done in parallel. + +--- + +## Acceptance Criteria + +- [ ] `.dockerignore` exists at the repository root +- [ ] `.git/` directory is excluded +- [ ] `.venv/` directory is excluded +- [ ] `node_modules/` directories are excluded +- [ ] Test files (`test/`) are excluded +- [ ] Build artifacts (`frontend/dist/`, `*.pyc`, `__pycache__/`) are excluded +- [ ] Config secrets (`config/config.json`, `config/config_secrets.json`) are excluded +- [ ] Documentation and sprint planning files are excluded + +--- + +## Implementation Checklist + +### 1. Create `.dockerignore` + +- [ ] Create `.dockerignore` at the repo root with the following exclusions: + - Version control: `.git/`, `.gitignore` + - Python: `.venv/`, `__pycache__/`, `*.pyc`, `*.pyo`, `.mypy_cache/`, `.pytest_cache/`, `.ruff_cache/` + - Node: `node_modules/`, `frontend/dist/`, `frontend/.angular/` + - IDE: `.vscode/`, `.idea/` + - User config (mounted at runtime): `config/config.json`, `config/config_secrets.json` + - Tests: `test/` + - Docs/planning: `docs/`, `sprints/`, `*.md` (except `README.md`) + - CI/tooling: `.github/`, `.claude/` + - Development: `plugin-repos/`, `*.log` + +### 2. Commit + +```bash +git add .dockerignore +git commit -m "chore(docker): add .dockerignore to exclude build artifacts and secrets" +``` + +--- + +## Verification Steps + +Run these commands after implementation; every one must pass before closing this ticket. + +```bash +# 1. File exists +test -f .dockerignore && echo "OK: .dockerignore exists" + +# 2. Key exclusions present +grep -q ".git" .dockerignore && echo "OK: .git excluded" +grep -q ".venv" .dockerignore && echo "OK: .venv excluded" +grep -q "node_modules" .dockerignore && echo "OK: node_modules excluded" +grep -q "config/config.json" .dockerignore && echo "OK: config.json excluded" +grep -q "test/" .dockerignore && echo "OK: test/ excluded" +``` + +--- + +## Notes + +- The `frontend/dist/` exclusion is intentional -- the Angular SPA is built inside the Docker build (stage 1), not copied from the host. +- `config/config.template.json` should NOT be excluded -- it is needed to generate default config inside the container. +- `plugins/` should NOT be excluded -- plugin source code needs to be in the image for built-in plugins. +- Keep this file in sync with `.gitignore` patterns where they overlap. diff --git a/docs/anvil/sprints/v4.0.0/DOCK-003-compose-production.md b/docs/anvil/sprints/v4.0.0/DOCK-003-compose-production.md new file mode 100644 index 000000000..5f6873056 --- /dev/null +++ b/docs/anvil/sprints/v4.0.0/DOCK-003-compose-production.md @@ -0,0 +1,131 @@ +# DOCK-003 — Production Docker Compose + +> **For Claude:** Use `superpowers:writing-plans` before touching any files. Use `superpowers:test-driven-development` for any logic you add. + +**Status:** Open +**Phase:** v4.0.0 — Containerization +**Type:** Feat +**Depends on:** [DOCK-001](DOCK-001-dockerfile-multi-stage.md) +**Blocks:** [DOCK-004](DOCK-004-compose-dev-overlay.md), [DOCK-005](DOCK-005-cli-docker-commands.md), [DOCK-006](DOCK-006-systemd-docker-units.md) + +--- + +## Context + +The ROADMAP specifies a `compose.yml` for the full stack: a display service and a web/API service, with named volumes for persistent data. Both services use the same Docker image (built in DOCK-001) but different entrypoints. + +On Raspberry Pi, the display service needs `--privileged` access and `/dev/mem`, `/dev/gpiomem` device mounts to control the LED matrix hardware. The web/API service does not need hardware access. + +Pydantic Settings in the FastAPI backend already reads configuration from environment variables, so Docker environment variables can override config values without modifying files. + +--- + +## Acceptance Criteria + +- [ ] `compose.yml` exists at the repository root +- [ ] Two services defined: `display` and `web` +- [ ] Both services build from the local `Dockerfile` (or reference an image name) +- [ ] `display` service runs `run.py` as its command +- [ ] `web` service runs `src/api/start.py` as its command +- [ ] `display` service has `privileged: true` and device mounts for Pi hardware +- [ ] Named volumes defined for `config`, `fonts`, and `data` +- [ ] Both services mount the config volume at `/app/config` +- [ ] Environment variables are configurable via `.env` file or inline +- [ ] `docker compose up` starts both services + +--- + +## Implementation Checklist + +### 1. Create compose.yml + +- [ ] Create `compose.yml` at the repo root (Compose v2 format, no `version:` key) +- [ ] Define `display` service: + - [ ] `build: .` (or `image: ledmatrix:latest` for pre-built) + - [ ] `command: python run.py` + - [ ] `privileged: true` + - [ ] `devices: ["/dev/mem", "/dev/gpiomem"]` + - [ ] `restart: unless-stopped` + - [ ] Mount named volumes: `ledmatrix-config:/app/config`, `ledmatrix-fonts:/app/fonts`, `ledmatrix-data:/app/data` + - [ ] Environment: `PYTHONDONTWRITEBYTECODE=1` +- [ ] Define `web` service: + - [ ] Same image as display + - [ ] `command: python src/api/start.py` + - [ ] `ports: ["5000:5000"]` + - [ ] `restart: unless-stopped` + - [ ] Mount same named volumes as display service + - [ ] `depends_on: [display]` +- [ ] Define named volumes: `ledmatrix-config`, `ledmatrix-fonts`, `ledmatrix-data` + +### 2. Create example .env file + +- [ ] Create `.env.example` with documented environment variables: + - [ ] `EMULATOR=false` + - [ ] `LEDMATRIX_DEBUG=false` + - [ ] `LEDMATRIX_JSON_LOGGING=true` + - [ ] `LEDMATRIX_HOT_RELOAD=true` +- [ ] Add `.env` to `.gitignore` if not already there + +### 3. Verify Compose file syntax + +- [ ] Run `docker compose config` to validate the file +- [ ] Confirm both services are listed + +### 4. Commit + +```bash +git add compose.yml .env.example +git commit -m "feat(docker): add production Docker Compose with display and web services" +``` + +--- + +## Verification Steps + +Run these commands after implementation; every one must pass before closing this ticket. + +```bash +# 1. Compose file exists +test -f compose.yml && echo "OK: compose.yml exists" + +# 2. Valid YAML with expected services +python3 -c " +import yaml +with open('compose.yml') as f: + c = yaml.safe_load(f) +services = c.get('services', {}) +assert 'display' in services, 'missing display service' +assert 'web' in services, 'missing web service' +print('OK: both services defined') +" 2>/dev/null || python3 -c " +import json, subprocess +result = subprocess.run(['docker', 'compose', 'config', '--format', 'json'], capture_output=True, text=True) +if result.returncode == 0: + print('OK: compose config valid') +else: + print('SKIP: docker compose not available') +" + +# 3. Named volumes defined +grep -q "ledmatrix-config" compose.yml && echo "OK: config volume" +grep -q "ledmatrix-fonts" compose.yml && echo "OK: fonts volume" + +# 4. Privileged mode for display +grep -q "privileged" compose.yml && echo "OK: privileged mode set" + +# 5. Web service exposes port 5000 +grep -q "5000" compose.yml && echo "OK: port 5000 exposed" + +# 6. .env.example exists +test -f .env.example && echo "OK: .env.example exists" +``` + +--- + +## Notes + +- The `privileged: true` flag and device mounts are only needed on Raspberry Pi. For non-Pi hosts, these settings are harmless but unnecessary -- the dev overlay (DOCK-004) removes them. +- Do NOT hardcode API keys or secrets in `compose.yml`. Secrets should be passed via `.env` file or Docker secrets. +- The `display` and `web` services share volumes so they can both read the same config and font files. +- Plugin data (downloaded plugin assets, caches) should persist in the `ledmatrix-data` volume. +- The Compose file uses `build: .` for local builds. For published images, this would change to `image: ghcr.io/olino3/ledmatrix:latest` or similar. diff --git a/docs/anvil/sprints/v4.0.0/DOCK-004-compose-dev-overlay.md b/docs/anvil/sprints/v4.0.0/DOCK-004-compose-dev-overlay.md new file mode 100644 index 000000000..24a0b09cc --- /dev/null +++ b/docs/anvil/sprints/v4.0.0/DOCK-004-compose-dev-overlay.md @@ -0,0 +1,98 @@ +# DOCK-004 — Development Compose Overlay + +> **For Claude:** Use `superpowers:writing-plans` before touching any files. Use `superpowers:test-driven-development` for any logic you add. + +**Status:** Open +**Phase:** v4.0.0 — Containerization +**Type:** Feat +**Depends on:** [DOCK-003](DOCK-003-compose-production.md) +**Blocks:** _(none)_ + +--- + +## Context + +The ROADMAP specifies a `compose.dev.yml` overlay that mounts source code for live reload and enables emulator mode. This allows developers to run the full stack in Docker without hardware, with code changes reflected immediately without rebuilding the image. + +The overlay uses `docker compose -f compose.yml -f compose.dev.yml up` to merge the dev configuration on top of the production base. + +--- + +## Acceptance Criteria + +- [ ] `compose.dev.yml` exists at the repository root +- [ ] Overlay enables `EMULATOR=true` for the display service +- [ ] Source directories (`src/`, `plugins/`, `config/`, `fonts/`) are bind-mounted for live reload +- [ ] `privileged` mode and device mounts are removed (not needed for emulator) +- [ ] Web service mounts source for live reload +- [ ] Angular dev server or hot-reload is supported +- [ ] `docker compose -f compose.yml -f compose.dev.yml config` validates successfully + +--- + +## Implementation Checklist + +### 1. Create compose.dev.yml + +- [ ] Create `compose.dev.yml` at the repo root +- [ ] Override `display` service: + - [ ] Set `environment: EMULATOR=true` + - [ ] Remove `privileged: true` (set to `false`) + - [ ] Remove `devices` list + - [ ] Add bind mounts for source: `./src:/app/src`, `./run.py:/app/run.py`, `./config:/app/config`, `./plugins:/app/plugins`, `./fonts:/app/fonts` +- [ ] Override `web` service: + - [ ] Add bind mounts for source: `./src:/app/src`, `./config:/app/config` + - [ ] Set `environment: LEDMATRIX_DEBUG=true` +- [ ] Optionally add a `frontend` service: + - [ ] `image: node:22-slim` + - [ ] `command: npx ng serve --host 0.0.0.0` + - [ ] `ports: ["4200:4200"]` + - [ ] Bind mount `./frontend:/app/frontend` + - [ ] This provides Angular hot-reload during development + +### 2. Add convenience script or Makefile target + +- [ ] Add a comment or note in the file header showing the usage pattern: + ``` + # Usage: docker compose -f compose.yml -f compose.dev.yml up + ``` + +### 3. Commit + +```bash +git add compose.dev.yml +git commit -m "feat(docker): add dev Compose overlay with emulator mode and source mounts" +``` + +--- + +## Verification Steps + +Run these commands after implementation; every one must pass before closing this ticket. + +```bash +# 1. File exists +test -f compose.dev.yml && echo "OK: compose.dev.yml exists" + +# 2. Emulator environment set +grep -q "EMULATOR" compose.dev.yml && echo "OK: EMULATOR env var present" + +# 3. Source bind mounts present +grep -q "./src:/app/src" compose.dev.yml && echo "OK: src bind mount" + +# 4. Privileged mode disabled +grep -q "privileged.*false" compose.dev.yml && echo "OK: privileged disabled" || echo "INFO: check privileged override" + +# 5. Combined config validates (requires Docker) +docker compose -f compose.yml -f compose.dev.yml config > /dev/null 2>&1 && echo "OK: combined config valid" || echo "SKIP: Docker not available" +``` + +--- + +## Notes + +- Bind mounts override named volumes from the base `compose.yml`. The dev overlay uses the host filesystem directly, so changes are reflected immediately. +- The Angular dev server (port 4200) is optional in the overlay. Developers can also run `ng serve` on the host and proxy to the containerized API. +- Do NOT mount `.venv/` or `node_modules/` from the host -- the container has its own dependency installations. +- Live reload for the Python display service may require restarting the container (no watchdog in `run.py`). The web service uses uvicorn's built-in reload if `--reload` is added to the command. +- Consider adding `command: python -m uvicorn src.api.main:app --host 0.0.0.0 --port 5000 --reload` to the web service override for auto-reload. diff --git a/docs/anvil/sprints/v4.0.0/DOCK-005-cli-docker-commands.md b/docs/anvil/sprints/v4.0.0/DOCK-005-cli-docker-commands.md new file mode 100644 index 000000000..fe53758b4 --- /dev/null +++ b/docs/anvil/sprints/v4.0.0/DOCK-005-cli-docker-commands.md @@ -0,0 +1,132 @@ +# DOCK-005 — Matrix CLI Docker Command Group + +> **For Claude:** Use `superpowers:writing-plans` before touching any files. Use `superpowers:test-driven-development` for any logic you add. + +**Status:** Open +**Phase:** v4.0.0 — Containerization +**Type:** Feat +**Depends on:** [DOCK-003](DOCK-003-compose-production.md) +**Blocks:** [DOCK-007](DOCK-007-install-docker-detection.md) + +--- + +## Context + +The ROADMAP specifies five new CLI subcommands under `matrix docker`: `start`, `stop`, `logs`, `update`, and `build`. These commands wrap `docker compose` operations so users do not need to remember Compose file paths or flags. + +The CLI is a single-file Click application at `scripts/matrix_cli.py` (~2900 lines). New command groups are added before the `# Entry point` section, following the existing pattern of section headers and helper functions. + +--- + +## Acceptance Criteria + +- [ ] `matrix docker` command group exists with a help message +- [ ] `matrix docker start` pulls (if needed) and starts containers via `docker compose up -d` +- [ ] `matrix docker stop` stops containers via `docker compose down` +- [ ] `matrix docker logs` tails container logs via `docker compose logs -f` +- [ ] `matrix docker update` pulls the latest image and restarts containers +- [ ] `matrix docker build` builds the image locally via `docker compose build` +- [ ] All commands detect if Docker/Docker Compose is not installed and exit gracefully +- [ ] Commands use the correct Compose file path relative to the project root + +--- + +## Implementation Checklist + +### 1. Add Docker detection helper + +- [ ] Add `_is_docker_available()` helper function that checks for `docker` and `docker compose` in PATH +- [ ] Return a descriptive error message if Docker is not available + +### 2. Create the `docker` command group + +- [ ] Add `@cli.group()` for `docker` with help text: "Manage LEDMatrix Docker containers" +- [ ] Add section header comment: `# ---------------------------------------------------------------------------` + +### 3. Implement `docker start` + +- [ ] `@docker.command()` named `start` +- [ ] Option `--dev` flag to include `compose.dev.yml` overlay +- [ ] Check Docker availability, exit with error if not found +- [ ] Build the `docker compose` command with correct `-f` flags +- [ ] Run `docker compose up -d` (detached mode) +- [ ] Print status message on success + +### 4. Implement `docker stop` + +- [ ] `@docker.command()` named `stop` +- [ ] Run `docker compose down` +- [ ] Option `--volumes` flag to also remove named volumes (with confirmation) + +### 5. Implement `docker logs` + +- [ ] `@docker.command()` named `logs` +- [ ] Option `--service` to filter by service name (`display` or `web`) +- [ ] Option `--tail` with default 100 for number of lines +- [ ] Run `docker compose logs -f --tail N [service]` + +### 6. Implement `docker update` + +- [ ] `@docker.command()` named `update` +- [ ] Run `docker compose pull` to get latest images +- [ ] Run `docker compose up -d` to restart with new images +- [ ] Print before/after image digest for verification + +### 7. Implement `docker build` + +- [ ] `@docker.command()` named `build` +- [ ] Option `--no-cache` flag +- [ ] Run `docker compose build [--no-cache]` + +### 8. Add tests + +- [ ] Create `test/test_matrix_cli_docker.py` +- [ ] Test that `docker` group and all subcommands are registered +- [ ] Test `_is_docker_available()` with mocked `shutil.which` +- [ ] Test that commands exit gracefully when Docker is not available +- [ ] Mock all `subprocess.run` calls -- do NOT actually run Docker commands in tests + +### 9. Commit + +```bash +git add scripts/matrix_cli.py test/test_matrix_cli_docker.py +git commit -m "feat(cli): add matrix docker command group for container management" +``` + +--- + +## Verification Steps + +Run these commands after implementation; every one must pass before closing this ticket. + +```bash +# 1. Syntax check (no distrobox needed) +python3 -c "import ast; ast.parse(open('scripts/matrix_cli.py').read()); print('OK: syntax valid')" + +# 2. Docker group registered +grep -q "def docker" scripts/matrix_cli.py && echo "OK: docker group defined" + +# 3. All subcommands defined +grep -q "def start" scripts/matrix_cli.py && echo "OK: start command" +grep -q "def stop" scripts/matrix_cli.py && echo "OK: stop command" +grep -q "def logs" scripts/matrix_cli.py && echo "OK: logs command" +grep -q "def update" scripts/matrix_cli.py && echo "OK: update command" +grep -q "def build" scripts/matrix_cli.py && echo "OK: build command" + +# 4. Test file exists +test -f test/test_matrix_cli_docker.py && echo "OK: test file exists" + +# 5. Tests pass (distrobox) +distrobox enter debian-trixie -- bash -c 'uv sync --extra test --extra dev --extra emulator && EMULATOR=true .venv/bin/pytest test/test_matrix_cli_docker.py -q --override-ini="addopts="' +``` + +--- + +## Notes + +- Follow the existing CLI pattern: use `_run()` helper for subprocess calls, `console` for Rich output, `LEDMATRIX_ROOT` for project path resolution. +- The `--dev` flag on `docker start` adds `-f compose.dev.yml` to the Compose command. This is the primary way developers will run in emulator mode. +- All `docker compose` commands must be run from the project root directory (where `compose.yml` lives). Use `LEDMATRIX_ROOT` to ensure correct working directory. +- The `logs` command should use `subprocess.run` with `stdin=sys.stdin` to allow Ctrl+C to stop tailing. +- Do NOT add container health checks in this ticket -- that is Phase 5 (Observability) work. +- Test safety: mock all subprocess calls. Do NOT run actual Docker commands in tests. diff --git a/docs/anvil/sprints/v4.0.0/DOCK-006-systemd-docker-units.md b/docs/anvil/sprints/v4.0.0/DOCK-006-systemd-docker-units.md new file mode 100644 index 000000000..c9b178b3f --- /dev/null +++ b/docs/anvil/sprints/v4.0.0/DOCK-006-systemd-docker-units.md @@ -0,0 +1,91 @@ +# DOCK-006 — Systemd Units for Docker Deployment + +> **For Claude:** Use `superpowers:writing-plans` before touching any files. Use `superpowers:test-driven-development` for any logic you add. + +**Status:** Open +**Phase:** v4.0.0 — Containerization +**Type:** Feat +**Depends on:** [DOCK-003](DOCK-003-compose-production.md) +**Blocks:** [DOCK-007](DOCK-007-install-docker-detection.md) + +--- + +## Context + +Currently, LEDMatrix uses two systemd unit files (`systemd/ledmatrix.service` and `systemd/ledmatrix-web.service`) that run Python processes directly. For Docker deployments, a single systemd unit should manage the Docker Compose stack instead. + +The existing native units are preserved for users who prefer bare-metal installs. The new Docker unit is an alternative, not a replacement. The `matrix install` command (DOCK-007) will choose which units to install based on whether Docker is available. + +--- + +## Acceptance Criteria + +- [ ] `systemd/ledmatrix-docker.service` template file exists +- [ ] Unit starts the Docker Compose stack with `docker compose up` +- [ ] Unit stops containers with `docker compose down` on service stop +- [ ] Unit depends on `docker.service` being active +- [ ] Template uses `__PROJECT_ROOT_DIR__` placeholder (matching existing convention) +- [ ] Existing native units (`ledmatrix.service`, `ledmatrix-web.service`) are not modified +- [ ] Unit supports `systemctl status ledmatrix-docker` for health checking + +--- + +## Implementation Checklist + +### 1. Create Docker systemd unit template + +- [ ] Create `systemd/ledmatrix-docker.service` +- [ ] Set `After=docker.service network-online.target` +- [ ] Set `Requires=docker.service` +- [ ] Set `WorkingDirectory=__PROJECT_ROOT_DIR__` +- [ ] Set `ExecStart=/usr/bin/docker compose -f __PROJECT_ROOT_DIR__/compose.yml up` +- [ ] Set `ExecStop=/usr/bin/docker compose -f __PROJECT_ROOT_DIR__/compose.yml down` +- [ ] Set `Restart=on-failure` with `RestartSec=15` +- [ ] Set `Type=simple` (docker compose up runs in foreground without `-d`) +- [ ] Set `SyslogIdentifier=ledmatrix-docker` +- [ ] Add `[Install]` section with `WantedBy=multi-user.target` + +### 2. Document the unit + +- [ ] Add a comment header explaining this is for Docker deployments +- [ ] Note that `__PROJECT_ROOT_DIR__` is replaced during `matrix install` + +### 3. Commit + +```bash +git add systemd/ledmatrix-docker.service +git commit -m "feat(docker): add systemd unit template for Docker Compose stack" +``` + +--- + +## Verification Steps + +Run these commands after implementation; every one must pass before closing this ticket. + +```bash +# 1. File exists +test -f systemd/ledmatrix-docker.service && echo "OK: docker unit exists" + +# 2. Depends on docker.service +grep -q "docker.service" systemd/ledmatrix-docker.service && echo "OK: docker dependency" + +# 3. Uses compose commands +grep -q "docker compose" systemd/ledmatrix-docker.service && echo "OK: compose in ExecStart" + +# 4. Has placeholder for project root +grep -q "__PROJECT_ROOT_DIR__" systemd/ledmatrix-docker.service && echo "OK: path placeholder" + +# 5. Existing units unchanged +git diff --name-only systemd/ledmatrix.service systemd/ledmatrix-web.service 2>/dev/null | wc -l | grep -q "0" && echo "OK: native units unchanged" +``` + +--- + +## Notes + +- The unit runs `docker compose up` (without `-d`) so systemd can track the process lifecycle. Detached mode would cause systemd to think the service exited immediately. +- `Type=simple` is correct because `docker compose up` (foreground) is the main process. +- The `ExecStop` command ensures containers are stopped cleanly when the systemd service is stopped. +- On Pi, the Docker daemon itself is a separate service (`docker.service`). The `Requires=` directive ensures it starts first. +- Do NOT add the unit to systemd in this ticket. The `matrix install` flow (DOCK-007) handles installation and path substitution. diff --git a/docs/anvil/sprints/v4.0.0/DOCK-007-install-docker-detection.md b/docs/anvil/sprints/v4.0.0/DOCK-007-install-docker-detection.md new file mode 100644 index 000000000..197dae1b4 --- /dev/null +++ b/docs/anvil/sprints/v4.0.0/DOCK-007-install-docker-detection.md @@ -0,0 +1,102 @@ +# DOCK-007 — matrix install Docker Detection + +> **For Claude:** Use `superpowers:writing-plans` before touching any files. Use `superpowers:test-driven-development` for any logic you add. + +**Status:** Open +**Phase:** v4.0.0 — Containerization +**Type:** Feat +**Depends on:** [DOCK-005](DOCK-005-cli-docker-commands.md), [DOCK-006](DOCK-006-systemd-docker-units.md) +**Blocks:** _(none)_ + +--- + +## Context + +The ROADMAP specifies that `matrix install` should detect Docker availability and offer the user a choice between container-based and native (bare-metal) installation. Currently, `matrix install` only supports native installation with venv setup, systemd unit installation, and dependency compilation. + +This ticket adds Docker detection logic and a branching install flow. When Docker is available, the user is prompted to choose their deployment mode. The Docker path installs the `ledmatrix-docker.service` unit and skips venv/dependency compilation. The native path continues as before. + +--- + +## Acceptance Criteria + +- [ ] `matrix install` detects whether Docker and Docker Compose are available +- [ ] User is prompted to choose: "Container (Docker)" or "Native (bare-metal)" +- [ ] If Docker is not available, native install proceeds automatically (no prompt) +- [ ] Docker install path: builds image, installs `ledmatrix-docker.service`, enables it +- [ ] Docker install path skips venv creation and pip dependency compilation +- [ ] Native install path remains unchanged +- [ ] Choice is logged and stored in config for future reference + +--- + +## Implementation Checklist + +### 1. Add Docker detection to install flow + +- [ ] In `matrix install`, after initial checks, call `_is_docker_available()` (from DOCK-005) +- [ ] If Docker is available, prompt user with `click.confirm()` or `click.prompt()`: + ``` + Docker detected. Install as a Docker container? (recommended) + [1] Container (Docker) - runs in Docker, easy updates + [2] Native (bare-metal) - traditional install, direct hardware access + ``` +- [ ] If Docker is not available, skip prompt and proceed with native install + +### 2. Implement Docker install branch + +- [ ] Build the Docker image: `docker compose build` +- [ ] Copy and configure `systemd/ledmatrix-docker.service`: + - [ ] Replace `__PROJECT_ROOT_DIR__` with actual project root + - [ ] Copy to `/etc/systemd/system/ledmatrix-docker.service` +- [ ] Run `systemctl daemon-reload` +- [ ] Enable and start the service: `systemctl enable --now ledmatrix-docker.service` +- [ ] Disable native units if they exist: `ledmatrix.service`, `ledmatrix-web.service` + +### 3. Store deployment mode in config + +- [ ] Write `deployment_mode: "docker"` or `deployment_mode: "native"` to config +- [ ] Future `matrix` CLI commands can check this to decide behavior + +### 4. Add tests + +- [ ] Add tests to `test/test_matrix_cli_docker.py` (or new file) +- [ ] Test Docker detection prompts with mocked `shutil.which` +- [ ] Test that Docker install branch calls correct subprocess commands +- [ ] Test that native install branch is unchanged +- [ ] Mock ALL filesystem and subprocess operations + +### 5. Commit + +```bash +git add scripts/matrix_cli.py test/test_matrix_cli_docker.py +git commit -m "feat(cli): add Docker detection and container install path to matrix install" +``` + +--- + +## Verification Steps + +Run these commands after implementation; every one must pass before closing this ticket. + +```bash +# 1. Syntax check +python3 -c "import ast; ast.parse(open('scripts/matrix_cli.py').read()); print('OK: syntax valid')" + +# 2. Docker detection in install flow +grep -q "docker" scripts/matrix_cli.py && echo "OK: docker referenced in CLI" +grep -q "deployment_mode" scripts/matrix_cli.py && echo "OK: deployment_mode referenced" + +# 3. Tests pass +distrobox enter debian-trixie -- bash -c 'uv sync --extra test --extra dev --extra emulator && EMULATOR=true .venv/bin/pytest test/test_matrix_cli_docker.py -q --override-ini="addopts="' +``` + +--- + +## Notes + +- The Docker install flow must still run as root (for systemd operations), same as the native install. +- If a user switches from native to Docker (or vice versa), the old systemd units should be disabled but NOT removed. This allows switching back. +- The `deployment_mode` config value is informational. It does NOT gate functionality -- users can still run `matrix docker start` even with `deployment_mode: "native"`. +- Test safety is critical: mock `subprocess.run`, `shutil.copy`, `Path.write_text`, and any systemctl calls. Do NOT touch real systemd units in tests. +- The `click.prompt()` with choices is preferred over `click.confirm()` for the two-option selection. diff --git a/docs/anvil/sprints/v4.0.0/README.md b/docs/anvil/sprints/v4.0.0/README.md new file mode 100644 index 000000000..4f98703aa --- /dev/null +++ b/docs/anvil/sprints/v4.0.0/README.md @@ -0,0 +1,112 @@ +# Sprint v4.0.0 -- Containerization + +**Goal:** Establish a Docker-first deployment model so LEDMatrix runs as a privileged container on Raspberry Pi, with a dev-friendly emulator mode via Docker Compose overlays. + +**ROADMAP phase:** Phase 4 + +--- + +## Tickets + +| ID | Title | Status | Depends On | +|---|---|---|---| +| [DOCK-001](DOCK-001-dockerfile-multi-stage.md) | Multi-stage Dockerfile | Open | -- | +| [DOCK-002](DOCK-002-dockerignore.md) | .dockerignore file | Open | -- | +| [DOCK-003](DOCK-003-compose-production.md) | Production Docker Compose | Open | DOCK-001 | +| [DOCK-004](DOCK-004-compose-dev-overlay.md) | Development Compose overlay | Open | DOCK-003 | +| [DOCK-005](DOCK-005-cli-docker-commands.md) | Matrix CLI docker command group | Open | DOCK-003 | +| [DOCK-006](DOCK-006-systemd-docker-units.md) | Systemd units for Docker deployment | Open | DOCK-003 | +| [DOCK-007](DOCK-007-install-docker-detection.md) | matrix install Docker detection | Open | DOCK-005, DOCK-006 | +| [SPIKE-001](SPIKE-001-rgbmatrix-in-container.md) | SPIKE: rgbmatrix library in container | Open | DOCK-001 | +| [SPIKE-002](SPIKE-002-pi-gpio-device-permissions.md) | SPIKE: Pi GPIO/device permissions | Open | DOCK-003 | +| [SPIKE-003](SPIKE-003-ci-docker-build.md) | SPIKE: CI pipeline for Docker image build | Open | DOCK-001 | + +## Dependency Graph + +``` +DOCK-001 (Dockerfile) + +-- DOCK-003 (compose.yml) + | +-- DOCK-004 (compose.dev.yml) + | +-- DOCK-005 (CLI docker commands) + | | +-- DOCK-007 (install Docker detection) + | +-- DOCK-006 (systemd Docker units) + | +-- DOCK-007 (install Docker detection) +DOCK-002 (.dockerignore) +SPIKE-001 (rgbmatrix in container) +SPIKE-002 (Pi GPIO permissions) +SPIKE-003 (CI Docker build) +``` + +## Definition of Done (Phase 4) + +- [ ] Multi-stage Dockerfile builds Angular SPA and Python runtime in a single image +- [ ] `docker build .` produces a working image with `uv`-installed dependencies +- [ ] `compose.yml` runs display + web services with named volumes for config, data, fonts +- [ ] `compose.dev.yml` overlay enables emulator mode with source mounts for live reload +- [ ] `matrix docker start|stop|logs|update|build` CLI commands work +- [ ] Pi deployment uses `--privileged` with `/dev/mem` and `/dev/gpiomem` mounts +- [ ] Systemd unit files manage Docker containers instead of bare processes +- [ ] `matrix install` detects Docker availability and offers container vs. native install +- [ ] Emulator mode works without hardware mounts (`EMULATOR=true`) +- [ ] No regressions in backend Python tests +- [ ] No regressions in Angular build or tests +- [ ] Plugin impact: none (Phase 4 is deployment-only; no plugin API changes) + +## Architecture Notes + +### New files created in Phase 4 + +``` +Dockerfile # Multi-stage build (Angular + Python) +.dockerignore # Exclude .git, .venv, node_modules, etc. +compose.yml # Production stack: display + web services +compose.dev.yml # Dev overlay: emulator, source mounts, live reload +systemd/ledmatrix-docker.service # Systemd unit for Docker Compose stack +``` + +### Modified files + +``` +scripts/matrix_cli.py # New `docker` command group (~5 subcommands) +systemd/ledmatrix.service # Preserved for native install (not replaced) +systemd/ledmatrix-web.service # Preserved for native install (not replaced) +``` + +### Container architecture + +``` ++---------------------------+ +| Docker Container | +| | +| Stage 1: node:22-slim | +| npm install + ng build | +| -> /app/frontend/dist/ | +| | +| Stage 2: python:3.12-slim| +| uv sync (deps) | +| COPY --from=stage1 dist| +| COPY src/ plugins/ ... | +| | +| Entrypoint: | +| run.py (display) | +| OR src/api/start.py | ++---------------------------+ + | + | --privileged (Pi only) + | /dev/mem, /dev/gpiomem + | ++------+------+ +| Host volumes | +| config/ | +| fonts/ | +| data/ | ++--------------+ +``` + +### Key technical decisions + +- **Single image, two services:** The same Docker image runs as either the display service or the web/API service, controlled by the entrypoint/command override in Compose. +- **`uv` inside the container:** Dependencies installed via `uv sync` in the Dockerfile, matching the host dev workflow. +- **Named volumes:** `config/`, `fonts/`, and plugin data persist across container restarts and image updates. +- **Privileged mode for Pi only:** The production `compose.yml` includes device mounts; the dev overlay disables them and sets `EMULATOR=true`. +- **Native install preserved:** Existing systemd units and bare-metal install path remain functional. Docker is an alternative, not a replacement. diff --git a/docs/anvil/sprints/v4.0.0/SPIKE-001-rgbmatrix-in-container.md b/docs/anvil/sprints/v4.0.0/SPIKE-001-rgbmatrix-in-container.md new file mode 100644 index 000000000..f6b6e3c0c --- /dev/null +++ b/docs/anvil/sprints/v4.0.0/SPIKE-001-rgbmatrix-in-container.md @@ -0,0 +1,96 @@ +# SPIKE-001 — rgbmatrix Library in Container + +> **For Claude:** Use `superpowers:writing-plans` before touching any files. Use `superpowers:test-driven-development` for any logic you add. + +**Status:** Open +**Phase:** v4.0.0 — Containerization +**Type:** Chore +**Depends on:** [DOCK-001](DOCK-001-dockerfile-multi-stage.md) +**Blocks:** _(none)_ + +--- + +## Context + +The `rgbmatrix` library (hzeller/rpi-rgb-led-matrix) is a C library with Python bindings that controls the LED matrix hardware on Raspberry Pi. It requires compilation from source with specific build flags and GPIO pin mappings. Installing it inside a Docker container is non-trivial because: + +1. The library must be compiled against the container's Python version, not the host's. +2. It needs build tools (`gcc`, `make`, `python3-dev`) that should not remain in the final image. +3. The compiled `.so` file must match the ARM architecture of the Pi. + +This SPIKE investigates the best approach for including `rgbmatrix` in the container image and documents the solution for DOCK-001 to implement. + +--- + +## Acceptance Criteria + +- [ ] Document how `rgbmatrix` is currently installed on bare-metal Pi +- [ ] Test at least two approaches for containerizing rgbmatrix: + 1. Compile from source in a Dockerfile build stage + 2. Pre-compiled wheel (if available for ARM/Python 3.12) +- [ ] Document the chosen approach with exact Dockerfile commands +- [ ] Verify the library imports successfully inside the container +- [ ] Document any Pi-specific build flags or GPIO configuration needed + +--- + +## Implementation Checklist + +### 1. Research current installation method + +- [ ] Read `scripts/matrix_cli.py` install logic for rgbmatrix compilation steps +- [ ] Document the build flags, pin mappings, and compile commands used today +- [ ] Check if `rgbmatrix` is listed in `pyproject.toml` or installed separately + +### 2. Test Dockerfile build-stage approach + +- [ ] Create a test Dockerfile that clones `hzeller/rpi-rgb-led-matrix` +- [ ] Compile the Python bindings in a build stage with `python3-dev`, `gcc`, `make` +- [ ] Copy only the compiled `.so` and Python files to the final stage +- [ ] Test `python -c "import rgbmatrix"` in the final image + +### 3. Evaluate pre-compiled wheel approach + +- [ ] Check PyPI and GitHub releases for ARM-compatible wheels +- [ ] If available, test `pip install rgbmatrix` inside the container +- [ ] Compare image size and build time with the build-stage approach + +### 4. Document findings + +- [ ] Write a summary in this ticket's Notes section (or a separate doc) +- [ ] Provide the exact Dockerfile snippet for the chosen approach +- [ ] Note any architecture constraints (ARM-only vs. multi-arch) + +### 5. Commit + +```bash +git add sprints/v4.0.0/SPIKE-001-rgbmatrix-in-container.md +git commit -m "docs(docker): document rgbmatrix containerization approach" +``` + +--- + +## Verification Steps + +Run these commands after investigation; document results in this ticket. + +```bash +# On a Raspberry Pi with Docker: +# 1. Build the test image +docker build -f Dockerfile.rgbmatrix-test -t rgbmatrix-test . + +# 2. Verify import works +docker run --rm rgbmatrix-test python -c "import rgbmatrix; print('OK')" + +# 3. Check image size +docker images rgbmatrix-test --format "{{.Size}}" +``` + +--- + +## Notes + +- This is a SPIKE (investigation) ticket. The output is documentation and a proven Dockerfile snippet, not production code. +- The `RGBMatrixEmulator` package (`rgbmatrixemulator` on PyPI) is a pure-Python drop-in that does NOT need hardware. It is already in `pyproject.toml` under `[project.optional-dependencies.emulator]`. The emulator path is the default for non-Pi builds. +- Multi-arch Docker builds (ARM + x86) are desirable but not required. The primary target is ARM (Raspberry Pi 4/5). +- If rgbmatrix cannot be easily containerized, an alternative is to install it on the host and mount it into the container via a volume. Document this as a fallback. diff --git a/docs/anvil/sprints/v4.0.0/SPIKE-002-pi-gpio-device-permissions.md b/docs/anvil/sprints/v4.0.0/SPIKE-002-pi-gpio-device-permissions.md new file mode 100644 index 000000000..92fe1b78e --- /dev/null +++ b/docs/anvil/sprints/v4.0.0/SPIKE-002-pi-gpio-device-permissions.md @@ -0,0 +1,94 @@ +# SPIKE-002 — Pi GPIO and Device Permissions + +> **For Claude:** Use `superpowers:writing-plans` before touching any files. Use `superpowers:test-driven-development` for any logic you add. + +**Status:** Open +**Phase:** v4.0.0 — Containerization +**Type:** Chore +**Depends on:** [DOCK-003](DOCK-003-compose-production.md) +**Blocks:** _(none)_ + +--- + +## Context + +Running the LED matrix display in a Docker container on Raspberry Pi requires access to GPIO pins and memory-mapped device files (`/dev/mem`, `/dev/gpiomem`). The ROADMAP specifies `--privileged` mode, but this grants the container full host access, which is a security concern. + +This SPIKE investigates the minimum set of capabilities and device mappings needed for the LED matrix to function, with the goal of reducing the security surface compared to full `--privileged` mode. + +--- + +## Acceptance Criteria + +- [ ] Document which `/dev` devices the rgbmatrix library accesses +- [ ] Test `--privileged` mode and confirm LED matrix works in container +- [ ] Test minimal capabilities approach (`--cap-add`, `--device`) as alternative +- [ ] Document the recommended approach with security trade-offs +- [ ] Provide exact `compose.yml` device/capability configuration + +--- + +## Implementation Checklist + +### 1. Audit device access requirements + +- [ ] Run `strace` on the display process to identify `/dev/*` file opens +- [ ] Document which devices are opened: `/dev/mem`, `/dev/gpiomem`, others +- [ ] Check if `rgbmatrix` uses `mmap()` for GPIO access (requires `SYS_RAWIO` capability) + +### 2. Test privileged mode + +- [ ] Run the container with `--privileged` on a Pi +- [ ] Confirm the LED matrix display works correctly +- [ ] Note any permission errors or warnings + +### 3. Test minimal capabilities + +- [ ] Try `--cap-add=SYS_RAWIO --device=/dev/mem --device=/dev/gpiomem` +- [ ] If that fails, try adding `--cap-add=DAC_OVERRIDE` +- [ ] Document which combination works and which does not + +### 4. Document findings + +- [ ] Write recommended configuration for `compose.yml` +- [ ] Note security implications of each approach +- [ ] Provide fallback instructions if minimal capabilities do not work + +### 5. Commit + +```bash +git add sprints/v4.0.0/SPIKE-002-pi-gpio-device-permissions.md +git commit -m "docs(docker): document Pi GPIO device permissions for containers" +``` + +--- + +## Verification Steps + +These must be run on a Raspberry Pi with a connected LED matrix. + +```bash +# 1. Privileged mode test +docker run --rm --privileged \ + -v /dev/mem:/dev/mem \ + ledmatrix:latest python run.py & +sleep 5 && echo "OK: display started" && kill %1 + +# 2. Minimal capabilities test +docker run --rm \ + --cap-add=SYS_RAWIO \ + --device=/dev/mem \ + --device=/dev/gpiomem \ + ledmatrix:latest python run.py & +sleep 5 && echo "OK: minimal caps work" && kill %1 +``` + +--- + +## Notes + +- This SPIKE requires physical Raspberry Pi hardware with a connected LED matrix. It cannot be fully tested in emulator mode. +- `--privileged` is the safe default that is known to work. The minimal capabilities approach is an optimization, not a requirement for Phase 4. +- If minimal capabilities work, update `compose.yml` in DOCK-003 to use them instead of `--privileged`. If not, document the finding and keep `--privileged`. +- Some Raspberry Pi OS configurations require the user to be in the `gpio` group. Inside a container running as root, this should not be an issue. +- Consider documenting `--security-opt=no-new-privileges` as an additional hardening measure. diff --git a/docs/anvil/sprints/v4.0.0/SPIKE-003-ci-docker-build.md b/docs/anvil/sprints/v4.0.0/SPIKE-003-ci-docker-build.md new file mode 100644 index 000000000..588e09a00 --- /dev/null +++ b/docs/anvil/sprints/v4.0.0/SPIKE-003-ci-docker-build.md @@ -0,0 +1,87 @@ +# SPIKE-003 — CI Pipeline for Docker Image Build + +> **For Claude:** Use `superpowers:writing-plans` before touching any files. Use `superpowers:test-driven-development` for any logic you add. + +**Status:** Open +**Phase:** v4.0.0 — Containerization +**Type:** Chore +**Depends on:** [DOCK-001](DOCK-001-dockerfile-multi-stage.md) +**Blocks:** _(none)_ + +--- + +## Context + +With a Dockerfile in the repository, the CI pipeline should verify that the Docker image builds successfully on every push/PR. Optionally, tagged releases could push the built image to a container registry (GitHub Container Registry). + +This SPIKE sets up the GitHub Actions workflow for Docker image builds and investigates multi-architecture build support (ARM for Pi + x86 for dev). + +--- + +## Acceptance Criteria + +- [ ] GitHub Actions workflow file exists for Docker builds +- [ ] Workflow triggers on pushes to `develop` and PRs that modify Docker-related files +- [ ] `docker build` runs successfully in CI +- [ ] Build failures block PR merge +- [ ] (Optional) Tagged releases push image to GitHub Container Registry + +--- + +## Implementation Checklist + +### 1. Create GitHub Actions workflow + +- [ ] Create `.github/workflows/docker-build.yml` +- [ ] Trigger on push to `develop` and PRs modifying: `Dockerfile`, `compose.yml`, `compose.dev.yml`, `.dockerignore` +- [ ] Use `docker/setup-buildx-action` for advanced build features +- [ ] Use `docker/build-push-action` to build the image +- [ ] Set `push: false` for PR builds (build-only, no push) + +### 2. Add build caching + +- [ ] Use GitHub Actions cache for Docker layers +- [ ] Configure `cache-from` and `cache-to` in the build action + +### 3. (Optional) Multi-arch build + +- [ ] Investigate `docker buildx build --platform linux/arm64,linux/amd64` +- [ ] Document whether the Dockerfile supports multi-arch (Node and Python base images do) +- [ ] Note if rgbmatrix compilation is architecture-specific + +### 4. (Optional) Registry push on release + +- [ ] On tag push (`v*`), push to `ghcr.io/olino3/ledmatrix:TAG` +- [ ] Use `docker/login-action` with `GITHUB_TOKEN` + +### 5. Commit + +```bash +git add .github/workflows/docker-build.yml +git commit -m "ci(docker): add GitHub Actions workflow for Docker image builds" +``` + +--- + +## Verification Steps + +```bash +# 1. Workflow file exists +test -f .github/workflows/docker-build.yml && echo "OK: workflow exists" + +# 2. Workflow triggers on Dockerfile changes +grep -q "Dockerfile" .github/workflows/docker-build.yml && echo "OK: Dockerfile trigger" + +# 3. Local build still works +docker build -t ledmatrix:ci-test . && echo "OK: local build" || echo "SKIP: Docker not available" +``` + +--- + +## Notes + +- This is a SPIKE ticket. The primary output is a working CI workflow file. +- Multi-arch builds are nice-to-have but add significant CI time. Start with x86-only (for build validation) and add ARM later. +- The CI workflow should NOT run the container (that requires Pi hardware or emulator X11). It only verifies the build succeeds. +- GitHub Container Registry (`ghcr.io`) is free for public repos. Private repos have storage limits. +- Consider adding a `docker compose config` validation step to catch YAML errors in the Compose files. diff --git a/docs/superpowers/plans/2026-03-20-api-service-layer.md b/docs/superpowers/plans/2026-03-20-api-service-layer.md new file mode 100644 index 000000000..af5e3e948 --- /dev/null +++ b/docs/superpowers/plans/2026-03-20-api-service-layer.md @@ -0,0 +1,1216 @@ +# FRONT-003: API Service Layer Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Create a typed Angular service layer with TypeScript interfaces matching the FastAPI backend, an SSE client with auto-reconnect, and domain services for system, plugin, and config operations. + +**Architecture:** All services use Angular's `providedIn: 'root'` for tree-shakeable singletons. A base `ApiService` wraps `HttpClient` with typed generics and UUID request headers. An `SseService` wraps native `EventSource` in RxJS Observables with exponential backoff reconnect. Domain services (`SystemService`, `PluginService`, `ConfigService`) delegate to `ApiService`. A functional HTTP interceptor converts error responses to typed `ApiError` instances. + +**Tech Stack:** Angular 21 (standalone), TypeScript 5.9, RxJS 7.8, Vitest (via `@angular/build:unit-test`) + +--- + +## File Structure + +``` +frontend/src/app/core/ +├── models/ +│ ├── system.model.ts — SystemStatus, SystemVersion, HealthResponse interfaces +│ ├── plugin.model.ts — PluginInfo, PluginConfig, PluginToggleRequest, StorePlugin interfaces +│ ├── config.model.ts — SystemConfig, ScheduleConfig, ConfigUpdateRequest interfaces +│ ├── common.model.ts — SuccessResponse, ErrorResponse, PaginatedResponse interfaces +│ └── stream.model.ts — StatsEvent, DisplayEvent, LogEvent interfaces +├── services/ +│ ├── api.service.ts — Base HTTP wrapper with typed generics +│ ├── api.service.spec.ts — Tests for ApiService +│ ├── sse.service.ts — EventSource → Observable wrapper with reconnect +│ ├── sse.service.spec.ts — Tests for SseService +│ ├── system.service.ts — System domain service +│ ├── system.service.spec.ts — Tests for SystemService +│ ├── plugin.service.ts — Plugin domain service +│ ├── plugin.service.spec.ts — Tests for PluginService +│ ├── config.service.ts — Config domain service +│ └── config.service.spec.ts — Tests for ConfigService +├── interceptors/ +│ └── error.interceptor.ts — Functional HTTP error interceptor +├── errors/ +│ ├── api-error.ts — ApiError class +│ └── api-error.spec.ts — Tests for ApiError +└── index.ts — Public API barrel export +``` + +**Modified files:** +- `frontend/src/app/app.config.ts` — Add `provideHttpClient(withInterceptors([...]))` and `provideAnimationsAsync()` + +--- + +## Scope & Out-of-Scope + +**In scope (this ticket):** Models for all API types, ApiService, SseService, SystemService, PluginService, ConfigService, error interceptor, ApiError class. + +**Out of scope (future tickets):** +- `FontService` — SPIKE: FRONT-003a +- `WifiService` — SPIKE: FRONT-003b +- `StarlarkService` — SPIKE: FRONT-003c +- `AssetService` — covered by FRONT-005 (plugins module) + +These domain services follow the same pattern established here and can be added as needed by downstream tickets. + +--- + +## Task 1: TypeScript Model Interfaces + +**Files:** +- Create: `frontend/src/app/core/models/common.model.ts` +- Create: `frontend/src/app/core/models/system.model.ts` +- Create: `frontend/src/app/core/models/plugin.model.ts` +- Create: `frontend/src/app/core/models/config.model.ts` +- Create: `frontend/src/app/core/models/stream.model.ts` + +These are pure type definitions (interfaces) — no runtime logic, no TDD needed. + +- [ ] **Step 1: Create common.model.ts** + +```typescript +// Matches src/api/models/common.py +export interface SuccessResponse<T = unknown> { + status: string; + message: string; + data: T | null; +} + +export interface ErrorResponse { + status: string; + error_code: string; + message: string; + details: Record<string, unknown> | null; +} + +export interface PaginatedResponse<T = unknown> { + items: T[]; + total: number; + page: number; + page_size: number; +} +``` + +- [ ] **Step 2: Create system.model.ts** + +```typescript +// Matches src/api/models/system.py +export interface SystemStatus { + cpu_percent: number; + memory_percent: number; + cpu_temp: number | null; + disk_percent: number; + service_active: boolean; + uptime: number; +} + +export interface SystemVersion { + version: string; + python_version: string; + platform: string; +} + +export interface HealthResponse { + status: string; + checks: Record<string, unknown>; +} +``` + +- [ ] **Step 3: Create plugin.model.ts** + +```typescript +// Matches src/api/models/plugin.py +export interface PluginInfo { + id: string; + name: string; + version: string; + enabled: boolean; + description: string; + display_modes: string[]; +} + +export interface PluginConfigResponse { + plugin_id: string; + config: Record<string, unknown>; + schema: Record<string, unknown>; +} + +export interface PluginToggleRequest { + plugin_id: string; + enabled: boolean; +} + +export interface PluginInstallRequest { + plugin_id: string; + source_url: string; +} + +// Store plugin — based on store router response shape +export interface StorePlugin { + id: string; + name: string; + version: string; + description: string; + author: string; + category: string; + tags: string[]; + installed: boolean; +} +``` + +- [ ] **Step 4: Create config.model.ts** + +```typescript +// Matches src/api/models/config.py +export interface DisplayHardwareConfig { + rows: number; + cols: number; + chain_length: number; + parallel: number; + brightness: number; + hardware_mapping: string; + scan_mode: number; + pwm_bits: number; + pwm_dither_bits: number; + pwm_lsb_nanoseconds: number; + disable_hardware_pulsing: boolean; + inverse_colors: boolean; + show_refresh_rate: boolean; + led_rgb_sequence: string; + limit_refresh_rate_hz: number; +} + +export interface ScheduleConfig { + enabled: boolean; + mode: string; + start_time: string; + end_time: string; +} + +export interface SystemConfigResponse { + display: Record<string, unknown>; + schedule: Record<string, unknown>; + general: Record<string, unknown>; +} + +export interface ConfigUpdateRequest { + display?: Record<string, unknown>; + schedule?: Record<string, unknown>; + general?: Record<string, unknown>; +} +``` + +- [ ] **Step 5: Create stream.model.ts** + +```typescript +// Matches SSE event shapes from src/api/routers/streams.py +export interface StatsEvent { + timestamp: number; + uptime: string; + service_active: boolean; + cpu_percent: number; + memory_used_percent: number; + cpu_temp: number; + disk_used_percent: number; +} + +export interface DisplayEvent { + timestamp: number; + width: number; + height: number; + image: string | null; // base64-encoded PNG +} + +export interface LogEvent { + timestamp: number; + logs: string; +} +``` + +- [ ] **Step 6: Commit models** + +```bash +git add frontend/src/app/core/models/ +git commit -m "feat(frontend): add TypeScript interfaces for API response types" +``` + +--- + +## Task 2: ApiError Class + +**Files:** +- Create: `frontend/src/app/core/errors/api-error.ts` +- Create: `frontend/src/app/core/errors/api-error.spec.ts` + +- [ ] **Step 1: Write failing test** + +```typescript +// api-error.spec.ts +import { HttpErrorResponse } from '@angular/common/http'; +import { ApiError } from './api-error'; + +describe('ApiError', () => { + it('should parse a structured error response', () => { + const httpError = new HttpErrorResponse({ + error: { + status: 'error', + error_code: 'NOT_FOUND', + message: 'Plugin not found', + details: { plugin_id: 'foo' }, + }, + status: 404, + statusText: 'Not Found', + }); + + const apiError = ApiError.fromHttpError(httpError); + + expect(apiError.errorCode).toBe('NOT_FOUND'); + expect(apiError.message).toBe('Plugin not found'); + expect(apiError.statusCode).toBe(404); + expect(apiError.details).toEqual({ plugin_id: 'foo' }); + }); + + it('should handle unstructured error responses', () => { + const httpError = new HttpErrorResponse({ + error: 'Internal Server Error', + status: 500, + statusText: 'Internal Server Error', + }); + + const apiError = ApiError.fromHttpError(httpError); + + expect(apiError.errorCode).toBe('UNKNOWN'); + expect(apiError.statusCode).toBe(500); + expect(apiError.message).toBe('Internal Server Error'); + }); + + it('should handle network errors (status 0)', () => { + const httpError = new HttpErrorResponse({ + error: new ProgressEvent('error'), + status: 0, + statusText: 'Unknown Error', + }); + + const apiError = ApiError.fromHttpError(httpError); + + expect(apiError.errorCode).toBe('NETWORK_ERROR'); + expect(apiError.statusCode).toBe(0); + expect(apiError.message).toBe('Network error — server may be unreachable'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd frontend && npx ng test -- --run --reporter=verbose 2>&1 | tail -20` +Expected: FAIL — `ApiError` not found + +- [ ] **Step 3: Write minimal implementation** + +```typescript +// api-error.ts +import { HttpErrorResponse } from '@angular/common/http'; +import type { ErrorResponse } from '../models/common.model'; + +export class ApiError extends Error { + readonly errorCode: string; + readonly statusCode: number; + readonly details: Record<string, unknown> | null; + + constructor(message: string, errorCode: string, statusCode: number, details: Record<string, unknown> | null = null) { + super(message); + this.name = 'ApiError'; + this.errorCode = errorCode; + this.statusCode = statusCode; + this.details = details; + } + + static fromHttpError(httpError: HttpErrorResponse): ApiError { + if (httpError.status === 0) { + return new ApiError('Network error — server may be unreachable', 'NETWORK_ERROR', 0); + } + + const body = httpError.error; + if (body && typeof body === 'object' && 'error_code' in body) { + const err = body as ErrorResponse; + return new ApiError(err.message, err.error_code, httpError.status, err.details); + } + + return new ApiError( + typeof body === 'string' ? body : httpError.statusText, + 'UNKNOWN', + httpError.status, + ); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd frontend && npx ng test -- --run --reporter=verbose 2>&1 | tail -20` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add frontend/src/app/core/errors/ +git commit -m "feat(frontend): add ApiError class with HTTP error parsing" +``` + +--- + +## Task 3: HTTP Error Interceptor + +**Files:** +- Create: `frontend/src/app/core/interceptors/error.interceptor.ts` +- Modify: `frontend/src/app/app.config.ts` + +The interceptor is a functional interceptor (Angular 21 style) — no class needed. + +- [ ] **Step 1: Write the interceptor** + +```typescript +// error.interceptor.ts +import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http'; +import { catchError, throwError } from 'rxjs'; +import { ApiError } from '../errors/api-error'; +import { environment } from '../../../environments/environment'; + +export const errorInterceptor: HttpInterceptorFn = (req, next) => { + return next(req).pipe( + catchError((error: HttpErrorResponse) => { + if (!environment.production) { + console.error(`[API Error] ${req.method} ${req.url}:`, error); + } + return throwError(() => ApiError.fromHttpError(error)); + }), + ); +}; +``` + +- [ ] **Step 2: Wire up app.config.ts** + +Add `provideHttpClient(withInterceptors([errorInterceptor]))`: + +```typescript +// app.config.ts +import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; +import { provideRouter } from '@angular/router'; +import { provideHttpClient, withInterceptors } from '@angular/common/http'; +import { routes } from './app.routes'; +import { errorInterceptor } from './core/interceptors/error.interceptor'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideBrowserGlobalErrorListeners(), + provideRouter(routes), + provideHttpClient(withInterceptors([errorInterceptor])), + ] +}; +``` + +- [ ] **Step 3: Commit** + +```bash +git add frontend/src/app/core/interceptors/ frontend/src/app/app.config.ts +git commit -m "feat(frontend): add HTTP error interceptor with ApiError conversion" +``` + +--- + +## Task 4: Base ApiService + +**Files:** +- Create: `frontend/src/app/core/services/api.service.ts` +- Create: `frontend/src/app/core/services/api.service.spec.ts` + +- [ ] **Step 1: Write failing tests** + +```typescript +// api.service.spec.ts +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { ApiService } from './api.service'; +import type { SuccessResponse } from '../models/common.model'; + +describe('ApiService', () => { + let service: ApiService; + let httpTesting: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting()], + }); + service = TestBed.inject(ApiService); + httpTesting = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpTesting.verify(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should send GET request with correct base URL', () => { + service.get<SuccessResponse>('/system/status').subscribe(); + const req = httpTesting.expectOne('/api/v3/system/status'); + expect(req.request.method).toBe('GET'); + req.flush({ status: 'success', message: 'ok', data: null }); + }); + + it('should include X-Request-ID header', () => { + service.get('/system/version').subscribe(); + const req = httpTesting.expectOne('/api/v3/system/version'); + const requestId = req.request.headers.get('X-Request-ID'); + expect(requestId).toBeTruthy(); + expect(requestId!.length).toBeGreaterThan(0); + req.flush({ status: 'success', message: 'ok', data: null }); + }); + + it('should send POST request with body', () => { + const body = { plugin_id: 'clock', enabled: true }; + service.post('/plugins/toggle', body).subscribe(); + const req = httpTesting.expectOne('/api/v3/plugins/toggle'); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual(body); + req.flush({ status: 'success', message: 'toggled', data: null }); + }); + + it('should send PUT request', () => { + const body = { display: { brightness: 50 } }; + service.put('/config/main', body).subscribe(); + const req = httpTesting.expectOne('/api/v3/config/main'); + expect(req.request.method).toBe('PUT'); + req.flush({ status: 'success', message: 'updated', data: null }); + }); + + it('should send DELETE request', () => { + service.delete('/fonts/custom-font').subscribe(); + const req = httpTesting.expectOne('/api/v3/fonts/custom-font'); + expect(req.request.method).toBe('DELETE'); + req.flush({ status: 'success', message: 'deleted', data: null }); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd frontend && npx ng test -- --run --reporter=verbose 2>&1 | tail -30` +Expected: FAIL — `ApiService` not found + +- [ ] **Step 3: Implement ApiService** + +```typescript +// api.service.ts +import { Injectable } from '@angular/core'; +import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { environment } from '../../../environments/environment'; + +@Injectable({ providedIn: 'root' }) +export class ApiService { + private readonly baseUrl = environment.apiBase; + + constructor(private readonly http: HttpClient) {} + + get<T>(path: string): Observable<T> { + return this.http.get<T>(`${this.baseUrl}${path}`, { headers: this.headers() }); + } + + post<T>(path: string, body: unknown = {}): Observable<T> { + return this.http.post<T>(`${this.baseUrl}${path}`, body, { headers: this.headers() }); + } + + put<T>(path: string, body: unknown = {}): Observable<T> { + return this.http.put<T>(`${this.baseUrl}${path}`, body, { headers: this.headers() }); + } + + delete<T>(path: string): Observable<T> { + return this.http.delete<T>(`${this.baseUrl}${path}`, { headers: this.headers() }); + } + + private headers(): HttpHeaders { + return new HttpHeaders({ 'X-Request-ID': crypto.randomUUID() }); + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd frontend && npx ng test -- --run --reporter=verbose 2>&1 | tail -30` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add frontend/src/app/core/services/api.service.ts frontend/src/app/core/services/api.service.spec.ts +git commit -m "feat(frontend): add base ApiService with typed HTTP methods" +``` + +--- + +## Task 5: SSE Service + +**Files:** +- Create: `frontend/src/app/core/services/sse.service.ts` +- Create: `frontend/src/app/core/services/sse.service.spec.ts` + +The SSE service wraps native `EventSource` in RxJS Observables with auto-reconnect. + +- [ ] **Step 1: Write failing tests** + +```typescript +// sse.service.spec.ts +import { TestBed } from '@angular/core/testing'; +import { SseService } from './sse.service'; +import type { StatsEvent } from '../models/stream.model'; + +// Mock EventSource +class MockEventSource { + static instances: MockEventSource[] = []; + onmessage: ((event: MessageEvent) => void) | null = null; + onerror: ((event: Event) => void) | null = null; + onopen: (() => void) | null = null; + readyState = 0; + url: string; + + constructor(url: string) { + this.url = url; + MockEventSource.instances.push(this); + // Simulate connection open + setTimeout(() => { + this.readyState = 1; + this.onopen?.(); + }); + } + + close = vi.fn(); + + simulateMessage(data: string): void { + this.onmessage?.({ data } as MessageEvent); + } + + simulateError(): void { + this.readyState = 2; + this.onerror?.(new Event('error')); + } +} + +describe('SseService', () => { + let service: SseService; + + beforeEach(() => { + MockEventSource.instances = []; + vi.stubGlobal('EventSource', MockEventSource); + TestBed.configureTestingModule({}); + service = TestBed.inject(SseService); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should create EventSource and emit parsed messages', () => { + const values: StatsEvent[] = []; + const sub = service.connect<StatsEvent>('/stream/stats').subscribe(v => values.push(v)); + + const instance = MockEventSource.instances[0]; + expect(instance.url).toBe('/api/v3/stream/stats'); + + instance.simulateMessage(JSON.stringify({ timestamp: 1, cpu_percent: 50, memory_used_percent: 40, cpu_temp: 55, disk_used_percent: 30, uptime: 'Running', service_active: true })); + expect(values).toHaveLength(1); + expect(values[0].cpu_percent).toBe(50); + + sub.unsubscribe(); + expect(instance.close).toHaveBeenCalled(); + }); + + it('should close EventSource on unsubscribe', () => { + const sub = service.connect('/stream/logs').subscribe(); + const instance = MockEventSource.instances[0]; + sub.unsubscribe(); + expect(instance.close).toHaveBeenCalled(); + }); + + it('should provide typed stream accessors', () => { + expect(service.statsStream$).toBeDefined(); + expect(service.displayStream$).toBeDefined(); + expect(service.logStream$).toBeDefined(); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd frontend && npx ng test -- --run --reporter=verbose 2>&1 | tail -30` +Expected: FAIL — `SseService` not found + +- [ ] **Step 3: Implement SseService** + +```typescript +// sse.service.ts +import { Injectable, NgZone } from '@angular/core'; +import { Observable, share, Subject } from 'rxjs'; +import { environment } from '../../../environments/environment'; +import type { StatsEvent, DisplayEvent, LogEvent } from '../models/stream.model'; + +@Injectable({ providedIn: 'root' }) +export class SseService { + private readonly baseUrl = environment.apiBase; + + constructor(private readonly zone: NgZone) {} + + connect<T>(endpoint: string): Observable<T> { + return new Observable<T>(subscriber => { + const url = `${this.baseUrl}${endpoint}`; + let eventSource: EventSource; + let retryCount = 0; + let retryTimeout: ReturnType<typeof setTimeout> | null = null; + + const createConnection = (): void => { + eventSource = new EventSource(url); + + eventSource.onopen = () => { + retryCount = 0; + }; + + eventSource.onmessage = (event: MessageEvent) => { + this.zone.run(() => { + try { + subscriber.next(JSON.parse(event.data) as T); + } catch { + // Skip unparseable messages + } + }); + }; + + eventSource.onerror = () => { + eventSource.close(); + // Exponential backoff: 1s, 2s, 4s, 8s, ... max 30s + const delay = Math.min(1000 * Math.pow(2, retryCount), 30_000); + retryCount++; + retryTimeout = setTimeout(() => createConnection(), delay); + }; + }; + + createConnection(); + + return () => { + if (retryTimeout !== null) { + clearTimeout(retryTimeout); + } + eventSource?.close(); + }; + }); + } + + readonly statsStream$: Observable<StatsEvent> = this.connect<StatsEvent>('/stream/stats').pipe(share()); + readonly displayStream$: Observable<DisplayEvent> = this.connect<DisplayEvent>('/stream/display').pipe(share()); + readonly logStream$: Observable<LogEvent> = this.connect<LogEvent>('/stream/logs').pipe(share()); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd frontend && npx ng test -- --run --reporter=verbose 2>&1 | tail -30` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add frontend/src/app/core/services/sse.service.ts frontend/src/app/core/services/sse.service.spec.ts +git commit -m "feat(frontend): add SSE service with auto-reconnect and typed streams" +``` + +--- + +## Task 6: SystemService + +**Files:** +- Create: `frontend/src/app/core/services/system.service.ts` +- Create: `frontend/src/app/core/services/system.service.spec.ts` + +- [ ] **Step 1: Write failing tests** + +```typescript +// system.service.spec.ts +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { SystemService } from './system.service'; + +describe('SystemService', () => { + let service: SystemService; + let httpTesting: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting()], + }); + service = TestBed.inject(SystemService); + httpTesting = TestBed.inject(HttpTestingController); + }); + + afterEach(() => httpTesting.verify()); + + it('should get system status', () => { + service.getStatus().subscribe(res => { + expect(res.data).toBeTruthy(); + }); + const req = httpTesting.expectOne(r => r.url.endsWith('/system/status')); + expect(req.request.method).toBe('GET'); + req.flush({ status: 'success', message: 'ok', data: { cpu_percent: 10 } }); + }); + + it('should get system version', () => { + service.getVersion().subscribe(res => { + expect(res.data).toBeTruthy(); + }); + const req = httpTesting.expectOne(r => r.url.endsWith('/system/version')); + expect(req.request.method).toBe('GET'); + req.flush({ status: 'success', message: 'ok', data: { version: '3.0.0' } }); + }); + + it('should get health', () => { + service.getHealth().subscribe(); + const req = httpTesting.expectOne(r => r.url.endsWith('/health')); + expect(req.request.method).toBe('GET'); + req.flush({ status: 'healthy', checks: {} }); + }); + + it('should perform system action', () => { + service.performAction('restart').subscribe(); + const req = httpTesting.expectOne(r => r.url.endsWith('/system/action')); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual({ action: 'restart' }); + req.flush({ status: 'success', message: 'restarting', data: null }); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd frontend && npx ng test -- --run --reporter=verbose 2>&1 | tail -20` +Expected: FAIL + +- [ ] **Step 3: Implement SystemService** + +```typescript +// system.service.ts +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { ApiService } from './api.service'; +import type { SuccessResponse } from '../models/common.model'; +import type { SystemStatus, SystemVersion, HealthResponse } from '../models/system.model'; + +@Injectable({ providedIn: 'root' }) +export class SystemService { + constructor(private readonly api: ApiService) {} + + getStatus(): Observable<SuccessResponse<SystemStatus>> { + return this.api.get('/system/status'); + } + + getVersion(): Observable<SuccessResponse<SystemVersion>> { + return this.api.get('/system/version'); + } + + getHealth(): Observable<HealthResponse> { + return this.api.get('/health'); + } + + performAction(action: string): Observable<SuccessResponse> { + return this.api.post('/system/action', { action }); + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd frontend && npx ng test -- --run --reporter=verbose 2>&1 | tail -20` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add frontend/src/app/core/services/system.service.ts frontend/src/app/core/services/system.service.spec.ts +git commit -m "feat(frontend): add SystemService for system status, health, version" +``` + +--- + +## Task 7: PluginService + +**Files:** +- Create: `frontend/src/app/core/services/plugin.service.ts` +- Create: `frontend/src/app/core/services/plugin.service.spec.ts` + +- [ ] **Step 1: Write failing tests** + +```typescript +// plugin.service.spec.ts +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { PluginService } from './plugin.service'; + +describe('PluginService', () => { + let service: PluginService; + let httpTesting: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting()], + }); + service = TestBed.inject(PluginService); + httpTesting = TestBed.inject(HttpTestingController); + }); + + afterEach(() => httpTesting.verify()); + + it('should list plugins', () => { + service.list().subscribe(res => { + expect(res.data).toBeTruthy(); + }); + const req = httpTesting.expectOne(r => r.url.endsWith('/plugins/installed')); + expect(req.request.method).toBe('GET'); + req.flush({ status: 'success', message: 'ok', data: [] }); + }); + + it('should get single plugin by id', () => { + service.get('clock').subscribe(); + const req = httpTesting.expectOne(r => r.url.includes('/plugins/config') && r.url.includes('plugin_id=clock')); + expect(req.request.method).toBe('GET'); + req.flush({ status: 'success', message: 'ok', data: { plugin_id: 'clock', config: {}, schema: {} } }); + }); + + it('should get plugin config', () => { + service.getConfig('clock').subscribe(); + const req = httpTesting.expectOne(r => r.url.includes('/plugins/config') && r.url.includes('plugin_id=clock')); + expect(req.request.method).toBe('GET'); + req.flush({ status: 'success', message: 'ok', data: { plugin_id: 'clock', config: {}, schema: {} } }); + }); + + it('should update plugin config', () => { + const config = { brightness: 50 }; + service.updateConfig('clock', config).subscribe(); + const req = httpTesting.expectOne(r => r.url.endsWith('/plugins/config')); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual({ plugin_id: 'clock', config }); + req.flush({ status: 'success', message: 'saved', data: null }); + }); + + it('should toggle plugin', () => { + service.toggle('clock', true).subscribe(); + const req = httpTesting.expectOne(r => r.url.endsWith('/plugins/toggle')); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual({ plugin_id: 'clock', enabled: true }); + req.flush({ status: 'success', message: 'toggled', data: null }); + }); + + it('should install plugin', () => { + service.install('weather').subscribe(); + const req = httpTesting.expectOne(r => r.url.endsWith('/plugins/install')); + expect(req.request.method).toBe('POST'); + req.flush({ status: 'success', message: 'installed', data: null }); + }); + + it('should uninstall plugin', () => { + service.uninstall('weather').subscribe(); + const req = httpTesting.expectOne(r => r.url.endsWith('/plugins/uninstall')); + expect(req.request.method).toBe('POST'); + req.flush({ status: 'success', message: 'uninstalled', data: null }); + }); + + it('should get store plugins', () => { + service.getStorePlugins().subscribe(); + const req = httpTesting.expectOne(r => r.url.endsWith('/plugins/store/list')); + expect(req.request.method).toBe('GET'); + req.flush({ status: 'success', message: 'ok', data: [] }); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd frontend && npx ng test -- --run --reporter=verbose 2>&1 | tail -20` +Expected: FAIL + +- [ ] **Step 3: Implement PluginService** + +```typescript +// plugin.service.ts +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { ApiService } from './api.service'; +import type { SuccessResponse } from '../models/common.model'; +import type { PluginInfo, PluginConfigResponse, StorePlugin } from '../models/plugin.model'; + +@Injectable({ providedIn: 'root' }) +export class PluginService { + constructor(private readonly api: ApiService) {} + + list(): Observable<SuccessResponse<PluginInfo[]>> { + return this.api.get('/plugins/installed'); + } + + get(pluginId: string): Observable<SuccessResponse<PluginConfigResponse>> { + return this.api.get(`/plugins/config?plugin_id=${encodeURIComponent(pluginId)}`); + } + + getConfig(pluginId: string): Observable<SuccessResponse<PluginConfigResponse>> { + return this.api.get(`/plugins/config?plugin_id=${encodeURIComponent(pluginId)}`); + } + + updateConfig(pluginId: string, config: Record<string, unknown>): Observable<SuccessResponse> { + return this.api.post('/plugins/config', { plugin_id: pluginId, config }); + } + + toggle(pluginId: string, enabled: boolean): Observable<SuccessResponse> { + return this.api.post('/plugins/toggle', { plugin_id: pluginId, enabled }); + } + + install(pluginId: string): Observable<SuccessResponse> { + return this.api.post('/plugins/install', { plugin_id: pluginId }); + } + + uninstall(pluginId: string): Observable<SuccessResponse> { + return this.api.post('/plugins/uninstall', { plugin_id: pluginId }); + } + + getStorePlugins(): Observable<SuccessResponse<StorePlugin[]>> { + return this.api.get('/plugins/store/list'); + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd frontend && npx ng test -- --run --reporter=verbose 2>&1 | tail -20` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add frontend/src/app/core/services/plugin.service.ts frontend/src/app/core/services/plugin.service.spec.ts +git commit -m "feat(frontend): add PluginService for plugin CRUD and store operations" +``` + +--- + +## Task 8: ConfigService + +**Files:** +- Create: `frontend/src/app/core/services/config.service.ts` +- Create: `frontend/src/app/core/services/config.service.spec.ts` + +- [ ] **Step 1: Write failing tests** + +```typescript +// config.service.spec.ts +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { ConfigService } from './config.service'; + +describe('ConfigService', () => { + let service: ConfigService; + let httpTesting: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting()], + }); + service = TestBed.inject(ConfigService); + httpTesting = TestBed.inject(HttpTestingController); + }); + + afterEach(() => httpTesting.verify()); + + it('should get main config', () => { + service.getMainConfig().subscribe(res => { + expect(res.data).toBeTruthy(); + }); + const req = httpTesting.expectOne(r => r.url.endsWith('/config/main')); + expect(req.request.method).toBe('GET'); + req.flush({ status: 'success', message: 'ok', data: { display: {}, schedule: {}, general: {} } }); + }); + + it('should update main config', () => { + const update = { display: { brightness: 80 } }; + service.updateMainConfig(update).subscribe(); + const req = httpTesting.expectOne(r => r.url.endsWith('/config/main')); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual(update); + req.flush({ status: 'success', message: 'saved', data: null }); + }); + + it('should get schedule', () => { + service.getSchedule().subscribe(); + const req = httpTesting.expectOne(r => r.url.endsWith('/config/schedule')); + expect(req.request.method).toBe('GET'); + req.flush({ status: 'success', message: 'ok', data: { enabled: false, mode: 'global', start_time: '07:00', end_time: '23:00' } }); + }); + + it('should update schedule', () => { + const schedule = { enabled: true, mode: 'global', start_time: '08:00', end_time: '22:00' }; + service.updateSchedule(schedule).subscribe(); + const req = httpTesting.expectOne(r => r.url.endsWith('/config/schedule')); + expect(req.request.method).toBe('POST'); + req.flush({ status: 'success', message: 'saved', data: null }); + }); + + it('should get secrets', () => { + service.getSecrets().subscribe(); + const req = httpTesting.expectOne(r => r.url.endsWith('/config/secrets')); + expect(req.request.method).toBe('GET'); + req.flush({ status: 'success', message: 'ok', data: {} }); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd frontend && npx ng test -- --run --reporter=verbose 2>&1 | tail -20` +Expected: FAIL + +- [ ] **Step 3: Implement ConfigService** + +```typescript +// config.service.ts +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { ApiService } from './api.service'; +import type { SuccessResponse } from '../models/common.model'; +import type { SystemConfigResponse, ConfigUpdateRequest, ScheduleConfig } from '../models/config.model'; + +@Injectable({ providedIn: 'root' }) +export class ConfigService { + constructor(private readonly api: ApiService) {} + + getMainConfig(): Observable<SuccessResponse<SystemConfigResponse>> { + return this.api.get('/config/main'); + } + + updateMainConfig(config: ConfigUpdateRequest): Observable<SuccessResponse> { + return this.api.post('/config/main', config); + } + + getSchedule(): Observable<SuccessResponse<ScheduleConfig>> { + return this.api.get('/config/schedule'); + } + + updateSchedule(schedule: Partial<ScheduleConfig>): Observable<SuccessResponse> { + return this.api.post('/config/schedule', schedule); + } + + getSecrets(): Observable<SuccessResponse<Record<string, unknown>>> { + return this.api.get('/config/secrets'); + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd frontend && npx ng test -- --run --reporter=verbose 2>&1 | tail -20` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add frontend/src/app/core/services/config.service.ts frontend/src/app/core/services/config.service.spec.ts +git commit -m "feat(frontend): add ConfigService for config and schedule operations" +``` + +--- + +## Task 9: Barrel Export and Build Verification + +**Files:** +- Create: `frontend/src/app/core/index.ts` + +- [ ] **Step 1: Create barrel export** + +```typescript +// index.ts — public API for core module +// Models +export * from './models/common.model'; +export * from './models/system.model'; +export * from './models/plugin.model'; +export * from './models/config.model'; +export * from './models/stream.model'; + +// Errors +export { ApiError } from './errors/api-error'; + +// Services +export { ApiService } from './services/api.service'; +export { SseService } from './services/sse.service'; +export { SystemService } from './services/system.service'; +export { PluginService } from './services/plugin.service'; +export { ConfigService } from './services/config.service'; +``` + +- [ ] **Step 2: Run full test suite** + +Run: `cd frontend && npx ng test -- --run --reporter=verbose 2>&1 | tail -40` +Expected: All tests pass + +- [ ] **Step 3: Run production build** + +Run: `cd frontend && npx ng build 2>&1 | tail -10` +Expected: Build succeeds with no errors + +- [ ] **Step 4: Run lint** + +Run: `cd frontend && npx ng lint 2>&1 | tail -10` +Expected: No lint errors (fix any that arise) + +- [ ] **Step 5: Run verification checks from ticket** + +```bash +test -f frontend/src/app/core/services/api.service.ts && echo "OK: api service" +test -f frontend/src/app/core/services/sse.service.ts && echo "OK: sse service" +test -f frontend/src/app/core/services/system.service.ts && echo "OK: system service" +test -f frontend/src/app/core/services/plugin.service.ts && echo "OK: plugin service" +test -f frontend/src/app/core/services/config.service.ts && echo "OK: config service" +test -f frontend/src/app/core/models/system.model.ts && echo "OK: system models" +test -f frontend/src/app/core/models/plugin.model.ts && echo "OK: plugin models" +test -f frontend/src/app/core/models/common.model.ts && echo "OK: common models" +test -f frontend/src/app/core/models/stream.model.ts && echo "OK: stream models" +grep -q "error.interceptor\|errorInterceptor" frontend/src/app/app.config.ts && echo "OK: interceptor registered" +``` + +- [ ] **Step 6: Commit barrel and finalize** + +```bash +git add frontend/src/app/core/index.ts +git commit -m "feat(frontend): add barrel export for core service layer" +``` + +--- + +## SPIKE Tickets (Out of Scope) + +The following domain services are NOT covered by this ticket but will be needed by downstream feature modules. Create these as separate tickets: + +### SPIKE: FRONT-003a — FontService +**Scope:** `FontService` wrapping `/api/v3/fonts/*` endpoints (catalog, upload, preview, overrides, delete). Needed by settings module. + +### SPIKE: FRONT-003b — WifiService +**Scope:** `WifiService` wrapping `/api/v3/wifi/*` endpoints (status, scan, connect, disconnect, AP mode). Needed by settings module. + +### SPIKE: FRONT-003c — StarlarkService +**Scope:** `StarlarkService` wrapping `/api/v3/starlark/*` endpoints (apps CRUD, repository, pixlet install). Needed by plugins module if Starlark support is exposed. + +These follow the exact same pattern as SystemService/PluginService/ConfigService — inject `ApiService`, add typed methods. Each should take ~15 minutes to implement. diff --git a/docs/superpowers/plans/2026-03-20-primeng-theme-layout.md b/docs/superpowers/plans/2026-03-20-primeng-theme-layout.md new file mode 100644 index 000000000..3a8091160 --- /dev/null +++ b/docs/superpowers/plans/2026-03-20-primeng-theme-layout.md @@ -0,0 +1,1238 @@ +# FRONT-002: PrimeNG Integration and Dark Theme Layout — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Install PrimeNG, configure a dark-first theme, and build the application shell layout (sidebar + topbar + content area) that all feature modules render into. + +**Architecture:** Standalone Angular components using PrimeNG v20 with Aura dark theme. The `AppLayoutComponent` acts as the shell, composing a `SidebarComponent` (PrimeNG Drawer + Menu) and `TopbarComponent` (PrimeNG Toolbar). Responsive behavior toggles sidebar visibility via a signal. Three shared state components (Loading, ErrorState, EmptyState) are simple presentational components with inputs. + +**Tech Stack:** Angular 21, PrimeNG 20, @primeuix/themes (Aura), PrimeIcons, Vitest + +**Ticket:** `sprints/v3.0.0/FRONT-002-primeng-theme-layout.md` + +--- + +## File Structure + +### New files to create + +``` +frontend/src/app/ +├── layout/ +│ ├── app-layout.component.ts # Shell: composes sidebar + topbar + router-outlet +│ ├── app-layout.component.html # Template for the shell +│ ├── app-layout.component.scss # Shell styles (grid/flex layout) +│ ├── app-layout.component.spec.ts # Tests for layout logic +│ ├── sidebar/ +│ │ ├── sidebar.component.ts # Navigation sidebar with PrimeNG Menu +│ │ ├── sidebar.component.html # Sidebar template +│ │ ├── sidebar.component.scss # Sidebar styles +│ │ └── sidebar.component.spec.ts # Tests for sidebar nav items +│ └── topbar/ +│ ├── topbar.component.ts # Top bar with hamburger toggle + title +│ ├── topbar.component.html # Topbar template +│ ├── topbar.component.scss # Topbar styles +│ └── topbar.component.spec.ts # Tests for topbar toggle +├── shared/ +│ ├── loading/ +│ │ ├── loading.component.ts # Spinner with optional message +│ │ ├── loading.component.html +│ │ ├── loading.component.scss +│ │ └── loading.component.spec.ts +│ ├── error-state/ +│ │ ├── error-state.component.ts # Error icon + message + retry button +│ │ ├── error-state.component.html +│ │ ├── error-state.component.scss +│ │ └── error-state.component.spec.ts +│ └── empty-state/ +│ ├── empty-state.component.ts # Icon + message + optional action +│ ├── empty-state.component.html +│ ├── empty-state.component.scss +│ └── empty-state.component.spec.ts +``` + +### Files to modify + +``` +frontend/package.json # New dependencies (via npm install) +frontend/src/app/app.config.ts # Add providePrimeNG + provideAnimationsAsync +frontend/src/app/app.routes.ts # Add layout route with children +frontend/src/app/app.ts # Minimal changes (layout is routed) +frontend/src/app/app.html # Just router-outlet +frontend/src/index.html # Add app-dark class for dark-first +frontend/src/styles.scss # Global dark theme overrides + PrimeIcons +``` + +--- + +## Task 1: Install PrimeNG Dependencies + +**Files:** +- Modify: `frontend/package.json` (via npm install) + +- [ ] **Step 1: Install packages** + +```bash +cd frontend && npm install primeng @primeuix/themes primeicons @angular/animations +``` + +- [ ] **Step 2: Verify installation** + +```bash +cd frontend && grep -q '"primeng"' package.json && echo "OK: primeng" +cd frontend && grep -q '"@primeuix/themes"' package.json && echo "OK: @primeuix/themes" +cd frontend && grep -q '"primeicons"' package.json && echo "OK: primeicons" +cd frontend && grep -q '"@angular/animations"' package.json && echo "OK: @angular/animations" +``` + +- [ ] **Step 3: Commit** + +```bash +cd frontend && git add package.json package-lock.json +git commit -m "chore(frontend): install PrimeNG, PrimeIcons, and @primeuix/themes" +``` + +--- + +## Task 2: Configure Dark Theme and Animations + +**Files:** +- Modify: `frontend/src/app/app.config.ts` +- Modify: `frontend/src/index.html` +- Modify: `frontend/src/styles.scss` + +- [ ] **Step 1: Configure providePrimeNG in app.config.ts** + +```typescript +import { + ApplicationConfig, + provideBrowserGlobalErrorListeners, +} from '@angular/core'; +import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; +import { provideRouter } from '@angular/router'; +import { providePrimeNG } from 'primeng/config'; +import Aura from '@primeuix/themes/aura'; + +import { routes } from './app.routes'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideBrowserGlobalErrorListeners(), + provideAnimationsAsync(), + provideRouter(routes), + providePrimeNG({ + theme: { + preset: Aura, + options: { + darkModeSelector: '.app-dark', + }, + }, + }), + ], +}; +``` + +- [ ] **Step 2: Add dark mode class to index.html** + +Add `class="app-dark"` to the `<html>` tag in `frontend/src/index.html` so dark theme is active by default. + +- [ ] **Step 3: Add PrimeIcons font import and base styles to styles.scss** + +```scss +@import 'primeicons/primeicons.css'; + +html, +body { + margin: 0; + padding: 0; + height: 100%; + font-family: var(--font-family); + background: var(--p-surface-ground); + color: var(--p-text-color); +} +``` + +- [ ] **Step 4: Verify build still works** + +```bash +cd frontend && npx ng build +``` +Expected: Build succeeds with no errors. + +- [ ] **Step 5: Commit** + +```bash +git add frontend/src/app/app.config.ts frontend/src/index.html frontend/src/styles.scss +git commit -m "feat(frontend): configure PrimeNG Aura dark theme" +``` + +--- + +## Task 3: Create TopbarComponent + +**Files:** +- Create: `frontend/src/app/layout/topbar/topbar.component.ts` +- Create: `frontend/src/app/layout/topbar/topbar.component.html` +- Create: `frontend/src/app/layout/topbar/topbar.component.scss` +- Test: `frontend/src/app/layout/topbar/topbar.component.spec.ts` + +- [ ] **Step 1: Write failing test (RED)** + +```typescript +// frontend/src/app/layout/topbar/topbar.component.spec.ts +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TopbarComponent } from './topbar.component'; +import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; + +describe('TopbarComponent', () => { + let component: TopbarComponent; + let fixture: ComponentFixture<TopbarComponent>; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TopbarComponent], + providers: [provideAnimationsAsync()], + }).compileComponents(); + + fixture = TestBed.createComponent(TopbarComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should emit toggleSidebar when hamburger is clicked', () => { + const spy = vi.fn(); + component.toggleSidebar.subscribe(spy); + component.onToggleSidebar(); + expect(spy).toHaveBeenCalled(); + }); + + it('should display the app title', () => { + const el: HTMLElement = fixture.nativeElement; + expect(el.textContent).toContain('LEDMatrix'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cd frontend && npx ng test --watch=false +``` +Expected: FAIL — TopbarComponent does not exist. + +- [ ] **Step 3: Implement TopbarComponent (GREEN)** + +```typescript +// frontend/src/app/layout/topbar/topbar.component.ts +import { Component, output } from '@angular/core'; +import { ToolbarModule } from 'primeng/toolbar'; +import { ButtonModule } from 'primeng/button'; + +@Component({ + selector: 'app-topbar', + standalone: true, + imports: [ToolbarModule, ButtonModule], + templateUrl: './topbar.component.html', + styleUrl: './topbar.component.scss', +}) +export class TopbarComponent { + toggleSidebar = output<void>(); + + onToggleSidebar(): void { + this.toggleSidebar.emit(); + } +} +``` + +```html +<!-- frontend/src/app/layout/topbar/topbar.component.html --> +<p-toolbar> + <ng-template #start> + <p-button + icon="pi pi-bars" + [text]="true" + (click)="onToggleSidebar()" + aria-label="Toggle sidebar" + /> + <span class="app-title">LEDMatrix</span> + </ng-template> + <ng-template #end> + <span class="status-indicator"> + <i class="pi pi-circle-fill status-dot"></i> + </span> + </ng-template> +</p-toolbar> +``` + +```scss +// frontend/src/app/layout/topbar/topbar.component.scss +.app-title { + font-size: 1.25rem; + font-weight: 600; + margin-left: 0.5rem; +} + +.status-dot { + color: var(--p-green-400); + font-size: 0.75rem; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +cd frontend && npx ng test --watch=false +``` +Expected: All TopbarComponent tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add frontend/src/app/layout/topbar/ +git commit -m "feat(frontend): add TopbarComponent with hamburger toggle" +``` + +--- + +## Task 4: Create SidebarComponent + +**Files:** +- Create: `frontend/src/app/layout/sidebar/sidebar.component.ts` +- Create: `frontend/src/app/layout/sidebar/sidebar.component.html` +- Create: `frontend/src/app/layout/sidebar/sidebar.component.scss` +- Test: `frontend/src/app/layout/sidebar/sidebar.component.spec.ts` + +- [ ] **Step 1: Write failing test (RED)** + +```typescript +// frontend/src/app/layout/sidebar/sidebar.component.spec.ts +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; +import { SidebarComponent } from './sidebar.component'; + +describe('SidebarComponent', () => { + let component: SidebarComponent; + let fixture: ComponentFixture<SidebarComponent>; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SidebarComponent], + providers: [provideRouter([]), provideAnimationsAsync()], + }).compileComponents(); + + fixture = TestBed.createComponent(SidebarComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should have 5 navigation items', () => { + expect(component.navItems.length).toBe(5); + }); + + it('should have Dashboard as first item with route /', () => { + expect(component.navItems[0].label).toBe('Dashboard'); + expect(component.navItems[0].routerLink).toBe('/'); + }); + + it('should have Plugins item with route /plugins', () => { + const item = component.navItems.find((i) => i.label === 'Plugins'); + expect(item).toBeTruthy(); + expect(item!.routerLink).toBe('/plugins'); + }); + + it('should have Settings item with route /settings', () => { + const item = component.navItems.find((i) => i.label === 'Settings'); + expect(item).toBeTruthy(); + expect(item!.routerLink).toBe('/settings'); + }); + + it('should have Logs item with route /logs', () => { + const item = component.navItems.find((i) => i.label === 'Logs'); + expect(item).toBeTruthy(); + expect(item!.routerLink).toBe('/logs'); + }); + + it('should have Store item with route /store', () => { + const item = component.navItems.find((i) => i.label === 'Store'); + expect(item).toBeTruthy(); + expect(item!.routerLink).toBe('/store'); + }); + + it('should have an icon for each nav item', () => { + for (const item of component.navItems) { + expect(item.icon).toBeTruthy(); + expect(item.icon).toMatch(/^pi pi-/); + } + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cd frontend && npx ng test --watch=false +``` +Expected: FAIL — SidebarComponent does not exist. + +- [ ] **Step 3: Implement SidebarComponent (GREEN)** + +```typescript +// frontend/src/app/layout/sidebar/sidebar.component.ts +import { Component } from '@angular/core'; +import { RouterLink, RouterLinkActive } from '@angular/router'; +import { CommonModule } from '@angular/common'; + +export interface NavItem { + label: string; + icon: string; + routerLink: string; +} + +@Component({ + selector: 'app-sidebar', + standalone: true, + imports: [CommonModule, RouterLink, RouterLinkActive], + templateUrl: './sidebar.component.html', + styleUrl: './sidebar.component.scss', +}) +export class SidebarComponent { + readonly navItems: NavItem[] = [ + { label: 'Dashboard', icon: 'pi pi-home', routerLink: '/' }, + { label: 'Plugins', icon: 'pi pi-th-large', routerLink: '/plugins' }, + { label: 'Settings', icon: 'pi pi-cog', routerLink: '/settings' }, + { label: 'Logs', icon: 'pi pi-list', routerLink: '/logs' }, + { label: 'Store', icon: 'pi pi-shopping-bag', routerLink: '/store' }, + ]; +} +``` + +```html +<!-- frontend/src/app/layout/sidebar/sidebar.component.html --> +<nav class="sidebar-nav" aria-label="Main navigation"> + @for (item of navItems; track item.routerLink) { + <a + [routerLink]="item.routerLink" + routerLinkActive="active" + [routerLinkActiveOptions]="item.routerLink === '/' ? { exact: true } : { exact: false }" + class="nav-item" + > + <i [class]="item.icon"></i> + <span class="nav-label">{{ item.label }}</span> + </a> + } +</nav> +``` + +```scss +// frontend/src/app/layout/sidebar/sidebar.component.scss +.sidebar-nav { + display: flex; + flex-direction: column; + padding: 1rem 0; +} + +.nav-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1.5rem; + color: var(--p-text-color); + text-decoration: none; + border-radius: 6px; + margin: 0.125rem 0.5rem; + transition: background-color 0.2s; + + &:hover { + background: var(--p-surface-hover); + } + + &.active { + background: var(--p-primary-color); + color: var(--p-primary-contrast-color); + } + + i { + font-size: 1.25rem; + width: 1.5rem; + text-align: center; + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +cd frontend && npx ng test --watch=false +``` +Expected: All SidebarComponent tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add frontend/src/app/layout/sidebar/ +git commit -m "feat(frontend): add SidebarComponent with navigation items" +``` + +--- + +## Task 5: Create AppLayoutComponent (Shell) + +**Files:** +- Create: `frontend/src/app/layout/app-layout.component.ts` +- Create: `frontend/src/app/layout/app-layout.component.html` +- Create: `frontend/src/app/layout/app-layout.component.scss` +- Test: `frontend/src/app/layout/app-layout.component.spec.ts` + +- [ ] **Step 1: Write failing test (RED)** + +```typescript +// frontend/src/app/layout/app-layout.component.spec.ts +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; +import { AppLayoutComponent } from './app-layout.component'; + +describe('AppLayoutComponent', () => { + let component: AppLayoutComponent; + let fixture: ComponentFixture<AppLayoutComponent>; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [AppLayoutComponent], + providers: [provideRouter([]), provideAnimationsAsync()], + }).compileComponents(); + + fixture = TestBed.createComponent(AppLayoutComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should have sidebarVisible default to true', () => { + expect(component.sidebarVisible()).toBe(true); + }); + + it('should toggle sidebarVisible when toggleSidebar is called', () => { + expect(component.sidebarVisible()).toBe(true); + component.toggleSidebar(); + expect(component.sidebarVisible()).toBe(false); + component.toggleSidebar(); + expect(component.sidebarVisible()).toBe(true); + }); + + it('should render the topbar', () => { + const el: HTMLElement = fixture.nativeElement; + expect(el.querySelector('app-topbar')).toBeTruthy(); + }); + + it('should render a router-outlet for content', () => { + const el: HTMLElement = fixture.nativeElement; + expect(el.querySelector('router-outlet')).toBeTruthy(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cd frontend && npx ng test --watch=false +``` +Expected: FAIL — AppLayoutComponent does not exist. + +- [ ] **Step 3: Implement AppLayoutComponent (GREEN)** + +```typescript +// frontend/src/app/layout/app-layout.component.ts +import { Component, signal, HostListener } from '@angular/core'; +import { RouterOutlet } from '@angular/router'; +import { DrawerModule } from 'primeng/drawer'; +import { TopbarComponent } from './topbar/topbar.component'; +import { SidebarComponent } from './sidebar/sidebar.component'; + +const MOBILE_BREAKPOINT = 768; + +@Component({ + selector: 'app-layout', + standalone: true, + imports: [RouterOutlet, DrawerModule, TopbarComponent, SidebarComponent], + templateUrl: './app-layout.component.html', + styleUrl: './app-layout.component.scss', +}) +export class AppLayoutComponent { + sidebarVisible = signal(true); + isMobile = signal(false); + + constructor() { + this.checkMobile(); + } + + toggleSidebar(): void { + this.sidebarVisible.update((v) => !v); + } + + @HostListener('window:resize') + onResize(): void { + this.checkMobile(); + } + + private checkMobile(): void { + if (typeof window !== 'undefined') { + const mobile = window.innerWidth < MOBILE_BREAKPOINT; + this.isMobile.set(mobile); + if (mobile) { + this.sidebarVisible.set(false); + } + } + } +} +``` + +```html +<!-- frontend/src/app/layout/app-layout.component.html --> +<div class="app-layout"> + <app-topbar (toggleSidebar)="toggleSidebar()" /> + + @if (isMobile()) { + <!-- Mobile: PrimeNG Drawer overlay --> + <p-drawer + [(visible)]="sidebarVisible" + [modal]="true" + [showCloseIcon]="true" + position="left" + styleClass="app-sidebar-drawer" + > + <ng-template #header> + <span class="drawer-title">LEDMatrix</span> + </ng-template> + <app-sidebar /> + </p-drawer> + } @else { + <!-- Desktop: static sidebar --> + @if (sidebarVisible()) { + <aside class="app-sidebar"> + <app-sidebar /> + </aside> + } + } + + <main class="app-content" [class.sidebar-open]="!isMobile() && sidebarVisible()"> + <router-outlet /> + </main> +</div> +``` + +```scss +// frontend/src/app/layout/app-layout.component.scss +.app-layout { + display: flex; + flex-direction: column; + height: 100vh; +} + +.app-sidebar { + position: fixed; + top: 0; + left: 0; + bottom: 0; + width: 240px; + padding-top: 60px; // below topbar + background: var(--p-surface-card); + border-right: 1px solid var(--p-surface-border); + overflow-y: auto; + z-index: 100; +} + +.app-content { + flex: 1; + padding: 1.5rem; + margin-top: 60px; // below topbar + transition: margin-left 0.2s; + + &.sidebar-open { + margin-left: 240px; + } +} + +.drawer-title { + font-size: 1.25rem; + font-weight: 600; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +cd frontend && npx ng test --watch=false +``` +Expected: All AppLayoutComponent tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add frontend/src/app/layout/app-layout.component.* +git commit -m "feat(frontend): add AppLayoutComponent shell with responsive sidebar" +``` + +--- + +## Task 6: Wire Layout into Routing + +**Files:** +- Modify: `frontend/src/app/app.routes.ts` +- Modify: `frontend/src/app/app.ts` +- Modify: `frontend/src/app/app.html` + +- [ ] **Step 1: Configure routes with layout shell** + +```typescript +// frontend/src/app/app.routes.ts +import { Routes } from '@angular/router'; + +export const routes: Routes = [ + { + path: '', + loadComponent: () => + import('./layout/app-layout.component').then((m) => m.AppLayoutComponent), + children: [ + // Feature modules will be lazy-loaded here in FRONT-004/005/006 + ], + }, +]; +``` + +- [ ] **Step 2: Simplify app.html to just router-outlet** + +```html +<!-- frontend/src/app/app.html --> +<router-outlet /> +``` + +- [ ] **Step 3: Update app.ts (remove unused title signal)** + +```typescript +// frontend/src/app/app.ts +import { Component } from '@angular/core'; +import { RouterOutlet } from '@angular/router'; + +@Component({ + selector: 'app-root', + standalone: true, + imports: [RouterOutlet], + templateUrl: './app.html', + styleUrl: './app.scss', +}) +export class App {} +``` + +- [ ] **Step 4: Update app.spec.ts to match simplified App component** + +The existing `app.spec.ts` tests for a `title` signal and `<h1>` rendering that no longer exist. Replace with tests that match the shell wrapper role. + +```typescript +// frontend/src/app/app.spec.ts +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { App } from './app'; + +describe('App', () => { + let fixture: ComponentFixture<App>; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [App], + providers: [provideRouter([])], + }).compileComponents(); + fixture = TestBed.createComponent(App); + fixture.detectChanges(); + }); + + it('should create the app', () => { + expect(fixture.componentInstance).toBeTruthy(); + }); + + it('should render a router-outlet', () => { + const el: HTMLElement = fixture.nativeElement; + expect(el.querySelector('router-outlet')).toBeTruthy(); + }); +}); +``` + +- [ ] **Step 5: Verify build** + +```bash +cd frontend && npx ng build +``` +Expected: Build succeeds. + +- [ ] **Step 6: Run tests** + +```bash +cd frontend && npx ng test --watch=false +``` +Expected: All tests pass (including updated app.spec.ts). + +- [ ] **Step 7: Commit** + +```bash +git add frontend/src/app/app.routes.ts frontend/src/app/app.ts frontend/src/app/app.html frontend/src/app/app.spec.ts +git commit -m "feat(frontend): wire layout shell into app routing" +``` + +--- + +## Task 7: Create Shared State Components + +**Files:** +- Create: `frontend/src/app/shared/loading/loading.component.ts` + html + scss + spec +- Create: `frontend/src/app/shared/error-state/error-state.component.ts` + html + scss + spec +- Create: `frontend/src/app/shared/empty-state/empty-state.component.ts` + html + scss + spec + +### 7a: LoadingComponent + +- [ ] **Step 1: Write failing test (RED)** + +```typescript +// frontend/src/app/shared/loading/loading.component.spec.ts +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { LoadingComponent } from './loading.component'; + +describe('LoadingComponent', () => { + let fixture: ComponentFixture<LoadingComponent>; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [LoadingComponent], + }).compileComponents(); + fixture = TestBed.createComponent(LoadingComponent); + }); + + it('should create', () => { + expect(fixture.componentInstance).toBeTruthy(); + }); + + it('should show default message when none provided', () => { + fixture.detectChanges(); + const el: HTMLElement = fixture.nativeElement; + expect(el.querySelector('.loading-message')?.textContent?.trim()).toBe('Loading...'); + }); + + it('should show custom message when provided', () => { + fixture.componentRef.setInput('message', 'Fetching plugins'); + fixture.detectChanges(); + const el: HTMLElement = fixture.nativeElement; + expect(el.querySelector('.loading-message')?.textContent?.trim()).toBe('Fetching plugins'); + }); + + it('should render a spinner element', () => { + fixture.detectChanges(); + const el: HTMLElement = fixture.nativeElement; + expect(el.querySelector('.pi-spin')).toBeTruthy(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cd frontend && npx ng test --watch=false +``` + +- [ ] **Step 3: Implement LoadingComponent (GREEN)** + +```typescript +// frontend/src/app/shared/loading/loading.component.ts +import { Component, input } from '@angular/core'; + +@Component({ + selector: 'app-loading', + standalone: true, + templateUrl: './loading.component.html', + styleUrl: './loading.component.scss', +}) +export class LoadingComponent { + message = input('Loading...'); +} +``` + +```html +<!-- frontend/src/app/shared/loading/loading.component.html --> +<div class="loading-container"> + <i class="pi pi-spin pi-spinner loading-spinner"></i> + <span class="loading-message">{{ message() }}</span> +</div> +``` + +```scss +// frontend/src/app/shared/loading/loading.component.scss +.loading-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1rem; + padding: 3rem; +} + +.loading-spinner { + font-size: 2.5rem; + color: var(--p-primary-color); +} + +.loading-message { + color: var(--p-text-muted-color); + font-size: 0.95rem; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +cd frontend && npx ng test --watch=false +``` + +- [ ] **Step 5: Commit** + +```bash +git add frontend/src/app/shared/loading/ +git commit -m "feat(frontend): add LoadingComponent with spinner and message" +``` + +### 7b: ErrorStateComponent + +- [ ] **Step 6: Write failing test (RED)** + +```typescript +// frontend/src/app/shared/error-state/error-state.component.spec.ts +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; +import { ErrorStateComponent } from './error-state.component'; + +describe('ErrorStateComponent', () => { + let fixture: ComponentFixture<ErrorStateComponent>; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ErrorStateComponent], + providers: [provideAnimationsAsync()], + }).compileComponents(); + fixture = TestBed.createComponent(ErrorStateComponent); + }); + + it('should create', () => { + expect(fixture.componentInstance).toBeTruthy(); + }); + + it('should display the error message', () => { + fixture.componentRef.setInput('message', 'Something went wrong'); + fixture.detectChanges(); + const el: HTMLElement = fixture.nativeElement; + expect(el.textContent).toContain('Something went wrong'); + }); + + it('should show default message when none provided', () => { + fixture.detectChanges(); + const el: HTMLElement = fixture.nativeElement; + expect(el.textContent).toContain('An error occurred'); + }); + + it('should emit retry when retry button is clicked', () => { + fixture.detectChanges(); + const spy = vi.fn(); + fixture.componentInstance.retry.subscribe(spy); + fixture.componentInstance.onRetry(); + expect(spy).toHaveBeenCalled(); + }); + + it('should render an error icon', () => { + fixture.detectChanges(); + const el: HTMLElement = fixture.nativeElement; + expect(el.querySelector('.pi-exclamation-circle')).toBeTruthy(); + }); +}); +``` + +- [ ] **Step 7: Run test to verify it fails** + +```bash +cd frontend && npx ng test --watch=false +``` + +- [ ] **Step 8: Implement ErrorStateComponent (GREEN)** + +```typescript +// frontend/src/app/shared/error-state/error-state.component.ts +import { Component, input, output } from '@angular/core'; +import { ButtonModule } from 'primeng/button'; + +@Component({ + selector: 'app-error-state', + standalone: true, + imports: [ButtonModule], + templateUrl: './error-state.component.html', + styleUrl: './error-state.component.scss', +}) +export class ErrorStateComponent { + message = input('An error occurred'); + retry = output<void>(); + + onRetry(): void { + this.retry.emit(); + } +} +``` + +```html +<!-- frontend/src/app/shared/error-state/error-state.component.html --> +<div class="error-container"> + <i class="pi pi-exclamation-circle error-icon"></i> + <p class="error-message">{{ message() }}</p> + <p-button label="Retry" icon="pi pi-refresh" (click)="onRetry()" severity="secondary" /> +</div> +``` + +```scss +// frontend/src/app/shared/error-state/error-state.component.scss +.error-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1rem; + padding: 3rem; +} + +.error-icon { + font-size: 3rem; + color: var(--p-red-400); +} + +.error-message { + color: var(--p-text-muted-color); + font-size: 0.95rem; + margin: 0; +} +``` + +- [ ] **Step 9: Run tests to verify they pass** + +```bash +cd frontend && npx ng test --watch=false +``` + +- [ ] **Step 10: Commit** + +```bash +git add frontend/src/app/shared/error-state/ +git commit -m "feat(frontend): add ErrorStateComponent with retry button" +``` + +### 7c: EmptyStateComponent + +- [ ] **Step 11: Write failing test (RED)** + +```typescript +// frontend/src/app/shared/empty-state/empty-state.component.spec.ts +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; +import { EmptyStateComponent } from './empty-state.component'; + +describe('EmptyStateComponent', () => { + let fixture: ComponentFixture<EmptyStateComponent>; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [EmptyStateComponent], + providers: [provideAnimationsAsync()], + }).compileComponents(); + fixture = TestBed.createComponent(EmptyStateComponent); + }); + + it('should create', () => { + expect(fixture.componentInstance).toBeTruthy(); + }); + + it('should display the message', () => { + fixture.componentRef.setInput('message', 'No plugins found'); + fixture.detectChanges(); + const el: HTMLElement = fixture.nativeElement; + expect(el.textContent).toContain('No plugins found'); + }); + + it('should show default message when none provided', () => { + fixture.detectChanges(); + const el: HTMLElement = fixture.nativeElement; + expect(el.textContent).toContain('Nothing here yet'); + }); + + it('should show action button when actionLabel is provided', () => { + fixture.componentRef.setInput('actionLabel', 'Add Plugin'); + fixture.detectChanges(); + const el: HTMLElement = fixture.nativeElement; + expect(el.textContent).toContain('Add Plugin'); + }); + + it('should not show action button when actionLabel is not provided', () => { + fixture.detectChanges(); + const el: HTMLElement = fixture.nativeElement; + expect(el.querySelector('p-button')).toBeFalsy(); + }); + + it('should emit action when action button is clicked', () => { + fixture.componentRef.setInput('actionLabel', 'Add Plugin'); + fixture.detectChanges(); + const spy = vi.fn(); + fixture.componentInstance.action.subscribe(spy); + fixture.componentInstance.onAction(); + expect(spy).toHaveBeenCalled(); + }); + + it('should render an icon', () => { + fixture.detectChanges(); + const el: HTMLElement = fixture.nativeElement; + expect(el.querySelector('.pi-inbox')).toBeTruthy(); + }); +}); +``` + +- [ ] **Step 12: Run test to verify it fails** + +```bash +cd frontend && npx ng test --watch=false +``` + +- [ ] **Step 13: Implement EmptyStateComponent (GREEN)** + +```typescript +// frontend/src/app/shared/empty-state/empty-state.component.ts +import { Component, input, output } from '@angular/core'; +import { ButtonModule } from 'primeng/button'; + +@Component({ + selector: 'app-empty-state', + standalone: true, + imports: [ButtonModule], + templateUrl: './empty-state.component.html', + styleUrl: './empty-state.component.scss', +}) +export class EmptyStateComponent { + icon = input('pi pi-inbox'); + message = input('Nothing here yet'); + actionLabel = input<string | undefined>(undefined); + action = output<void>(); + + onAction(): void { + this.action.emit(); + } +} +``` + +```html +<!-- frontend/src/app/shared/empty-state/empty-state.component.html --> +<div class="empty-container"> + <i [class]="icon() + ' empty-icon'"></i> + <p class="empty-message">{{ message() }}</p> + @if (actionLabel()) { + <p-button [label]="actionLabel()!" (click)="onAction()" severity="secondary" /> + } +</div> +``` + +```scss +// frontend/src/app/shared/empty-state/empty-state.component.scss +.empty-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1rem; + padding: 3rem; +} + +.empty-icon { + font-size: 3rem; + color: var(--p-text-muted-color); +} + +.empty-message { + color: var(--p-text-muted-color); + font-size: 0.95rem; + margin: 0; +} +``` + +- [ ] **Step 14: Run tests to verify they pass** + +```bash +cd frontend && npx ng test --watch=false +``` + +- [ ] **Step 15: Commit** + +```bash +git add frontend/src/app/shared/empty-state/ +git commit -m "feat(frontend): add EmptyStateComponent with optional action button" +``` + +--- + +## Task 8: Final Verification + +- [ ] **Step 1: Run full test suite** + +```bash +cd frontend && npx ng test --watch=false +``` +Expected: All tests pass. + +- [ ] **Step 2: Run lint** + +```bash +cd frontend && npx ng lint +``` +Expected: No lint errors. + +- [ ] **Step 3: Run production build** + +```bash +cd frontend && npx ng build +``` +Expected: Build succeeds, output in `frontend/dist/ledmatrix/browser/`. + +- [ ] **Step 4: Run ticket verification commands** + +```bash +cd frontend && grep -q "primeng" package.json && echo "OK: primeng installed" +cd frontend && npx ng build && echo "OK: build with PrimeNG" +test -f frontend/src/app/layout/app-layout.component.ts && echo "OK: layout component" +test -f frontend/src/app/shared/loading/loading.component.ts && echo "OK: loading component" +test -f frontend/src/app/shared/error-state/error-state.component.ts && echo "OK: error-state component" +test -f frontend/src/app/shared/empty-state/empty-state.component.ts && echo "OK: empty-state component" +grep -q "Dashboard" frontend/src/app/layout/sidebar/sidebar.component.ts && echo "OK: nav items defined" +``` + +- [ ] **Step 5: Update ticket status** + +Edit `sprints/v3.0.0/FRONT-002-primeng-theme-layout.md` — change `**Status:** Open` to `**Status:** Done`. + +--- + +## Notes + +- **PrimeNG v20** uses `@primeuix/themes` (not `@primeng/themes` as the ticket suggests — package was renamed). The import is `import Aura from '@primeuix/themes/aura'`. +- **Dark-first:** Achieved by adding `class="app-dark"` to `<html>` in `index.html` and setting `darkModeSelector: '.app-dark'` in PrimeNG config. This can later be toggled to support light mode. +- **`@angular/animations`** must be installed as a dependency for `provideAnimationsAsync()` which PrimeNG requires. +- **No feature module content** is built in this ticket — Dashboard, Plugins, Settings, Logs, Store are separate tickets (FRONT-004/005/006). +- **Responsive approach:** Desktop shows a static sidebar. Mobile (< 768px) uses PrimeNG Drawer as an overlay triggered by hamburger button. +- The sidebar uses plain `<a routerLink>` rather than PrimeNG Menu to keep it simple and fully styled with the dark theme. PrimeNG Menu could be swapped in later if needed.