// WashData - Home Assistant integration for appliance cycle monitoring via smart plugs.
// Copyright (C) 2026 Lukas Bandura
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
// ha-washdata-panel.js - WashData full-screen panel
// Registers the custom element.
//
// Display constants (state colors, device types) come from the backend
// (ha_washdata/get_constants) so they are defined in exactly one place. State
// and device-type labels are localized via hass.localize against the
// integration translations, with the backend values as canonical fallback.
'use strict';
const _DOMAIN = 'ha_washdata';
// Cache-buster for on-demand asset fetches (per-language translation files). The
// panel module is imported as ha-washdata-panel.js?v=, so reuse that same
// version: translation files are then cached per release and busted on upgrade.
// Empty when unavailable (e.g. loaded without a query) — we simply omit ?v=.
const _PANEL_VERSION = (() => {
try { return new URL(import.meta.url).searchParams.get('v') || ''; }
catch (_) { return ''; }
})();
// The panel is push-driven: HA calls set hass() on every entity state change
// (realtime), and subscribe_events / subscribe_tasks push cycle + task updates.
// So the interval poll is only a slow SAFETY heartbeat for store-derived data
// (suggestions/feedback counts) that isn't reflected in an entity state, and the
// set-hass refresh is coalesced. Previously these were a tight 5s poll + a 2s
// refetch-on-every-global-change, which did ~30 full refetches/min for nothing.
const _POLL_MS = 20000;
const _HASS_REFRESH_MS = 6000;
// Cycles-tab page size. Kept modest so the "Load more" control actually engages
// for typical histories instead of loading everything in one page.
const _CYCLE_PAGE_SIZE = 25;
// Declarative community-store preference toggles, rendered in the gear's Online &
// Community pane. To ship a new online setting: add one row here AND one default in
// store_account._DEFAULT_PREFS -- the generic get_prefs / store_set_prefs plumbing
// carries it end-to-end (no per-setting wiring). All are booleans.
const _STORE_PREFS = [
{ key: 'show_contributor', labelKey: 'lbl.show_contributor', labelFb: 'Show contributor names',
docKey: 'setting.show_contributor.doc', docFb: 'Show the "by " attribution on community appliances and reference cycles.' },
];
// Height (CSS px) of the band above the Playground plot where event pin heads
// sit, out of the busy curve area. Shared by _pgDrawCanvas and the pointer
// layout() closure so threshold-drag math stays aligned with the drawn plot.
const _PG_PIN_BAND_H = 34;
// Distinct colors for overlaying many cycle curves (history cleanup).
const _PALETTE = [
'#e6194B', '#3cb44b', '#4363d8', '#f58231', '#911eb4', '#42d4f4',
'#f032e6', '#bfef45', '#fabed4', '#469990', '#dcbeff', '#9A6324',
'#800000', '#aaffc3', '#808000', '#ffd8b1', '#000075', '#a9a9a9',
];
// ─── Settings schema (single declarative source) ───────────────────────────────
// Each field: {key, label, type, unit?, step?, min?, max?, def?, hint?, doc?, opts?}
// type: number | text | textarea | checkbox | select | entity | device | devicetype | list
// `doc` is shown in a hover tooltip (condensed from SETTINGS_VISUALIZED.md).
// Defined before _SETTINGS_SECTIONS because notification field docs reference it.
const _NOTIFY_VARS = '{device}, {duration}, {minutes}, {program}, {energy_kwh}, {cost}, {time_finished}, {vs_typical}, {cycle_count}';
const _SETTINGS_SECTIONS = [
{ id: 'basic', label: 'Basic', intro: 'Core identity and the essentials most setups need.', groups: [
{ sub: 'Device info', fields: [
{ key: 'name', label: 'Device Name', type: 'text',
doc: 'Display name shown in the HA integrations list and device registry.' },
{ key: 'device_type', label: 'Device Type', type: 'devicetype',
doc: 'Appliance class. Sets sensible detection defaults (thresholds, off-delay, end handling) tuned for that appliance type; change it only if the device was originally set up as the wrong type.' },
{ key: 'store_brand', label: 'Appliance Brand', type: 'storebrand', optional: true,
doc: 'Optional. The appliance brand, picked from the community catalog. Used to find and share matching reference recordings. Leave blank if you are not using online features.' },
{ key: 'store_model', label: 'Appliance Model', type: 'storemodel', optional: true,
doc: 'Optional. The appliance model, picked from the community catalog once a brand is set. If your model is not listed you can add it to the catalog.' },
] },
{ sub: 'Basic configuration', fields: [
{ key: 'power_sensor', label: 'Power Sensor', type: 'entity', domain: 'sensor',
doc: 'The sensor entity reporting live power in watts for this appliance (e.g. sensor.washer_power). All cycle detection is based on this signal.' },
{ key: 'min_power', label: 'Minimum Power', unit: 'W', type: 'number', step: 0.1, min: 0, def: 2.0, basic: true,
doc: 'Absolute minimum power considered active. Readings below this are treated as 0 W (standby), filtering out the phantom load of smart plugs and standby LEDs.' },
{ key: 'off_delay', label: 'Off Delay', unit: 's', type: 'number', min: 0, def: 180, basic: true,
doc: 'Time to wait after power drops before declaring the cycle finished. If power resumes within this window the cycle continues seamlessly - this bridges pauses between wash stages. Dishwashers have long drying phases (power off for 20-60 min) so the off-delay must exceed that to keep the whole wash+dry as one cycle.' },
{ key: 'linked_device', label: 'Group Under Device', type: 'device',
doc: 'Optionally nest this WashData device under another device (e.g. the smart plug) in the HA device registry, shown as "Connected via ...".' },
] },
] },
{ id: 'detection', label: 'Detection', intro: 'How a cycle is detected as starting, running and finishing.', groups: [
{ sub: 'Thresholds & Gap', fields: [
{ key: 'start_threshold_w', label: 'Start Threshold', unit: 'W', type: 'number', step: 1, min: 0, basic: true,
doc: 'Power must rise above this level to confirm a cycle has started. Setting it too low causes false starts from standby power; too high and slow-starting programs (cold fill) are missed. The suggestion engine sets this just above the machine\'s observed lowest active power.' },
{ key: 'stop_threshold_w', label: 'Stop Threshold', unit: 'W', type: 'number', step: 0.1, min: 0, basic: true,
doc: 'Power must fall below this level before the off-delay countdown begins. Set it below the Start Threshold - the gap between them is the hysteresis band that prevents flicker. If set too high, low-power phases (rinse holds, anti-crease) falsely trigger the end sequence.' },
{ key: 'min_off_gap', label: 'Min Off Gap', unit: 's', type: 'number', min: 0, basic: true,
doc: 'If the machine powers off for less than this time, the on/off/on sequence is treated as one continuous cycle. Prevents soak programs (machine powers off for several minutes mid-wash) from being split into two separate cycles. Set it shorter than the gap between your back-to-back loads if you want those counted as separate cycles. Device-type defaults protect the typical intra-cycle pause for each appliance.' },
] },
{ sub: 'Cycle Start', fields: [
{ key: 'start_duration_threshold', label: 'Start Duration', unit: 's', type: 'number', min: 0, def: 5,
doc: 'Power must stay above the start threshold this long to confirm a real start, preventing split-second on/off toggles from starting a cycle.' },
{ key: 'start_energy_threshold', label: 'Start Energy', unit: 'Wh', type: 'number', step: 0.01, min: 0, def: 0.2,
doc: 'Energy (power x time) the appliance must consume before RUNNING. A brief high-power spike has very low energy and is ignored, preventing false starts.' },
{ key: 'completion_min_seconds', label: 'Min Cycle Duration', unit: 's', type: 'number', min: 0, def: 600, basic: true,
doc: 'Cycles shorter than this are discarded as ghost cycles (test runs, opening the door to add a sock).' },
] },
{ sub: 'Cycle End', fields: [
{ key: 'end_energy_threshold', label: 'End Energy', unit: 'Wh', type: 'number', step: 0.001, min: 0, def: 0.05,
doc: 'During the off-delay countdown, accumulated energy (watts x time) is compared to this threshold. If exceeded, the countdown resets - keeping anti-crease tumbles and dishwasher drying tails attached to the cycle instead of cutting them short. Raise it if cycles end too early during cool-down; lower it if detection is sluggish.' },
{ key: 'end_repeat_count', label: 'End Repeat Count', type: 'number', min: 1, def: 1,
doc: 'Number of consecutive below-stop-threshold readings required before the cycle ends. 1 is fine for most plugs. Raise to 2-3 if your smart plug occasionally reports a false-zero sample mid-cycle and your cycles are ending prematurely.' },
] },
{ sub: 'Power Off', fields: [
{ key: 'power_off_threshold_w', label: 'Power Off Threshold', unit: 'W', type: 'number', step: 0.1, min: 0, def: 0,
doc: 'Optional power-based Off detection. When above 0, once a cycle has finished and power stays below this level for the Power Off Delay, the machine is treated as switched off and the state returns to Off. Leave at 0 to disable (the default). Set it above the true switched-off floor and below the Stop Threshold and your machine\'s finished-but-on standby draw; if it is not below the Stop Threshold it is ignored. When enabled it replaces the Progress Reset Delay for returning to Off, so a finished machine stays in Finished/Clean until it is actually powered off.' },
{ key: 'power_off_delay', label: 'Power Off Delay', unit: 's', type: 'number', min: 0, def: 30,
doc: 'How long power must stay below the Power Off Threshold after a cycle finishes before the state returns to Off. Only used when the Power Off Threshold is above 0. Checked on the background cadence, so the effective delay rounds up to the next state-expiry tick.' },
] },
{ sub: 'Signal Processing', fields: [
{ key: 'sampling_interval', label: 'Sampling Interval', unit: 's', type: 'number', min: 1, def: 30,
doc: 'Expected time between sensor readings - used to size the smoothing window and start debounce correctly. Every sensor update is captured regardless of this value; it only calibrates the downstream calculations. The suggestion engine measures your sensor\'s actual cadence from past cycles and sets this automatically.' },
{ key: 'smoothing_window', label: 'Smoothing Window', type: 'number', min: 1, def: 2,
doc: 'How much the raw power signal is smoothed. Low (2) is responsive but noisy; high (5) smooths spikes but adds lag.' },
] },
] },
{ id: 'matching', label: 'Matching', intro: 'How finished cycles are matched to learned profiles and labelled.', notDeviceTypes: ['other'], groups: [
{ sub: 'Match Scoring', fields: [
{ key: 'profile_match_threshold', label: 'Match Threshold', type: 'number', step: 0.01, min: 0, max: 1, def: 0.4,
doc: 'Minimum similarity score (0-1) required at cycle end to accept a program identification. Raise it to reduce wrong identifications; lower it if your machine\'s programs are not being matched. Default 0.4 is a conservative starting point.' },
{ key: 'profile_unmatch_threshold', label: 'Unmatch Threshold', type: 'number', step: 0.01, min: 0, max: 1, def: 0.35,
doc: 'If a live mid-cycle match drops below this score, the tentative identification is cleared. Keep it a little below the Match Threshold so a brief dip in similarity does not flip the display back to unmatched.' },
{ key: 'profile_match_interval', label: 'Match Interval', unit: 's', type: 'number', min: 0,
doc: 'How often to attempt profile matching during a running cycle. Default 300 s (5 minutes) balances detection speed and CPU.' },
] },
{ sub: 'Duration Gates', fields: [
{ key: 'profile_match_min_duration_ratio', label: 'Min Duration Ratio', type: 'number', step: 0.01, min: 0, max: 1, def: 0.1,
doc: 'Minimum cycle length relative to the profile. 0.9 means a cycle must be at least 90% of the profile duration to match.' },
{ key: 'profile_match_max_duration_ratio', label: 'Max Duration Ratio', type: 'number', step: 0.01, min: 0, def: 1.5,
doc: 'Maximum cycle length relative to the profile. 1.5 means a cycle must be under 150% of the profile duration to match.' },
{ key: 'profile_duration_tolerance', label: 'Profile Duration Tolerance', type: 'number', step: 0.01, min: 0, max: 1, def: 0.25,
doc: 'The +/- band around a profile average duration used during matching. 0.25 means a 60 min profile matches 45-75 min cycles.' },
{ key: 'duration_tolerance', label: 'Estimate Tolerance', type: 'number', step: 0.01, min: 0, max: 1, def: 0.1,
doc: 'Tolerance for time-remaining estimates (learning feedback, not matching). If the actual duration is within +/-X% of the estimate it counts as a good match.' },
] },
{ sub: 'Auto-Labeling', fields: [
{ key: 'auto_label_confidence', label: 'Auto-Label Confidence', type: 'number', step: 0.01, min: 0, max: 1, def: 0.9,
doc: 'If the match score at cycle end is at or above this, the program is labeled automatically without any confirmation prompt. Raise it to require higher certainty before auto-labeling; lower it to automate more. Works in conjunction with Learning Confidence below it.' },
{ key: 'learning_confidence', label: 'Learning Confidence', type: 'number', step: 0.01, min: 0, max: 1, def: 0.6,
doc: 'If the match score falls between this and Auto-Label Confidence, WashData flags the finished cycle for review in the Cycles queue so you can verify the identified program. Below this score the match is too uncertain to surface. Must be kept below Auto-Label Confidence.' },
] },
] },
{ id: 'phase_eta', label: 'Time Remaining', intro: 'Phase-aware time-remaining, for machines whose cycle length depends on temperature or spin.', onlyDeviceTypes: ['washing_machine', 'washer_dryer'], fields: [
{ key: 'enable_phase_matching', label: 'Phase-aware time remaining', type: 'checkbox', def: false,
doc: 'Break each running cycle into phases (heating, wash, spin) and budget the time remaining per phase, blended with the classic estimate - leaning on the phase budget early in the cycle and the classic estimate near the end. This personalises the countdown to how long your machine actually heats and runs, which is most noticeable in the first half of a cycle. Off = the classic estimate only. Only the time-remaining display is affected; program matching and cycle detection are unchanged.' },
] },
{ id: 'timing', label: 'Timing & Watchdog', intro: 'Background cadence, the offline watchdog and housekeeping.', groups: [
{ sub: 'Watchdog', fields: [
{ key: 'watchdog_interval', label: 'Watchdog Interval', unit: 's', type: 'number', min: 1, def: 30,
doc: 'How often the background watchdog checks for stalled sensors and elapsed timeouts. Default 30 s.' },
{ key: 'no_update_active_timeout', label: 'No-Update Timeout', unit: 's', type: 'number', min: 0, def: 600,
doc: 'If no power updates arrive for this long while running, assume the plug dropped offline and force-stop to avoid a zombie cycle. Default 600 s allows for cloud or mesh lag.' },
] },
{ sub: 'Housekeeping', fields: [
{ key: 'progress_reset_delay', label: 'Progress Reset Delay', unit: 's', type: 'number', min: 0, def: 1800,
doc: 'After finishing, hold progress at 100% for this long so Completed is visible on dashboards before resetting to Idle.' },
{ key: 'auto_maintenance', label: 'Auto Maintenance (nightly cleanup)', type: 'checkbox', def: true,
doc: 'Run nightly housekeeping: rebuild profile envelopes, recompute cycle health, prune debug traces and retain the most recent cycles.' },
] },
{ sub: 'Debug', fields: [
{ key: 'expose_debug_entities', label: 'Expose Debug Entities', type: 'checkbox',
doc: 'Publish extra diagnostic HA entities (match confidence, ambiguity, state internals). Off keeps the entity list clean for normal use.' },
{ key: 'save_debug_traces', label: 'Save Debug Traces', type: 'checkbox',
doc: 'Store the full power trace and matching debug data for each cycle. Useful for troubleshooting but increases storage size.' },
] },
] },
{ id: 'anti_wrinkle', label: 'Anti-Wrinkle', intro: 'Anti-wrinkle / anti-crease mode detects low-power tumble pulses after the main phase and keeps them attached to the finished cycle instead of reading them as new cycles.', onlyDeviceTypes: ['washing_machine', 'dryer', 'washer_dryer'], fields: [
{ key: 'anti_wrinkle_enabled', label: 'Enable Anti-Wrinkle Detection', type: 'checkbox',
doc: 'Recognise the short low-power tumble pulses a dryer emits after the main heat phase and keep them attached to the finished cycle instead of reading them as new cycles.' },
{ key: 'anti_wrinkle_max_power', label: 'Max Anti-Wrinkle Power', unit: 'W', type: 'number', step: 10, min: 0, def: 400,
doc: 'A pulse above this power is treated as a real new cycle, not an anti-wrinkle tumble. Set just above the tumble-pulse power.' },
{ key: 'anti_wrinkle_max_duration', label: 'Max Duration', unit: 's', type: 'number', min: 0, def: 60,
doc: 'Pulses longer than this are treated as a real cycle rather than an anti-wrinkle tumble.' },
{ key: 'anti_wrinkle_exit_power', label: 'Exit Power Threshold', unit: 'W', type: 'number', step: 0.1, min: 0, def: 0.8,
doc: 'Power must fall below this between pulses for anti-wrinkle mode to stay active.' },
{ key: 'anti_wrinkle_idle_timeout', label: 'Max Pulse Gap', unit: 's', type: 'number', step: 30, min: 0, def: 120,
doc: 'How long the machine may stay quiet between two tumble pulses before anti-wrinkle mode ends. Set it above the longest gap your dryer leaves between pulses, otherwise every later pulse is read as a false start.' },
] },
{ id: 'delay', label: 'Delay Start', intro: 'Delayed-start detection identifies when an appliance is powered but has not yet begun its cycle.', fields: [
{ key: 'delay_start_detect_enabled', label: 'Enable Delay-Start Detection', type: 'checkbox',
doc: 'Detect when the appliance is powered on and waiting (delayed start / standby) but has not begun its cycle, so standby draw is not mistaken for a running cycle.' },
{ key: 'delay_confirm_seconds', label: 'Confirm Seconds', unit: 's', type: 'number', min: 0, def: 60,
doc: 'Power must stay in the standby band for this long before the appliance is treated as waiting-to-start rather than running.' },
{ key: 'delay_timeout_hours', label: 'Timeout Hours', unit: 'h', type: 'number', step: 0.5, min: 0, def: 8.0,
doc: 'Stop waiting in delayed-start mode after this many hours and return to idle, so a machine left powered but never started does not wait forever.' },
] },
{ id: 'triggers', label: 'Triggers & Door', intro: 'Optional external signals: an end trigger, a door sensor, a pause switch, and the unload reminder.', groups: [
{ sub: 'External End Trigger', fields: [
{ key: 'external_end_trigger_enabled', label: 'Enable External End Trigger', type: 'checkbox',
doc: 'Let an external binary sensor signal the end of a cycle, in addition to the built-in power-based detection.' },
{ key: 'external_end_trigger', label: 'External Trigger Entity', type: 'entity', domain: 'binary_sensor',
doc: 'Binary sensor whose state change marks the cycle end (e.g. an appliance "finished" contact or a companion integration).' },
{ key: 'external_end_trigger_inverted', label: 'Invert External Trigger (trigger on OFF)', type: 'checkbox',
doc: 'Treat the trigger sensor turning OFF (rather than ON) as the end-of-cycle signal.' },
] },
{ sub: 'Door & Pause', fields: [
{ key: 'door_sensor_entity', label: 'Door Sensor Entity', type: 'entity', domain: 'binary_sensor',
doc: 'Optional door binary sensor. Used to detect when the appliance has been opened/unloaded after a cycle.' },
{ key: 'pause_cuts_power', label: 'Pause Also Cuts Power (via switch)', type: 'checkbox',
doc: 'When a cycle is paused, also switch off the Switch Entity below. Only for appliances whose plug can safely be cut mid-cycle.' },
{ key: 'switch_entity', label: 'Switch Entity', type: 'entity', domain: 'switch',
doc: 'Optional switch toggled off on pause and back on when resuming, used together with "Pause also cuts power".' },
] },
{ sub: 'Unload Reminder', fields: [
{ key: 'notify_unload_delay_minutes', label: 'Unload Nag Delay', unit: 'min', type: 'number', min: 0, def: 60, basic: true,
doc: 'Minutes after a cycle ends before sending the still-waiting "unload the machine" reminder. Set 0 to disable the reminder.' },
{ key: 'pump_stuck_duration', label: 'Pump Stuck Duration', unit: 's', type: 'number', min: 0, def: 1800,
onlyDeviceType: 'pump', doc: 'Seconds a pump may run continuously before it is flagged as possibly stuck (fires the stuck-pump event).' },
] },
] },
{ id: 'notifications', label: 'Notifications', groups: [
{ sub: 'Services', fields: [
{ key: 'notify_start_services', label: 'Start Services', type: 'entitylist', domain: 'notify', placeholder: 'add a notify service…', basic: true,
doc: 'notify.* services called when a cycle starts. Add one per target (phone, dashboard, etc.); leave empty for no start notification.' },
{ key: 'notify_finish_services', label: 'Finish Services', type: 'entitylist', domain: 'notify', placeholder: 'add a notify service…', basic: true,
doc: 'notify.* services called when a cycle finishes. Add one per target; leave empty for no finish notification.' },
{ key: 'notify_live_services', label: 'Live Progress Services', type: 'entitylist', domain: 'notify', placeholder: 'add a notify service…',
doc: 'notify.* services called for live progress updates while a cycle runs. Leave empty to disable live-progress notifications.' },
{ key: 'notify_people', label: 'People (for Only When Home)', type: 'entitylist', domain: 'person', placeholder: 'add a person…',
doc: 'person.* entities used by "Notify Only When Home" to decide whether anyone is home.' },
{ key: 'notify_only_when_home', label: 'Notify Only When Home', type: 'checkbox',
doc: 'Only send notifications when at least one of the linked people (above) is home.' },
{ key: 'notify_fire_events', label: 'Fire HA Events for Notifications', type: 'checkbox', def: true,
doc: 'Also fire ha_washdata_* events on cycle start/finish so you can build your own automations.' },
] },
{ sub: 'Timing', fields: [
{ key: 'notify_before_end_minutes', label: 'Pre-End Alert', unit: 'min', type: 'number', min: 0, def: 0,
doc: 'Send an Almost Done alert when estimated time remaining drops below this. 0 disables it.' },
{ key: 'notify_live_interval_seconds', label: 'Live Update Interval', unit: 's', type: 'number', min: 30, def: 300,
doc: 'How often live-progress notifications are refreshed while a cycle runs.' },
{ key: 'notify_live_overrun_percent', label: 'Live Overrun % Before Alert', unit: '%', type: 'number', min: 0, def: 20,
doc: 'If a cycle runs past its estimate by more than this percentage, send an overrun alert.' },
{ key: 'notify_live_chronometer', label: 'Use Live Chronometer', type: 'checkbox',
doc: 'Show a live-updating countdown timer in the notification (on platforms that support it) instead of a static estimate.' },
{ key: 'notify_timeout_seconds', label: 'Auto-Dismiss After', unit: 's', type: 'number', min: 0, def: 0,
doc: 'Automatically dismiss the notification after this many seconds (on platforms that support it). 0 keeps it until dismissed manually.' },
] },
{ sub: 'Messages', fields: [
{ key: 'notify_title', label: 'Notification Title', type: 'text', def: 'WashData: {device}',
doc: `Notification title. Template variables: ${_NOTIFY_VARS}.` },
{ key: 'notify_icon', label: 'Notification Icon', type: 'text', def: '',
doc: 'Optional mdi icon for the notification (e.g. mdi:washing-machine). Leave blank for the platform default.' },
{ key: 'notify_start_message', label: 'Start Message', type: 'textarea', def: '{device} started.',
doc: `Body sent when a cycle starts. Template variables: ${_NOTIFY_VARS}.` },
{ key: 'notify_finish_message', label: 'Finish Message', type: 'textarea', def: '{device} finished. Duration: {duration}m.', basic: true,
doc: `Body sent when a cycle finishes. Template variables: ${_NOTIFY_VARS}. {time_finished} and {vs_typical} are most useful here.` },
{ key: 'notify_pre_complete_message', label: 'Pre-Complete Message', type: 'textarea', def: '{device}: Less than {minutes} minutes remaining.',
doc: `Body of the pre-end / almost-done alert. Template variables: ${_NOTIFY_VARS}.` },
{ key: 'notify_reminder_message', label: 'Reminder Message', type: 'textarea', def: '',
doc: `Body of the still-waiting unload reminder. Blank uses the built-in default. Template variables: ${_NOTIFY_VARS}.` },
{ key: 'notify_channel', label: 'Android Channel (start/live)', type: 'text', def: '',
placeholder: 'e.g. WashData', suggestions: ['WashData', 'WashData Status', 'Appliance Status'],
doc: 'Android notification channel name for start/live messages (controls per-channel sound and priority on the mobile app). Blank uses the companion app default.' },
{ key: 'notify_finish_channel', label: 'Android Channel (finish)', type: 'text', def: '',
placeholder: 'e.g. WashData Finished', suggestions: ['WashData Finished', 'WashData Alerts', 'Appliance Finished'],
doc: 'Android notification channel name for the finish message. Blank reuses the start/live channel.' },
] },
{ sub: 'Energy', fields: [
{ key: 'energy_sensor', label: 'Energy Meter Entity', type: 'entity', domain: 'sensor', optional: true,
doc: 'Optional cumulative energy counter (total_increasing kWh/Wh, e.g. the plug\'s own lifetime meter). When set, each cycle\'s reported energy is taken from this counter\'s start-to-end delta, which avoids the under-counting you get from integrating a slow-reporting power sensor. Falls back to the integrated value if the reading is missing, its unit is unknown, or the delta is not positive. Leave blank to keep integrating the power sensor.' },
{ key: 'energy_price_entity', label: 'Energy Price Entity', type: 'entity', domain: 'sensor', basic: true,
doc: 'Sensor with the current electricity price per kWh (e.g. a dynamic tariff). Takes precedence over the static price below. Each cycle freezes the price in effect when it finished.' },
{ key: 'energy_price_static', label: 'Static Energy Price (per kWh)', type: 'number', step: 0.001, min: 0, basic: true,
doc: 'Fixed price per kWh used for cost figures when no live price entity is set above.' },
{ key: 'peak_rate_threshold', label: 'Peak-Rate Threshold (per kWh)', type: 'number', step: 0.001, min: 0, def: 0, clearable: true,
doc: 'When a cycle starts and the current price per kWh is at or above this value, append a peak-rate tip to the start notification. 0 or blank disables the tip.' },
{ key: 'peak_rate_message', label: 'Peak-Rate Message', type: 'text', def: '', placeholder: 'Running at peak rate ({price}/kWh).',
doc: 'Optional custom text for the peak-rate tip appended to the start notification. Template variables: {device}, {price}. Blank uses the built-in default.' },
] },
{ sub: 'Cycle Timers', fields: [
{ key: 'notify_cycle_timers', label: 'Cycle Timers', type: 'timerlist',
doc: 'Notifications at specific minutes into a cycle (e.g. to add softener). Message supports {device}, {program}, {minutes}. Enable Auto-pause to pause at that point and receive an interactive notification with a Resume button; resume via the panel, the pause/resume service, or the notification action.' },
] },
{ sub: 'Quiet Hours & Milestones', fields: [
{ key: 'notify_quiet_start_hour', label: 'Quiet Hours Start', unit: 'h', type: 'number', min: 0, max: 23, clearable: true,
doc: 'Start of a do-not-disturb window (0-23). Finish, reminder and clean-laundry notifications that would fire during quiet hours are held and delivered when the window ends. Leave blank to disable. Supports windows that cross midnight (e.g. start 22, end 7).' },
{ key: 'notify_quiet_end_hour', label: 'Quiet Hours End', unit: 'h', type: 'number', min: 0, max: 23, clearable: true,
doc: 'End of the do-not-disturb window (0-23). Held notifications are delivered at this hour. Leave blank to disable.' },
{ key: 'notify_milestones', label: 'Cycle Milestones', type: 'intlist', def: '50, 100, 500, 1000', placeholder: '50, 100, 500, 1000',
doc: 'Comma-separated cycle counts that trigger a one-off celebration notification when reached (e.g. 50, 100, 500, 1000). Blank disables milestone notifications.' },
{ key: 'notify_milestone_message', label: 'Milestone Message', type: 'textarea', def: '{device} has completed {cycle_count} cycles!',
doc: 'Message for the milestone notification. Template variables: {device}, {cycle_count}.' },
] },
] },
{ id: 'ml_training', label: 'ML Training', fields: [
{ key: 'enable_ml_models', label: 'Apply smart models during a cycle', type: 'checkbox', def: false,
doc: 'While a cycle runs, let the models refine the live results: a steadier time-remaining and energy/cost estimate, and an anti-premature-stop guard on end detection (it can only ever delay a finish, never end one early, and is bounded). Uses your fine-tuned models when available, otherwise the built-in ones. Off = the classic power-based logic only (still reliable).' },
{ key: 'ml_training_enabled', label: 'Learn from this machine', type: 'checkbox', def: false,
doc: 'Periodically study your reviewed cycles overnight and fine-tune the models to this specific machine. A change is only kept when it genuinely scores better on held-out cycles, so this can only help or stay the same — never regress.' },
{ key: 'ml_training_hour', label: 'Learn at hour', unit: 'h', type: 'number', min: 0, max: 23, def: 2,
doc: 'Local hour of day (0-23) to do the overnight fine-tuning. Pick a quiet hour such as 2 (02:00).' },
{ key: 'ml_training_min_cycles', label: 'Cycles needed first', type: 'number', min: 5, def: 30,
doc: 'Wait until at least this many cycles have been recorded before fine-tuning, so there is enough to learn from.' },
{ key: 'ml_training_interval_days', label: 'Check at most every', unit: 'days', type: 'number', min: 1, def: 7,
doc: 'Re-check for improvements at most once per this many days.' },
] },
];
// Flat key -> field-definition map (built from the schema; drives save coercion).
const _FIELD_BY_KEY = {};
for (const sec of _SETTINGS_SECTIONS) {
const groups = sec.groups || [{ fields: sec.fields }];
for (const grp of groups) for (const f of (grp.fields || [])) _FIELD_BY_KEY[f.key] = f;
}
// Default values for playground-only matcher params (code constants, not stored
// options, so _FIELD_BY_KEY has no entry for them).
const _PG_MATCH_DEFAULTS = {
profile_match_min_duration_ratio: 0.1,
profile_match_max_duration_ratio: 1.5,
corr_weight: 0.45,
keep_min_score: 0.1,
dtw_bandwidth: 0.2,
dtw_blend: 0.5,
dtw_ensemble_w: 0.7,
dtw_ddtw_scale: 30,
dtw_refine_top_n: 5,
duration_weight: 0.22,
energy_weight: 0.22,
duration_scale: 0.175,
energy_scale: 0.25,
};
// ─── Setting conflict rules ───────────────────────────────────────────────────
// Each rule describes a cross-parameter invariant. `check(vals)` returns true
// when the invariant is violated. `fieldErrors(vals)` maps each affected key to
// an error descriptor: `{msgKey, msgVars, msgFb, fixVal}` where `fixVal` is the
// suggested value for THAT field (always actionable in the current section).
const _SETTING_CONFLICTS = [
{
// start_threshold_w > stop_threshold_w (hysteresis band must be positive)
keys: ['start_threshold_w', 'stop_threshold_w'],
check: v => v.start_threshold_w != null && v.stop_threshold_w != null && v.start_threshold_w <= v.stop_threshold_w,
fieldErrors: v => ({
start_threshold_w: { msgKey: 'conflict.hysteresis.start', msgVars: {stop: v.stop_threshold_w}, msgFb: `Must be above Stop Threshold (${v.stop_threshold_w} W)`, fixVal: +Math.max(v.stop_threshold_w + 0.5, v.stop_threshold_w * 1.25).toFixed(1) },
stop_threshold_w: { msgKey: 'conflict.hysteresis.stop', msgVars: {start: v.start_threshold_w}, msgFb: `Must be below Start Threshold (${v.start_threshold_w} W)`, fixVal: +Math.min(v.start_threshold_w - 0.5, v.start_threshold_w * 0.8).toFixed(1) },
}),
},
{
// min_power <= stop_threshold_w (noise gate must sit below the stop floor)
keys: ['min_power', 'stop_threshold_w'],
check: v => v.min_power != null && v.stop_threshold_w != null && v.min_power > v.stop_threshold_w,
fieldErrors: v => ({
min_power: { msgKey: 'conflict.min_power.min_power', msgVars: {stop: v.stop_threshold_w}, msgFb: `Must be at or below Stop Threshold (${v.stop_threshold_w} W)`, fixVal: +(v.stop_threshold_w * 0.8).toFixed(1) },
stop_threshold_w: { msgKey: 'conflict.min_power.stop', msgVars: {min: v.min_power}, msgFb: `Must be at or above Min Power (${v.min_power} W)`, fixVal: +(v.min_power * 1.25).toFixed(1) },
}),
},
{
// power_off_threshold_w < stop_threshold_w when > 0 (else feature silently ignored)
keys: ['power_off_threshold_w', 'stop_threshold_w'],
check: v => v.power_off_threshold_w != null && v.power_off_threshold_w > 0 && v.stop_threshold_w != null && v.power_off_threshold_w >= v.stop_threshold_w,
fieldErrors: v => ({
power_off_threshold_w: { msgKey: 'conflict.power_off.threshold', msgVars: {stop: v.stop_threshold_w}, msgFb: `Must be below Stop Threshold (${v.stop_threshold_w} W) to take effect`, fixVal: +(v.stop_threshold_w * 0.6).toFixed(1) },
stop_threshold_w: { msgKey: 'conflict.power_off.stop', msgVars: {pot: v.power_off_threshold_w}, msgFb: `Must be above Power Off Threshold (${v.power_off_threshold_w} W)`, fixVal: +(v.power_off_threshold_w * 1.67).toFixed(1) },
}),
},
{
// off_delay <= min_off_gap (effective_off_delay = max(off_delay, min_off_gap))
keys: ['off_delay', 'min_off_gap'],
check: v => v.off_delay != null && v.min_off_gap != null && v.off_delay > v.min_off_gap,
fieldErrors: v => ({
off_delay: { msgKey: 'conflict.off_delay.off_delay', msgVars: {gap: v.min_off_gap}, msgFb: `Off Delay (${v.off_delay} s) overrides Min Off Gap (${v.min_off_gap} s); cycles within the gap may merge`, fixVal: v.min_off_gap },
min_off_gap: { msgKey: 'conflict.off_delay.gap', msgVars: {delay: v.off_delay}, msgFb: `Min Off Gap should be at least Off Delay (${v.off_delay} s)`, fixVal: v.off_delay },
}),
},
{
// watchdog_interval >= 2 * sampling_interval (avoid false-zero injections)
keys: ['watchdog_interval', 'sampling_interval'],
check: v => v.watchdog_interval != null && v.sampling_interval != null && v.watchdog_interval < 2 * v.sampling_interval,
fieldErrors: v => ({
watchdog_interval: { msgKey: 'conflict.watchdog.interval', msgVars: {si: v.sampling_interval}, msgFb: `Should be at least 2× Sampling Interval (${v.sampling_interval} s)`, fixVal: +(2 * v.sampling_interval + 1) },
sampling_interval: { msgKey: 'conflict.watchdog.sampling', msgVars: {wi: v.watchdog_interval}, msgFb: `Sampling Interval should be at most half of Watchdog Interval (${v.watchdog_interval} s)`, fixVal: +Math.floor(v.watchdog_interval / 2) },
}),
},
{
// no_update_active_timeout > watchdog_interval (timeout must outlast one tick)
keys: ['no_update_active_timeout', 'watchdog_interval'],
check: v => v.no_update_active_timeout != null && v.watchdog_interval != null && v.no_update_active_timeout <= v.watchdog_interval,
fieldErrors: v => ({
no_update_active_timeout: { msgKey: 'conflict.no_update_timeout.timeout', msgVars: {wi: v.watchdog_interval}, msgFb: `Must be greater than Watchdog Interval (${v.watchdog_interval} s)`, fixVal: v.watchdog_interval * 2 },
watchdog_interval: { msgKey: 'conflict.no_update_timeout.watchdog', msgVars: {to: v.no_update_active_timeout}, msgFb: `Must be less than No-Update Timeout (${v.no_update_active_timeout} s)`, fixVal: +Math.floor(v.no_update_active_timeout / 2) },
}),
},
{
// start_duration_threshold >= sampling_interval (debounce must span at least one sample)
keys: ['start_duration_threshold', 'sampling_interval'],
check: v => v.start_duration_threshold != null && v.sampling_interval != null && v.start_duration_threshold < v.sampling_interval,
fieldErrors: v => ({
start_duration_threshold: { msgKey: 'conflict.start_dur.threshold', msgVars: {si: v.sampling_interval}, msgFb: `Should be at least one Sampling Interval (${v.sampling_interval} s) to prevent single-sample false starts`, fixVal: v.sampling_interval },
sampling_interval: { msgKey: 'conflict.start_dur.sampling', msgVars: {sdt: v.start_duration_threshold}, msgFb: `Sampling Interval exceeds Start Duration (${v.start_duration_threshold} s); single-sample spikes can open a cycle`, fixVal: v.start_duration_threshold },
}),
},
{
// learning_confidence <= profile_match_threshold
keys: ['learning_confidence', 'profile_match_threshold'],
check: v => v.learning_confidence != null && v.profile_match_threshold != null && v.learning_confidence > v.profile_match_threshold,
fieldErrors: v => ({
learning_confidence: { msgKey: 'conflict.confidence.learning', msgVars: {match: v.profile_match_threshold}, msgFb: `Must be at or below Match Threshold (${v.profile_match_threshold})`, fixVal: +(v.profile_match_threshold).toFixed(2) },
profile_match_threshold: { msgKey: 'conflict.confidence.match_for_learning', msgVars: {lc: v.learning_confidence}, msgFb: `Must be at or above Learning Confidence (${v.learning_confidence})`, fixVal: +(v.learning_confidence).toFixed(2) },
}),
},
{
// profile_match_threshold <= auto_label_confidence
keys: ['profile_match_threshold', 'auto_label_confidence'],
check: v => v.profile_match_threshold != null && v.auto_label_confidence != null && v.profile_match_threshold > v.auto_label_confidence,
fieldErrors: v => ({
profile_match_threshold: { msgKey: 'conflict.confidence.match_for_auto', msgVars: {alc: v.auto_label_confidence}, msgFb: `Must be at or below Auto-Label Confidence (${v.auto_label_confidence})`, fixVal: +(v.auto_label_confidence).toFixed(2) },
auto_label_confidence: { msgKey: 'conflict.confidence.auto', msgVars: {match: v.profile_match_threshold}, msgFb: `Must be at or above Match Threshold (${v.profile_match_threshold})`, fixVal: +(v.profile_match_threshold).toFixed(2) },
}),
},
{
// profile_unmatch_threshold < profile_match_threshold (committed match must not immediately un-match)
keys: ['profile_unmatch_threshold', 'profile_match_threshold'],
check: v => v.profile_unmatch_threshold != null && v.profile_match_threshold != null && v.profile_unmatch_threshold >= v.profile_match_threshold,
fieldErrors: v => ({
profile_unmatch_threshold: { msgKey: 'conflict.unmatch.unmatch', msgVars: {match: v.profile_match_threshold}, msgFb: `Must be below Match Threshold (${v.profile_match_threshold}); otherwise a committed match un-matches instantly`, fixVal: +(v.profile_match_threshold - 0.05).toFixed(2) },
profile_match_threshold: { msgKey: 'conflict.unmatch.match', msgVars: {un: v.profile_unmatch_threshold}, msgFb: `Must be above Unmatch Threshold (${v.profile_unmatch_threshold})`, fixVal: +(v.profile_unmatch_threshold + 0.05).toFixed(2) },
}),
},
{
// anti_wrinkle_exit_power < stop_threshold_w — only for devices that support anti-wrinkle
keys: ['anti_wrinkle_exit_power', 'stop_threshold_w'],
check: v => ['washing_machine','dryer','washer_dryer'].includes(v.device_type) && v.anti_wrinkle_exit_power != null && v.stop_threshold_w != null && v.anti_wrinkle_exit_power >= v.stop_threshold_w,
fieldErrors: v => ({
anti_wrinkle_exit_power: { msgKey: 'conflict.anti_wrinkle_exit.exit', msgVars: {stop: v.stop_threshold_w}, msgFb: `Must be below Stop Threshold (${v.stop_threshold_w} W); otherwise the anti-wrinkle exit power is ignored`, fixVal: +(v.stop_threshold_w * 0.4).toFixed(1) },
stop_threshold_w: { msgKey: 'conflict.anti_wrinkle_exit.stop', msgVars: {exit: v.anti_wrinkle_exit_power}, msgFb: `Must be above Anti-Wrinkle Exit Power (${v.anti_wrinkle_exit_power} W)`, fixVal: +(v.anti_wrinkle_exit_power * 2.5).toFixed(1) },
}),
},
{
// anti_wrinkle_max_power > start_threshold_w — only for devices that support anti-wrinkle
keys: ['anti_wrinkle_max_power', 'start_threshold_w'],
check: v => ['washing_machine','dryer','washer_dryer'].includes(v.device_type) && v.anti_wrinkle_max_power != null && v.start_threshold_w != null && v.anti_wrinkle_max_power <= v.start_threshold_w,
fieldErrors: v => ({
anti_wrinkle_max_power: { msgKey: 'conflict.anti_wrinkle_max.max', msgVars: {start: v.start_threshold_w}, msgFb: `Must be above Start Threshold (${v.start_threshold_w} W); otherwise anti-wrinkle duration limit is bypassed`, fixVal: +(v.start_threshold_w * 2.0).toFixed(0) },
start_threshold_w: { msgKey: 'conflict.anti_wrinkle_max.start', msgVars: {max: v.anti_wrinkle_max_power}, msgFb: `Must be below Anti-Wrinkle Max Power (${v.anti_wrinkle_max_power} W)`, fixVal: +(v.anti_wrinkle_max_power * 0.5).toFixed(1) },
}),
},
{
// pump_stuck_duration < no_update_active_timeout — only for pump/sump-pump devices
keys: ['pump_stuck_duration', 'no_update_active_timeout'],
check: v => v.device_type === 'pump' && v.pump_stuck_duration != null && v.no_update_active_timeout != null && v.no_update_active_timeout <= v.pump_stuck_duration,
fieldErrors: v => ({
pump_stuck_duration: { msgKey: 'conflict.pump_stuck.duration', msgVars: {to: v.no_update_active_timeout}, msgFb: `Must be less than No-Update Timeout (${v.no_update_active_timeout} s) so the stuck alarm fires before the watchdog kills the cycle`, fixVal: v.no_update_active_timeout - 60 },
no_update_active_timeout: { msgKey: 'conflict.pump_stuck.timeout', msgVars: {ps: v.pump_stuck_duration}, msgFb: `Must exceed Pump Stuck Duration (${v.pump_stuck_duration} s) so the stuck alarm fires before the cycle is force-stopped`, fixVal: v.pump_stuck_duration + 60 },
}),
},
{
// profile_match_min_duration_ratio < profile_match_max_duration_ratio (matching window must be non-empty)
keys: ['profile_match_min_duration_ratio', 'profile_match_max_duration_ratio'],
check: v => v.profile_match_min_duration_ratio != null && v.profile_match_max_duration_ratio != null && v.profile_match_min_duration_ratio >= v.profile_match_max_duration_ratio,
fieldErrors: v => ({
profile_match_min_duration_ratio: { msgKey: 'conflict.duration_ratio.min', msgVars: {max: v.profile_match_max_duration_ratio}, msgFb: `Must be less than Max Duration Ratio (${v.profile_match_max_duration_ratio})`, fixVal: +(v.profile_match_max_duration_ratio * 0.5).toFixed(2) },
profile_match_max_duration_ratio: { msgKey: 'conflict.duration_ratio.max', msgVars: {min: v.profile_match_min_duration_ratio}, msgFb: `Must be greater than Min Duration Ratio (${v.profile_match_min_duration_ratio})`, fixVal: +(v.profile_match_min_duration_ratio * 2.0).toFixed(2) },
}),
},
];
// ─── Styles ──────────────────────────────────────────────────────────────────
const _CSS = `
:host {
display: block;
background: var(--primary-background-color);
color: var(--primary-text-color);
min-height: 100%;
font-family: var(--paper-font-body1_-_font-family, Roboto, sans-serif);
--wd-radius-sm: 4px;
--wd-radius-md: 8px;
--wd-radius-lg: 12px;
--wd-space-xs: 4px;
--wd-space-sm: 6px;
--wd-space-md: 10px;
--wd-space-lg: 16px;
--wd-space-xl: 24px;
--wd-font-sm: 0.75em;
--wd-font-xs: 0.7em;
--wd-white: #fff;
--wd-tint-xs: rgba(0,0,0,0.04);
--wd-tint-sm: rgba(0,0,0,0.08);
--wd-tint-md: rgba(0,0,0,0.12);
}
.wd-header {
display: flex; align-items: center; gap: 12px;
padding: 14px 24px;
background: var(--app-header-background-color, var(--primary-color));
color: var(--app-header-text-color, #fff);
position: sticky; top: 0; z-index: 20;
box-shadow: 0 2px 6px rgba(0,0,0,.25);
}
.wd-header h1 { margin: 0; font-size: 1.25em; font-weight: 600; letter-spacing: .01em; }
.wd-logo { flex-shrink: 0; opacity: .95; }
.wd-burger { display: none; align-items: center; justify-content: center; background: transparent; border: none; color: inherit; cursor: pointer; padding: 5px; margin: -2px 2px -2px -4px; border-radius: var(--wd-radius-md); flex-shrink: 0; }
.wd-burger:hover { background: rgba(255,255,255,.16); }
.wd-gear-btn { background: transparent; border: none; color: inherit; cursor: pointer; padding: 5px; margin-left: 4px; border-radius: var(--wd-radius-md); flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; opacity: .8; }
.wd-gear-btn:hover { background: rgba(255,255,255,.16); opacity: 1; }
@media (max-width: 870px) { .wd-burger { display: inline-flex; } }
.wd-header .wd-sub { font-size: .72em; opacity: .75; margin-top: 2px; }
.wd-header .wd-ts { margin-left: auto; font-size: .7em; opacity: .65; white-space: nowrap; }
.wd-body { max-width: 1160px; margin: 0 auto; padding: 20px 16px 60px; }
.wd-chips { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 20px; }
.wd-chip {
padding: 5px 16px; border-radius: 16px;
border: 1px solid var(--divider-color, rgba(0,0,0,.12));
background: var(--card-background-color); color: var(--primary-text-color);
cursor: pointer; font-size: .85em; transition: background .15s, color .15s;
}
.wd-chip:hover { background: var(--secondary-background-color); }
.wd-chip.active { background: var(--primary-color); color: var(--wd-white); border-color: var(--primary-color); }
.wd-tabs {
display: flex; gap: 2px;
border-bottom: 1px solid var(--divider-color, rgba(0,0,0,.1));
margin-bottom: 20px; overflow-x: auto;
}
.wd-tab {
padding: 10px 22px; border: none; background: transparent;
color: var(--secondary-text-color); font-size: .8em; font-weight: 600;
letter-spacing: .07em; text-transform: uppercase; cursor: pointer;
border-bottom: 2px solid transparent; transition: color .15s, border-color .15s;
white-space: nowrap;
}
.wd-tab:hover { color: var(--primary-text-color); }
.wd-tab.active { color: var(--primary-color); border-bottom-color: var(--primary-color); }
.wd-pane { display: none; }
.wd-pane.active { display: block; }
.wd-card {
background: var(--card-background-color); border-radius: var(--wd-radius-lg);
padding: 20px 22px; margin-bottom: 16px;
box-shadow: var(--ha-card-box-shadow, 0 2px 6px rgba(0,0,0,.08));
}
.wd-card-title {
margin: 0 0 14px; font-size: .72em; font-weight: 600;
letter-spacing: .09em; text-transform: uppercase;
color: var(--secondary-text-color);
display: flex; align-items: center; gap: 8px;
}
.wd-card-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 14px; align-items: center; }
.wd-badge {
display: inline-flex; align-items: center; gap: 7px;
padding: 5px 14px; border-radius: 20px; font-size: .85em; font-weight: 500;
margin-bottom: 18px;
}
.wd-dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; flex-shrink: 0; }
.wd-running .wd-dot { animation: wd-pulse 1.4s ease-in-out infinite; }
@keyframes wd-pulse { 0%, 100% { opacity: 1; } 50% { opacity: .3; } }
.wd-stats {
display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
gap: 12px; margin-bottom: 18px;
}
.wd-stat { background: var(--secondary-background-color); border-radius: var(--wd-radius-md); padding: 14px 10px; text-align: center; }
.wd-stat-val { font-size: 1.5em; font-weight: 600; line-height: 1.1; }
.wd-stat-lbl { margin-top: 5px; font-size: .72em; color: var(--secondary-text-color); }
.wd-prog-bg { background: var(--secondary-background-color); border-radius: 6px; height: 10px; overflow: hidden; }
.wd-prog-fill { height: 100%; background: var(--primary-color); border-radius: 6px; transition: width .6s ease; }
.wd-prog-row { display: flex; justify-content: space-between; margin-top: 6px; font-size: .78em; color: var(--secondary-text-color); }
/* D1: compact phase timeline below the progress bar */
.wd-ptl-wrap { margin-top: 8px; }
.wd-ptl { position: relative; height: 12px; border-radius: 6px; overflow: hidden; background: var(--secondary-background-color); }
.wd-ptl-seg { position: absolute; top: 0; bottom: 0; }
.wd-ptl-seg-lbl { position: absolute; left: 4px; top: 50%; transform: translateY(-50%); font-size: 8px; line-height: 1; color: var(--wd-white); white-space: nowrap; overflow: hidden; max-width: calc(100% - 6px); text-shadow: 0 0 2px rgba(0,0,0,.55); pointer-events: none; }
.wd-ptl-cursor { position: absolute; top: -2px; bottom: -2px; width: 2px; background: var(--primary-text-color, #111); box-shadow: 0 0 0 1px rgba(255,255,255,.6); }
.wd-ptl-cur { margin-top: 5px; font-size: .74em; color: var(--secondary-text-color); }
.wd-cycle-ctrl { display: flex; gap: 8px; margin-top: 14px; flex-wrap: wrap; }
.wd-table { width: 100%; border-collapse: collapse; font-size: .875em; }
.wd-table th {
text-align: left; padding: 8px 12px;
color: var(--secondary-text-color); font-weight: 600; font-size: .72em;
letter-spacing: .07em; text-transform: uppercase;
border-bottom: 1px solid var(--divider-color);
}
.wd-table td { padding: 10px 12px; border-bottom: 1px solid var(--divider-color, rgba(0,0,0,.05)); vertical-align: middle; }
.wd-table tr:last-child td { border-bottom: none; }
.wd-table tbody tr:hover td { background: var(--secondary-background-color); }
.wd-table-wrap { overflow-x: auto; }
.wd-th-sort { cursor: pointer; user-select: none; white-space: nowrap; }
.wd-th-sort:hover { color: var(--primary-color); }
.wd-tc-date { white-space: nowrap; color: var(--secondary-text-color); font-size: .82em; }
.wd-tc-num { white-space: nowrap; text-align: right; font-variant-numeric: tabular-nums; }
/* Dedicated flags/icons column: keep every badge on one line so review/anomaly/source
icons never overwrite each other or spill into the profile name. */
.wd-tc-flags { white-space: nowrap; font-size: .9em; }
th.wd-tc-flags { color: var(--secondary-text-color); font-weight: 500; }
.wd-filter-bar { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; }
.wd-filter-input { flex: 1; min-width: 120px; padding: 5px 10px; border-radius: 6px; border: 1px solid var(--divider-color); background: var(--card-background-color); color: var(--primary-text-color); font-size: .84em; }
.wd-filter-select { padding: 5px 8px; border-radius: 6px; border: 1px solid var(--divider-color); background: var(--card-background-color); color: var(--primary-text-color); font-size: .84em; }
.wd-row-link { cursor: pointer; }
.wd-pill { display: inline-block; padding: 2px 9px; border-radius: var(--wd-radius-sm); background: var(--secondary-background-color); color: var(--secondary-text-color); font-size: .78em; }
.wd-tag { display: inline-flex; align-items: center; padding: 1px 6px; border-radius: 10px; font-size: .72em; font-weight: 600; vertical-align: middle; background: var(--secondary-background-color); color: var(--secondary-text-color); margin-left: 4px; }
.wd-btn {
display: inline-flex; align-items: center; gap: 6px;
padding: 8px 16px; border-radius: 6px; border: none; cursor: pointer;
font-size: .85em; font-weight: 500; transition: opacity .15s;
white-space: nowrap;
}
.wd-btn:hover { opacity: .85; }
.wd-btn:disabled { opacity: .55; cursor: default; }
.wd-btn-primary { background: var(--primary-color); color: var(--wd-white); }
.wd-btn-secondary { background: var(--secondary-background-color); color: var(--primary-text-color); border: 1px solid var(--divider-color); }
.wd-btn-danger { background: var(--error-color, #f44336); color: var(--wd-white); }
.wd-btn-sm { padding: 4px 10px; font-size: .78em; }
.wd-btn-xs { padding: 2px 8px; font-size: .72em; }
.wd-spin {
display: inline-block; width: 13px; height: 13px;
border: 2px solid currentColor; border-right-color: transparent;
border-radius: 50%; animation: wd-rot .7s linear infinite; vertical-align: -2px;
}
@keyframes wd-rot { to { transform: rotate(360deg); } }
.wd-field { margin-bottom: 16px; }
.wd-field label { display: block; font-size: .82em; font-weight: 600; margin-bottom: 5px; color: var(--secondary-text-color); letter-spacing: .04em; text-transform: uppercase; }
.wd-field input[type=text], .wd-field input[type=number], .wd-field select, .wd-field textarea {
width: 100%; box-sizing: border-box; padding: 8px 10px; border-radius: 6px;
border: 1px solid var(--divider-color, rgba(0,0,0,.2));
background: var(--secondary-background-color);
color: var(--primary-text-color); font-size: .9em; font-family: inherit;
}
.wd-field textarea { min-height: 64px; resize: vertical; }
.wd-field input[type=checkbox] { width: auto; margin-right: 8px; }
/* Switch-style boolean settings (replaces the old plain checkbox). Scoped under
.wd-field-switch so the switch label wins over the generic ".wd-field label"
(display:block, higher specificity) rule and stays a centered flex row. */
.wd-field-switch label { margin: 0; }
.wd-field-switch .wd-switch-row { display: flex; align-items: center; gap: 10px; min-height: 22px; }
/* Switch label: a flex row [toggle][text], vertically centred. The .wd-field
.wd-switch-lbl selector is needed to beat .wd-field label (display:block +
.82em + uppercase, specificity 0,1,1) which otherwise leaks in and both breaks
the vertical centring (align-items is a no-op on a block) and shrinks the label.
The bare .wd-switch-lbl covers inline use outside a .wd-field (e.g. adopt). */
.wd-switch-lbl,
.wd-field .wd-switch-lbl {
display: inline-flex; align-items: center; gap: 10px; cursor: pointer;
min-width: 0; margin: 0; font-size: 1rem; font-weight: 400;
letter-spacing: normal; text-transform: none;
}
/* Match the switch label to every other setting name (see .wd-field label). */
.wd-switch-text { font-size: .82em; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; color: var(--secondary-text-color); }
#wd-settings-form .wd-switch-text, #wd-ml-form .wd-switch-text { color: var(--primary-text-color); }
/* Normal-case, readable label for switches outside the Settings form (store /
panel prefs / access control), whose labels are descriptive sentences. */
.wd-switch-text--plain { font-size: .9em; font-weight: 600; line-height: 1.3; letter-spacing: normal; text-transform: none; color: var(--primary-text-color); }
.wd-switch { position: relative; display: inline-flex; flex: 0 0 auto; width: 40px; height: 22px; }
.wd-switch input { position: absolute; opacity: 0; width: 0; height: 0; margin: 0; }
.wd-switch-slider { position: absolute; inset: 0; border-radius: 22px; background: var(--switch-unchecked-track-color, rgba(120,120,120,.5)); transition: background .2s; }
.wd-switch-slider::before { content: ""; position: absolute; height: 16px; width: 16px; left: 3px; top: 3px; border-radius: 50%; background: var(--switch-unchecked-button-color, #fafafa); box-shadow: 0 1px 2px rgba(0,0,0,.3); transition: transform .2s; }
.wd-switch input:checked + .wd-switch-slider { background: var(--switch-checked-track-color, var(--primary-color, #03a9f4)); }
/* Force a light thumb: some themes set --switch-checked-button-color to the accent,
which made the knob vanish into the (also-accent) track. A white thumb reads on
any track colour. */
.wd-switch input:checked + .wd-switch-slider::before { transform: translateX(18px); background: #fff; }
.wd-switch input:focus-visible + .wd-switch-slider { outline: 2px solid var(--primary-color, #03a9f4); outline-offset: 2px; }
/* A11y: a shared keyboard focus ring for all interactive controls (many HA themes
suppress the UA default outline). */
.wd-tab:focus-visible, .wd-btn:focus-visible, .wd-chip:focus-visible,
.wd-sec-btn:focus-visible, .wd-subtab:focus-visible, .wd-mini-tab:focus-visible,
.wd-devcard:focus-visible, [tabindex]:focus-visible, a:focus-visible, select:focus-visible {
outline: 2px solid var(--primary-color, #03a9f4); outline-offset: 2px; border-radius: var(--wd-radius-sm);
}
/* A11y: honor the user's reduced-motion preference — drop non-essential animation. */
@media (prefers-reduced-motion: reduce) {
.wd-dot, .wd-devdot, .wd-rec-active, .wd-spin, .wd-toast { animation: none !important; }
* { scroll-behavior: auto !important; }
}
/* Notifications > Automations: split "New" dropdown + pills. */
.wd-auto-dd summary { cursor: pointer; list-style: none; }
.wd-auto-dd summary::-webkit-details-marker { display: none; }
.wd-auto-dd summary::marker { content: ''; }
.wd-auto-pill { display: inline-flex; align-items: center; gap: 2px; max-width: 100%; background: var(--secondary-background-color); border: 1px solid var(--divider-color); border-radius: 16px; padding: 3px 4px 3px 12px; }
.wd-auto-pill-link { text-decoration: none; color: var(--primary-text-color); font-size: .92em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.wd-auto-pill-link:hover { text-decoration: underline; }
.wd-auto-pill-x { flex: 0 0 auto; border: none; background: transparent; color: var(--secondary-text-color); cursor: pointer; font-size: 1.15em; line-height: 1; padding: 0 5px; border-radius: 50%; }
.wd-auto-pill-x:hover { background: var(--error-color, #f44336); color: var(--wd-white); }
.wd-field-hint { font-size: .78em; color: var(--secondary-text-color); margin-top: 4px; }
/* Entity-pill multi-picker (compact chips + inline add input) */
.wd-pillbox { display: flex; flex-wrap: wrap; gap: 5px; align-items: center; padding: 5px 6px; min-height: 34px;
border: 1px solid var(--divider-color); border-radius: var(--wd-radius-md); background: var(--card-background-color); }
.wd-pillbox:focus-within { border-color: var(--primary-color); }
.wd-pillbox .wd-pill { display: inline-flex; align-items: center; gap: 4px; max-width: 100%; padding: 2px 4px 2px 9px;
font-size: .82em; line-height: 1.4; border-radius: var(--wd-radius-lg); background: var(--primary-color); color: var(--wd-white);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.wd-pill-x { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; padding: 0;
border: 0; border-radius: 50%; background: rgba(255,255,255,.25); color: var(--wd-white); font-size: 13px; line-height: 1;
cursor: pointer; flex: none; }
.wd-pill-x:hover { background: rgba(255,255,255,.45); }
.wd-pill-add { flex: 1; min-width: 90px; border: 0 !important; background: transparent !important; padding: 3px 4px !important;
font-size: .88em; color: var(--primary-text-color); outline: none; }
.wd-form-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 0 20px; }
/* Cycle timer list */
.wd-timerlist { display: flex; flex-direction: column; gap: 8px; }
.wd-timer-row { display: flex; flex-direction: column; gap: 8px; padding: 10px 12px;
border: 1px solid var(--divider-color); border-radius: var(--wd-radius-md); background: var(--card-background-color); }
.wd-timer-top { display: flex; align-items: center; gap: 8px; }
.wd-timer-top input[type="number"] { width: 70px; flex: 0 0 auto; }
.wd-timer-top textarea { flex: 1 1 auto; min-width: 0; box-sizing: border-box; resize: vertical; min-height: 32px; height: 34px; }
.wd-timer-footer { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.wd-timer-footer .wd-switch-lbl { display: flex; align-items: center; gap: 8px; cursor: pointer; }
.wd-timer-add { align-self: flex-start; margin-top: 4px; }
/* Roomier Settings layout (scoped so modals keep their compact spacing) */
#wd-settings-form .wd-form-grid { grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 18px 28px; align-items: start; }
#wd-settings-form .wd-field { margin-bottom: 0; background: var(--secondary-background-color); border-radius: 10px; padding: 12px 14px; }
#wd-settings-form .wd-field label { color: var(--primary-text-color); }
#wd-settings-form .wd-field input[type=text], #wd-settings-form .wd-field input[type=number], #wd-settings-form .wd-field select, #wd-settings-form .wd-field textarea { background: var(--card-background-color); padding: 9px 11px; }
#wd-settings-form .wd-subhead { margin: 22px 0 12px; padding-bottom: 6px; border-bottom: 1px solid var(--divider-color); }
.wd-sec-intro { font-size: .85em; color: var(--secondary-text-color); margin: 0 0 16px; line-height: 1.5; }
.wd-label-row { display: flex; align-items: center; }
.wd-tip {
display: inline-flex; width: 15px; height: 15px; border-radius: 50%;
align-items: center; justify-content: center; font-size: 10px; font-style: italic;
background: var(--secondary-background-color); color: var(--secondary-text-color);
cursor: help; margin-left: 6px; position: relative; border: 1px solid var(--divider-color);
font-weight: 700; text-transform: none; letter-spacing: normal;
}
.wd-tip-pop {
display: none; position: absolute; bottom: 150%; left: 50%; transform: translateX(-50%);
width: 264px; background: var(--card-background-color); color: var(--primary-text-color);
border: 1px solid var(--divider-color); border-radius: var(--wd-radius-md); padding: 10px 12px;
box-shadow: 0 4px 18px rgba(0,0,0,.35); z-index: 60;
text-align: left; font-weight: 400; text-transform: none; letter-spacing: normal;
}
.wd-tip:hover .wd-tip-pop { display: block; }
.wd-tip-txt { font-size: 12px; line-height: 1.5; display: block; }
.wd-dg { display: block; width: 100%; height: auto; margin-bottom: 8px; background: var(--secondary-background-color); border-radius: 6px; }
.wd-dg .ln { fill: none; stroke: var(--primary-color); stroke-width: 2.5; }
.wd-dg .ln2 { fill: none; stroke: var(--secondary-text-color); stroke-width: 1.5; opacity: .7; }
.wd-dg .ok { fill: none; stroke: var(--success-color, #4caf50); stroke-width: 2; }
.wd-dg .bad { fill: none; stroke: var(--error-color, #f44336); stroke-width: 2; }
.wd-dg .dash { stroke-dasharray: 4 3; }
.wd-dg .fz { fill: var(--primary-color); opacity: .18; }
.wd-dg .fw { fill: var(--warning-color, #ff9800); opacity: .2; }
.wd-dg .fb { fill: var(--error-color, #f44336); opacity: .14; }
.wd-dg text { fill: var(--secondary-text-color); font-size: 9px; }
.wd-dg .ax { stroke: var(--divider-color); stroke-width: 1; }
.wd-sug {
display: flex; align-items: center; gap: 8px; margin-top: 6px;
padding: 6px 10px; border-radius: var(--wd-radius-md); font-size: .82em;
background: rgba(255,152,0,.10); border: 1px solid rgba(255,152,0,.40);
box-sizing: border-box; flex-wrap: wrap;
}
.wd-sug.wd-sug-split { flex-direction: column; align-items: stretch; gap: 0; padding: 0; overflow: hidden; }
.wd-sug-opt { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 8px 10px; }
.wd-sug-opt:not(:last-child) { border-bottom: 1px solid rgba(255,152,0,.28); }
.wd-sug-chip {
display: inline-flex; align-items: center; gap: 3px; flex-shrink: 0;
font-size: .75em; font-weight: 700; letter-spacing: .04em;
padding: 2px 7px; border-radius: 10px; white-space: nowrap;
}
.wd-sug-chip-obs { background: rgba(255,152,0,.22); }
.wd-sug-chip-cal { background: rgba(33,150,243,.18); }
.wd-sug-val { font-weight: 700; flex-shrink: 0; }
.wd-sug-impact-line { flex-basis: 100%; font-size: .86em; opacity: .70; font-style: italic; margin-top: 2px; }
.wd-sug-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.wd-sug-sep { display: none; }
.wd-sug-impact { display: none; }
.wd-sug-use { border: none; background: var(--warning-color, #ff9800); color: var(--wd-white); border-radius: var(--wd-radius-sm); padding: 2px 8px; font-size: .92em; cursor: pointer; flex-shrink: 0; }
.wd-conflict-err { display: flex; flex-direction: column; gap: 4px; margin-top: 5px; }
.wd-conflict-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: .8em; color: var(--error-color, #b71c1c); padding: 5px 9px; border-left: 3px solid var(--error-color, #b71c1c); background: rgba(183,28,28,.07); border-radius: 0 5px 5px 0; }
.wd-conflict-fix { border: 1px solid var(--error-color, #b71c1c); background: none; color: var(--error-color, #b71c1c); border-radius: var(--wd-radius-sm); padding: 1px 7px; font-size: .92em; cursor: pointer; white-space: nowrap; flex: none; }
.wd-conflict-fix:hover { background: var(--error-color, #b71c1c); color: var(--wd-white); }
.wd-conflict-sug-note { font-style: italic; opacity: 0.85; flex: none; }
#wd-settings-form .wd-field.wd-has-conflict { outline: 2px solid var(--error-color, #b71c1c); outline-offset: -1px; }
.wd-rev-sub { display: flex; align-items: center; gap: 6px; margin: 14px 0 6px; font-size: .85em; font-weight: 600; color: var(--primary-text-color); }
.wd-rev-tags { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 8px; }
.wd-rev-tag { display: flex; align-items: center; gap: 7px; padding: 7px 10px; border-radius: var(--wd-radius-md); background: var(--secondary-background-color); border: 1px solid var(--divider-color); font-size: .85em; cursor: pointer; }
.wd-rev-tag input { margin: 0; }
.wd-rev-notes { width: 100%; box-sizing: border-box; background: var(--card-background-color); color: var(--primary-text-color); border: 1px solid var(--divider-color); border-radius: var(--wd-radius-md); padding: 9px 11px; font: inherit; resize: vertical; }
.wd-sug-banner {
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
padding: 12px 16px; border-radius: 10px; margin-bottom: 16px;
background: rgba(255,152,0,.12); border: 1px solid rgba(255,152,0,.4);
}
.wd-subhead { font-size: .76em; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: var(--primary-color); margin: 8px 0 10px; grid-column: 1 / -1; }
.wd-section-nav { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 0; }
.wd-sec-btn {
padding: 5px 14px; border-radius: 14px; border: 1px solid var(--divider-color);
background: transparent; color: var(--secondary-text-color); font-size: .8em; cursor: pointer; transition: background .15s;
}
.wd-sec-btn.active { background: var(--primary-color); color: var(--wd-white); border-color: var(--primary-color); }
.wd-level-toggle { display: inline-flex; gap: 4px; }
.wd-sec-btn { position: relative; }
/* Basic/Advanced slide toggle */
.wd-mode-switch { display: inline-flex; align-items: center; gap: 7px; cursor: pointer; font-size: .82em; user-select: none; white-space: nowrap; }
.wd-mode-switch-label { color: var(--secondary-text-color); transition: color .15s; }
.wd-mode-switch-label.active { color: var(--primary-color); font-weight: 600; }
.wd-toggle-track { position: relative; display: inline-block; width: 36px; height: 20px; flex-shrink: 0; }
.wd-toggle-track input { opacity: 0; width: 0; height: 0; position: absolute; }
.wd-toggle-knob { position: absolute; inset: 0; border-radius: 20px; background: var(--divider-color); transition: background .2s; }
.wd-toggle-knob::after { content: ''; position: absolute; top: 3px; left: 3px; width: 14px; height: 14px; border-radius: 50%; background: #fff; transition: transform .2s; }
.wd-toggle-track input:checked + .wd-toggle-knob { background: var(--primary-color); }
.wd-toggle-track input:checked + .wd-toggle-knob::after { transform: translateX(16px); }
.wd-sec-sug-dot { position: absolute; top: 2px; right: 3px; width: 6px; height: 6px; border-radius: 50%; background: var(--warning-color, #ff9800); display: inline-block; pointer-events: none; }
.wd-sec-conf-dot { position: absolute; top: 2px; right: 3px; width: 6px; height: 6px; border-radius: 50%; background: var(--error-color, #b71c1c); display: inline-block; pointer-events: none; }
.wd-subtabs { display: flex; gap: 2px; border-bottom: 1px solid var(--divider-color); margin-bottom: 18px; flex-wrap: wrap; }
.wd-subtab { padding: 8px 18px; border: none; background: transparent; color: var(--secondary-text-color); font-size: .8em; font-weight: 500; cursor: pointer; border-bottom: 2px solid transparent; transition: color .15s; }
.wd-subtab.active { color: var(--primary-color); border-bottom-color: var(--primary-color); }
.wd-profiles-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; }
.wd-profile-card {
background: var(--card-background-color); border-radius: 10px; padding: 16px;
border: 1px solid var(--divider-color, rgba(0,0,0,.08)); cursor: pointer; transition: border-color .15s, transform .1s;
}
.wd-profile-card:hover { border-color: var(--primary-color); transform: translateY(-1px); }
button.wd-attn-card, button.wd-profile-card { appearance: none; font: inherit; text-align: left; width: 100%; }
button.wd-profile-card { display: block; }
.wd-prof-wrap { position: relative; }
.wd-profile-name { font-weight: 600; font-size: 1em; margin-bottom: 6px; }
.wd-profile-meta { font-size: .8em; color: var(--secondary-text-color); }
/* Profile-card header: name on the left, mini power-signature sparkline on the
right, both on one line and vertically aligned regardless of badge count. */
.wd-profile-head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.wd-profile-head .wd-profile-name { margin: 0; flex: 1 1 auto; min-width: 0; }
/* Status-pill row: wraps cleanly on its own line below the name, and every pill
(health / trend / warm-up / imported) shares one uniform compact pill shape. */
.wd-profile-badges { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-bottom: 8px; }
.wd-profile-badges .wd-badge { margin: 0; padding: 2px 9px; border-radius: 999px; font-size: .72em; font-weight: 600; gap: 4px; }
/* D2: mini duration sparkline on profile cards */
.wd-prof-spark { width: 64px; height: 20px; display: block; flex-shrink: 0; }
/* Community Store */
.wd-store-crumbs { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-bottom: 14px; font-size: .85em; }
.wd-crumb { background: none; border: none; color: var(--primary-color); cursor: pointer; padding: 2px 4px; font: inherit; }
.wd-crumb:hover { text-decoration: underline; }
.wd-crumb.active { color: var(--primary-text-color); font-weight: 700; cursor: default; }
.wd-crumb-sep { color: var(--secondary-text-color); }
.wd-store-search { display: flex; gap: 8px; margin-bottom: 14px; flex-wrap: wrap; }
.wd-store-search input { flex: 1; min-width: 180px; padding: 8px 11px; border-radius: 6px; border: 1px solid var(--divider-color); background: var(--secondary-background-color); color: var(--primary-text-color); font-size: .9em; }
.wd-store-list { display: flex; flex-direction: column; gap: 8px; }
/* Browse rows (appliances / programs): tappable list rows with a hover affordance
and a chevron, instead of flat cards. */
.wd-store-rows { display: flex; flex-direction: column; gap: 6px; }
.wd-store-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; text-align: left; width: 100%; cursor: pointer; padding: 11px 14px; background: var(--secondary-background-color); border: 1px solid var(--divider-color); border-radius: var(--wd-radius-md); color: var(--primary-text-color); transition: border-color .12s ease, background .12s ease; }
.wd-store-row:hover { border-color: var(--primary-color); background: var(--card-background-color); }
.wd-store-row-main { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.wd-store-row-title { font-weight: 600; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.wd-store-row-sub { display: flex; align-items: center; gap: 10px; font-size: .8em; color: var(--secondary-text-color); }
.wd-store-chip { background: var(--accent-dim, rgba(0,180,216,.14)); color: var(--primary-color); border-radius: 999px; padding: 1px 8px; font-size: .92em; text-transform: capitalize; }
.wd-store-fav { color: var(--warning-color, #f0b429); }
.wd-store-row-arrow { color: var(--secondary-text-color); font-size: 1.3em; flex-shrink: 0; }
.wd-store-cycle-top { display: flex; align-items: center; gap: 12px; }
.wd-store-cycle-stats { flex: 1; min-width: 0; }
.wd-store-spark { width: 120px; height: 36px; flex-shrink: 0; display: block; background: var(--secondary-background-color); border-radius: 6px; }
.wd-store-conn { display: flex; align-items: center; gap: 10px; margin-top: 12px; flex-wrap: wrap; }
.wd-store-actions { display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
.wd-tag-pending { background: rgba(56,139,253,.18); color: var(--info-color, #58a6ff); }
.wd-tag-approved { background: rgba(63,185,80,.18); color: var(--success-color, #3fb950); }
.wd-store-picker-detail { margin-top: 6px; font-size: .85em; color: var(--secondary-text-color); display: flex; flex-wrap: wrap; gap: 4px 10px; align-items: center; }
.wd-store-picker-actions { display: flex; align-items: center; gap: 8px; flex-basis: 100%; margin-top: 4px; flex-wrap: wrap; }
.wd-star-row { display: inline-flex; gap: 2px; }
.wd-star-btn { background: none; border: none; cursor: pointer; color: var(--warning-color, #f0b429); font-size: 1.1em; padding: 0 1px; line-height: 1; }
.wd-star-btn:hover { transform: scale(1.15); }
/* Share-device selection tree (profile -> its reference cycles). */
.wd-sd-tree { display: flex; flex-direction: column; gap: 8px; max-height: 44vh; overflow-y: auto; margin-bottom: 16px; }
.wd-sd-group { border: 1px solid var(--divider-color); border-radius: var(--wd-radius-md); background: var(--secondary-background-color); overflow: hidden; }
.wd-sd-prof { display: flex; align-items: center; gap: 8px; padding: 9px 12px; cursor: pointer; font-weight: 600; }
.wd-sd-prof-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wd-sd-count { font-size: .8em; font-weight: 400; color: var(--secondary-text-color); }
.wd-sd-cycles { display: flex; flex-direction: column; border-top: 1px solid var(--divider-color); }
.wd-sd-cyc { display: flex; align-items: center; gap: 8px; padding: 6px 12px 6px 28px; cursor: pointer; font-size: .9em; }
.wd-sd-cyc:hover, .wd-sd-prof:hover { background: var(--card-background-color); }
.wd-sd-cyc-meta { color: var(--secondary-text-color); }
.wd-sd-phase { display: flex; align-items: center; gap: 8px; padding: 6px 12px; border-top: 1px solid var(--divider-color); cursor: pointer; font-size: .85em; color: var(--secondary-text-color); }
.wd-sd-phase:hover { background: var(--card-background-color); }
.wd-sd-settings { display: flex; align-items: center; gap: 8px; padding: 10px 2px 2px; cursor: pointer; font-size: .9em; }
.wd-sd-consent { display: flex; align-items: flex-start; gap: 8px; padding: 10px 2px 2px; cursor: pointer; font-size: .9em; }
.wd-sd-group-nocyc { opacity: .65; }
.wd-sd-prof-disabled { display: flex; align-items: baseline; gap: 8px; padding: 9px 12px; font-weight: 600; flex-wrap: wrap; }
.wd-sd-nocyc-note { font-size: .8em; font-weight: 400; color: var(--secondary-text-color); }
.wd-share-guide { margin-bottom: 10px; border: 1px solid var(--divider-color); border-radius: var(--wd-radius-md); overflow: hidden; }
.wd-share-guide > summary { padding: 8px 12px; cursor: pointer; font-size: .85em; font-weight: 600; color: var(--secondary-text-color); list-style: none; }
.wd-share-guide > summary::-webkit-details-marker { display: none; }
.wd-share-guide > summary::before { content: '▶ '; font-size: .7em; }
.wd-share-guide[open] > summary::before { content: '▼ '; }
.wd-share-guide-list { margin: 0; padding: 4px 12px 10px 28px; font-size: .85em; color: var(--secondary-text-color); line-height: 1.5; }
.wd-share-guide-list li { margin-bottom: 4px; }
.wd-linkbtn { background: none; border: none; padding: 0; color: var(--primary-color); cursor: pointer; font: inherit; text-decoration: underline; }
.wd-gear-body { margin-top: 12px; }
.wd-empty { text-align: center; padding: 48px 24px; color: var(--secondary-text-color); }
.wd-empty .wd-icon { font-size: 3em; margin-bottom: 10px; }
.wd-error-state { display: flex; align-items: center; gap: 10px; padding: 10px 14px; margin-bottom: 10px; border-radius: var(--wd-radius-md); background: var(--secondary-background-color); border: 1px solid var(--divider-color); color: var(--error-color, #b71c1c); font-size: .9em; }
.wd-info { font-size: .9em; color: var(--secondary-text-color); line-height: 1.6; margin: 0; }
.wd-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.5); z-index: 100; display: flex; align-items: center; justify-content: center; }
.wd-modal { background: var(--card-background-color); border-radius: var(--wd-radius-lg); padding: 24px; max-width: 480px; width: calc(100% - 32px); max-height: 90vh; overflow-y: auto; box-shadow: 0 8px 32px rgba(0,0,0,.3); }
.wd-modal-lg { max-width: 880px; }
.wd-modal h2 { margin: 0 0 16px; font-size: 1.1em; display: flex; align-items: center; gap: 10px; }
.wd-modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 20px; flex-wrap: wrap; }
.wd-canvas-wrap { margin: 10px 0; background: var(--secondary-background-color); border-radius: var(--wd-radius-md); padding: 6px; }
.wd-canvas-wrap canvas { width: 100%; height: 240px; display: block; touch-action: none; cursor: crosshair; }
.wd-mode-bar { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 8px; }
.wd-mini-tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--divider-color); margin-bottom: 16px; flex-wrap: wrap; }
.wd-mini-tab { padding: 7px 16px; border: none; background: transparent; color: var(--secondary-text-color); font-size: .82em; font-weight: 600; cursor: pointer; border-bottom: 2px solid transparent; }
.wd-mini-tab.active { color: var(--primary-color); border-bottom-color: var(--primary-color); }
.wd-kv { display: grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); gap: 10px; margin: 4px 0 14px; }
.wd-kv-item { background: var(--secondary-background-color); border-radius: var(--wd-radius-md); padding: 10px; text-align: center; }
.wd-kv-val { font-size: 1.25em; font-weight: 700; }
.wd-kv-lbl { font-size: .7em; color: var(--secondary-text-color); margin-top: 3px; }
.wd-seg-row, .wd-phase-row { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; flex-wrap: wrap; }
.wd-swatch { width: 12px; height: 12px; border-radius: 3px; flex-shrink: 0; display: inline-block; }
.wd-toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); z-index: 200; padding: 10px 20px; border-radius: var(--wd-radius-md); font-size: .9em; font-weight: 500; box-shadow: 0 4px 12px rgba(0,0,0,.25); animation: wd-toast-in .2s ease; }
@keyframes wd-toast-in { from { opacity: 0; transform: translateX(-50%) translateY(10px); } }
.wd-toast-success { background: var(--success-color, #4caf50); color: var(--wd-white); }
.wd-toast-error { background: var(--error-color, #f44336); color: var(--wd-white); }
.wd-toast-info { background: var(--info-color, #2196f3); color: var(--wd-white); }
/* D4: undo toast — action button + row layout */
.wd-toast { display: flex; align-items: center; gap: 14px; }
.wd-toast-action { background: rgba(255,255,255,.22); color: inherit; border: none; border-radius: 6px; padding: 5px 12px; font: inherit; font-weight: 700; cursor: pointer; text-transform: uppercase; letter-spacing: .04em; font-size: .85em; }
.wd-toast-action:hover { background: rgba(255,255,255,.34); }
/* D7: "changed since last save" marker beside a settings field label */
.wd-chg-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--info-color, #2196f3); margin-left: 6px; flex-shrink: 0; cursor: help; }
.wd-diag-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 10px; margin-bottom: 16px; }
.wd-diag-stat { background: var(--secondary-background-color); border-radius: var(--wd-radius-md); padding: 12px; text-align: center; }
.wd-diag-val { font-size: 1.6em; font-weight: 700; }
.wd-diag-lbl { font-size: .72em; color: var(--secondary-text-color); margin-top: 4px; }
.wd-feedback-item { display: flex; align-items: center; gap: 10px; padding: 10px 0; border-bottom: 1px solid var(--divider-color); }
.wd-feedback-item:last-child { border-bottom: none; }
.wd-feedback-body { flex: 1; }
.wd-feedback-profile { font-weight: 600; }
.wd-feedback-meta { font-size: .78em; color: var(--secondary-text-color); }
.wd-rec-status { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; }
.wd-rec-dot { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }
.wd-rec-active { background: var(--error-color, #f44336); animation: wd-pulse 1s ease-in-out infinite; }
.wd-rec-ready { background: var(--success-color, #4caf50); }
.wd-rec-idle { background: var(--disabled-color, #bdbdbd); }
/* Graph hover tooltip (follows the cursor) */
.wd-gtip { position: fixed; z-index: 300; display: none; pointer-events: none; background: var(--card-background-color); color: var(--primary-text-color); border: 1px solid var(--divider-color); border-radius: var(--wd-radius-md); padding: 7px 10px; font-size: 12px; line-height: 1.5; box-shadow: 0 4px 16px rgba(0,0,0,.4); white-space: nowrap; }
.wd-gtip b { font-weight: 700; }
/* Status chart legend + toggles */
.wd-leg { display: flex; gap: 14px; flex-wrap: wrap; margin-top: 10px; font-size: .8em; color: var(--secondary-text-color); }
.wd-leg-i { display: inline-flex; align-items: center; gap: 6px; }
.wd-leg-i input { margin: 0 2px 0 0; width: auto; }
.wd-leg-sw { width: 16px; height: 3px; border-radius: 2px; display: inline-block; }
/* Status program selector */
.wd-prog-ctl { display: flex; align-items: center; gap: 10px; margin-bottom: 16px; flex-wrap: wrap; }
.wd-prog-ctl label { font-size: .72em; text-transform: uppercase; letter-spacing: .08em; color: var(--secondary-text-color); margin: 0; }
.wd-prog-ctl select { padding: 8px 11px; border-radius: 6px; border: 1px solid var(--divider-color); background: var(--secondary-background-color); color: var(--primary-text-color); min-width: 200px; font-size: .9em; }
.wd-prog-tag { font-size: .78em; padding: 3px 9px; border-radius: 10px; }
.wd-prog-tag.auto { background: rgba(76,175,80,.18); color: var(--success-color, #4caf50); }
.wd-prog-tag.manual { background: rgba(255,152,0,.2); color: var(--warning-color, #ff9800); }
/* Status-rich device selector */
.wd-devbar { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 20px; }
.wd-devcard { display: flex; align-items: center; gap: 9px; padding: 9px 13px; border-radius: var(--wd-radius-lg); border: 1px solid var(--divider-color); background: var(--card-background-color); color: var(--primary-text-color); cursor: pointer; font-size: .9em; }
.wd-devcard.active { border-color: var(--primary-color); box-shadow: 0 0 0 1px var(--primary-color); }
.wd-devadd { border-style: dashed; color: var(--secondary-text-color); }
.wd-devadd:hover { border-color: var(--primary-color); color: var(--primary-color); }
.wd-devdot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.wd-devdot.run { animation: wd-pulse 1.4s ease-in-out infinite; }
.wd-devname { font-weight: 600; }
.wd-devsub { font-size: .72em; color: var(--secondary-text-color); }
.wd-dbadge { font-size: .72em; padding: 1px 7px; border-radius: 10px; background: var(--secondary-background-color); }
.wd-dbadge.rec { background: var(--error-color, #f44336); color: var(--wd-white); }
.wd-dbadge.sug { background: rgba(255,152,0,.22); }
.wd-dbadge.fb { background: rgba(33,150,243,.22); }
.wd-dbadge.conf { background: rgba(183,28,28,.18); color: var(--error-color, #b71c1c); }
/* Attention cards (status dashboard) */
.wd-attn { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 10px; margin-bottom: 16px; }
.wd-attn-card { display: flex; align-items: center; gap: 11px; padding: 12px 14px; border-radius: 10px; background: var(--card-background-color); border: 1px solid var(--divider-color); cursor: pointer; transition: border-color .15s; }
.wd-attn-card:hover { border-color: var(--primary-color); }
.wd-attn-icon { font-size: 1.5em; line-height: 1; }
.wd-attn-body { flex: 1; min-width: 0; }
.wd-attn-title { font-weight: 600; }
.wd-attn-sub { font-size: .76em; color: var(--secondary-text-color); }
/* F1 first-run onboarding card (Status power-chart area) */
.wd-onboard { margin-top: 12px; padding: 16px; border-radius: 10px; border: 1px dashed var(--divider-color); background: var(--secondary-background-color); }
.wd-onboard .wd-card-title { margin-top: 0; }
.wd-onboard-skip { font-size: .8em; color: var(--secondary-text-color); text-decoration: underline; cursor: pointer; }
.wd-onboard-skip:hover { color: var(--primary-color); }
/* Setup card (replaces getting-started card, phases 0-3) and phase-4 chip */
.wd-setup-card { margin-top: 12px; padding: 16px; border-radius: 10px; border: 1px solid var(--primary-color, #03a9f4); background: color-mix(in srgb, var(--primary-color, #03a9f4) 8%, var(--card-background-color, var(--ha-card-background))); }
.wd-setup-card .wd-card-title { margin-top: 0; }
.wd-setup-chip { display: inline-flex; align-items: center; gap: 6px; padding: 5px 12px; border-radius: 20px; font-size: .82em; font-weight: 600; cursor: pointer; border: 1px solid var(--divider-color); background: var(--secondary-background-color); margin-top: 12px; }
.wd-setup-chip--healthy { border-color: var(--success-color, #4caf50); background: color-mix(in srgb, var(--success-color, #4caf50) 10%, var(--card-background-color, transparent)); }
.wd-setup-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.wd-setup-dot--green { background: var(--success-color, #4caf50); }
.wd-link { background: none; border: none; padding: 0; color: var(--primary-color); cursor: pointer; font: inherit; text-decoration: underline; display: inline; }
.wd-link:hover { opacity: .8; }
/* Logs page */
.wd-logbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 12px; }
.wd-logs { font-family: monospace; font-size: .76em; background: var(--secondary-background-color); border-radius: var(--wd-radius-md); padding: 10px; height: 56vh; min-height: 140px; overflow: auto; resize: vertical; }
#wd-log-lines-page { height: auto; min-height: 200px; resize: none; }
/* Grouped stat blocks (profile overview) */
.wd-sg-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px; margin: 4px 0 16px; }
.wd-sg { background: var(--secondary-background-color); border-radius: 10px; padding: 14px; }
.wd-sg-h { font-size: .7em; text-transform: uppercase; letter-spacing: .08em; color: var(--secondary-text-color); margin-bottom: 6px; }
.wd-sg-main { font-size: 1.55em; font-weight: 700; line-height: 1.1; }
.wd-sg-main span { font-size: .5em; font-weight: 400; color: var(--secondary-text-color); margin-left: 4px; }
.wd-sg-sub { font-size: .78em; color: var(--secondary-text-color); margin-top: 5px; line-height: 1.5; }
.wd-logline { padding: 2px 0; border-bottom: 1px solid var(--divider-color); white-space: pre-wrap; word-break: break-word; }
.wd-logline:last-child { border-bottom: none; }
/* Entity combobox */
.wd-combo { position: relative; width: 100%; }
.wd-combo-drop { position: absolute; top: 100%; left: 0; right: 0; z-index: 60;
background: var(--card-background-color,#fff); border: 1px solid var(--divider-color);
border-radius: 6px; box-shadow: 0 4px 14px rgba(0,0,0,.18);
max-height: 220px; overflow-y: auto; margin-top: 3px; }
.wd-combo-item { padding: 7px 12px; cursor: pointer; font-size: .86em; white-space: nowrap;
overflow: hidden; text-overflow: ellipsis; }
.wd-combo-item:hover, .wd-combo-item.kbd { background: var(--secondary-background-color); }
.wd-combo-row { display: flex; gap: 6px; align-items: center; }
.wd-combo-row .wd-combo { flex: 1 1 auto; }
.wd-addbtn { flex: 0 0 auto; width: 34px; height: 34px; border-radius: var(--wd-radius-md); border: 1px solid var(--divider-color); background: var(--secondary-background-color); color: var(--primary-text-color); font-size: 1.25em; line-height: 1; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; }
.wd-addbtn:hover { background: var(--primary-color); color: #fff; border-color: var(--primary-color); }
.wd-loglvl { font-weight: 700; margin-right: 6px; }
.wd-logcomp { display: inline-block; font-size: .72em; color: var(--secondary-text-color); background: var(--secondary-background-color); border-radius: 4px; padding: 0 5px; margin-right: 6px; }
.wd-logdev { display: inline-block; font-size: .72em; color: var(--primary-color); margin-right: 6px; }
.wd-logts { color: var(--secondary-text-color); margin-right: 6px; }
.wd-lvl-ERROR, .wd-lvl-CRITICAL { color: var(--error-color, #f44336); }
.wd-lvl-WARNING { color: var(--warning-color, #ff9800); }
.wd-lvl-INFO { color: var(--info-color, #2196f3); }
.wd-lvl-DEBUG { color: var(--secondary-text-color); }
/* Compact cycle list */
.wd-clist { display: flex; flex-direction: column; }
.wd-crow { display: flex; align-items: center; gap: 10px; padding: 9px 6px; border-bottom: 1px solid var(--divider-color); cursor: pointer; }
.wd-crow:hover { background: var(--secondary-background-color); }
.wd-crow:last-child { border-bottom: none; }
.wd-cmain { flex: 1; min-width: 0; overflow: hidden; }
.wd-cprog { font-weight: 600; }
.wd-cdate { font-size: .74em; color: var(--secondary-text-color); }
.wd-cmeta { text-align: right; font-size: .76em; color: var(--secondary-text-color); white-space: nowrap; }
/* Responsive / touch (portrait, phones, side panel) */
@media (max-width: 680px) {
.wd-body { padding: 12px 10px 64px; }
.wd-card { padding: 14px; margin-bottom: 12px; }
.wd-form-grid { grid-template-columns: 1fr; }
.wd-stats { grid-template-columns: repeat(2, 1fr); }
.wd-kv { grid-template-columns: repeat(2, 1fr); }
.wd-tab { padding: 9px 13px; }
.wd-modal { padding: 16px; width: calc(100% - 18px); }
.wd-modal-lg { max-width: 100%; }
.wd-canvas-wrap canvas { height: 200px; }
.wd-header { padding: 12px 14px; }
.wd-btn { padding: 9px 15px; } /* larger touch targets */
.wd-tip-pop { width: 210px; }
.wd-pg-lane-lbl { flex: 0 1 120px; max-width: 120px; }
#wd-settings-form .wd-form-grid { grid-template-columns: 1fr; gap: 12px 0; }
}
/* Log drawer */
.wd-shell { display: flex; flex-direction: column; min-height: 100%; }
.wd-content-row { display: flex; flex: 1; overflow: hidden; min-height: 0; }
.wd-main { flex: 1; overflow-y: auto; min-width: 0; }
.wd-log-drawer {
position: relative; width: 0; overflow: hidden;
transition: width .28s cubic-bezier(.4,0,.2,1);
border-left: 1px solid var(--divider-color);
display: flex; flex-direction: column;
background: var(--primary-background-color);
}
.wd-log-drawer.open { width: 380px; }
.wd-log-resize {
position: absolute; left: 0; top: 0; bottom: 0; width: 6px; cursor: ew-resize; z-index: 2;
transition: background .15s;
}
.wd-log-resize:hover, .wd-log-resize.dragging { background: var(--primary-color, #03a9f4); opacity: .35; }
.wd-log-drawer-head {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 14px; border-bottom: 1px solid var(--divider-color);
font-weight: 600; font-size: .9em; flex-shrink: 0; white-space: nowrap;
}
.wd-log-drawer-body { flex: 1; overflow-y: auto; padding: 10px 14px; min-width: 0; }
.wd-log-close-btn {
background: none; border: none; cursor: pointer; color: inherit; opacity: .65;
padding: 3px 6px; border-radius: var(--wd-radius-sm); font-size: 1.1em; line-height: 1;
}
.wd-log-close-btn:hover { opacity: 1; background: var(--secondary-background-color); }
.wd-gear-btn.log-active { background: rgba(255,255,255,.22); }
@media (max-width: 680px) {
.wd-log-drawer.open { width: 100vw !important; position: fixed; top: 0; right: 0; bottom: 0; z-index: 30; border-left: none; }
.wd-log-resize { display: none; }
}
.wd-pg-delta-up { color: var(--success-color, #4caf50); font-weight: 700; }
.wd-pg-delta-down { color: var(--error-color, #f44336); font-weight: 700; }
.wd-pg-delta-flat { color: var(--secondary-text-color); }
/* F3: Unified Playground */
.wd-pg-canvas-wrap { position: relative; width: 100%; }
#wd-pg-canvas { display: block; width: 100%; height: 330px; cursor: crosshair; border-radius: 6px; background: var(--secondary-background-color); margin: 10px 0 0; }
.wd-pg-strip { display: flex; align-items: center; gap: 10px; padding: 8px 2px; font-size: .88em; font-variant-numeric: tabular-nums; flex-wrap: wrap; border-bottom: 1px solid var(--divider-color, rgba(127,127,127,.2)); margin-bottom: 12px; }
.wd-pg-strip-state { padding: 2px 10px; border-radius: 20px; font-weight: 700; font-size: .83em; white-space: nowrap; }
.wd-pg-strip-pbar { display: inline-flex; align-items: center; gap: 5px; }
.wd-pg-strip-track { width: 60px; height: 6px; background: var(--secondary-background-color); border-radius: 3px; overflow: hidden; display: inline-block; vertical-align: middle; }
.wd-pg-strip-fill { height: 100%; background: var(--primary-color); border-radius: 3px; transition: width .15s; }
.wd-pg-params { display: flex; flex-direction: column; gap: 2px; }
.wd-pg-param-row { display: flex; align-items: center; gap: 6px; }
.wd-pg-param-lbl { flex: 1; font-size: .83em; color: var(--secondary-text-color); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.wd-pg-param-inp { width: 76px; flex: 0 0 76px; }
.wd-pg-param-drag { font-size: .75em; color: var(--primary-color); cursor: default; flex: 0 0 12px; }
.wd-pg-score-bar-row { display: flex; align-items: center; gap: 6px; font-size: .82em; margin: 2px 0; }
.wd-pg-score-bar-lbl { flex: 0 0 80px; color: var(--secondary-text-color); }
.wd-pg-score-bar-track { flex: 1; height: 6px; background: var(--secondary-background-color); border-radius: 3px; overflow: hidden; }
.wd-pg-score-bar-fill { height: 100%; border-radius: 3px; }
.wd-pg-score-bar-val { flex: 0 0 42px; text-align: right; font-variant-numeric: tabular-nums; color: var(--secondary-text-color); }
.wd-pg-cand-row { display: flex; align-items: center; gap: 6px; font-size: .82em; margin: 3px 0; }
.wd-pg-cand-name { flex: 0 0 110px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.wd-pg-cand-track { flex: 1; height: 7px; background: var(--secondary-background-color); border-radius: var(--wd-radius-sm); overflow: hidden; }
.wd-pg-cand-fill { height: 100%; border-radius: var(--wd-radius-sm); }
.wd-pg-cand-pct { flex: 0 0 34px; text-align: right; color: var(--secondary-text-color); }
/* Playground: unified workbench (graph+settings always on top) + "Across your
cycles" drawer with History/Optimize sub-tabs. */
.wd-pg-drawer { margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--divider-color, rgba(127,127,127,.25)); }
.wd-pg-drawer-head { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin: 0 0 12px; }
.wd-pg-subtabs { display: inline-flex; gap: 2px; padding: 3px; border-radius: 10px; background: var(--secondary-background-color); }
.wd-pg-subtab { border: none; background: transparent; color: var(--secondary-text-color); font: inherit; font-size: .84em; font-weight: 600; padding: 5px 13px; border-radius: 8px; cursor: pointer; }
.wd-pg-subtab:hover { color: var(--primary-text-color); }
.wd-pg-subtab.active { background: var(--card-background-color, var(--primary-background-color)); color: var(--primary-color); box-shadow: 0 1px 3px rgba(0,0,0,.12); }
.wd-pg-hrow { cursor: pointer; }
.wd-pg-hrow:hover td { background: var(--secondary-background-color); }
.wd-pg-hrow.selected td { background: color-mix(in srgb, var(--primary-color) 14%, transparent); box-shadow: inset 2px 0 0 var(--primary-color); }
.wd-pg-sim-grid { display: grid; grid-template-columns: 1.4fr 1fr; gap: 16px; margin-top: 4px; }
.wd-pg-sim-main, .wd-pg-sim-side { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.wd-pg-simbar { height: 6px; border-radius: 3px; background: var(--secondary-background-color); overflow: hidden; margin: 6px 0 0; }
.wd-pg-simbar-fill { height: 100%; width: 40%; border-radius: 3px; background: var(--primary-color); animation: wd-pg-indeterminate 1.1s ease-in-out infinite; }
@keyframes wd-pg-indeterminate { 0% { margin-left: -40%; } 100% { margin-left: 100%; } }
.wd-pg-batchbar-fill { height: 100%; width: 0%; border-radius: 3px; background: var(--primary-color); transition: width .18s ease; }
/* Header activity pills (background-task registry) */
.wd-task-pills { display: inline-flex; gap: 6px; align-items: center; flex-wrap: wrap; margin: 0 0 0 12px; }
.wd-task-pill { display: inline-flex; align-items: center; gap: 6px; padding: 3px 4px 3px 9px; border-radius: 12px; background: rgba(255,255,255,.16); color: var(--app-header-text-color, #fff); font-size: .78em; line-height: 1; }
.wd-task-pill-lbl { font-weight: 600; max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wd-task-pill-pct { font-variant-numeric: tabular-nums; opacity: .95; }
.wd-task-pill-eta { opacity: .75; }
.wd-task-pill-x { border: none; background: rgba(0,0,0,.18); color: inherit; width: 16px; height: 16px; border-radius: 50%; cursor: pointer; font-size: .9em; line-height: 1; display: inline-flex; align-items: center; justify-content: center; padding: 0; }
.wd-task-pill-x:hover { background: rgba(0,0,0,.32); }
.wd-task-pill--cancelling { background: rgba(255,160,0,.28); }
.wd-task-pill-cancelling { opacity: .9; font-style: italic; }
.wd-task-pill-x--cancelling { opacity: .4; cursor: default; pointer-events: none; }
.wd-task-spin { width: 10px; height: 10px; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: wd-spin-kf .8s linear infinite; opacity: .9; }
@keyframes wd-spin-kf { to { transform: rotate(360deg); } }
.wd-pg-batchrow { display: flex; align-items: center; gap: 10px; margin: 6px 0 8px; }
.wd-pg-batchrow .wd-pg-simbar { flex: 1; margin: 0; }
#wd-pg-canvas.wd-pg-panning { cursor: grabbing; }
.wd-pg-alerts-card { background: var(--secondary-background-color); border-radius: 10px; padding: 12px; }
.wd-pg-outcome-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.wd-pg-outcome-item { text-align: center; background: var(--card-background-color, var(--primary-background-color)); border-radius: 8px; padding: 8px 4px; }
.wd-pg-outcome-val { font-size: 1.05em; font-weight: 700; }
.wd-pg-outcome-lbl { font-size: .68em; color: var(--secondary-text-color); text-transform: uppercase; letter-spacing: .04em; margin-top: 2px; }
.wd-pg-alert { border-left: 3px solid var(--info-color, #2196f3); padding: 4px 0 4px 10px; }
/* History table */
.wd-pg-htable { width: 100%; border-collapse: collapse; font-size: .84em; }
.wd-pg-htable th { text-align: left; font-weight: 600; color: var(--secondary-text-color); padding: 6px 8px; border-bottom: 1px solid var(--divider-color, rgba(127,127,127,.2)); font-size: .82em; }
.wd-pg-htable td { padding: 6px 8px; border-bottom: 1px solid var(--divider-color, rgba(127,127,127,.12)); }
.wd-pg-htable tr[data-action] { cursor: pointer; }
.wd-pg-htable tr[data-action]:hover { background: var(--secondary-background-color); }
.wd-pg-diffbadge { display: inline-flex; align-items: center; gap: 5px; padding: 3px 10px; border-radius: 20px; font-size: .82em; font-weight: 600; margin: 0 6px 6px 0; }
/* Sweep heatmap */
@media (max-width: 720px) {
.wd-pg-sim-grid { grid-template-columns: 1fr; }
}
@media (max-width: 640px) {
.wd-pg-strip { gap: 7px; font-size: .82em; }
}
`;
// ─── Helpers ─────────────────────────────────────────────────────────────────
function _fmtDuration(s) {
if (s == null || s < 0) return '-';
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = Math.floor(s % 60);
if (h > 0) return `${h}h ${m}m`;
if (m > 0) return `${m}m ${sec}s`;
return `${sec}s`;
}
function _fmtPower(w) {
if (w == null) return '-';
return w >= 100 ? `${Math.round(w)} W` : `${w.toFixed(1)} W`;
}
function _fmtEnergy(kwh) {
if (kwh == null) return '-';
return `${kwh.toFixed(2)} kWh`;
}
// Current cycle-date display mode ('relative' | 'absolute'), synced from the
// user's persisted "Cycle date display" preference by _render() on each paint.
let _datePref = 'relative';
// Locale-aware "3 hours ago" / "in 2 days" formatting. Intl handles localization,
// so this needs no translation strings; falls back to absolute if unsupported.
function _relTime(ms) {
const diffSec = Math.round((ms - Date.now()) / 1000); // < 0 = in the past
let rtf;
try { rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }); }
catch (_) { return _fmtAbsDate(ms); }
const abs = Math.abs(diffSec);
const units = [['year', 31536000], ['month', 2592000], ['week', 604800], ['day', 86400], ['hour', 3600], ['minute', 60]];
for (const [name, span] of units) {
if (abs >= span) return rtf.format(Math.round(diffSec / span), name);
}
return rtf.format(diffSec, 'second');
}
function _fmtAbsDate(ms) {
return new Date(ms).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
}
// Normalize any timestamp (ISO string, unix seconds, unix millis, or a bare
// YYYY-MM-DD calendar date) to epoch millis, then format per the date-display
// preference. `mode` overrides the preference for a single call site.
function _fmtDate(ts, mode) {
if (!ts) return '-';
let ms;
if (typeof ts === 'number') {
// Numeric epoch: ms (Date.now(), ~1e12+) vs seconds (~1e9). Anything >= 1e12
// is already-milliseconds — handles Date.now() values consistently.
ms = ts >= 1e12 ? ts : ts * 1000;
} else {
const s = String(ts);
const md = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
// Bare calendar dates (maintenance YYYY-MM-DD) parse as LOCAL midnight, not
// UTC, so they don't shift a day in negative-offset timezones.
ms = md ? new Date(+md[1], +md[2] - 1, +md[3]).getTime() : new Date(s).getTime();
}
if (isNaN(ms)) return '-';
return (mode || _datePref) === 'relative' ? _relTime(ms) : _fmtAbsDate(ms);
}
function _esc(s) {
return String(s == null ? '' : s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''');
}
// Allow only http(s) links. Community-supplied URLs (e.g. a device's manualUrl)
// must never render a `javascript:`/`data:` href, which _esc does not neutralise.
// Returns '' for anything that is not an absolute http(s) URL.
function _safeHttpUrl(u) {
const s = String(u == null ? '' : u).trim();
return /^https?:\/\//i.test(s) ? s : '';
}
// Apply an alpha channel to any CSS color and return a valid rgba() string.
// The base color may come from a theme's `--primary-color` (issues #314/#315),
// which `getPropertyValue` returns as the raw declared token: `#03a9f4` for hex
// themes but `rgb(238, 147, 0)` for many custom themes / HA's accent picker.
// The old `col + '55'` alpha-suffix concatenation produced `rgb(238, 147, 0)55`,
// an invalid color that made canvas `addColorStop`/`fillStyle` throw and froze
// the panel. This parses hex (#rgb/#rgba/#rrggbb/#rrggbbaa) and rgb()/rgba(),
// and never throws -- an unrecognised color falls back to itself so the draw
// still succeeds (worst case: no transparency) rather than aborting the render.
function _withAlpha(col, alpha) {
const s = String(col == null ? '' : col).trim();
let m = s.match(/^#([0-9a-fA-F]{3,8})$/);
if (m) {
let h = m[1];
if (h.length === 3 || h.length === 4) h = h.split('').map(c => c + c).join('');
if (h.length === 6 || h.length === 8) {
const r = parseInt(h.slice(0, 2), 16);
const g = parseInt(h.slice(2, 4), 16);
const b = parseInt(h.slice(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
}
m = s.match(/^rgba?\(([^)]+)\)$/i);
if (m) {
const parts = m[1].split(',').map(p => p.trim());
if (parts.length >= 3) return `rgba(${parts[0]}, ${parts[1]}, ${parts[2]}, ${alpha})`;
}
return s;
}
function _num(v, def) { const n = parseFloat(v); return isNaN(n) ? def : n; }
// Visible, keyboard-focusable descendants of `root` (for modal focus trapping).
function _focusableEls(root) {
if (!root) return [];
const sel = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
return Array.from(root.querySelectorAll(sel)).filter(el => el.getClientRects().length > 0);
}
// D7: humanize a changelog value for display (null → em-dash-free placeholder).
function _chgVal(v) {
if (v == null || v === '') return '(none)';
if (v === true) return 'on';
if (v === false) return 'off';
if (Array.isArray(v)) return v.join(', ') || '(none)';
return String(v);
}
// Sort an array by a getter function, direction +1=asc -1=desc.
function _sortBy(arr, getter, dir) {
return arr.slice().sort((a, b) => {
const av = getter(a), bv = getter(b);
if (av == null && bv == null) return 0;
if (av == null) return 1;
if (bv == null) return -1;
return (av < bv ? -1 : av > bv ? 1 : 0) * dir;
});
}
// Sortable
element: shows ▲/▼ on the active column, ↕ on others.
// Pass align='right' for numeric columns, tipText for a native title= tooltip (no icon needed).
function _th(label, col, active, dir, action, align, tipText) {
const icon = active ? (dir === 1 ? ' ▲' : ' ▼') : ' ↕';
const alignStyle = align === 'right' ? 'text-align:right;' : '';
const titleAttr = tipText ? ` title="${_esc(tipText)}"` : '';
return `
${label}${icon}
`;
}
// mm:ss for a seconds value (graph hover readout).
function _fmtClock(s) {
s = Math.max(0, Math.round(s));
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60;
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
return `${m}:${String(sec).padStart(2, '0')}`;
}
// Plain label for a detected cycle artifact type.
function _artifactLabel(type, t) {
const entries = { pause: ['lbl.artifact_interruption', 'Interruption'], dip: ['lbl.artifact_low_power', 'Low power'], spike: ['lbl.artifact_high_power', 'High power'] };
const [key, fb] = entries[type] || ['lbl.artifact_anomaly', 'Anomaly'];
return t ? t(key, {}, fb) : fb;
}
// Slugify a sub-group label to a translation key fragment.
// "Door & Pause" → "door_pause", "Auto-Labeling" → "auto_labeling", etc.
function _slugSub(s) {
return s.toLowerCase().replace(/[\s&/\-]+/g, '_').replace(/^_+|_+$/g, '').replace(/_+/g, '_');
}
// Parse a comma-separated string into a sorted list of unique positive ints.
// Backs the `intlist` setting type (e.g. notify_milestones), which the backend
// stores as a list of ints but the panel edits as a comma-separated string.
function _parseIntList(s) {
const seen = new Set();
String(s == null ? '' : s).split(',').forEach(part => {
const n = parseInt(part.trim(), 10);
if (Number.isFinite(n) && n > 0) seen.add(n);
});
return Array.from(seen).sort((a, b) => a - b);
}
// Linear-interpolated y at offset x for a sorted [[x,y],...] series.
function _valueAt(pts, x) {
if (!pts || !pts.length) return null;
if (x <= pts[0][0]) return pts[0][1];
if (x >= pts[pts.length - 1][0]) return pts[pts.length - 1][1];
for (let i = 1; i < pts.length; i++) {
if (pts[i][0] >= x) {
const a = pts[i - 1], b = pts[i];
const span = (b[0] - a[0]) || 1;
return a[1] + (b[1] - a[1]) * ((x - a[0]) / span);
}
}
return pts[pts.length - 1][1];
}
// Build one form field group. `f` is a schema field; opts are resolved by caller.
function _field(f, value, extra) {
extra = extra || {};
const key = f.key;
const labelText = f.unit ? `${f.label} (${f.unit})` : f.label;
const _u = f.unit ? ` ${f.unit}` : '';
const tip = f.doc ? _tip(f.doc, f.diagram || _DIAGRAM_BY_KEY[key]) : '';
// D7: "changed" marker (a small dot with a tooltip) when this field has a
// recorded change in the settings changelog.
const chgDot = extra.changed ? `` : '';
if (f.type === 'checkbox') {
const chk = value ? 'checked' : '';
// Switch style. The tooltip sits inline at the end of the row (outside the
//