From 250319b82c1879c58798f968de200de0096f665d Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Wed, 2 Sep 2026 01:27:22 -0700 Subject: [PATCH 1/6] fix(backends): treat unknown select states as no current option Home Assistant reports `unknown` when a select entity has no current_option (no active scene, no saved preset) and `unavailable` when the entity is gone. readSelectModel copied that sentinel straight into `current`, so the dropdown header rendered the literal word "unknown" instead of the empty text the select spec carries ("No active scene"). Hypercolor's builtin Default scene is not part of the scene catalog, so every fresh install hit this on the scene header. Both backends now map those two states to an empty current option and leave the option list and availability untouched. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Y994hFQGNrDey7wfXAEhNd --- src/backends/hypercolor.ts | 12 +++++++- src/backends/signalrgb.ts | 12 +++++++- tests/backends/hypercolor.test.ts | 46 +++++++++++++++++++++++++++++++ tests/backends/signalrgb.test.ts | 37 +++++++++++++++++++++++++ 4 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 tests/backends/signalrgb.test.ts diff --git a/src/backends/hypercolor.ts b/src/backends/hypercolor.ts index 7da2955..2fda8b0 100644 --- a/src/backends/hypercolor.ts +++ b/src/backends/hypercolor.ts @@ -550,12 +550,22 @@ function siblingEntityOnDevice( return matches.length === 1 ? matches[0] : null; } +/** + * A select whose entity reports `unknown` or `unavailable` has no current + * option. Home Assistant uses those sentinel states when `current_option` + * is None, and they must not leak into the header as literal text. + */ +function selectCurrent(state: string | undefined): string { + if (!state || state === 'unknown' || state === 'unavailable') return ''; + return state; +} + function readSelectModel(hass: HomeAssistant, entityId?: string): SelectModel | null { if (!entityId) return null; const stateObj = hass.states[entityId]; if (!stateObj) return { current: '', options: [], available: false }; return { - current: stateObj.state ?? '', + current: selectCurrent(stateObj.state), options: Array.isArray(stateObj.attributes.options) ? (stateObj.attributes.options as string[]) : [], diff --git a/src/backends/signalrgb.ts b/src/backends/signalrgb.ts index 6771af4..25a368b 100644 --- a/src/backends/signalrgb.ts +++ b/src/backends/signalrgb.ts @@ -198,12 +198,22 @@ export const signalRgbBackend: LightBackend = { }, }; +/** + * A select whose entity reports `unknown` or `unavailable` has no current + * option. Home Assistant uses those sentinel states when `current_option` + * is None, and they must not leak into the header as literal text. + */ +function selectCurrent(state: string | undefined): string { + if (!state || state === 'unknown' || state === 'unavailable') return ''; + return state; +} + function readSelectModel(hass: HomeAssistant, entityId?: string): SelectModel | null { if (!entityId) return null; const stateObj = hass.states[entityId]; if (!stateObj) return { current: '', options: [], available: false }; return { - current: stateObj.state ?? '', + current: selectCurrent(stateObj.state), options: Array.isArray(stateObj.attributes.options) ? (stateObj.attributes.options as string[]) : [], diff --git a/tests/backends/hypercolor.test.ts b/tests/backends/hypercolor.test.ts index 0fc0335..816da85 100644 --- a/tests/backends/hypercolor.test.ts +++ b/tests/backends/hypercolor.test.ts @@ -542,6 +542,52 @@ describe('hypercolorBackend.autoDiscover', () => { }); }); +describe('hypercolorBackend select models', () => { + const config = { + entity: 'light.hypercolor', + preset_entity: 'select.hypercolor_preset', + hypercolor: { scene_entity: 'select.hypercolor_scene' }, + } as Config; + + it('treats an unknown scene state as no current option', () => { + const { hass } = hassWithStates({ + 'select.hypercolor_scene': { + state: 'unknown', + attributes: { options: ['Studio Session', 'Movie Night'] }, + }, + }); + expect(hypercolorBackend.scenes?.({ hass, config })).toEqual({ + current: '', + options: ['Studio Session', 'Movie Night'], + available: true, + }); + }); + + it('keeps the options of an unavailable preset select but drops its state', () => { + const { hass } = hassWithStates({ + 'select.hypercolor_preset': { + state: 'unavailable', + attributes: { options: ['Hyperkandi'] }, + }, + }); + expect(hypercolorBackend.presets({ hass, config })).toEqual({ + current: '', + options: ['Hyperkandi'], + available: false, + }); + }); + + it('passes a real option through untouched', () => { + const { hass } = hassWithStates({ + 'select.hypercolor_scene': { + state: 'Movie Night', + attributes: { options: ['Studio Session', 'Movie Night'] }, + }, + }); + expect(hypercolorBackend.scenes?.({ hass, config })?.current).toBe('Movie Night'); + }); +}); + describe('hypercolorBackend.liveControls', () => { it('labels effect brightness distinctly from the card brightness slider', () => { const hass = hassWith(['light.hypercolor', 'number.hypercolor_brightness']); diff --git a/tests/backends/signalrgb.test.ts b/tests/backends/signalrgb.test.ts new file mode 100644 index 0000000..93fb3ed --- /dev/null +++ b/tests/backends/signalrgb.test.ts @@ -0,0 +1,37 @@ +import type { HomeAssistant } from 'custom-card-helpers'; +import { describe, expect, it } from 'vitest'; +import { signalRgbBackend } from '@/backends/signalrgb'; +import type { Config } from '@/config'; + +function hassWithSelect(state: string, options: string[]): HomeAssistant { + return { + states: { + 'select.signalrgb_layout': { + entity_id: 'select.signalrgb_layout', + state, + attributes: { options }, + }, + }, + } as unknown as HomeAssistant; +} + +const config = { + entity: 'light.signalrgb_home', + layout_entity: 'select.signalrgb_layout', +} as Config; + +describe('signalRgbBackend.layouts', () => { + it('treats an unknown layout state as no current option', () => { + const hass = hassWithSelect('unknown', ['Desk', 'Wall']); + expect(signalRgbBackend.layouts({ hass, config })).toEqual({ + current: '', + options: ['Desk', 'Wall'], + available: true, + }); + }); + + it('passes a real option through untouched', () => { + const hass = hassWithSelect('Wall', ['Desk', 'Wall']); + expect(signalRgbBackend.layouts({ hass, config })?.current).toBe('Wall'); + }); +}); From 0f5c5a6d6a90b928bfc7a9296c7e542630d902b0 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Wed, 2 Sep 2026 01:28:16 -0700 Subject: [PATCH 2/6] refactor: remove the dead profile selector The Hypercolor integration retired its profile select when profiles folded into scenes upstream, and it ships no `profile` translation key, so registry discovery could never populate profile_entity and the selector could never render. The card still carried a full vertical slice for it: config keys, backend contract methods, dropdown state, outside-click and scroll bookkeeping, an editor toggle, styles, and README rows advertising a control that cannot appear. Everything profile-shaped is gone. Scene handling is untouched and the select kinds are now layout, preset, and scene. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Y994hFQGNrDey7wfXAEhNd --- README.md | 8 ++--- src/backends/hypercolor.ts | 12 ------- src/backends/types.ts | 4 +-- src/config.ts | 2 -- src/hyper-light-card-editor.ts | 3 -- src/hyper-light-card-styles.css | 8 ++--- src/hyper-light-card.ts | 55 +++++---------------------------- src/state-manager.ts | 18 +---------- src/state.ts | 10 ------ 9 files changed, 15 insertions(+), 105 deletions(-) diff --git a/README.md b/README.md index 970a2a2..d06e260 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ _A dazzling custom card for controlling SignalRGB and Hypercolor lights through - ℹ️ Rich effect info: description, publisher, and tag chips - πŸŽ›οΈ Full effect controls: sliders, toggles, palette pickers, and color swatches - πŸ“Š Layout and preset selection for SignalRGB -- 🎬 Scenes and profiles for Hypercolor, with live runtime controls +- 🎬 Scenes for Hypercolor, with live runtime controls - πŸ—ΊοΈ Scene zone controls for tuning each render group's brightness and power - 🎧 Audio controls for toggling reactivity and picking the input device - πŸ›°οΈ Status chips for FPS, audio reactivity, and connectivity (Hypercolor) @@ -140,7 +140,6 @@ name: 'Living Room' show_status_chips: true show_live_controls: true show_scene_select: true -show_profile_select: true show_audio_controls: true show_zones: true show_per_device: false @@ -163,7 +162,6 @@ entity: light.hypercolor_hyperia backend: hypercolor hypercolor: scene_entity: select.hypercolor_hyperia_scene - profile_entity: select.hypercolor_hyperia_profile stop_effect_entity: button.hypercolor_hyperia_stop_effect fps_entity: sensor.hypercolor_hyperia_fps connected_entity: binary_sensor.hypercolor_hyperia_connected @@ -221,7 +219,6 @@ These default to the device-scoped helpers exposed by the SignalRGB integration, | Option | Type | Default | Description | | --------------------- | ------- | ------- | -------------------------------------------------------- | | `show_scene_select` | boolean | `true` | Show the scene selector dropdown | -| `show_profile_select` | boolean | `false` | Show the profile selector dropdown | | `show_live_controls` | boolean | `true` | Show effect controls (sliders, toggles, palette + color pickers) | | `show_audio_controls` | boolean | `true` | Show the audio-reactive toggle and input device selector (when present) | | `show_zones` | boolean | `true` | Show scene zone controls for per-group brightness and power (when present) | @@ -242,7 +239,6 @@ discovery. | Option | Type | Description | | ------------------------------ | -------- | ------------------------------------------------------- | | `scene_entity` | string | Scene `select` entity | -| `profile_entity` | string | Profile `select` entity | | `stop_effect_entity` | string | Stop-effect `button` entity | | `fps_entity` | string | Render-rate `sensor` for the FPS chip | | `connected_entity` | string | Daemon connectivity `binary_sensor` | @@ -264,7 +260,7 @@ hyper-light-card adapts to whichever backend it detects: - **Power Toggle**: Click the light icon to turn the light on or off. - **Effect Selection**: Use the dropdown to choose from available effects. -- **Layout & Preset / Scene & Profile**: SignalRGB exposes layouts and presets; Hypercolor exposes scenes and (optionally) profiles. +- **Layout & Preset / Scene**: SignalRGB exposes layouts and presets; Hypercolor exposes scenes. - **Effect Navigation**: Cycle through effects with next, previous, and random buttons. - **Brightness Control**: Adjust brightness with the slider. - **Live Controls**: Drag Hypercolor's intensity, speed, and parameter sliders without flooding the bus. Input is coalesced while you drag and committed on release. diff --git a/src/backends/hypercolor.ts b/src/backends/hypercolor.ts index 2fda8b0..88e1909 100644 --- a/src/backends/hypercolor.ts +++ b/src/backends/hypercolor.ts @@ -89,10 +89,6 @@ export const hypercolorBackend: LightBackend = { return readSelectModel(ctx.hass, addenda(ctx.config).scene_entity); }, - profiles(ctx) { - return readSelectModel(ctx.hass, addenda(ctx.config).profile_entity); - }, - navigation(ctx) { const extra = addenda(ctx.config); return { @@ -255,12 +251,6 @@ export const hypercolorBackend: LightBackend = { await ctx.hass.callService('select', 'select_option', { entity_id: entityId, option: value }); }, - async setProfile(ctx, value) { - const entityId = addenda(ctx.config).profile_entity; - if (!entityId) return; - await ctx.hass.callService('select', 'select_option', { entity_id: entityId, option: value }); - }, - async setLiveControl(ctx, id, value) { const entityId = addenda(ctx.config).live_control_entities?.[id as HypercolorLiveControlId]; // The four canonical controls have smooth `number.*` entities; use them so @@ -363,7 +353,6 @@ export const hypercolorBackend: LightBackend = { if (found) extra[key] = found as HypercolorConfigAddenda[K]; }; discoverExtra('scene_entity', 'select', 'scene'); - discoverExtra('profile_entity', 'select', 'profile'); discoverExtra('stop_effect_entity', 'button', 'stop_effect'); discoverExtra('fps_entity', 'sensor', 'fps'); discoverExtra('connected_entity', 'binary_sensor', 'connected'); @@ -431,7 +420,6 @@ export const hypercolorBackend: LightBackend = { show_preset_select: true, show_effect_controls: true, show_scene_select: true, - show_profile_select: false, show_live_controls: true, show_status_chips: true, show_per_device: false, diff --git a/src/backends/types.ts b/src/backends/types.ts index ffe8d06..4cd3f96 100644 --- a/src/backends/types.ts +++ b/src/backends/types.ts @@ -152,7 +152,7 @@ export interface ZoneModel { * (SignalRGB, Hypercolor, future ones) behind view models. Render code * consumes the view models and never reads `stateObj.attributes` directly. * - * Optional methods (`scenes`, `profiles`, `liveControls`, etc.) signal that + * Optional methods (`scenes`, `liveControls`, etc.) signal that * the backend exposes a richer surface; the card gates UI accordingly. */ export interface LightBackend { @@ -166,7 +166,6 @@ export interface LightBackend { layouts(ctx: BackendContext): SelectModel | null; presets(ctx: BackendContext): SelectModel | null; scenes?(ctx: BackendContext): SelectModel | null; - profiles?(ctx: BackendContext): SelectModel | null; navigation(ctx: BackendContext): NavigationModel; liveControls?(ctx: BackendContext): LiveControlModel[]; @@ -183,7 +182,6 @@ export interface LightBackend { setLayout(ctx: BackendContext, value: string): Promise; setPreset(ctx: BackendContext, value: string): Promise; setScene?(ctx: BackendContext, value: string): Promise; - setProfile?(ctx: BackendContext, value: string): Promise; setLiveControl?(ctx: BackendContext, id: string, value: number | string | boolean): Promise; /** Toggle audio-reactive processing on/off. */ setAudioReactive?(ctx: BackendContext, on: boolean): Promise; diff --git a/src/config.ts b/src/config.ts index b2e00ab..0f840ba 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,7 +2,6 @@ export type HypercolorLiveControlId = 'brightness' | 'speed' | 'hue_shift' | 'in export interface HypercolorConfigAddenda { scene_entity?: string; - profile_entity?: string; stop_effect_entity?: string; fps_entity?: string; connected_entity?: string; @@ -45,7 +44,6 @@ export interface Config { /** Hypercolor-only feature toggles (Phase 4). */ show_scene_select?: boolean; - show_profile_select?: boolean; show_live_controls?: boolean; show_status_chips?: boolean; show_per_device?: boolean; diff --git a/src/hyper-light-card-editor.ts b/src/hyper-light-card-editor.ts index e36ed71..0a291de 100644 --- a/src/hyper-light-card-editor.ts +++ b/src/hyper-light-card-editor.ts @@ -47,7 +47,6 @@ export class HyperLightCardEditor extends LitElement implements LovelaceCardEdit show_preset_select: true, show_effect_controls: true, show_scene_select: true, - show_profile_select: false, show_live_controls: true, show_status_chips: true, show_per_device: false, @@ -152,7 +151,6 @@ function buildSchema( const hypercolorVisibilitySchema: FormSchemaEntry[] = isHypercolor ? [ { name: 'show_scene_select', selector: { boolean: {} } }, - { name: 'show_profile_select', selector: { boolean: {} } }, { name: 'show_live_controls', selector: { boolean: {} } }, { name: 'show_audio_controls', selector: { boolean: {} } }, { name: 'show_zones', selector: { boolean: {} } }, @@ -220,7 +218,6 @@ const LABELS: Record = { show_preset_select: 'Show preset selector', show_effect_controls: 'Show effect navigation', show_scene_select: 'Show scene selector', - show_profile_select: 'Show profile selector', show_live_controls: 'Show live effect controls', show_audio_controls: 'Show audio controls (reactive toggle, input device)', show_zones: 'Show scene zones', diff --git a/src/hyper-light-card-styles.css b/src/hyper-light-card-styles.css index c45ca92..ed9b129 100644 --- a/src/hyper-light-card-styles.css +++ b/src/hyper-light-card-styles.css @@ -1018,15 +1018,13 @@ ha-switch { cursor: not-allowed; } -/* Scene + profile selector wrappers reuse the existing dropdown styles */ -.scene-select-wrapper, -.profile-select-wrapper { +/* The scene selector wrapper reuses the existing dropdown styles */ +.scene-select-wrapper { position: relative; z-index: 5; } -.scene-select-wrapper .dropdown-header, -.profile-select-wrapper .dropdown-header { +.scene-select-wrapper .dropdown-header { border-width: 1px; padding: 6px 10px; background-color: rgba(var(--background-color-rgb, 255, 255, 255), 0.5); diff --git a/src/hyper-light-card.ts b/src/hyper-light-card.ts index 277c3d3..ca576f2 100644 --- a/src/hyper-light-card.ts +++ b/src/hyper-light-card.ts @@ -113,7 +113,7 @@ export class HyperLightCard extends LitElement { } // Scroll each newly-opened list to its active row exactly once, and re-arm - // when it closes. Scene and profile were previously left out entirely. + // when it closes. for (const [isOpen, selector] of this._dropdownScrollTargets()) { if (isOpen && !this._scrolledDropdowns.has(selector)) { this._scrolledDropdowns.add(selector); @@ -130,7 +130,6 @@ export class HyperLightCard extends LitElement { [this.state.isLayoutDropdownOpen, '.layout-select-wrapper'], [this.state.isPresetDropdownOpen, '.preset-select-wrapper'], [this.state.isSceneDropdownOpen, '.scene-select-wrapper'], - [this.state.isProfileDropdownOpen, '.profile-select-wrapper'], ]; } @@ -156,7 +155,6 @@ export class HyperLightCard extends LitElement { const layouts = backend.layouts(ctx); const presets = backend.presets(ctx); const scenes = backend.scenes?.(ctx) ?? null; - const profiles = backend.profiles?.(ctx) ?? null; const navigation = backend.navigation(ctx); const liveControls = backend.liveControls?.(ctx) ?? []; const connectivity = backend.connectivity?.(ctx) ?? null; @@ -168,7 +166,6 @@ export class HyperLightCard extends LitElement { const showStatusChips = this.config.show_status_chips !== false; const showSceneSelect = this.config.show_scene_select !== false && scenes !== null; - const showProfileSelect = this.config.show_profile_select === true && profiles !== null; const showLiveControls = this.config.show_live_controls !== false && liveControls.length > 0; const showAudioControls = this.config.show_audio_controls !== false && audioControls !== null; const showPerDevice = this.config.show_per_device === true && devices.length > 0; @@ -193,10 +190,8 @@ export class HyperLightCard extends LitElement { ${this.state.showEffectParameters ? this._renderAttributes(effect, layouts, presets, { scenes, - profiles, liveControls, showSceneSelect, - showProfileSelect, showLiveControls, }) : ''} @@ -336,7 +331,7 @@ export class HyperLightCard extends LitElement { /** * One dropdown row. Every selector renders through this so selection * highlighting, keyboard activation, and ARIA stay identical across the - * effect, layout, preset, scene, and profile pickers. + * effect, layout, preset, and scene pickers. */ private _renderDropdownItem(label: string, selected: boolean, activate: () => void) { return html` @@ -502,10 +497,8 @@ export class HyperLightCard extends LitElement { presets: SelectModel | null, extras: { scenes: SelectModel | null; - profiles: SelectModel | null; liveControls: LiveControlModel[]; showSceneSelect: boolean; - showProfileSelect: boolean; showLiveControls: boolean; } ) { @@ -513,17 +506,8 @@ export class HyperLightCard extends LitElement { const hasLayouts = Boolean(layouts?.options.length) && this.state.showLayoutSelect; const hasPresets = Boolean(presets?.options.length) && this.state.showPresetSelect; const hasScenes = extras.scenes && extras.scenes.options.length > 0 && extras.showSceneSelect; - const hasProfiles = - extras.profiles && extras.profiles.options.length > 0 && extras.showProfileSelect; const hasLiveControls = extras.showLiveControls && extras.liveControls.length > 0; - if ( - !hasParameters && - !hasLayouts && - !hasPresets && - !hasScenes && - !hasProfiles && - !hasLiveControls - ) { + if (!hasParameters && !hasLayouts && !hasPresets && !hasScenes && !hasLiveControls) { return html``; } @@ -533,8 +517,7 @@ export class HyperLightCard extends LitElement { const nestedDropdownOpen = this.state.isLayoutDropdownOpen || this.state.isPresetDropdownOpen || - this.state.isSceneDropdownOpen || - this.state.isProfileDropdownOpen; + this.state.isSceneDropdownOpen; return html`
${this._renderLayoutSelect(layouts)} ${this._renderPresetSelect(presets)} ${hasScenes ? this._renderSceneSelect(extras.scenes!) : ''} - ${hasProfiles ? this._renderProfileSelect(extras.profiles!) : ''}
${hasLiveControls ? this._renderLiveControls(extras.liveControls) @@ -676,14 +658,14 @@ export class HyperLightCard extends LitElement { } /** - * Shared renderer for the compact selectors (layout, preset, scene, - * profile). A selector whose entity exposes no options renders nothing at + * Shared renderer for the compact selectors (layout, preset, scene). + * A selector whose entity exposes no options renders nothing at * all rather than a dead "No X available" row. Hypercolor's preset entity * legitimately has an empty option list for most effects, and a permanently * disabled control reads as breakage. */ private _renderSelect( - kind: 'layout' | 'preset' | 'scene' | 'profile', + kind: 'layout' | 'preset' | 'scene', model: SelectModel | null, spec: { icon: string; @@ -733,16 +715,6 @@ export class HyperLightCard extends LitElement { }); } - private _renderProfileSelect(profiles: SelectModel) { - return this._renderSelect('profile', profiles, { - icon: 'mdi:account-cog', - title: 'Profile', - empty: 'No active profile', - isOpen: this.state.isProfileDropdownOpen, - select: profile => this._selectProfile(profile), - }); - } - private _renderAudioControls(audioControls: AudioControlsModel) { const { reactive, device } = audioControls; const deviceOptions = device?.options ?? []; @@ -1000,7 +972,7 @@ export class HyperLightCard extends LitElement { this.stateManager.toggleDropdown(); } - private _toggleSelectDropdown(e: Event, kind: 'layout' | 'preset' | 'scene' | 'profile') { + private _toggleSelectDropdown(e: Event, kind: 'layout' | 'preset' | 'scene') { e.stopPropagation(); switch (kind) { case 'layout': @@ -1009,8 +981,6 @@ export class HyperLightCard extends LitElement { return this.stateManager.togglePresetDropdown(); case 'scene': return this.stateManager.toggleSceneDropdown(); - case 'profile': - return this.stateManager.toggleProfileDropdown(); } } @@ -1024,10 +994,6 @@ export class HyperLightCard extends LitElement { await this.stateManager.setScene(scene); } - private async _selectProfile(profile: string) { - await this.stateManager.setProfile(profile); - } - private async _identifyDevice(entityId: string) { if (!this.hass) return; await this.hass.callService('button', 'press', { entity_id: entityId }); @@ -1099,11 +1065,6 @@ export class HyperLightCard extends LitElement { '.scene-select-wrapper .dropdown', () => this.stateManager.toggleSceneDropdown(), ], - [ - this.state.isProfileDropdownOpen, - '.profile-select-wrapper .dropdown', - () => this.stateManager.toggleProfileDropdown(), - ], ]; for (const [isOpen, selector, close] of dropdowns) { if (!isOpen) continue; diff --git a/src/state-manager.ts b/src/state-manager.ts index d626a8f..16dd10f 100644 --- a/src/state-manager.ts +++ b/src/state-manager.ts @@ -148,27 +148,18 @@ export class StateManager { } } - toggleProfileDropdown() { - this._state.isProfileDropdownOpen = !this._state.isProfileDropdownOpen; - if (this._state.isProfileDropdownOpen) { - this._closeOtherDropdowns('profile'); - } - } - closeAllDropdowns() { this._state.isDropdownOpen = false; this._state.isLayoutDropdownOpen = false; this._state.isPresetDropdownOpen = false; this._state.isSceneDropdownOpen = false; - this._state.isProfileDropdownOpen = false; } - private _closeOtherDropdowns(keep: 'effect' | 'layout' | 'preset' | 'scene' | 'profile') { + private _closeOtherDropdowns(keep: 'effect' | 'layout' | 'preset' | 'scene') { if (keep !== 'effect') this._state.isDropdownOpen = false; if (keep !== 'layout') this._state.isLayoutDropdownOpen = false; if (keep !== 'preset') this._state.isPresetDropdownOpen = false; if (keep !== 'scene') this._state.isSceneDropdownOpen = false; - if (keep !== 'profile') this._state.isProfileDropdownOpen = false; } async setScene(value: string) { @@ -178,13 +169,6 @@ export class StateManager { this._state.isSceneDropdownOpen = false; } - async setProfile(value: string) { - const ctx = this._ctx; - if (!ctx) return; - await this._backend.setProfile?.(ctx, value); - this._state.isProfileDropdownOpen = false; - } - /** * Update a live control value. Coalesces rapid drag input into one * service call per ~50ms window; the final value always lands on diff --git a/src/state.ts b/src/state.ts index 8dfd7a6..25922eb 100644 --- a/src/state.ts +++ b/src/state.ts @@ -21,7 +21,6 @@ export class State implements ReactiveController { private _showPresetSelect = true; private _isSceneDropdownOpen = false; - private _isProfileDropdownOpen = false; // Effect navigation controls private _showEffectControls = true; @@ -185,13 +184,4 @@ export class State implements ReactiveController { this._isSceneDropdownOpen = value; this._host.requestUpdate(); } - - get isProfileDropdownOpen() { - return this._isProfileDropdownOpen; - } - - set isProfileDropdownOpen(value: boolean) { - this._isProfileDropdownOpen = value; - this._host.requestUpdate(); - } } From 64aba494907babc738860c1605b8325f76671e62 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Wed, 2 Sep 2026 01:28:57 -0700 Subject: [PATCH 3/6] feat(editor): offer an explicit auto-detect backend choice The backend dropdown only listed SignalRGB and Hypercolor, so the first time a user touched it the override was pinned into the card config and auto-detection was gone for good, with no way back short of editing YAML. The label even said "(auto-detected)" while offering no such option. The form now shows an Auto-detect entry first. It is a display-only sentinel: toFormData() substitutes it when the config has no `backend` key, and fromFormData() strips it again before the config is emitted, so the stored card config never carries the sentinel and detectBackend() keeps treating an absent key as auto. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Y994hFQGNrDey7wfXAEhNd --- src/hyper-light-card-editor.ts | 31 ++++++++++++++++++++--- tests/hyper-light-card-editor.test.ts | 36 +++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 4 deletions(-) create mode 100644 tests/hyper-light-card-editor.test.ts diff --git a/src/hyper-light-card-editor.ts b/src/hyper-light-card-editor.ts index 0a291de..a401279 100644 --- a/src/hyper-light-card-editor.ts +++ b/src/hyper-light-card-editor.ts @@ -16,6 +16,28 @@ interface FormSchemaEntry { default?: unknown; } +/** + * Sentinel the backend dropdown uses for "no override". The card config + * never stores it: an absent `backend` key means auto-detect, and the form + * needs a concrete value to show that state as selected. + */ +export const AUTO_BACKEND = 'auto'; + +type BackendChoice = NonNullable | typeof AUTO_BACKEND; +type FormData = Omit & { backend: BackendChoice }; + +/** Shape the stored config into what ha-form displays. */ +export function toFormData(config: Config): FormData { + return { ...config, backend: config.backend || AUTO_BACKEND }; +} + +/** Turn a form value back into a card config, dropping the auto sentinel. */ +export function fromFormData(value: Omit & { backend?: string }): Config { + const { backend, ...rest } = value; + if (!backend || backend === AUTO_BACKEND) return rest as Config; + return { ...rest, backend: backend as Config['backend'] }; +} + export class HyperLightCardEditor extends LitElement implements LovelaceCardEditor { @property({ attribute: false }) public hass!: HomeAssistant; @state() private _config: Config = { @@ -66,7 +88,7 @@ export class HyperLightCardEditor extends LitElement implements LovelaceCardEdit return html` = { entity: 'Light entity', name: 'Card name (optional)', icon: 'Icon (optional)', - backend: 'Backend (auto-detected)', + backend: 'Backend', layout_entity: 'Layout select entity', preset_entity: 'Preset select entity', next_effect_entity: 'Next-effect button', @@ -232,7 +255,7 @@ function computeLabel(schema: FormSchemaEntry): string { } const HELPERS: Record = { - backend: 'Override backend detection. Leave on the auto value unless something is off.', + backend: 'Auto-detect picks the backend from the entity. Override only if detection is wrong.', show_per_device: 'Adds an expandable list of child lights below the main card. Off by default.', allowed_effects: 'Limit the effect dropdown. Empty means all available effects.', }; diff --git a/tests/hyper-light-card-editor.test.ts b/tests/hyper-light-card-editor.test.ts new file mode 100644 index 0000000..ec20d01 --- /dev/null +++ b/tests/hyper-light-card-editor.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import type { Config } from '@/config'; +import { AUTO_BACKEND, fromFormData, toFormData } from '@/hyper-light-card-editor'; + +describe('editor backend choice', () => { + it('shows an absent backend as auto-detect', () => { + const config = { entity: 'light.hyperia' } as Config; + expect(toFormData(config).backend).toBe(AUTO_BACKEND); + }); + + it('shows a legacy empty backend as auto-detect', () => { + const config = { entity: 'light.hypercolor', backend: '' } as unknown as Config; + expect(toFormData(config).backend).toBe(AUTO_BACKEND); + }); + + it('shows an explicit override as itself', () => { + const config = { entity: 'light.hyperia', backend: 'hypercolor' } as Config; + expect(toFormData(config).backend).toBe('hypercolor'); + }); + + it('drops the backend key when auto-detect is chosen', () => { + const next = fromFormData({ entity: 'light.hyperia', backend: AUTO_BACKEND }); + expect('backend' in next).toBe(false); + expect(next.entity).toBe('light.hyperia'); + }); + + it('drops an empty backend the same way', () => { + const next = fromFormData({ entity: 'light.hyperia', backend: '' }); + expect('backend' in next).toBe(false); + }); + + it('keeps a real override', () => { + const next = fromFormData({ entity: 'light.hyperia', backend: 'signalrgb' }); + expect(next.backend).toBe('signalrgb'); + }); +}); From 7f7ceb50fe16cf8a808db4fc1399e78fdf74981c Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Wed, 2 Sep 2026 01:29:54 -0700 Subject: [PATCH 4/6] fix(card): make the controls row keyboard and screen-reader clean The brightness wrapper declared role="slider" with its own aria-value attributes while wrapping a native range input that already carries slider semantics, so assistive tech announced two sliders for one control. The wrapper is plain layout again and the input keeps its label. The effect-parameters chevron was role="button" with only a click handler, so it was invisible to Tab and dead to Enter and Space. It now takes focus, reuses the dropdown keydown handler that clicks on Enter/Space, and gets the same focus ring the selectors use. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Y994hFQGNrDey7wfXAEhNd --- src/hyper-light-card-styles.css | 5 +++++ src/hyper-light-card.ts | 6 ++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/hyper-light-card-styles.css b/src/hyper-light-card-styles.css index ed9b129..b142d90 100644 --- a/src/hyper-light-card-styles.css +++ b/src/hyper-light-card-styles.css @@ -493,6 +493,11 @@ ha-card { background-color: rgba(var(--rgb-primary-text-color, 225, 225, 225), 0.2); } +.attributes-toggle:focus-visible { + outline: 2px solid var(--accent-color, var(--primary-color, #03a9f4)); + outline-offset: 2px; +} + .attributes-toggle ha-icon { display: block; transition: transform 0.3s ease; diff --git a/src/hyper-light-card.ts b/src/hyper-light-card.ts index ca576f2..83d57a9 100644 --- a/src/hyper-light-card.ts +++ b/src/hyper-light-card.ts @@ -454,10 +454,6 @@ export class HyperLightCard extends LitElement {
From e0806a5cf5d089db6e475b6167b688ad7e728941 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Wed, 2 Sep 2026 01:29:55 -0700 Subject: [PATCH 5/6] docs: describe the controls the card actually renders The README promised colour pickers and per-device toggles. Colour and palette controls are read-only swatches (the backend marks every colour control unavailable), and per-device rows show a brightness readout and an Identify button, never a power toggle. The wording now matches the code, and package.json stops describing the card as SignalRGB-only. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Y994hFQGNrDey7wfXAEhNd --- README.md | 6 +++--- package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index d06e260..1f3a5f4 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ _A dazzling custom card for controlling SignalRGB and Hypercolor lights through - πŸ”€ Effect switching with an intelligent dropdown menu that highlights and scrolls to the running effect - ⌨️ Keyboard-navigable selectors with visible focus rings - ℹ️ Rich effect info: description, publisher, and tag chips -- πŸŽ›οΈ Full effect controls: sliders, toggles, palette pickers, and color swatches +- πŸŽ›οΈ Live effect controls: sliders, toggles, and read-only palette and color swatches - πŸ“Š Layout and preset selection for SignalRGB - 🎬 Scenes for Hypercolor, with live runtime controls - πŸ—ΊοΈ Scene zone controls for tuning each render group's brightness and power @@ -219,7 +219,7 @@ These default to the device-scoped helpers exposed by the SignalRGB integration, | Option | Type | Default | Description | | --------------------- | ------- | ------- | -------------------------------------------------------- | | `show_scene_select` | boolean | `true` | Show the scene selector dropdown | -| `show_live_controls` | boolean | `true` | Show effect controls (sliders, toggles, palette + color pickers) | +| `show_live_controls` | boolean | `true` | Show effect controls (sliders, toggles, palette and color swatches) | | `show_audio_controls` | boolean | `true` | Show the audio-reactive toggle and input device selector (when present) | | `show_zones` | boolean | `true` | Show scene zone controls for per-group brightness and power (when present) | | `show_status_chips` | boolean | `true` | Show FPS, audio reactivity, and connectivity chips | @@ -266,7 +266,7 @@ hyper-light-card adapts to whichever backend it detects: - **Live Controls**: Drag Hypercolor's intensity, speed, and parameter sliders without flooding the bus. Input is coalesced while you drag and committed on release. - **Keyboard**: Tab to any selector, then Enter or Space to open it and to pick a row. - **Status Chips**: Hypercolor surfaces FPS, audio reactivity, and connectivity at a glance. -- **Per-Device Drilldown**: Expand grouped Hypercolor installs to see and toggle each child light. +- **Per-Device Drilldown**: Expand grouped Hypercolor installs to see each child light's brightness and flash it with Identify. - **Effect Info & Parameters**: Description, publisher, and current parameter values for the running effect. The card pulls its accent palette from the running effect image, giving each effect its own coherent look. The extracted background is measured against the WCAG relative-luminance threshold to pick black or white foreground text, so labels stay readable on any cover art. diff --git a/package.json b/package.json index 53db10f..6d13491 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "hyper-light-card", "version": "1.0.0", - "description": "A custom Lovelace card for Home Assistant to control SignalRGB", + "description": "A custom Lovelace card for Home Assistant that controls SignalRGB and Hypercolor lighting", "author": "Stefanie Jane ", "license": "Apache 2.0", "keywords": [ From 3b6adcaa8eeb7688088f7a4a93e31d3c4b9aa4d7 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Wed, 2 Sep 2026 01:30:21 -0700 Subject: [PATCH 6/6] ci(release): dispatch the build so the release asset actually lands release.yml pushes the version tag with GITHUB_TOKEN, and GitHub never starts workflow runs for pushes made with that token. cicd.yml listens on tag pushes, so the chain stopped dead after the tag: no build, no GitHub release, no hyper-light-card.js asset for HACS. The step summary claimed otherwise. No release has gone through this path yet. The release job now dispatches cicd.yml on the tag ref by hand (a workflow_dispatch from GITHUB_TOKEN is allowed), passes its own run id through for release notes, and watches the run to completion so a red build fails the release job too. The job gains actions: write for the dispatch. Dry runs stop before the tag as before. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Y994hFQGNrDey7wfXAEhNd --- .github/workflows/release.yml | 45 ++++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e565a8f..716bac6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,6 +23,7 @@ on: default: false permissions: + actions: write contents: write concurrency: @@ -130,6 +131,48 @@ jobs: git push origin HEAD:${{ github.ref_name }} git push origin "v${{ steps.version.outputs.version }}" + # A tag pushed with GITHUB_TOKEN never fires cicd.yml's push trigger + # (GitHub suppresses workflow runs caused by that token), so the build + # and release job is dispatched by hand on the tag ref and watched + # here until it finishes. + - name: Dispatch and watch the build and release + id: publish + if: ${{ !inputs.dry_run }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: v${{ steps.version.outputs.version }} + run: | + set -euo pipefail + candidate_sha="$(git rev-parse HEAD)" + before_id="$( + gh run list --workflow cicd.yml --event workflow_dispatch \ + --branch "${TAG}" --commit "${candidate_sha}" --limit 1 \ + --json databaseId --jq '.[0].databaseId // empty' + )" + gh workflow run cicd.yml --ref "${TAG}" -f release_run_id="${GITHUB_RUN_ID}" + + run_id="" + for _ in {1..30}; do + candidate_id="$( + gh run list --workflow cicd.yml --event workflow_dispatch \ + --branch "${TAG}" --commit "${candidate_sha}" --limit 1 \ + --json databaseId --jq '.[0].databaseId // empty' + )" + if [[ -n "${candidate_id}" && "${candidate_id}" != "${before_id}" ]]; then + run_id="${candidate_id}" + break + fi + sleep 2 + done + if [[ -z "${run_id}" ]]; then + echo "::error::Timed out locating the cicd.yml run for ${TAG}" + exit 1 + fi + + run_url="$(gh run view "${run_id}" --json url --jq '.url')" + echo "run_url=${run_url}" >> "${GITHUB_OUTPUT}" + gh run watch "${run_id}" --exit-status + - name: Summary run: | echo "## Release ${{ inputs.dry_run && '[DRY RUN] ' || '' }}v${{ steps.version.outputs.version }}" >> $GITHUB_STEP_SUMMARY @@ -139,5 +182,5 @@ jobs: else echo "Version bumped: ${{ steps.version.outputs.current }} -> ${{ steps.version.outputs.version }}" >> $GITHUB_STEP_SUMMARY echo "Tag pushed: v${{ steps.version.outputs.version }}" >> $GITHUB_STEP_SUMMARY - echo "The tag push will run cicd.yml and attach the release artifact." >> $GITHUB_STEP_SUMMARY + echo "Build and release run: ${{ steps.publish.outputs.run_url }}" >> $GITHUB_STEP_SUMMARY fi