diff --git a/README.md b/README.md
index ad15623..4e93065 100644
--- a/README.md
+++ b/README.md
@@ -51,12 +51,17 @@ also be changed on the screen or by an automation, and your own YAML for one scr
+
+
+
+
+
-
Tap a tile to switch it, hold it for the full card. Keys and sliders right on the tile, pastel colors, a clock, the weather and history you can read with a finger. The media card shows what plays with its album cover, fetched by ESP Screens like a camera picture (how). Rendered from the firmware's own LVGL code with a demo home.
+
Tap a tile to switch it, hold it for the full card. Keys and sliders right on the tile, pastel colors, a clock, the weather and history you can read with a finger. A lamp with modes (a WLED) gets an effects page with a drum picker, named and filled from what Home Assistant lists for it. The media card shows what plays with its album cover, fetched by ESP Screens like a camera picture (how). Rendered from the firmware's own LVGL code with a demo home.
diff --git a/README_EXTENDED.md b/README_EXTENDED.md
index 03b607a..9eabe99 100644
--- a/README_EXTENDED.md
+++ b/README_EXTENDED.md
@@ -32,6 +32,14 @@ on the screen itself, and how updates work.
dial; double-width shows the digital time with the date beside it.
- **Light control:** brightness, rainbow color, and white temperature according to
the light's capabilities. Open the detailed control with a long touch.
+- **Effects (firmware 0.2.70+):** a light that offers effects (a WLED, a Hue with
+ its candle effect) gets a sparkles key at the top right of its colour card. It
+ opens a page with a row per thing the lamp offers, named and ordered as Home
+ Assistant lists them: the light's effect and the select entities of its device
+ (a WLED's colour palette, preset and playlist), plus a slider per number entity
+ (speed, intensity). A row opens a drum picker with every name Home Assistant has
+ at that moment; the check at the top right sends the choice. While an effect
+ runs, the tile names it instead of the brightness.
- **More cards:** climate, vacuum, fan, cover, media player, sensors,
select/input_select, number/input_number, switches, scenes, scripts, and
buttons.
diff --git a/components/smart_display/effects_page.h b/components/smart_display/effects_page.h
new file mode 100644
index 0000000..66d7dcc
--- /dev/null
+++ b/components/smart_display/effects_page.h
@@ -0,0 +1,473 @@
+#pragma once
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include "runtime_model.h"
+
+// A light's effects page (firmware 0.2.70+, app 0.2.83+): the modes of a lamp such as a WLED, reached from the
+// sparkles key at the top right of the light's colour card. One card of rows, each the name of something the lamp
+// offers and what it is set to (the light's effect, and the select entities of its device: colour palette, preset,
+// playlist), and under it a slider card per number entity of the device (speed, intensity). A row opens a picker:
+// LVGL's roller with every name Home Assistant lists at that moment (asked for when the picker opens, one page of
+// names per message), and the check key at the top right sends the name the drum stands on. Nothing here knows a brand: the rows,
+// their names and their icons come from Home Assistant through the add-on.
+//
+// Like the settings page, the page is built when it opens and thrown away when it closes, so it costs nothing while
+// closed. The model part (percentages, texts, what the page can show) stays free of LVGL for tests/test_effects_page.cpp.
+namespace effects_page {
+using runtime_tiles::NumberRow;
+using runtime_tiles::OptionRow;
+using runtime_tiles::Tile;
+
+// LightEntityFeature.EFFECT: the light offers an effect list.
+constexpr uint32_t EFFECT_FEATURE = 4;
+// Names a picker holds at most; a WLED lists about 220 effects.
+constexpr size_t MAX_NAMES = 400;
+// A chosen name stays on its row while Home Assistant still reports the old one, this long at most.
+constexpr uint32_t SENT_HOLD_MS = 4000;
+
+inline int clamp(int value, int low, int high) { return std::max(low, std::min(high, value)); }
+// Whether a light has an effects page: it offers effects, or its device has rows to show.
+inline bool available(const Tile &t) {
+ return t.domain() == "light" && ((t.supported & EFFECT_FEATURE) != 0 || !t.extra().option_rows.empty() || !t.extra().number_rows.empty());
+}
+// A number's place on its slider, 0-100 % of its range.
+inline int percent_of(float value, float low, float high) {
+ if (!std::isfinite(value) || !(high > low)) return 0;
+ return clamp(static_cast(std::lround((value - low) * 100 / (high - low))), 0, 100);
+}
+// The number a slider stands for, on the entity's own step and inside its range.
+inline float value_at(int percent, float low, float high, float step) {
+ float value = low + (high - low) * clamp(percent, 0, 100) / 100.0f;
+ if (step > 0) value = low + std::round((value - low) / step) * step;
+ return std::min(high, std::max(low, value));
+}
+// A number as Home Assistant's action takes it: "128", "12.5", never "128.000000".
+inline std::string number_text(float value) {
+ char b[24];
+ snprintf(b, sizeof(b), "%.3f", value);
+ std::string text = b;
+ while (text.size() > 1 && text.back() == '0') text.pop_back();
+ if (text.back() == '.') text.pop_back();
+ return text;
+}
+inline std::string percent_text(int percent) { return std::to_string(percent) + " %"; }
+// The roller takes its names as one string, a line per name.
+inline std::string joined(const std::vector &names) {
+ std::string out;
+ for (size_t i = 0; i < names.size(); ++i) { if (i) out += '\n'; out += names[i]; }
+ return out;
+}
+inline int index_of(const std::vector &names, const std::string &name) {
+ for (size_t i = 0; i < names.size(); ++i) if (names[i] == name) return static_cast(i);
+ return -1;
+}
+// The value a row shows: the name Home Assistant reports, a dash while it reports none.
+inline std::string row_text(const std::string ¤t) {
+ return current.empty() || current == "unknown" || current == "unavailable" || current == "None" ? "—" : current;
+}
+
+// Sizes of both boards, from the display's width like light_controls.
+struct Metrics {
+ int width = 480, height = 480, pad = 20, bar_y = 16, bar = 60, title_y = 35, rows_y = 100, row_h = 56, inset = 18,
+ icon = 26, gap = 12, number_h = 92, track_h = 28, number_inset = 16, roller_rows = 5, roller_row_h = 56, roller_pad = 10,
+ radius = 18, knob = 4;
+};
+inline Metrics metrics(int width, int height) {
+ Metrics m;
+ m.width = width; m.height = height;
+ if (width < 480) {
+ m.pad = 12; m.bar_y = 8; m.bar = 40; m.title_y = 21; m.rows_y = 52; m.row_h = 34; m.inset = 10; m.icon = 18; m.gap = 6;
+ m.number_h = 44; m.track_h = 14; m.number_inset = 9; m.roller_row_h = 30; m.roller_pad = 5; m.radius = 10; m.knob = 3;
+ }
+ return m;
+}
+} // namespace effects_page
+
+#ifndef EFFECTS_PAGE_TEST
+#include "esphome/core/log.h"
+#include "lvgl.h"
+#include "theme.h"
+#include "tile_icon.h"
+namespace effects_page {
+// ---- what the board profile wires up at boot ----
+inline const lv_font_t *title_font = nullptr, *row_font = nullptr, *roller_font = nullptr, *icon_font = nullptr;
+inline std::function tile_of; // a tile by entity
+inline std::function send; // service, entity, key, value
+inline std::function ask; // options_request
+inline std::function drift; // finger travel this touch
+inline int tap_limit = 0;
+inline uint32_t (*now)() = nullptr;
+
+// ---- state while open ----
+inline lv_obj_t *root = nullptr, *picker = nullptr;
+inline std::string entity; // the light
+struct RowDrawn { lv_obj_t *card = nullptr, *value = nullptr; std::string entity, sent; uint32_t sent_at = 0; bool light = false; std::string name; };
+struct NumberDrawn { lv_obj_t *slider = nullptr, *value = nullptr; std::string entity; float low = 0, high = 100, step = 1; bool dirty = false; };
+inline std::vector rows;
+inline std::vector numbers;
+// The picker's names: one entity at a time, page by page from the add-on.
+struct Options { std::string entity; std::vector names; unsigned next = 0, pages = 0; bool complete = false; uint32_t asked_at = 0; };
+inline Options options;
+inline lv_obj_t *roller = nullptr, *roller_note = nullptr;
+inline int picker_row = -1;
+
+inline uint32_t clock() { return now ? now() : lv_tick_get(); }
+// The page's sizes from the screen (a page just made has no width of its own until LVGL lays it out).
+inline Metrics screen_metrics() {
+ auto *display = lv_display_get_default();
+ return metrics(lv_display_get_horizontal_resolution(display), lv_display_get_vertical_resolution(display));
+}
+inline bool visible() { return root != nullptr; }
+inline bool steady() { return !drift || tap_limit <= 0 || drift() <= tap_limit; }
+inline const Tile *tile() { return tile_of ? tile_of(entity) : nullptr; }
+// Whether the colour card of this light shows the sparkles key.
+inline bool offered(const std::string &light) { const Tile *t = tile_of ? tile_of(light) : nullptr; return t && available(*t); }
+
+inline lv_obj_t *plain(lv_obj_t *parent, int x, int y, int w, int h) {
+ auto *obj = lv_obj_create(parent);
+ lv_obj_remove_style_all(obj);
+ lv_obj_remove_flag(obj, static_cast(LV_OBJ_FLAG_SCROLLABLE | LV_OBJ_FLAG_CLICKABLE));
+ lv_obj_set_pos(obj, x, y);
+ lv_obj_set_size(obj, w, h);
+ return obj;
+}
+inline lv_obj_t *text(lv_obj_t *parent, const std::string &value, const lv_font_t *font, theme::Role color,
+ lv_text_align_t align = LV_TEXT_ALIGN_LEFT) {
+ auto *label = lv_label_create(parent);
+ lv_label_set_text(label, value.c_str());
+ lv_label_set_long_mode(label, LV_LABEL_LONG_DOT);
+ lv_obj_remove_flag(label, LV_OBJ_FLAG_CLICKABLE);
+ lv_obj_set_style_text_font(label, font, 0);
+ lv_obj_set_style_text_color(label, theme::color(color), 0);
+ lv_obj_set_style_text_align(label, align, 0);
+ lv_obj_set_height(label, lv_font_get_line_height(font));
+ return label;
+}
+inline lv_obj_t *card(lv_obj_t *parent, int x, int y, int w, int h, int radius) {
+ auto *obj = plain(parent, x, y, w, h);
+ lv_obj_set_style_bg_opa(obj, LV_OPA_COVER, 0);
+ lv_obj_set_style_bg_color(obj, theme::color(theme::CARD), 0);
+ lv_obj_set_style_border_width(obj, 1, 0);
+ lv_obj_set_style_border_color(obj, theme::color(theme::LINE), 0);
+ lv_obj_set_style_radius(obj, radius, 0);
+ return obj;
+}
+// The glyph a row shows: Home Assistant's icon when these fonts carry it, else one of the page's own.
+inline std::string glyph(uint32_t icon, const char *fallback) {
+ if (icon && icon_font) { lv_font_glyph_dsc_t dsc; if (lv_font_get_glyph_dsc(icon_font, &dsc, icon, 0)) return tile_icon::utf8(icon); }
+ return fallback;
+}
+// A round key like the back key of every card, with a glyph in the middle.
+inline lv_obj_t *round_key(lv_obj_t *parent, int x, int y, int size, const char *glyph, lv_event_cb_t handler) {
+ auto *key = plain(parent, x, y, size, size);
+ lv_obj_add_flag(key, LV_OBJ_FLAG_CLICKABLE);
+ lv_obj_set_style_bg_opa(key, LV_OPA_COVER, 0);
+ lv_obj_set_style_bg_color(key, theme::color(theme::KEY), 0);
+ lv_obj_set_style_bg_color(key, theme::color(theme::KEY_PRESSED), LV_STATE_PRESSED);
+ lv_obj_set_style_radius(key, LV_RADIUS_CIRCLE, 0);
+ auto *label = text(key, glyph, icon_font ? icon_font : row_font, theme::INK);
+ lv_obj_set_width(label, LV_SIZE_CONTENT);
+ lv_obj_center(label);
+ lv_obj_add_event_cb(key, handler, LV_EVENT_SHORT_CLICKED, nullptr);
+ return key;
+}
+// The same top bar as every card: a round back key and the name in the middle, and on the picker the check key at
+// the top right, where the colour card has its sparkles key.
+inline lv_obj_t *top_bar(lv_obj_t *parent, const Metrics &m, const std::string &title, lv_event_cb_t back,
+ const char *right_glyph = nullptr, lv_event_cb_t right = nullptr) {
+ auto *key = round_key(parent, m.pad, m.bar_y, m.bar, "\U000F004D", back);
+ if (right_glyph && right) round_key(parent, m.width - m.pad - m.bar, m.bar_y, m.bar, right_glyph, right);
+ const lv_font_t *heading = title_font ? title_font : row_font;
+ auto *label = text(parent, title, heading, theme::INK, LV_TEXT_ALIGN_CENTER);
+ lv_obj_set_width(label, m.width - 2 * (m.pad + m.bar + 8));
+ lv_obj_set_pos(label, m.pad + m.bar + 8, m.bar_y + (m.bar - lv_font_get_line_height(heading)) / 2);
+ return key;
+}
+inline lv_obj_t *page_root() {
+ auto *obj = lv_obj_create(lv_screen_active());
+ lv_obj_remove_style_all(obj);
+ lv_obj_set_size(obj, lv_pct(100), lv_pct(100));
+ lv_obj_remove_flag(obj, LV_OBJ_FLAG_SCROLLABLE);
+ lv_obj_add_flag(obj, LV_OBJ_FLAG_CLICKABLE); // nothing leaks through to what lies under it
+ lv_obj_set_style_bg_color(obj, theme::color(theme::PAGE_SOFT), 0);
+ lv_obj_set_style_bg_opa(obj, LV_OPA_COVER, 0);
+ lv_obj_move_foreground(obj);
+ return obj;
+}
+
+// ---- the picker ----
+inline void close_picker() {
+ if (picker) { lv_obj_delete(picker); picker = nullptr; }
+ roller = roller_note = nullptr;
+ picker_row = -1;
+}
+inline void choose(const std::string &name) {
+ if (picker_row < 0 || picker_row >= static_cast(rows.size()) || name.empty()) return;
+ auto &row = rows[picker_row];
+ // What Home Assistant reports for the row now: the drum resting on it again sends nothing.
+ std::string current;
+ if (const Tile *t = tile()) {
+ if (row.light) current = t->extra().effect;
+ else for (auto &r : t->extra().option_rows) if (r.entity == row.entity) current = r.current;
+ }
+ if (name == row.sent || (row.sent.empty() && name == current)) return;
+ row.sent = name; row.sent_at = clock();
+ if (row.value) lv_label_set_text(row.value, name.c_str());
+ ESP_LOGI("effects", "%s: %s", row.entity.c_str(), name.c_str());
+ if (!send) return;
+ if (row.light) send("light.turn_on", row.entity, "effect", name);
+ else send("select.select_option", row.entity, "option", name);
+}
+// The check key: the name the drum stands on goes out, and the picker closes. Nothing is sent before it.
+inline void confirm() {
+ if (!roller) return;
+ char name[64];
+ lv_roller_get_selected_str(roller, name, sizeof(name));
+ choose(name);
+ close_picker();
+}
+inline void confirm_event(lv_event_t *) { if (steady()) confirm(); }
+inline void show_roller() {
+ if (!picker || !roller_note || options.names.empty()) return;
+ auto *holder = lv_obj_get_parent(roller_note);
+ const Metrics m = screen_metrics();
+ lv_obj_add_flag(roller_note, LV_OBJ_FLAG_HIDDEN);
+ const lv_font_t *font = roller_font ? roller_font : row_font;
+ roller = lv_roller_create(holder);
+ lv_obj_remove_style_all(roller);
+ lv_obj_set_style_bg_opa(roller, LV_OPA_TRANSP, LV_PART_MAIN);
+ lv_obj_set_style_text_font(roller, font, LV_PART_MAIN);
+ lv_obj_set_style_text_color(roller, theme::color(theme::MUTED), LV_PART_MAIN);
+ lv_obj_set_style_text_align(roller, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN);
+ lv_obj_set_style_text_line_space(roller, std::max(0, m.roller_row_h - (int) lv_font_get_line_height(font)), LV_PART_MAIN);
+ lv_obj_set_style_anim_duration(roller, 200, LV_PART_MAIN);
+ lv_obj_set_style_bg_opa(roller, LV_OPA_COVER, LV_PART_SELECTED);
+ lv_obj_set_style_bg_color(roller, theme::color(theme::ACCENT_TINT), LV_PART_SELECTED);
+ lv_obj_set_style_radius(roller, m.roller_row_h / 2, LV_PART_SELECTED);
+ lv_obj_set_style_text_color(roller, theme::color(theme::INK), LV_PART_SELECTED);
+ lv_obj_set_style_text_font(roller, font, LV_PART_SELECTED);
+ lv_roller_set_options(roller, joined(options.names).c_str(), LV_ROLLER_MODE_NORMAL);
+ lv_roller_set_visible_row_count(roller, m.roller_rows);
+ // The card's width from the sizes, not from the card: a card made a moment ago has no width until LVGL lays it out.
+ lv_obj_set_width(roller, m.width - 2 * m.pad - 2 * m.inset);
+ lv_obj_center(roller);
+ const auto &row = rows[picker_row];
+ std::string current = !row.sent.empty() ? row.sent : row.light ? (tile() ? tile()->extra().effect : std::string()) : [&]() {
+ if (auto *t = tile()) for (auto &r : t->extra().option_rows) if (r.entity == row.entity) return r.current;
+ return std::string();
+ }();
+ int index = index_of(options.names, current);
+ if (index >= 0) lv_roller_set_selected(roller, static_cast(index), LV_ANIM_OFF);
+}
+// One page of names from the add-on (runtime_tiles::options_received); the roller appears with the last one.
+inline void received(const std::string &for_entity, unsigned page, unsigned pages, std::vector &&names) {
+ if (!picker || for_entity != options.entity || options.complete) return;
+ if (page != options.next) return; // an answer to an older question, or one that arrived twice
+ for (auto &name : names) { if (options.names.size() >= MAX_NAMES) break; options.names.push_back(std::move(name)); }
+ options.next = page + 1;
+ options.pages = std::max(1u, pages);
+ if (options.next < options.pages && options.names.size() < MAX_NAMES) { options.asked_at = clock(); if (ask) ask(options.entity, options.next); return; }
+ options.complete = true;
+ if (options.names.empty()) { if (roller_note) lv_label_set_text(roller_note, "Nothing to choose"); return; }
+ show_roller();
+}
+inline void picker_back(lv_event_t *) { if (steady()) close_picker(); }
+inline void open_picker(int index) {
+ if (!root || index < 0 || index >= static_cast(rows.size())) return;
+ close_picker();
+ picker_row = index;
+ const auto &row = rows[index];
+ const Metrics m = screen_metrics();
+ picker = page_root();
+ top_bar(picker, m, row.name, picker_back, "\U000F012C", confirm_event);
+ const int w = m.width - 2 * m.pad, h = m.roller_rows * m.roller_row_h + 2 * m.roller_pad;
+ auto *holder = card(picker, m.pad, m.rows_y, w, h, m.radius);
+ roller_note = text(holder, "Loading...", row_font, theme::MUTED, LV_TEXT_ALIGN_CENTER);
+ lv_obj_set_width(roller_note, w - 2 * m.inset);
+ lv_obj_center(roller_note);
+ // The names of the same entity from a moment ago serve again; anything else is asked for afresh.
+ if (options.entity == row.entity && options.complete && !options.names.empty()) { show_roller(); return; }
+ options = Options{};
+ options.entity = row.entity;
+ options.asked_at = clock();
+ if (ask) ask(row.entity, 0);
+ else lv_label_set_text(roller_note, "Not connected");
+}
+
+// ---- the page ----
+inline void row_event(lv_event_t *e) {
+ if (lv_event_get_code(e) != LV_EVENT_SHORT_CLICKED || !steady()) return;
+ open_picker(static_cast(reinterpret_cast(lv_event_get_user_data(e))));
+}
+inline void preview(NumberDrawn &n) {
+ if (n.value) lv_label_set_text(n.value, percent_text(lv_slider_get_value(n.slider)).c_str());
+}
+inline void slider_event(lv_event_t *e) {
+ auto &n = numbers[reinterpret_cast(lv_event_get_user_data(e))];
+ const auto code = lv_event_get_code(e);
+ if (code == LV_EVENT_VALUE_CHANGED) { n.dirty = true; preview(n); }
+ if (code == LV_EVENT_PRESS_LOST) n.dirty = false;
+ if (code == LV_EVENT_RELEASED && n.dirty) {
+ n.dirty = false;
+ const float value = value_at(lv_slider_get_value(n.slider), n.low, n.high, n.step);
+ ESP_LOGI("effects", "%s: %s", n.entity.c_str(), number_text(value).c_str());
+ if (send) send("number.set_value", n.entity, "value", number_text(value));
+ }
+}
+inline void back_event(lv_event_t *) { if (steady()) { close_picker(); if (root) { lv_obj_delete(root); root = nullptr; } rows.clear(); numbers.clear(); } }
+inline void draw() {
+ if (!root) return;
+ lv_obj_clean(root);
+ rows.clear(); numbers.clear();
+ const Tile *t = tile();
+ const Metrics m = screen_metrics();
+ top_bar(root, m, t ? (t->name.empty() ? t->entity : t->name) : entity, back_event);
+ const int w = m.width - 2 * m.pad;
+ // The rows: the light's effect first, then the selects of its device.
+ struct Spec { std::string entity, name, current, fallback; uint32_t icon; bool light; };
+ std::vector specs;
+ if (t && (t->supported & EFFECT_FEATURE)) specs.push_back({t->entity, "Effect", t->extra().effect, "\U000F0674", 0, true});
+ if (t) for (auto &r : t->extra().option_rows) specs.push_back({r.entity, r.name, r.current, "\U000F0411", r.icon, false});
+ int y = m.rows_y;
+ if (!specs.empty()) {
+ auto *holder = card(root, m.pad, y, w, static_cast(specs.size()) * m.row_h, m.radius);
+ for (size_t i = 0; i < specs.size(); ++i) {
+ auto &spec = specs[i];
+ auto *row = plain(holder, 0, static_cast(i) * m.row_h, w, m.row_h);
+ lv_obj_add_flag(row, LV_OBJ_FLAG_CLICKABLE);
+ lv_obj_set_style_bg_opa(row, LV_OPA_COVER, LV_STATE_PRESSED);
+ lv_obj_set_style_bg_color(row, theme::color(theme::CARD_PRESSED), LV_STATE_PRESSED);
+ lv_obj_set_style_radius(row, m.radius, 0);
+ lv_obj_add_event_cb(row, row_event, LV_EVENT_SHORT_CLICKED, reinterpret_cast(static_cast(i)));
+ const int icon_h = icon_font ? lv_font_get_line_height(icon_font) : 0, text_h = lv_font_get_line_height(row_font);
+ int x = m.inset;
+ if (icon_font) {
+ auto *icon = text(row, glyph(spec.icon, spec.fallback.c_str()), icon_font, theme::MUTED);
+ lv_obj_set_width(icon, LV_SIZE_CONTENT);
+ lv_obj_set_pos(icon, m.inset - 2, (m.row_h - icon_h) / 2);
+ x += m.icon + 6;
+ }
+ auto *name = text(row, spec.name, row_font, theme::INK);
+ lv_obj_set_pos(name, x, (m.row_h - text_h) / 2);
+ const int chevron_w = icon_font ? m.icon : 0;
+ lv_obj_set_width(name, std::max(20, w / 2 - x));
+ if (icon_font) {
+ auto *chevron = text(row, "\U000F0142", icon_font, theme::CHEVRON);
+ lv_obj_set_width(chevron, LV_SIZE_CONTENT);
+ lv_obj_set_pos(chevron, w - m.inset - chevron_w + 2, (m.row_h - icon_h) / 2);
+ }
+ auto *value = text(row, row_text(spec.current), row_font, theme::MUTED, LV_TEXT_ALIGN_RIGHT);
+ const int value_x = x + w / 2 - x + 4;
+ lv_obj_set_pos(value, value_x, (m.row_h - text_h) / 2);
+ lv_obj_set_width(value, std::max(20, w - m.inset - chevron_w - 4 - value_x));
+ RowDrawn drawn; drawn.card = row; drawn.value = value; drawn.entity = spec.entity; drawn.light = spec.light; drawn.name = spec.name;
+ rows.push_back(std::move(drawn));
+ }
+ y += static_cast(specs.size()) * m.row_h + m.gap;
+ }
+ // The numbers: half-width slider cards side by side, like the brightness slider of the colour card.
+ if (t && !t->extra().number_rows.empty()) {
+ const size_t count = std::min(2, t->extra().number_rows.size());
+ const int card_w = count == 1 ? w : (w - m.gap) / 2;
+ for (size_t i = 0; i < count; ++i) {
+ const auto &n = t->extra().number_rows[i];
+ const int x = m.pad + static_cast(i) * (card_w + m.gap);
+ auto *holder = card(root, x, y, card_w, m.number_h, m.radius);
+ const int text_h = lv_font_get_line_height(row_font), text_y = m.number_inset * 3 / 4;
+ int tx = m.number_inset;
+ if (icon_font) {
+ auto *icon = text(holder, glyph(n.icon, "\U000F00DF"), icon_font, theme::MUTED);
+ lv_obj_set_width(icon, LV_SIZE_CONTENT);
+ const int icon_h = lv_font_get_line_height(icon_font);
+ lv_obj_set_pos(icon, m.number_inset - 2, text_y + (text_h - icon_h) / 2);
+ tx += m.icon - 2 + 5;
+ }
+ auto *name = text(holder, n.name, row_font, theme::INK);
+ lv_obj_set_pos(name, tx, text_y);
+ lv_obj_set_width(name, std::max(20, card_w - tx - m.number_inset - 52));
+ auto *value = text(holder, percent_text(percent_of(n.value, n.low, n.high)), row_font, theme::MUTED, LV_TEXT_ALIGN_RIGHT);
+ lv_obj_set_width(value, 50);
+ lv_obj_set_pos(value, card_w - m.number_inset - 50, text_y);
+ // Corners, handle and shortest fill as the colour card's brightness slider: the fill keeps the track's radius
+ // (a smaller one makes LVGL draw it into a buffer of its own on every redraw).
+ const int track_x = m.number_inset, track_w = card_w - 2 * m.number_inset, track_h = m.track_h, track_y = m.number_h - m.number_inset - track_h + 2;
+ auto *slider = lv_slider_create(holder);
+ lv_obj_remove_style_all(slider);
+ lv_obj_remove_flag(slider, LV_OBJ_FLAG_SCROLLABLE);
+ lv_obj_set_pos(slider, track_x, track_y); lv_obj_set_size(slider, track_w, track_h);
+ lv_obj_set_ext_click_area(slider, m.width >= 480 ? 12 : 8);
+ const int corner = track_h * 12 / 42;
+ lv_obj_set_style_radius(slider, corner, LV_PART_MAIN);
+ lv_obj_set_style_radius(slider, corner, LV_PART_INDICATOR);
+ lv_obj_set_style_bg_opa(slider, LV_OPA_COVER, LV_PART_MAIN);
+ lv_obj_set_style_bg_color(slider, theme::color(theme::ACCENT_TINT), LV_PART_MAIN);
+ lv_obj_set_style_bg_opa(slider, LV_OPA_COVER, LV_PART_INDICATOR);
+ lv_obj_set_style_bg_color(slider, theme::color(theme::ACCENT_BRIGHT), LV_PART_INDICATOR);
+ const int handle = m.knob, back = std::max(1, track_h / 8) + handle / 2, half = track_h >> 1;
+ lv_obj_set_style_bg_opa(slider, LV_OPA_COVER, LV_PART_KNOB);
+ lv_obj_set_style_bg_color(slider, theme::color(theme::KNOB), LV_PART_KNOB);
+ lv_obj_set_style_radius(slider, 2, LV_PART_KNOB);
+ lv_obj_set_style_pad_left(slider, back + handle / 2 - half, LV_PART_KNOB);
+ lv_obj_set_style_pad_right(slider, handle / 2 - back - (track_h - half), LV_PART_KNOB);
+ lv_obj_set_style_pad_top(slider, -(track_h / 4), LV_PART_KNOB);
+ lv_obj_set_style_pad_bottom(slider, -(track_h / 4), LV_PART_KNOB);
+ lv_slider_set_range(slider, 0, 100);
+ lv_slider_set_value(slider, percent_of(n.value, n.low, n.high), LV_ANIM_OFF);
+ NumberDrawn drawn; drawn.slider = slider; drawn.value = value; drawn.entity = n.entity; drawn.low = n.low; drawn.high = n.high; drawn.step = n.step;
+ numbers.push_back(std::move(drawn));
+ lv_obj_add_event_cb(slider, slider_event, LV_EVENT_ALL, reinterpret_cast(static_cast(i)));
+ }
+ }
+}
+// A state of the light while its page is open: rows follow Home Assistant, a row just chosen keeps its choice a
+// moment, a slider under a finger is left alone.
+inline void updated(const Tile &t) {
+ if (!root || t.entity != entity) return;
+ const uint32_t moment = clock();
+ for (auto &row : rows) {
+ std::string current;
+ if (row.light) current = t.extra().effect;
+ else for (auto &r : t.extra().option_rows) if (r.entity == row.entity) current = r.current;
+ if (!row.sent.empty() && current != row.sent && moment - row.sent_at < SENT_HOLD_MS) continue;
+ if (!row.sent.empty() && (current == row.sent || moment - row.sent_at >= SENT_HOLD_MS)) row.sent.clear();
+ if (row.value) lv_label_set_text(row.value, row_text(current).c_str());
+ }
+ for (auto &n : numbers) {
+ if (!n.slider || lv_obj_has_state(n.slider, LV_STATE_PRESSED) || n.dirty) continue;
+ for (auto &r : t.extra().number_rows) if (r.entity == n.entity) {
+ const int percent = percent_of(r.value, r.low, r.high);
+ if (lv_slider_get_value(n.slider) != percent) { lv_slider_set_value(n.slider, percent, LV_ANIM_OFF); preview(n); }
+ }
+ }
+}
+inline void open(const std::string &light) {
+ const Tile *t = tile_of ? tile_of(light) : nullptr;
+ if (!t || !available(*t)) return;
+ close_picker();
+ entity = light;
+ if (!root) root = page_root();
+ lv_obj_move_foreground(root);
+ draw();
+ ESP_LOGI("effects", "Effects page of %s: %u rows, %u sliders", light.c_str(), (unsigned) rows.size(), (unsigned) numbers.size());
+}
+inline void close() {
+ close_picker();
+ rows.clear(); numbers.clear();
+ options = Options{};
+ if (root) { lv_obj_delete(root); root = nullptr; }
+ entity.clear();
+}
+// A change of look while the page is open: drawn again in place, the picker closed.
+inline void restyle() {
+ if (!root) return;
+ close_picker();
+ lv_obj_set_style_bg_color(root, theme::color(theme::PAGE_SOFT), 0);
+ draw();
+}
+} // namespace effects_page
+#endif
diff --git a/components/smart_display/runtime_model.h b/components/smart_display/runtime_model.h
index 1c46e62..876b6a6 100644
--- a/components/smart_display/runtime_model.h
+++ b/components/smart_display/runtime_model.h
@@ -92,7 +92,15 @@ struct Choice {
// What only some tiles carry: climate modes, a select's options, weather, sun and timer times, a media
// title, the vacuum rows. A light or a sensor has none of it, so a tile holds this block only while its
// state needs one: twenty tiles with these fields inline took 24 KB of the CYD's RAM, mostly empty.
+// A light's effects page (firmware 0.2.70+): a select entity of the light's device with what it is set to and how many
+// options it has, and a number entity with its range; the names and icons are Home Assistant's, through the add-on.
+struct OptionRow { std::string entity, name, current; uint16_t count = 0; uint32_t icon = 0; };
+struct NumberRow { std::string entity, name; float value = NAN, low = 0, high = 100, step = 1; uint32_t icon = 0; };
struct Extra {
+ // The effect a light runs (its `effect` attribute), and the rows of its effects page.
+ std::string effect;
+ std::vector option_rows;
+ std::vector number_rows;
// Climate: the modes as JSON lists, the current fan and swing mode, and what it is doing now.
std::string hvac_modes, fan_modes, swing_modes, fan_mode, swing_mode, hvac_action;
// A select's options, at most eight.
@@ -133,7 +141,7 @@ struct Extra {
remaining.empty() && !timer_end && media_title.empty() && media_artist.empty() && media_album.empty() &&
media_picture.empty() && !media_duration && !media_position && !media_position_at && fan_speeds.empty() && fan_speed.empty() &&
choices.empty() && room.empty() && !charging && std::isnan(tilt) && action.empty() && action_data.empty() &&
- action_templates.empty() && state_word.empty();
+ action_templates.empty() && state_word.empty() && effect.empty() && option_rows.empty() && number_rows.empty();
}
};
// The Extra of a tile on the heap, copied along with the tile like an ordinary member.
diff --git a/components/smart_display/runtime_tiles.h b/components/smart_display/runtime_tiles.h
index e4e256d..22c6c52 100644
--- a/components/smart_display/runtime_tiles.h
+++ b/components/smart_display/runtime_tiles.h
@@ -9,6 +9,7 @@
#include "esphome/core/preferences.h"
#include "cyd_ui.h"
#include "light_controls.h"
+#include "effects_page.h"
#include "tile_controls.h"
#include "history_view.h"
#include "camera_view.h"
@@ -194,6 +195,8 @@ inline bool parse_settings(JsonObject obj, screen_settings::Settings &s) {
return s.valid();
}
inline std::function detail, detail_update;
+// One page of a picker's names (op "options", app 0.2.83+), for the light's effects page.
+inline std::function &&)> options_received;
struct Widgets {
lv_obj_t *tile{}, *title{}, *value{}, *circle{}, *icon{}; size_t index{}; int cached_active = -1; lv_obj_t *slider{}, *progress{}, *unit{};
int title_x=0,title_y=0,value_x=0,value_y=0; const lv_font_t *value_font{}, *icon_font{};
@@ -486,6 +489,20 @@ inline std::string receive(const std::string &payload) {
result = model.ready() ? "Synced" : "Loading tiles";
return true;
}
+ if (op == "options") {
+ // The names a picker on a light's effects page asked for (options_request), one page per message.
+ std::string entity = string(root["e"], 120);
+ if (!valid_entity(entity) || !root["o"].is()) return false;
+ std::vector names;
+ for (JsonVariant name : root["o"].as()) {
+ if (names.size() == effects_page::MAX_NAMES) break;
+ std::string text = string(name, 48);
+ if (!text.empty()) names.push_back(std::move(text));
+ }
+ if (options_received) options_received(entity, root["i"] | 0u, root["n"] | 1u, std::move(names));
+ result = model.ready() ? "Synced" : "Loading tiles";
+ return true;
+ }
#ifdef SWIPE_PROFILE
if (op == "swipe_test") {
// Diagnostic builds only: page switches without a finger, `n` of them `ms` apart; `back`
@@ -644,6 +661,25 @@ inline std::string receive(const std::string &payload) {
};
if (tile.domain() == "vacuum") { choice("mode", 'm'); choice("water", 'w'); choice("fan", 's'); tile_controls::settle_suction(next); }
for (auto &[kind, value] : tapped) if (auto *c = next.choice(kind)) if (c->current != value) c->sent = value;
+ // A light's effects page (app 0.2.83+): the effect it runs, and the selects and numbers of its device.
+ next.effect = string(a["effect"], 48);
+ if (extra["rows"].is()) for (JsonVariant r : extra["rows"].as()) {
+ if (next.option_rows.size() == 3) break;
+ OptionRow row; row.entity = string(r["e"], 120);
+ if (!valid_entity(row.entity)) continue;
+ row.name = string(r["n"], 32); row.current = string(r["s"], 48); row.count = static_cast(std::min(r["c"] | 0u, 65535u));
+ row.icon = tile_icon::codepoint(string(r["i"], 8));
+ next.option_rows.push_back(std::move(row));
+ }
+ if (extra["nums"].is()) for (JsonVariant r : extra["nums"].as()) {
+ if (next.number_rows.size() == 2) break;
+ NumberRow row; row.entity = string(r["e"], 120);
+ if (!valid_entity(row.entity)) continue;
+ row.name = string(r["n"], 32); row.value = number(r["v"]); row.low = number(r["lo"], 0); row.high = number(r["hi"], 100); row.step = number(r["st"], 1);
+ if (!(row.high > row.low)) continue;
+ row.icon = tile_icon::codepoint(string(r["i"], 8));
+ next.number_rows.push_back(std::move(row));
+ }
if (!std::isfinite(tile.battery)) tile.battery = number(extra["bat"]);
next.charging = extra["chg"].is() && extra["chg"].as() == 1;
next.room = string(extra["room"], 32);
@@ -785,6 +821,25 @@ inline void perform(const Tile &tile) {
ESP_LOGI("runtime_action", "Sent service=%s entity=%s (%u values)", x.action.c_str(), tile.entity.c_str(),
(unsigned) (x.action_data.size() + x.action_templates.size()));
}
+// A picker on a light's effects page asks the manager for its names (app 0.2.83+ answers with op "options"), one
+// page at a time. An event, like history_request.
+inline void options_request(const std::string &entity, unsigned page) {
+ if (inbox.empty()) return;
+ esphome::api::HomeassistantActionRequest request;
+ request.service = esphome::StringRef("esphome.screen_options");
+ request.is_event = true;
+ const std::string number = std::to_string(page);
+ const std::string keys[] = {"inbox", "entity", "page"}, values[] = {inbox, entity, number};
+ request.data.init(3);
+ for (int i = 0; i < 3; ++i) {
+ esphome::api::HomeassistantServiceMap entry;
+ entry.key = esphome::StringRef(keys[i]);
+ entry.value = esphome::StringRef(values[i]);
+ request.data.push_back(entry);
+ }
+ esphome::api::global_api_server->send_homeassistant_action(request);
+ ESP_LOGI("effects", "Asked for the names of %s, page %u", entity.c_str(), page);
+}
// A detail card asks the manager for its history (app 0.2.59+ answers with op "history"). An event, like
// setting_event: it needs no permission to call Home Assistant actions.
inline void history_request(const std::string &entity, uint32_t hours) {
@@ -3066,6 +3121,7 @@ inline void render_slot(size_t slot) {
else if (t.is_page()) value = "Page " + std::to_string(t.page_target());
else if (!fresh() || !t.available()) value = "Unavailable";
else if (t.refused_at && esphome::millis() - t.refused_at < 4000) value = "Refused";
+ else if (d == "light" && t.state == "on" && tile_controls::effect_running(t.extra().effect)) value = t.extra().effect;
else if (d == "light" && t.state == "on" && std::isfinite(t.brightness)) value = std::to_string(static_cast(std::lround(std::clamp(t.brightness, 0.0f, 255.0f) * 100 / 255))) + " %";
else if (d == "climate" && std::isfinite(t.target)) { char b[32]; snprintf(b, sizeof(b), "%.1f°", t.target); value = b; }
else if (d == "person") value = t.state=="home"?"Home":t.state=="not_home"?"Away":t.state;
diff --git a/components/smart_display/tile_controls.h b/components/smart_display/tile_controls.h
index 83360ff..3ed7ab9 100644
--- a/components/smart_display/tile_controls.h
+++ b/components/smart_display/tile_controls.h
@@ -350,6 +350,14 @@ inline void settle_suction(runtime_tiles::Extra &x) {
}
if (row) row->current = x.fan_speed;
}
+// Whether a light runs an effect worth naming on its tile (firmware 0.2.70+): WLED's "Solid" is its plain colour, a Hue's
+// "off" and Home Assistant's "None" mean no effect.
+inline bool effect_running(const std::string &effect) {
+ std::string lower;
+ for (char c : effect) lower += static_cast(c >= 'A' && c <= 'Z' ? c - 'A' + 'a' : c);
+ while (!lower.empty() && lower.back() == ' ') lower.pop_back();
+ return !lower.empty() && lower != "solid" && lower != "off" && lower != "none" && lower != "unknown" && lower != "unavailable";
+}
// The service call behind a chip: a select option on the device, or the vacuum's own fan speed.
inline Action choice_action(const Tile &t, char kind, const std::string &value) {
if (kind == 's') return {"vacuum.set_fan_speed", "fan_speed", value};
diff --git a/components/smart_display/tile_icon_names.h b/components/smart_display/tile_icon_names.h
index 4911a4a..be1d22d 100644
--- a/components/smart_display/tile_icon_names.h
+++ b/components/smart_display/tile_icon_names.h
@@ -294,6 +294,11 @@ inline constexpr Named NAMES[] = {
{"speaker-pause", 0xF1B73},
{"speaker-play", 0xF1B72},
{"speedometer", 0xF04C5},
+ {"creation", 0xF0674},
+ {"palette-outline", 0xF0E0C},
+ {"playlist-play", 0xF0411},
+ {"play-speed", 0xF08FF},
+ {"brightness-6", 0xF00DF},
{"spoon-sugar", 0xF1429},
{"sprout-outline", 0xF0E67},
{"square", 0xF0764},
diff --git a/docs/TEST_RESULTS_0283.md b/docs/TEST_RESULTS_0283.md
new file mode 100644
index 0000000..e674125
--- /dev/null
+++ b/docs/TEST_RESULTS_0283.md
@@ -0,0 +1,64 @@
+# Test results app 0.2.83 / firmware 0.2.70 (2026-09-18)
+
+Max asked how hard it is to choose a WLED lamp's modes on the screen ("daar heb je een mega lijst aan modes"), said the
+list is dynamic and differs per lamp, wanted it as LVGL/ESPHome-native as the documentation allows, and then added the
+other WLED things: presets "en die andere". From six questions on a design page he chose: the sparkles key at the top
+right of the colour card (1A), LVGL's roller as the picker for every row (3B), the rows as drawn without preset chips,
+effects alphabetically with Solid on top, English names from Home Assistant's own translations, and the effect's name
+on the tile while one runs. Mid-build he asked for an explicit confirmation: the check key at the top right of the
+picker, in the same slot; the back key sends nothing.
+
+## What changed
+
+- App: `light_effects.py` (the rows of the light's device, the names and icons Home Assistant gives them, the picker's
+ names and their pages), `ATTRS` carries `effect`, `extras()` builds `x.rows` and `x.nums` for a light, the manager
+ watches the device's selects and numbers with the light, subscribes to `esphome.screen_options` and answers with
+ `op: options` (`card_options_loop`), and five firmware-only glyphs join `tile_icons.FIXED` (creation, palette-outline,
+ playlist-play, play-speed, brightness-6). Nothing about WLED is hardcoded beyond a preferred order of translation keys.
+- Firmware: `effects_page.h` (the page, the picker, the sliders; the model part is LVGL-free), `Extra` gained `effect`,
+ `option_rows` and `number_rows`, the state parser reads them, the tile's value line names a running effect
+ (`tile_controls::effect_running`), `runtime_tiles::options_request` asks for a picker's names and `op: options`
+ answers reach `effects_page::received`. Both profiles: a hidden `roller_seed` so ESPHome compiles LVGL's roller, the
+ `color_effects_button` on the colour card, the page's hooks at boot, and its close on dismiss, redraw and live updates.
+- Docs: README pictures (`guition-effects.png`, `guition-effects-picker.png`, `cyd-effects.png`, rendered by the host
+ program), a bullet in README_EXTENDED, the CHANGELOG.
+
+## Automated
+
+- `tools/check.sh`: Python 463 tests (`tests/test_light_effects.py` is new: rows, names, exclusions, caps, sorting,
+ pages under 4096 bytes, who may ask), C++ 19/19 (`tests/test_effects_page.cpp` is new: what has a page, percent and
+ value mapping, texts, both boards' sizes; `effect_running`), packages, icons, Vitest, vue-tsc, build and the bundle.
+- `tools/check.sh --firmware` (ESPHome 2026.6.2): CYD 1,675,424 B = 91.3 % of the 1,835,008 B slot, +20,288 B against
+ 0.2.82 (tight band: LVGL's roller is compiled in for the first time, the page's code, and five glyphs in three icon
+ fonts), Guition well under its 8 MB slot. The first ESP32 compile caught the usual trap the host build cannot:
+ `std::max(0, int - lv_font_get_line_height())` needs an `(int)` cast (int32_t is long there).
+
+## On the host (the real firmware for the Mac, SDL, own API ports 6231/6232)
+
+`effects_host.py` builds each board's package as a host program with probe actions, sends a layout of three lights (a
+WLED, a plain light, a Hue with its candle effect) whose messages the real add-on code builds
+(`core.extras` + `state_message`), answers the screen's `esphome.screen_options` events with the real
+`light_effects.options_for/pages/message`, and checks 35 steps per board, 35/35 on the Guition and 35/35 on the CYD:
+
+- The WLED tile reads "TV Simulator", the plain light "59 %", the Hue "candle".
+- The colour card of the WLED shows the sparkles key; a plain light's card has none; a Hue with effects has one.
+- The page shows Effect, Color palette, Preset and Playlist with Home Assistant's values (a dash for unknown), Speed
+ and Intensity at 50 %, everything inside the screen on both boards.
+- A row asks for its names (`esphome.screen_options` with inbox, entity and page 0) and says "Loading..." meanwhile; the
+ add-on answers one page with Solid first; the roller shows all 216 and rests on the current effect.
+- Turning the drum sends nothing; the check key sends one `light.turn_on` with the effect and closes the picker; the row
+ says the choice at once, a stale state does not flip it back, the reported effect settles it; opening the picker again
+ uses the names it still holds and confirming the current effect sends nothing.
+- A second LVGL pointer drags the drum without sending; the check sends the effect it stands on.
+- The palette row asks for the select's options, lists them in Home Assistant's order, rests on Aurora, and a choice
+ goes out as `select.select_option`; the back key of the picker sends nothing.
+- A slider release sends `number.set_value` on the entity's own scale (80 % of 0-255 is 204); the label follows the
+ finger; a reported value keeps the slider, a value changed elsewhere moves it.
+- Dark mode draws the page again; closing the page returns to the colour card; closing the cards closes the page too.
+- Renders of every state on both boards, light and dark, are what the README shows.
+
+## Not tested
+
+- On real hardware: the roller under a real finger (GT911 and XPT2046), the keys, and the answer path through a live
+ Home Assistant with a real WLED. Max's Guition Wallbox at home and his VUELTA are the place for that.
+- A select with more names than one message holds (the paging is covered by the Python tests only).
diff --git a/docs/images/cyd-effects.png b/docs/images/cyd-effects.png
new file mode 100644
index 0000000..0fbd613
Binary files /dev/null and b/docs/images/cyd-effects.png differ
diff --git a/docs/images/guition-effects-picker.png b/docs/images/guition-effects-picker.png
new file mode 100644
index 0000000..019faba
Binary files /dev/null and b/docs/images/guition-effects-picker.png differ
diff --git a/docs/images/guition-effects.png b/docs/images/guition-effects.png
new file mode 100644
index 0000000..6efbbb2
Binary files /dev/null and b/docs/images/guition-effects.png differ
diff --git a/guition-4848s040.yaml b/guition-4848s040.yaml
index a260690..195d4f3 100644
--- a/guition-4848s040.yaml
+++ b/guition-4848s040.yaml
@@ -2,7 +2,7 @@
# Source profile for packages/guition.yaml (tools/generate_packages.py); see docs/GUITION.md.
# A screen gets its tiles from the ESP Screen Manager add-on while it runs. The CYD is its own profile.
substitutions:
- SCREEN_FIRMWARE_VERSION: "0.2.69"
+ SCREEN_FIRMWARE_VERSION: "0.2.70"
# 0: no drift limit. A press that drifts is still a tap as long as the finger lets go on the tile
# (runtime_tiles::event checks where it let go: LVGL keeps the press on the tile and would click it
# anywhere), and a quick flick is LVGL's gesture, never a tap (the home_page gesture handler in on_boot).
@@ -219,6 +219,7 @@ esphome:
theme::set_dark(settings_screen::dark_mode != 0);
runtime_tiles::settings_changed = []() { id(apply_screen_settings).execute(); };
runtime_tiles::dismiss = []() {
+ effects_page::close();
runtime_tiles::hide_detail();
runtime_tiles::camera_close();
id(active_entity).clear();
@@ -296,6 +297,7 @@ esphome:
std::to_string(t.minimum), std::to_string(t.maximum), std::to_string(t.step));
};
runtime_tiles::detail_update = [](runtime_tiles::Tile &t) {
+ effects_page::updated(t);
if (id(active_entity) != t.entity || id(touch_down)) return;
if (t.domain() == "climate" && !lv_obj_has_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN)) {
if (id(climate_edit_pending) && millis() - id(climate_edit_ms) < 3000) return;
@@ -319,6 +321,24 @@ esphome:
light_controls::commits[0] = [](int value) { id(set_light_hue).execute(value); };
light_controls::commits[1] = [](int value) { id(set_light_color_temperature).execute(value); };
light_controls::commits[2] = [](int value) { id(color_brightness_commit).execute(value); };
+ // The effects page behind the colour card's sparkles key (firmware 0.2.70+): fonts, the tile it shows, and
+ // the ways out: actions to Home Assistant and the question for a picker's names.
+ effects_page::title_font = id(headline)->get_lv_font();
+ effects_page::row_font = id(sublabel)->get_lv_font();
+ effects_page::roller_font = id(sublabel_big)->get_lv_font();
+ effects_page::icon_font = id(materialdesign_icons_mini)->get_lv_font();
+ effects_page::tile_of = [](const std::string &entity) -> const runtime_tiles::Tile * {
+ for (size_t i = 0; i < runtime_tiles::model.count; ++i) if (runtime_tiles::model.tiles[i].entity == entity) return &runtime_tiles::model.tiles[i];
+ return nullptr;
+ };
+ effects_page::send = [](const std::string &service, const std::string &entity, const std::string &key, const std::string &value) {
+ runtime_tiles::action(service, entity, key, value);
+ };
+ effects_page::ask = [](const std::string &entity, unsigned page) { runtime_tiles::options_request(entity, page); };
+ effects_page::drift = []() { return cyd::touch_guard.distance(); };
+ effects_page::tap_limit = ${TOUCH_MOVE_LIMIT_PX};
+ effects_page::now = []() { return (uint32_t) millis(); };
+ runtime_tiles::options_received = effects_page::received;
climate_card::look.card = id(climate_settings_card);
climate_card::look.font = id(label)->get_lv_font();
climate_card::look.icon_font = id(materialdesign_icons_mini)->get_lv_font();
@@ -355,6 +375,7 @@ esphome:
theme::redraw = []() {
runtime_tiles::restyle();
light_controls::restyle();
+ effects_page::restyle();
settings_screen::restyle();
if (id(alert_active)) lv_obj_set_style_bg_color(id(alert_card), lv_color_hex(theme::surface(id(alert_card_color))), LV_PART_MAIN);
if (!lv_obj_has_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN)) id(climate_card_refresh).execute();
@@ -1215,6 +1236,11 @@ font:
- "\U000F1B73" # speaker-pause
- "\U000F1B72" # speaker-play
- "\U000F04C5" # speedometer
+ - "\U000F0674" # creation
+ - "\U000F0E0C" # palette-outline
+ - "\U000F0411" # playlist-play
+ - "\U000F08FF" # play-speed
+ - "\U000F00DF" # brightness-6
- "\U000F1429" # spoon-sugar
- "\U000F0E67" # sprout-outline
- "\U000F0764" # square
@@ -1914,6 +1940,14 @@ lvgl:
height: 2
spin_time: 1s
arc_length: 60deg
+ # Never shown either: it makes ESPHome compile LVGL's roller, the drum a light's effects page picks from
+ # (firmware 0.2.70+).
+ - roller:
+ id: roller_seed
+ hidden: true
+ width: 2
+ height: 2
+ options: ["-"]
# Never shown either: it makes ESPHome compile LVGL's image widget, which a camera image is drawn with.
- image:
id: camera_image_seed
@@ -2949,6 +2983,31 @@ lvgl:
text_font: materialdesign_icons_mini
clickable: false
+ # The sparkles key (firmware 0.2.70+): a light with effects, or with modes on its device (a WLED's palette,
+ # preset, speed), opens its effects page. Hidden for every other light.
+ - obj:
+ id: color_effects_button
+ align: TOP_RIGHT
+ x: -16
+ y: 16
+ width: 60
+ height: 60
+ radius: 999
+ styles: paint_key
+ bg_opa: COVER
+ border_width: 0
+ hidden: true
+ clickable: true
+ scrollable: false
+ on_click:
+ - lambda: 'effects_page::open(id(active_entity));'
+ widgets:
+ - label:
+ align: CENTER
+ text: "\U000F0674"
+ text_font: materialdesign_icons_mini
+ clickable: false
+
- label:
id: color_detail_title
width: 310
@@ -4505,6 +4564,8 @@ script:
- lambda: |-
lv_label_set_text(id(color_detail_title), id(active_title).c_str());
light_controls::open(id(active_entity), id(active_supports_color), id(active_supports_temperature), id(active_brightness_pct));
+ if (effects_page::offered(id(active_entity))) lv_obj_remove_flag(id(color_effects_button), LV_OBJ_FLAG_HIDDEN);
+ else lv_obj_add_flag(id(color_effects_button), LV_OBJ_FLAG_HIDDEN);
- lvgl.widget.hide: brightness_overlay
- lvgl.widget.show: color_detail_overlay
diff --git a/home-like-2432s028.yaml b/home-like-2432s028.yaml
index 0afde43..75de110 100644
--- a/home-like-2432s028.yaml
+++ b/home-like-2432s028.yaml
@@ -17,7 +17,7 @@
############################################################
substitutions:
- SCREEN_FIRMWARE_VERSION: "0.2.69"
+ SCREEN_FIRMWARE_VERSION: "0.2.70"
# A tap survives a finger that drifts up to this far (320 px over 58 mm: 1 cm); further is a swipe.
TOUCH_MOVE_LIMIT_PX: "56"
# Shortest contact that counts as a tap; a resistive panel bounces, a capacitive one does not.
@@ -314,6 +314,7 @@ esphome:
theme::set_dark(settings_screen::dark_mode != 0);
runtime_tiles::settings_changed = []() { id(apply_screen_settings).execute(); };
runtime_tiles::dismiss = []() {
+ effects_page::close();
runtime_tiles::hide_detail();
id(active_entity).clear();
lv_obj_add_flag(id(brightness_overlay), LV_OBJ_FLAG_HIDDEN);
@@ -363,6 +364,7 @@ esphome:
std::to_string(t.minimum), std::to_string(t.maximum), std::to_string(t.step));
};
runtime_tiles::detail_update = [](runtime_tiles::Tile &t) {
+ effects_page::updated(t);
if (id(active_entity) != t.entity || id(touch_down)) return;
if (t.domain() == "climate" && !lv_obj_has_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN)) {
if (id(climate_edit_pending) && millis() - id(climate_edit_ms) < 3000) return;
@@ -386,6 +388,24 @@ esphome:
light_controls::commits[0] = [](int value) { id(set_light_hue).execute(value); };
light_controls::commits[1] = [](int value) { id(set_light_color_temperature).execute(value); };
light_controls::commits[2] = [](int value) { id(color_brightness_commit).execute(value); };
+ // The effects page behind the colour card's sparkles key (firmware 0.2.70+): fonts, the tile it shows, and
+ // the ways out: actions to Home Assistant and the question for a picker's names.
+ effects_page::title_font = id(headline)->get_lv_font();
+ effects_page::row_font = id(sublabel_big)->get_lv_font();
+ effects_page::roller_font = id(sublabel_big)->get_lv_font();
+ effects_page::icon_font = id(materialdesign_icons_mini)->get_lv_font();
+ effects_page::tile_of = [](const std::string &entity) -> const runtime_tiles::Tile * {
+ for (size_t i = 0; i < runtime_tiles::model.count; ++i) if (runtime_tiles::model.tiles[i].entity == entity) return &runtime_tiles::model.tiles[i];
+ return nullptr;
+ };
+ effects_page::send = [](const std::string &service, const std::string &entity, const std::string &key, const std::string &value) {
+ runtime_tiles::action(service, entity, key, value);
+ };
+ effects_page::ask = [](const std::string &entity, unsigned page) { runtime_tiles::options_request(entity, page); };
+ effects_page::drift = []() { return cyd::touch_guard.distance(); };
+ effects_page::tap_limit = ${TOUCH_MOVE_LIMIT_PX};
+ effects_page::now = []() { return (uint32_t) millis(); };
+ runtime_tiles::options_received = effects_page::received;
- lambda: |-
if (!runtime_tiles::enabled) return;
screen_calibration::apply = [](const screen_calibration::Calibration &cal) {
@@ -423,6 +443,7 @@ esphome:
theme::redraw = []() {
runtime_tiles::restyle();
light_controls::restyle();
+ effects_page::restyle();
settings_screen::restyle();
if (id(alert_active)) lv_obj_set_style_bg_color(id(alert_card), lv_color_hex(theme::surface(id(alert_card_color))), LV_PART_MAIN);
if (!lv_obj_has_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN)) id(climate_card_refresh).execute();
@@ -1231,6 +1252,11 @@ font:
- "\U000F1B73" # speaker-pause
- "\U000F1B72" # speaker-play
- "\U000F04C5" # speedometer
+ - "\U000F0674" # creation
+ - "\U000F0E0C" # palette-outline
+ - "\U000F0411" # playlist-play
+ - "\U000F08FF" # play-speed
+ - "\U000F00DF" # brightness-6
- "\U000F1429" # spoon-sugar
- "\U000F0E67" # sprout-outline
- "\U000F0764" # square
@@ -1879,6 +1905,14 @@ lvgl:
height: 2
spin_time: 1s
arc_length: 60deg
+ # Never shown either: it makes ESPHome compile LVGL's roller, the drum a light's effects page picks from
+ # (firmware 0.2.70+).
+ - roller:
+ id: roller_seed
+ hidden: true
+ width: 2
+ height: 2
+ options: ["-"]
- label:
id: lbl_room
x: 11
@@ -2849,6 +2883,31 @@ lvgl:
text_font: materialdesign_icons_mini
clickable: false
+ # The sparkles key (firmware 0.2.70+): a light with effects, or with modes on its device (a WLED's palette,
+ # preset, speed), opens its effects page. Hidden for every other light.
+ - obj:
+ id: color_effects_button
+ align: TOP_RIGHT
+ x: -10
+ y: 8
+ width: 40
+ height: 40
+ radius: 999
+ styles: paint_key
+ bg_opa: COVER
+ border_width: 0
+ hidden: true
+ clickable: true
+ scrollable: false
+ on_click:
+ - lambda: 'effects_page::open(id(active_entity));'
+ widgets:
+ - label:
+ align: CENTER
+ text: "\U000F0674"
+ text_font: materialdesign_icons_mini
+ clickable: false
+
- label:
id: color_detail_title
width: 210
@@ -4318,6 +4377,8 @@ script:
- lambda: |-
lv_label_set_text(id(color_detail_title), id(active_title).c_str());
light_controls::open(id(active_entity), id(active_supports_color), id(active_supports_temperature), id(active_brightness_pct));
+ if (effects_page::offered(id(active_entity))) lv_obj_remove_flag(id(color_effects_button), LV_OBJ_FLAG_HIDDEN);
+ else lv_obj_add_flag(id(color_effects_button), LV_OBJ_FLAG_HIDDEN);
- lvgl.widget.hide: brightness_overlay
- lvgl.widget.show: color_detail_overlay
diff --git a/packages/cyd.yaml b/packages/cyd.yaml
index f7b39ad..dfb5e02 100644
--- a/packages/cyd.yaml
+++ b/packages/cyd.yaml
@@ -18,7 +18,7 @@
############################################################
substitutions:
- SCREEN_FIRMWARE_VERSION: "0.2.69"
+ SCREEN_FIRMWARE_VERSION: "0.2.70"
# A tap survives a finger that drifts up to this far (320 px over 58 mm: 1 cm); further is a swipe.
TOUCH_MOVE_LIMIT_PX: "56"
# Shortest contact that counts as a tap; a resistive panel bounces, a capacitive one does not.
@@ -318,6 +318,7 @@ esphome:
theme::set_dark(settings_screen::dark_mode != 0);
runtime_tiles::settings_changed = []() { id(apply_screen_settings).execute(); };
runtime_tiles::dismiss = []() {
+ effects_page::close();
runtime_tiles::hide_detail();
id(active_entity).clear();
lv_obj_add_flag(id(brightness_overlay), LV_OBJ_FLAG_HIDDEN);
@@ -367,6 +368,7 @@ esphome:
std::to_string(t.minimum), std::to_string(t.maximum), std::to_string(t.step));
};
runtime_tiles::detail_update = [](runtime_tiles::Tile &t) {
+ effects_page::updated(t);
if (id(active_entity) != t.entity || id(touch_down)) return;
if (t.domain() == "climate" && !lv_obj_has_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN)) {
if (id(climate_edit_pending) && millis() - id(climate_edit_ms) < 3000) return;
@@ -390,6 +392,24 @@ esphome:
light_controls::commits[0] = [](int value) { id(set_light_hue).execute(value); };
light_controls::commits[1] = [](int value) { id(set_light_color_temperature).execute(value); };
light_controls::commits[2] = [](int value) { id(color_brightness_commit).execute(value); };
+ // The effects page behind the colour card's sparkles key (firmware 0.2.70+): fonts, the tile it shows, and
+ // the ways out: actions to Home Assistant and the question for a picker's names.
+ effects_page::title_font = id(headline)->get_lv_font();
+ effects_page::row_font = id(sublabel_big)->get_lv_font();
+ effects_page::roller_font = id(sublabel_big)->get_lv_font();
+ effects_page::icon_font = id(materialdesign_icons_mini)->get_lv_font();
+ effects_page::tile_of = [](const std::string &entity) -> const runtime_tiles::Tile * {
+ for (size_t i = 0; i < runtime_tiles::model.count; ++i) if (runtime_tiles::model.tiles[i].entity == entity) return &runtime_tiles::model.tiles[i];
+ return nullptr;
+ };
+ effects_page::send = [](const std::string &service, const std::string &entity, const std::string &key, const std::string &value) {
+ runtime_tiles::action(service, entity, key, value);
+ };
+ effects_page::ask = [](const std::string &entity, unsigned page) { runtime_tiles::options_request(entity, page); };
+ effects_page::drift = []() { return cyd::touch_guard.distance(); };
+ effects_page::tap_limit = ${TOUCH_MOVE_LIMIT_PX};
+ effects_page::now = []() { return (uint32_t) millis(); };
+ runtime_tiles::options_received = effects_page::received;
- lambda: |-
if (!runtime_tiles::enabled) return;
screen_calibration::apply = [](const screen_calibration::Calibration &cal) {
@@ -427,6 +447,7 @@ esphome:
theme::redraw = []() {
runtime_tiles::restyle();
light_controls::restyle();
+ effects_page::restyle();
settings_screen::restyle();
if (id(alert_active)) lv_obj_set_style_bg_color(id(alert_card), lv_color_hex(theme::surface(id(alert_card_color))), LV_PART_MAIN);
if (!lv_obj_has_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN)) id(climate_card_refresh).execute();
@@ -1220,6 +1241,11 @@ font:
- "\U000F1B73" # speaker-pause
- "\U000F1B72" # speaker-play
- "\U000F04C5" # speedometer
+ - "\U000F0674" # creation
+ - "\U000F0E0C" # palette-outline
+ - "\U000F0411" # playlist-play
+ - "\U000F08FF" # play-speed
+ - "\U000F00DF" # brightness-6
- "\U000F1429" # spoon-sugar
- "\U000F0E67" # sprout-outline
- "\U000F0764" # square
@@ -1868,6 +1894,14 @@ lvgl:
height: 2
spin_time: 1s
arc_length: 60deg
+ # Never shown either: it makes ESPHome compile LVGL's roller, the drum a light's effects page picks from
+ # (firmware 0.2.70+).
+ - roller:
+ id: roller_seed
+ hidden: true
+ width: 2
+ height: 2
+ options: ["-"]
- label:
id: lbl_room
x: 11
@@ -2838,6 +2872,31 @@ lvgl:
text_font: materialdesign_icons_mini
clickable: false
+ # The sparkles key (firmware 0.2.70+): a light with effects, or with modes on its device (a WLED's palette,
+ # preset, speed), opens its effects page. Hidden for every other light.
+ - obj:
+ id: color_effects_button
+ align: TOP_RIGHT
+ x: -10
+ y: 8
+ width: 40
+ height: 40
+ radius: 999
+ styles: paint_key
+ bg_opa: COVER
+ border_width: 0
+ hidden: true
+ clickable: true
+ scrollable: false
+ on_click:
+ - lambda: 'effects_page::open(id(active_entity));'
+ widgets:
+ - label:
+ align: CENTER
+ text: "\U000F0674"
+ text_font: materialdesign_icons_mini
+ clickable: false
+
- label:
id: color_detail_title
width: 210
@@ -4307,6 +4366,8 @@ script:
- lambda: |-
lv_label_set_text(id(color_detail_title), id(active_title).c_str());
light_controls::open(id(active_entity), id(active_supports_color), id(active_supports_temperature), id(active_brightness_pct));
+ if (effects_page::offered(id(active_entity))) lv_obj_remove_flag(id(color_effects_button), LV_OBJ_FLAG_HIDDEN);
+ else lv_obj_add_flag(id(color_effects_button), LV_OBJ_FLAG_HIDDEN);
- lvgl.widget.hide: brightness_overlay
- lvgl.widget.show: color_detail_overlay
diff --git a/packages/guition.yaml b/packages/guition.yaml
index 43e61c0..ab58ace 100644
--- a/packages/guition.yaml
+++ b/packages/guition.yaml
@@ -3,7 +3,7 @@
# Source profile for packages/guition.yaml (tools/generate_packages.py); see docs/GUITION.md.
# A screen gets its tiles from the ESP Screen Manager add-on while it runs. The CYD is its own profile.
substitutions:
- SCREEN_FIRMWARE_VERSION: "0.2.69"
+ SCREEN_FIRMWARE_VERSION: "0.2.70"
# 0: no drift limit. A press that drifts is still a tap as long as the finger lets go on the tile
# (runtime_tiles::event checks where it let go: LVGL keeps the press on the tile and would click it
# anywhere), and a quick flick is LVGL's gesture, never a tap (the home_page gesture handler in on_boot).
@@ -223,6 +223,7 @@ esphome:
theme::set_dark(settings_screen::dark_mode != 0);
runtime_tiles::settings_changed = []() { id(apply_screen_settings).execute(); };
runtime_tiles::dismiss = []() {
+ effects_page::close();
runtime_tiles::hide_detail();
runtime_tiles::camera_close();
id(active_entity).clear();
@@ -300,6 +301,7 @@ esphome:
std::to_string(t.minimum), std::to_string(t.maximum), std::to_string(t.step));
};
runtime_tiles::detail_update = [](runtime_tiles::Tile &t) {
+ effects_page::updated(t);
if (id(active_entity) != t.entity || id(touch_down)) return;
if (t.domain() == "climate" && !lv_obj_has_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN)) {
if (id(climate_edit_pending) && millis() - id(climate_edit_ms) < 3000) return;
@@ -323,6 +325,24 @@ esphome:
light_controls::commits[0] = [](int value) { id(set_light_hue).execute(value); };
light_controls::commits[1] = [](int value) { id(set_light_color_temperature).execute(value); };
light_controls::commits[2] = [](int value) { id(color_brightness_commit).execute(value); };
+ // The effects page behind the colour card's sparkles key (firmware 0.2.70+): fonts, the tile it shows, and
+ // the ways out: actions to Home Assistant and the question for a picker's names.
+ effects_page::title_font = id(headline)->get_lv_font();
+ effects_page::row_font = id(sublabel)->get_lv_font();
+ effects_page::roller_font = id(sublabel_big)->get_lv_font();
+ effects_page::icon_font = id(materialdesign_icons_mini)->get_lv_font();
+ effects_page::tile_of = [](const std::string &entity) -> const runtime_tiles::Tile * {
+ for (size_t i = 0; i < runtime_tiles::model.count; ++i) if (runtime_tiles::model.tiles[i].entity == entity) return &runtime_tiles::model.tiles[i];
+ return nullptr;
+ };
+ effects_page::send = [](const std::string &service, const std::string &entity, const std::string &key, const std::string &value) {
+ runtime_tiles::action(service, entity, key, value);
+ };
+ effects_page::ask = [](const std::string &entity, unsigned page) { runtime_tiles::options_request(entity, page); };
+ effects_page::drift = []() { return cyd::touch_guard.distance(); };
+ effects_page::tap_limit = ${TOUCH_MOVE_LIMIT_PX};
+ effects_page::now = []() { return (uint32_t) millis(); };
+ runtime_tiles::options_received = effects_page::received;
climate_card::look.card = id(climate_settings_card);
climate_card::look.font = id(label)->get_lv_font();
climate_card::look.icon_font = id(materialdesign_icons_mini)->get_lv_font();
@@ -359,6 +379,7 @@ esphome:
theme::redraw = []() {
runtime_tiles::restyle();
light_controls::restyle();
+ effects_page::restyle();
settings_screen::restyle();
if (id(alert_active)) lv_obj_set_style_bg_color(id(alert_card), lv_color_hex(theme::surface(id(alert_card_color))), LV_PART_MAIN);
if (!lv_obj_has_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN)) id(climate_card_refresh).execute();
@@ -1204,6 +1225,11 @@ font:
- "\U000F1B73" # speaker-pause
- "\U000F1B72" # speaker-play
- "\U000F04C5" # speedometer
+ - "\U000F0674" # creation
+ - "\U000F0E0C" # palette-outline
+ - "\U000F0411" # playlist-play
+ - "\U000F08FF" # play-speed
+ - "\U000F00DF" # brightness-6
- "\U000F1429" # spoon-sugar
- "\U000F0E67" # sprout-outline
- "\U000F0764" # square
@@ -1903,6 +1929,14 @@ lvgl:
height: 2
spin_time: 1s
arc_length: 60deg
+ # Never shown either: it makes ESPHome compile LVGL's roller, the drum a light's effects page picks from
+ # (firmware 0.2.70+).
+ - roller:
+ id: roller_seed
+ hidden: true
+ width: 2
+ height: 2
+ options: ["-"]
# Never shown either: it makes ESPHome compile LVGL's image widget, which a camera image is drawn with.
- image:
id: camera_image_seed
@@ -2938,6 +2972,31 @@ lvgl:
text_font: materialdesign_icons_mini
clickable: false
+ # The sparkles key (firmware 0.2.70+): a light with effects, or with modes on its device (a WLED's palette,
+ # preset, speed), opens its effects page. Hidden for every other light.
+ - obj:
+ id: color_effects_button
+ align: TOP_RIGHT
+ x: -16
+ y: 16
+ width: 60
+ height: 60
+ radius: 999
+ styles: paint_key
+ bg_opa: COVER
+ border_width: 0
+ hidden: true
+ clickable: true
+ scrollable: false
+ on_click:
+ - lambda: 'effects_page::open(id(active_entity));'
+ widgets:
+ - label:
+ align: CENTER
+ text: "\U000F0674"
+ text_font: materialdesign_icons_mini
+ clickable: false
+
- label:
id: color_detail_title
width: 310
@@ -4494,6 +4553,8 @@ script:
- lambda: |-
lv_label_set_text(id(color_detail_title), id(active_title).c_str());
light_controls::open(id(active_entity), id(active_supports_color), id(active_supports_temperature), id(active_brightness_pct));
+ if (effects_page::offered(id(active_entity))) lv_obj_remove_flag(id(color_effects_button), LV_OBJ_FLAG_HIDDEN);
+ else lv_obj_add_flag(id(color_effects_button), LV_OBJ_FLAG_HIDDEN);
- lvgl.widget.hide: brightness_overlay
- lvgl.widget.show: color_detail_overlay
diff --git a/screen_manager/CHANGELOG.md b/screen_manager/CHANGELOG.md
index 50714f2..d9d05c6 100644
--- a/screen_manager/CHANGELOG.md
+++ b/screen_manager/CHANGELOG.md
@@ -1,3 +1,12 @@
+## 0.2.83 (firmware 0.2.70)
+
+A lamp's modes on the screen: effects, palettes and presets of a WLED, and the effect of any light that has one.
+
+- **An effects page behind the light's colour card.** A light that offers effects (a WLED, a Hue with its candle effect) gets a sparkles key at the top right of its colour card. It opens a page with one row per thing the lamp offers: the light's *Effect*, and the select entities on the lamp's device (a WLED's *Color palette*, *Preset* and *Playlist*), each with what it is set to. Under them a slider per number entity of the device (a WLED's *Speed* and *Intensity*). Nothing is hardcoded for a brand: the rows, their English names and their icons are what Home Assistant lists for the device, so another lamp shows its own rows and a lamp without any shows none.
+- **A picker that turns like a drum.** A row opens LVGL's roller with every name Home Assistant has at that moment, the effects alphabetically with *Solid* on top and a select's options in Home Assistant's order. Turn it to a name and press the check at the top right: that sends one action (`light.turn_on` with the effect, `select.select_option`; a slider sends `number.set_value` when you let go), the row shows the choice at once, and the back key sends nothing. The list is asked for when the picker opens (event `esphome.screen_options`, answered with `op: options`, one page of names per message), so the screen holds no list while it is closed and a WLED update shows its new effects the next time.
+- **The tile names the effect.** A light tile reads *TV Simulator* instead of *100 %* while an effect runs; *Solid*, *off* and *None* count as none.
+- Needs firmware 0.2.70: press **Update** on the screen. Includes everything from 0.2.82. CYD firmware: 1,675,424 bytes, 91.3 % of the update slot (20,288 bytes more than 0.2.82: LVGL's roller is compiled in for the first time, plus the page and five icons).
+
## 0.2.82 (firmware 0.2.69)
The page buttons can make way, and the tiles fill the screen.
diff --git a/screen_manager/app/core.py b/screen_manager/app/core.py
index 2522c03..17322f1 100644
--- a/screen_manager/app/core.py
+++ b/screen_manager/app/core.py
@@ -37,7 +37,7 @@ WEEKDAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
REPO = 'https://github.com/MaxGramser/homeassistant_espscreen'
REFS = {'cyd': 'main', 'guition': 'main'}
# Firmware shipped with this app release; screens below it get an update offer.
-FIRMWARE_VERSION = '0.2.69'
+FIRMWARE_VERSION = '0.2.70'
# The Auto standby switch a screen offers Home Assistant automations.
AUTO_STANDBY_MIN_FIRMWARE = '0.2.41'
# The settings page the screen opens itself, and the screen.settings tile that opens it.
@@ -50,7 +50,7 @@ SETTING_ENTITIES_MIN_FIRMWARE = '0.2.49'
DARK_MODE_MIN_FIRMWARE = '0.2.54'
# Page buttons: the Previous and Next bar under the tiles, a setting and entity of its own; off, the tiles take its room.
PAGE_BUTTONS_MIN_FIRMWARE = '0.2.69'
-ATTRS = frozenset('brightness percentage current_position current_tilt_position current_temperature temperature current_humidity min_temp max_temp target_temp_step supported_color_modes hvac_modes hvac_action hs_color color_temp_kelvin min_color_temp_kelvin max_color_temp_kelvin fan_speed_list unit_of_measurement battery_level fan_speed volume_level is_volume_muted media_title options min max step temperature_unit supported_features device_class next_rising next_setting finishes_at duration remaining humidity wind_speed wind_speed_unit apparent_temperature fan_modes swing_modes fan_mode swing_mode'.split())
+ATTRS = frozenset('brightness percentage current_position current_tilt_position current_temperature temperature current_humidity min_temp max_temp target_temp_step supported_color_modes hvac_modes hvac_action hs_color color_temp_kelvin min_color_temp_kelvin max_color_temp_kelvin fan_speed_list unit_of_measurement battery_level fan_speed volume_level is_volume_muted media_title options min max step temperature_unit supported_features device_class next_rising next_setting finishes_at duration remaining humidity wind_speed wind_speed_unit apparent_temperature fan_modes swing_modes fan_mode swing_mode effect'.split())
# Attributes whose boolean value the screen needs; every other bool stays behind.
BOOL_ATTRS = frozenset(['is_volume_muted'])
@@ -1131,10 +1131,16 @@ def media_extras(attrs):
return result or None
-def extras(tile, states, forecast=None, tz=None, hourly=None, now=None, device=None):
- """Small, pre-computed values the firmware cannot derive itself (time zones, forecasts, a vacuum's device)."""
+def extras(tile, states, forecast=None, tz=None, hourly=None, now=None, device=None, entries=None, words=None, icon_of=None, device_name=None):
+ """Small, pre-computed values the firmware cannot derive itself (time zones, forecasts, a vacuum's device, the rows of
+ a light's effects page)."""
domain = tile['entity'].split('.')[0]
attrs = states.get(tile['entity'], {}).get('attributes', {})
+ if domain == 'light':
+ # The effects page (app 0.2.83): the select and number entities of the light's device, named as Home Assistant
+ # names them; the effect itself travels as the `effect` attribute.
+ import light_effects
+ return light_effects.rows(tile['entity'], device, states, entries, words, icon_of, device_name) or None
if domain == 'vacuum':
return vacuum_extras(tile, states, device)
if domain == 'cover':
diff --git a/screen_manager/app/light_effects.py b/screen_manager/app/light_effects.py
new file mode 100644
index 0000000..c4370f2
--- /dev/null
+++ b/screen_manager/app/light_effects.py
@@ -0,0 +1,210 @@
+"""A light's effects page (app 0.2.83 / firmware 0.2.70): what the screen shows for a lamp with modes, such as a WLED.
+
+Home Assistant is the source of every name and list here. The light's own `effect_list` is one row; the select and
+number entities on the light's device (WLED: color palette, preset, playlist, speed, intensity) are the others, named
+as Home Assistant names them in English (frontend/get_translations `entity`) with the icons its frontend shows. Nothing
+about a brand is hardcoded: a lamp with only an effect list gets one row, a lamp without any gets no page.
+
+The current values travel in the tile's state message (`x.rows`, `x.nums`); the lists themselves are asked for when a
+picker opens (`esphome.screen_options` -> `op: options`), one page per message, so the screen holds no list while the
+picker is closed and always shows what Home Assistant has at that moment.
+"""
+import json
+
+# LightEntityFeature.EFFECT
+EFFECT_FEATURE = 4
+# Rows the effects page has room for: the effect plus three selects, and two numbers (a Guition shows them all).
+SELECT_ROWS = 3
+NUMBER_ROWS = 2
+# Where these translation keys occur they come first, in this order; everything else follows by name. This is
+# presentation only: the rows themselves still come from the device.
+PREFERRED_SELECTS = ('color_palette', 'preset', 'playlist')
+PREFERRED_NUMBERS = ('speed', 'intensity')
+# A message to the screen is at most 4096 bytes; the names of one page stay well under it.
+PAGE_BYTES = 3400
+NAME_LIMIT = 48
+OPTIONS_EVENT = 'esphome.screen_options'
+
+
+def supports_effects(attrs):
+ """Whether the light offers effects: Home Assistant's EFFECT feature and a list to choose from."""
+ features = (attrs or {}).get('supported_features')
+ return isinstance(features, int) and bool(features & EFFECT_FEATURE) and isinstance((attrs or {}).get('effect_list'), list)
+
+
+def effect_options(attrs):
+ """The light's effects as the picker lists them: alphabetically, with "Solid" (WLED's plain colour) first."""
+ names = [str(v) for v in (attrs or {}).get('effect_list') or [] if isinstance(v, (str, int, float)) and not isinstance(v, bool)]
+ names = [n for n in names if n.strip()]
+ solid = [n for n in names if n.strip().lower() == 'solid']
+ rest = sorted((n for n in names if n.strip().lower() != 'solid'), key=lambda n: (n.casefold(), n))
+ return solid[:1] + rest
+
+
+def _numeric(options):
+ """A select whose every option is a bare number (WLED's live override: 0, 1, 2) is a technical setting, not a mode."""
+ return all(isinstance(o, str) and o.strip().lstrip('-').isdigit() for o in options)
+
+
+def _qualifies(item, states):
+ """A select or number of the device the page can show: enabled, not hidden, not diagnostic."""
+ eid = item.get('entity_id') or ''
+ if item.get('disabled_by') or item.get('hidden_by') or item.get('entity_category') == 'diagnostic':
+ return False
+ state = states.get(eid)
+ if not isinstance(state, dict):
+ return False
+ attrs = state.get('attributes') or {}
+ if eid.startswith('select.'):
+ options = attrs.get('options')
+ return isinstance(options, list) and bool(options) and all(isinstance(o, str) for o in options) and not _numeric(options)
+ if eid.startswith('number.'):
+ try:
+ return float(attrs.get('min')) < float(attrs.get('max'))
+ except (TypeError, ValueError):
+ return False
+ return False
+
+
+def _order(items, preferred, names):
+ def rank(item):
+ key = item.get('translation_key') or ''
+ return (preferred.index(key) if key in preferred else len(preferred), names[item['entity_id']].casefold())
+ return sorted(items, key=rank)
+
+
+def entity_name(eid, entry, states, words):
+ """Home Assistant's English name of a device's entity: its integration's translation for the entity's key
+ (component.wled.entity.select.color_palette.name = "Color palette"), else the name Home Assistant shows, without the
+ device's name in front of it ("VUELTA Kleurenpalet" is "Kleurenpalet")."""
+ entry = entry if isinstance(entry, dict) else {}
+ domain = eid.split('.', 1)[0]
+ if isinstance(words, dict) and entry.get('platform') and entry.get('translation_key'):
+ word = words.get(f"component.{entry['platform']}.entity.{domain}.{entry['translation_key']}.name")
+ if isinstance(word, str) and word and '{' not in word:
+ return word
+ name = entry.get('name') or entry.get('original_name') or (states.get(eid, {}).get('attributes') or {}).get('friendly_name') or eid
+ return str(name)
+
+
+def strip_device_name(name, device_name):
+ """"VUELTA Kleurenpalet" without the device's name, as Home Assistant's device page shows it."""
+ if isinstance(device_name, str) and device_name and name.lower().startswith(device_name.lower() + ' '):
+ return name[len(device_name) + 1:] or name
+ return name
+
+
+def siblings(entity, device, states):
+ """The select and number entities on the light's device that its effects page shows, in page order."""
+ selects, numbers = [], []
+ for item in device or ():
+ eid = item.get('entity_id') or ''
+ if eid == entity or not _qualifies(item, states):
+ continue
+ (selects if eid.startswith('select.') else numbers).append(item)
+ return selects, numbers
+
+
+def related(entity, device, states):
+ """Entity ids the light's card reads besides its own, for the manager's watch list."""
+ selects, numbers = siblings(entity, device, states)
+ return tuple(item['entity_id'] for item in selects + numbers)
+
+
+def rows(entity, device, states, entries=None, words=None, icon_of=None, device_name=None):
+ """The `rows` (selects) and `nums` (numbers) of the light's effects page for the tile's extra block, or {}.
+
+ `entries` is the registry by entity id (names and translation keys), `words` Home Assistant's English entity
+ translations, `icon_of(eid, state, attributes, entry)` the codepoint of the icon Home Assistant shows, or None."""
+ selects, numbers = siblings(entity, device, states)
+ entries = entries or {}
+ names = {}
+ for item in selects + numbers:
+ eid = item['entity_id']
+ entry = entries.get(eid, item)
+ names[eid] = strip_device_name(entity_name(eid, entry, states, words), device_name)
+ result = {}
+ found = []
+ for item in _order(selects, PREFERRED_SELECTS, names)[:SELECT_ROWS]:
+ eid = item['entity_id']
+ state = states[eid]
+ attrs = state.get('attributes') or {}
+ row = {'e': eid, 'n': _short(names[eid], 32), 's': _short(state.get('state') if isinstance(state.get('state'), str) else '', NAME_LIMIT),
+ 'c': len(attrs.get('options') or [])}
+ icon = icon_of(eid, state.get('state'), attrs, entries.get(eid, item)) if icon_of else None
+ if icon:
+ row['i'] = icon
+ found.append(row)
+ if found:
+ result['rows'] = found
+ found = []
+ for item in _order(numbers, PREFERRED_NUMBERS, names)[:NUMBER_ROWS]:
+ eid = item['entity_id']
+ state = states[eid]
+ attrs = state.get('attributes') or {}
+ row = {'e': eid, 'n': _short(names[eid], 32), 'lo': _number(attrs.get('min'), 0), 'hi': _number(attrs.get('max'), 100),
+ 'st': _number(attrs.get('step'), 1)}
+ value = _number(state.get('state'), None)
+ if value is not None:
+ row['v'] = value
+ icon = icon_of(eid, state.get('state'), attrs, entries.get(eid, item)) if icon_of else None
+ if icon:
+ row['i'] = icon
+ found.append(row)
+ if found:
+ result['nums'] = found
+ return result
+
+
+def options_for(entity, states, layout_entities=(), device_of=None):
+ """The names a picker lists for `entity`, or None when the screen may not ask for them: a light on the layout (its
+ effects) or a select on the device of such a light (its options). `device_of(light)` gives that device's entries."""
+ state = states.get(entity)
+ if not isinstance(state, dict):
+ return None
+ attrs = state.get('attributes') or {}
+ if entity.startswith('light.'):
+ return effect_options(attrs) if entity in layout_entities and supports_effects(attrs) else None
+ if entity.startswith('select.'):
+ for light in layout_entities:
+ if light.startswith('light.') and device_of and entity in related(light, device_of(light), states):
+ options = attrs.get('options')
+ return [str(o) for o in options if isinstance(o, str)] if isinstance(options, list) else None
+ return None
+
+
+def pages(names):
+ """The names cut into pages whose message stays under the screen's limit (216 WLED effects are one page)."""
+ result, page, size = [], [], 0
+ for name in names:
+ name = _short(name, NAME_LIMIT)
+ cost = len(json.dumps(name, ensure_ascii=False).encode()) + 1
+ if page and size + cost > PAGE_BYTES:
+ result.append(page)
+ page, size = [], 0
+ page.append(name)
+ size += cost
+ if page or not result:
+ result.append(page)
+ return result
+
+
+def message(entity, page, all_pages):
+ """One page of a picker's names, as the screen parses it (`op: options`)."""
+ page = max(0, min(int(page), len(all_pages) - 1))
+ return {'v': 1, 'op': 'options', 'e': entity, 'i': page, 'n': len(all_pages), 'o': all_pages[page]}
+
+
+def _short(value, limit):
+ value = str(value if value is not None else '')
+ return value if len(value) <= limit else value[:limit - 1] + '…'
+
+
+def _number(value, fallback):
+ try:
+ number = float(value)
+ except (TypeError, ValueError):
+ return fallback
+ if number != number or number in (float('inf'), float('-inf')):
+ return fallback
+ return int(number) if number.is_integer() else round(number, 3)
diff --git a/screen_manager/app/server.py b/screen_manager/app/server.py
index 3a9fecd..7e3ec6d 100644
--- a/screen_manager/app/server.py
+++ b/screen_manager/app/server.py
@@ -14,6 +14,7 @@ import camera_feed
import claude_skill
from firmware import Firmware
import ha_catalogue
+import light_effects
import tile_icons
from updates import Updater
@@ -25,6 +26,11 @@ import header_bar
import history_card
from zoneinfo import ZoneInfo
+
+def row_icon(eid, state, attrs, entry):
+ """Codepoint of the icon Home Assistant shows for a row of a light's effects page, when the screen's fonts carry it."""
+ return tile_icons.ha_icon(attrs) or tile_icons.default_glyph(eid, state, attrs, entry)
+
LOG = logging.getLogger('screen_manager')
REGISTRY_EVENTS = ('entity_registry_updated', 'device_registry_updated', 'area_registry_updated')
@@ -124,6 +130,8 @@ class HomeAssistant:
self.history_requests = asyncio.Queue()
# A camera a screen opens full screen (firmware 0.2.57+): {inbox, entity}, for Manager.camera_loop.
self.camera_requests = asyncio.Queue()
+ # The names a light's picker lists (firmware 0.2.70+): {inbox, entity, page}, for Manager.card_options_loop.
+ self.options_requests = asyncio.Queue()
async def request(self, kind, **data):
if self.ws is None or self.ws.closed:
@@ -161,6 +169,8 @@ class HomeAssistant:
self.history_requests.put_nowait(body)
elif event.get('event_type') == 'esphome.screen_camera':
self.camera_requests.put_nowait(body)
+ elif event.get('event_type') == light_effects.OPTIONS_EVENT:
+ self.options_requests.put_nowait(body)
elif event.get('event_type') == 'state_changed':
eid = body.get('entity_id')
if body.get('new_state'):
@@ -295,6 +305,7 @@ class HomeAssistant:
await self.request('subscribe_events', event_type='esphome.screen_setting')
await self.request('subscribe_events', event_type='esphome.screen_history')
await self.request('subscribe_events', event_type='esphome.screen_camera')
+ await self.request('subscribe_events', event_type=light_effects.OPTIONS_EVENT)
for event_type in (*REGISTRY_EVENTS, *BROADCAST_EVENTS, *TILE_EVENTS, *SERVICE_EVENTS):
await self.request('subscribe_events', event_type=event_type)
self.states = {s['entity_id']: s for s in await self.request('get_states')}
@@ -818,6 +829,37 @@ class Manager:
self.retries[inbox] = failures + 1
self.retry_at[inbox] = due
+ # ----- The names a light's picker lists (app 0.2.83, firmware 0.2.70+) -----
+ async def card_options_loop(self):
+ """Answer a screen that opens a picker on a light's effects page: the effects of the light, or the options of a
+ select on its device, one page per message."""
+ while True:
+ request = await self.ha.options_requests.get()
+ try:
+ await self.answer_options(request)
+ except (ClientError, ConnectionError, TimeoutError, OSError, ValueError) as error:
+ LOG.info('No options for %s (%s)', request.get('entity') if isinstance(request, dict) else '?', type(error).__name__)
+
+ async def answer_options(self, request):
+ """One screen's request: a light on its own layout, or a select on that light's device; a page it asked for."""
+ if not isinstance(request, dict):
+ return
+ inbox = self.aliases.get(request.get('inbox'), request.get('inbox'))
+ entity = request.get('entity')
+ try:
+ page = int(request.get('page') or 0)
+ except (TypeError, ValueError):
+ page = 0
+ layout, screen = self.layouts.get(inbox), self.screen(inbox) if isinstance(inbox, str) else None
+ if not isinstance(entity, str) or not layout or not screen or not screen.get('online'):
+ return
+ names = light_effects.options_for(entity, self.ha.states, [tile['entity'] for tile in layout['tiles']], self.device_entries)
+ action = self.transport(inbox, screen)
+ if names is None or not action:
+ return
+ all_pages = light_effects.pages(names)
+ await self.ha.send(inbox, light_effects.message(entity, page, all_pages), action)
+
# ----- History on a detail card (firmware 0.2.51+) -----
async def card_history_loop(self):
"""Answer the history a screen asks for when a card opens, a few at a time."""
@@ -918,8 +960,18 @@ class Manager:
return tuple(vacuum_related(tile['entity'], self.device_entries(tile['entity']), self.ha.states).values())
if tile['entity'].startswith('cover.'):
return tuple(cover_related(tile['entity'], self.device_entries(tile['entity']), self.ha.states).values())
+ if tile['entity'].startswith('light.'):
+ return light_effects.related(tile['entity'], self.device_entries(tile['entity']), self.ha.states)
return ()
+ def device_name_of(self, entity):
+ """The name of the entity's device as Home Assistant shows it (the user's name first), or None."""
+ device = self.registry_index().get(entity, {}).get('device_id')
+ for item in getattr(self.ha, 'devices', None) or ():
+ if isinstance(item, dict) and item.get('id') == device:
+ return item.get('name_by_user') or item.get('name')
+ return None
+
def header_message(self, layout):
return header_bar.message(layout, self.ha.states, self.registry_index(), getattr(self.ha, 'units', {}), getattr(self.ha, 'time_zone', None),
getattr(self.ha, 'state_words', None))
@@ -1088,9 +1140,13 @@ class Manager:
# Hourly forecasts feed the weather card's next-hours strip (0.2.23+); refreshed every half hour.
hourly=await self.cached(self.forecasts, (entity,'hourly'), FORECAST_SECONDS, (lambda: self.ha.forecast(entity,'hourly')) if 'hourly' in kinds else nothing)
# A vacuum's card also reads selects and the battery sensor of its device (app 0.2.46), a cover's card its battery (0.2.58).
- device=self.device_entries(tile['entity']) if tile['entity'].startswith(('vacuum.', 'cover.')) else None
+ device=self.device_entries(tile['entity']) if tile['entity'].startswith(('vacuum.', 'cover.', 'light.')) else None
entry=self.registry_index().get(tile['entity'])
- extra=extras(tile,self.ha.states,forecast,getattr(self.ha,'time_zone',None),hourly,device=device)
+ # A light's effects page (app 0.2.83) names the device's selects and numbers as Home Assistant does, with its icons.
+ light=tile['entity'].startswith('light.')
+ extra=extras(tile,self.ha.states,forecast,getattr(self.ha,'time_zone',None),hourly,device=device,
+ entries=self.registry_index() if light else None,words=getattr(self.ha,'state_words',None) if light else None,
+ icon_of=row_icon if light else None,device_name=self.device_name_of(tile['entity']) if light else None)
if tile['entity'].startswith('vacuum.'):
ha_catalogue.chip_words(extra,tile['entity'],self.ha.states,device,getattr(self.ha,'state_words',None))
message=state_message(index,tile,self.ha.states,extra,
@@ -1843,7 +1899,7 @@ async def main():
LOG.error('Camera images are off: port %d is not available (%s)', camera_feed.port(), error)
try:
await asyncio.gather(ha.run(), manager.run(), manager.history_loop(), manager.updates.run(),
- manager.alert_loop(), manager.tile_loop(), manager.card_history_loop(), manager.camera_loop())
+ manager.alert_loop(), manager.tile_loop(), manager.card_history_loop(), manager.camera_loop(), manager.card_options_loop())
finally:
await cameras.cleanup()
await runner.cleanup()
diff --git a/screen_manager/app/static/assets/bar-roboto-400-H0q_-5Ia.woff b/screen_manager/app/static/assets/bar-roboto-400-DWGTva7D.woff
similarity index 99%
rename from screen_manager/app/static/assets/bar-roboto-400-H0q_-5Ia.woff
rename to screen_manager/app/static/assets/bar-roboto-400-DWGTva7D.woff
index 6fcdaf3..eeb874d 100644
Binary files a/screen_manager/app/static/assets/bar-roboto-400-H0q_-5Ia.woff and b/screen_manager/app/static/assets/bar-roboto-400-DWGTva7D.woff differ
diff --git a/screen_manager/app/static/assets/bar-roboto-500-Bn7ZL9yA.woff b/screen_manager/app/static/assets/bar-roboto-500-B8TsR_Fs.woff
similarity index 99%
rename from screen_manager/app/static/assets/bar-roboto-500-Bn7ZL9yA.woff
rename to screen_manager/app/static/assets/bar-roboto-500-B8TsR_Fs.woff
index e95315b..353464b 100644
Binary files a/screen_manager/app/static/assets/bar-roboto-500-Bn7ZL9yA.woff and b/screen_manager/app/static/assets/bar-roboto-500-B8TsR_Fs.woff differ
diff --git a/screen_manager/app/static/assets/index-CKR1N3aP.css b/screen_manager/app/static/assets/index-C6Sfd-UM.css
similarity index 99%
rename from screen_manager/app/static/assets/index-CKR1N3aP.css
rename to screen_manager/app/static/assets/index-C6Sfd-UM.css
index 87bee2a..6a6b914 100644
--- a/screen_manager/app/static/assets/index-CKR1N3aP.css
+++ b/screen_manager/app/static/assets/index-C6Sfd-UM.css
@@ -1 +1 @@
-:root{color-scheme:light dark;--ground: #f5f6f8;--surface: #ffffff;--surface-2: #f9fafb;--line: #e4e7ec;--line-strong: #d3d8e0;--ink: #16181d;--ink-2: #565d6b;--muted: #7a8290;--accent: #2f63f0;--accent-ink: #ffffff;--accent-soft: #edf2ff;--good: #1e9a5f;--good-soft: #e6f6ee;--warn: #b56d00;--warn-soft: #fff3df;--danger: #c6372e;--danger-soft: #fdecea;--seg: #eef0f3;--shadow: 0 12px 40px rgba(20, 24, 40, .12), 0 1px 2px rgba(20, 24, 40, .06);--code-bg: #f1f3f6;--ui-font: -apple-system, BlinkMacSystemFont, system-ui, "SF Pro Text", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace}@media(prefers-color-scheme:dark){:root{--ground: #0e1014;--surface: #161a21;--surface-2: #1b2029;--line: #262c37;--line-strong: #333b49;--ink: #e9ecf1;--ink-2: #b4bbc7;--muted: #8a93a3;--accent: #6d93ff;--accent-ink: #0b1633;--accent-soft: #1b2540;--good: #3fbf7f;--good-soft: #14302a;--warn: #e0a03a;--warn-soft: #302610;--danger: #f07167;--danger-soft: #3a1b19;--seg: #222834;--shadow: 0 16px 48px rgba(0, 0, 0, .55), 0 1px 2px rgba(0, 0, 0, .4);--code-bg: #12151b}}@font-face{font-family:Tile Icons;src:url(./tile-icons-BawP5y9D.woff) format("woff");font-display:block}@font-face{font-family:Bar Roboto;font-weight:400;src:url(./bar-roboto-400-H0q_-5Ia.woff) format("woff");font-display:block}@font-face{font-family:Bar Roboto;font-weight:500;src:url(./bar-roboto-500-Bn7ZL9yA.woff) format("woff");font-display:block}*{box-sizing:border-box}html,body,#app{height:100%}body{margin:0;background:var(--ground);color:var(--ink);font:13px/1.45 var(--ui-font);-webkit-font-smoothing:antialiased;font-variant-numeric:tabular-nums}button,input,select,textarea{font:inherit;color:inherit}button{background:none;border:0;padding:0;cursor:pointer;border-radius:8px}button:disabled{opacity:.45;cursor:default}button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible,[tabindex]:focus-visible,summary:focus-visible{outline:2px solid var(--accent);outline-offset:2px}input,select,textarea{background:var(--surface);border:1px solid var(--line-strong);border-radius:8px;padding:7px 10px;width:100%;color:var(--ink)}input::placeholder,textarea::placeholder{color:var(--muted)}select{appearance:auto}h1,h2,h3,h4{margin:0;font-weight:600;letter-spacing:-.01em}h1{font-size:20px;letter-spacing:-.02em}h2{font-size:15px}h3,h4{font-size:13px}p{margin:0}small,.hint{display:block;color:var(--muted);font-size:11.5px;font-weight:400;line-height:1.4}.hint.warn,small.warn{color:var(--warn)}code{font-family:var(--mono);font-size:.92em;background:var(--code-bg);padding:1px 6px;border-radius:5px;overflow-wrap:anywhere}pre{margin:0;padding:12px 14px;background:var(--code-bg);border:1px solid var(--line);border-radius:10px;overflow:auto;font:12px/1.5 var(--mono);color:var(--ink);white-space:pre-wrap;overflow-wrap:anywhere}a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}.mdi{font-family:Tile Icons;font-style:normal;font-weight:400;line-height:1}[hidden]{display:none!important}.app{display:grid;grid-template-columns:232px minmax(0,1fr);height:100%;min-height:100vh}.app.dragging{user-select:none;-webkit-user-select:none;cursor:grabbing}.main{display:flex;flex-direction:column;min-width:0;min-height:0}.side{background:var(--surface);border-right:1px solid var(--line);display:flex;flex-direction:column;padding:14px 10px;gap:4px;min-height:0;overflow:auto;position:sticky;top:0;height:100vh}.brand{display:flex;align-items:center;gap:9px;padding:4px 8px 6px;font-weight:600;font-size:13px}.brand .mark{width:22px;height:22px;border-radius:6px;background:var(--ink);color:var(--surface);display:grid;place-items:center;font-size:14px;line-height:1}.conn{display:block;padding:0 8px 8px;font-size:11px;color:var(--muted)}.conn:before{content:"●";margin-right:6px;color:var(--warn)}.conn.online:before{color:var(--good)}.side .label{font-size:10.5px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);padding:10px 8px 4px}.nav-item{display:flex;align-items:center;gap:9px;width:100%;padding:7px 8px;border-radius:8px;text-align:left;color:var(--ink-2)}.nav-item:hover{background:var(--surface-2);color:var(--ink)}.nav-item[aria-current=true]{background:var(--seg);color:var(--ink);font-weight:500}.nav-item .dot{width:7px;height:7px;border-radius:50%;background:var(--good);flex:none}.nav-item .dot.off{background:var(--line-strong)}.nav-item .unsaved{width:7px;height:7px;border-radius:50%;background:var(--warn);flex:none}.nav-item .txt{display:grid;min-width:0;flex:1}.nav-item .txt small{font-size:11px;color:var(--muted);font-weight:400;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.nav-item .mdi{font-size:16px;width:18px;text-align:center;color:var(--muted)}.nav-item .plus{width:18px;text-align:center;font-size:14px}.nav-item.ghost{color:var(--muted)}.nav-item.ghost:hover{color:var(--accent);background:var(--accent-soft)}.pill{font-size:10.5px;font-weight:600;padding:2px 7px;border-radius:999px;background:var(--warn-soft);color:var(--warn);flex:none}.side .spacer{flex:1}.screen-item{display:grid;gap:4px}.screen-update{display:grid;gap:6px;padding:2px 8px 8px 24px}.screen-update.available{grid-template-columns:1fr auto;align-items:center}.screen-update.available:has(.screen-host){grid-template-columns:1fr}.screen-update small.failed{color:var(--danger)}.screen-host{display:flex;gap:6px;align-items:center}.screen-host input{padding:5px 8px;font-size:12px}.pending{margin:4px 0;padding:10px;border:1px dashed var(--line-strong);border-radius:10px;display:grid;gap:6px}.pending strong{font-size:12.5px}.pending-actions{display:flex;flex-wrap:wrap;gap:6px}.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:6px 12px;border-radius:8px;font-weight:600;font-size:12.5px;white-space:nowrap}.btn.primary{background:var(--accent);color:var(--accent-ink)}.btn.primary:hover:not(:disabled){filter:brightness(1.06)}.btn.quiet{color:var(--ink-2);border:1px solid var(--line-strong);background:var(--surface)}.btn.quiet:hover:not(:disabled){background:var(--surface-2);color:var(--ink)}.btn.danger{color:var(--danger)}.btn.danger:hover:not(:disabled){background:var(--danger-soft)}.btn.mini{padding:4px 9px;font-size:11.5px}.btn.link{color:var(--accent);padding:0;font-weight:600}.icon-btn{width:30px;height:30px;display:grid;place-items:center;border-radius:8px;color:var(--ink-2);font-size:15px;flex:none}.icon-btn:hover{background:var(--seg);color:var(--ink)}.chip{display:inline-flex;align-items:center;gap:6px;padding:3px 9px;border-radius:999px;font-size:11.5px;font-weight:500;color:var(--ink-2);background:var(--surface-2);border:1px solid var(--line);white-space:nowrap}.chip .dot{width:6px;height:6px;border-radius:50%;background:var(--good);flex:none}.chip.off .dot{background:var(--line-strong)}.chip.dirty{color:var(--warn);background:var(--warn-soft);border-color:transparent}.chip.sent,.chip.good{color:var(--good);background:var(--good-soft);border-color:transparent}.chip.update{color:var(--warn);background:var(--warn-soft);border-color:transparent}.seg{display:inline-flex;padding:2px;background:var(--seg);border-radius:8px;gap:2px}.seg button{padding:4px 11px;border-radius:6px;font-size:12px;font-weight:500;color:var(--ink-2);white-space:nowrap}.seg button[aria-pressed=true]{background:var(--surface);color:var(--ink);box-shadow:0 1px 2px #14182824}.seg.wrap{flex-wrap:wrap}.seg button:hover:not(:disabled):not([aria-pressed=true]){color:var(--ink)}.spin{display:inline-block;width:14px;height:14px;border:2px solid var(--line-strong);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite;flex:none}.spin.small{width:11px;height:11px;border-width:1.5px}@keyframes spin{to{transform:rotate(360deg)}}.switch{width:34px;height:20px;border-radius:10px;background:var(--line-strong);position:relative;flex:none;padding:0}.switch:after{content:"";position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:#fff;box-shadow:0 1px 2px #0003;transition:left .12s}.switch[aria-checked=true]{background:var(--accent)}.switch[aria-checked=true]:after{left:16px}.switch.unknown:after{left:9px;opacity:.6}@media(prefers-reduced-motion:reduce){.switch:after,.drawer,.spin{transition:none;animation:none}}.main-head{display:flex;align-items:center;gap:12px;padding:0 18px;min-height:52px;border-bottom:1px solid var(--line);background:var(--surface);position:sticky;top:0;z-index:4}.main-head h1{font-size:15px}.main-head .eyebrow{font-size:10.5px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--muted)}.head-right{margin-left:auto;display:flex;align-items:center;gap:8px;position:relative}.menu{position:absolute;top:38px;right:0;min-width:210px;background:var(--surface);border:1px solid var(--line);border-radius:10px;box-shadow:var(--shadow);padding:6px;display:grid;z-index:6}.menu button{text-align:left;padding:7px 10px;border-radius:7px;color:var(--ink);display:flex;justify-content:space-between;gap:12px;width:100%}.menu button small{color:var(--muted);display:inline}.menu button:hover:not(:disabled){background:var(--surface-2)}.body{position:relative;display:grid;grid-template-columns:minmax(0,1fr) 300px;flex:1;min-height:0;overflow:hidden}.canvas{overflow:auto;padding:22px 22px 26px;min-width:0}.canvas-head{display:flex;align-items:baseline;gap:12px;margin-bottom:14px;color:var(--muted);font-size:12px;flex-wrap:wrap}.canvas-head b{color:var(--ink);font-weight:600;font-size:13px}.canvas-head .warn{color:var(--warn)}.pages{display:flex;gap:18px;align-items:flex-start}.page{flex:0 0 292px;display:grid;gap:8px}.page-label{display:flex;justify-content:space-between;align-items:center;font-size:11.5px;color:var(--muted);padding:0 4px;min-height:22px}.page-label .btn.mini{color:var(--muted)}.page-label .btn.mini:hover{color:var(--danger);background:var(--danger-soft)}.page.ghost .device{display:grid;place-items:center;border:1.5px dashed var(--line-strong);background:transparent;color:var(--muted);font-weight:500;cursor:pointer}.page.ghost .device:hover,.page.ghost .device:focus-visible{color:var(--accent);border-color:var(--accent)}.page.ghost .device.drop{color:var(--accent);border-color:var(--accent);background:var(--accent-soft)}.page.ghost.disabled .device{opacity:.5;cursor:default}.device{background:#e7e7e7;border-radius:18px;padding:10px;display:grid;grid-template-rows:auto minmax(0,1fr);gap:8px;color:#1b1b1b;aspect-ratio:1 / 1}.device.cyd{aspect-ratio:4 / 3}.bar-wrap{cursor:pointer;border-radius:8px;padding:0 2px}.bar-wrap:hover{background:#dedede}.bar-wrap.selected{outline:2px solid var(--accent);outline-offset:1px}.bar-wrap svg{display:block;width:100%;height:auto}.tiles{display:grid;grid-template-columns:1fr 1fr;grid-auto-rows:minmax(0,1fr);gap:8px;min-height:0}.tile{position:relative;background:#fff;border:1px solid #dcdcdc;border-radius:12px;padding:8px 9px;display:flex;flex-direction:column;justify-content:space-between;gap:4px;min-height:0;cursor:pointer;text-align:left;overflow:hidden;color:#1b1b1b;touch-action:manipulation}.tile:hover{border-color:#b9c4d3}.tile.chosen{outline:2px solid var(--accent);outline-offset:1px}.tile.wide{grid-column:span 2;flex-direction:row;align-items:center;gap:8px}.tile.bare{background:transparent;border-style:dashed;border-color:#c3c9d0}.tile.placeholder{opacity:.55;border:2px dashed var(--accent);background:var(--accent-soft);cursor:default}.tile .ic{font-size:18px;line-height:1;color:#1b1b1b;flex:none}.tile .lead{display:grid;gap:2px;min-width:0;flex:1;align-content:end}.tile.wide .lead{display:flex;align-items:center;gap:8px;align-content:initial}.tile .tx{display:grid;min-width:0;gap:1px}.tile .head{display:flex;align-items:center;gap:8px;min-width:0;flex:1}.tile .head.top{flex:none}.tile .nm{font-size:11.5px;font-weight:600;line-height:1.2;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.tile .st{font-size:10.5px;color:#5a5f66;line-height:1.2;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.device.cyd .tile{padding:6px 7px}.device.cyd .tile .nm{font-size:10.5px}.device.cyd .tile .ic{font-size:15px}.device.cyd .tile .head{gap:6px}.device.cyd .tile:has(>.mini-slider){padding-block:4px;gap:3px}.device.cyd .tile .mini-slider{height:8px}.tile .mini-slider{height:10px;border-radius:5px;background:linear-gradient(to right,#ffbf38 60%,#fff1d3 60%);flex:none;width:100%}.tile.wide .mini-slider{width:70px}.tile .ctl{flex:none;display:flex;align-items:center;gap:4px;color:#1b1b1b}.tile .tog{width:30px;height:17px;border-radius:9px;background:#2196f3;position:relative}.tile .tog:after{content:"";position:absolute;top:2px;right:2px;width:13px;height:13px;border-radius:50%;background:#fff}.tile .tog.off{background:#c9ccd1}.tile .tog.off:after{right:auto;left:2px}.tile .ic.lit{color:#d9930f}.tile .big{font-size:19px;font-weight:600;letter-spacing:-.02em;line-height:1.05;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.tile .big small{display:inline;font-size:10.5px;font-weight:500;color:#5a5f66;margin-left:2px}.device.cyd .tile .big{font-size:16px}.tile .range{width:56px;height:14px;border-radius:7px;background:linear-gradient(to right,#2196f3 35%,#d3e8fb 35%)}.tile .key{height:20px;min-width:22px;padding:0 5px;border-radius:6px;background:#eceef1;display:grid;place-items:center;font-size:12px}.tile .stp{display:flex;align-items:center;gap:5px;height:20px;padding:0 7px;border-radius:10px;background:#eceef1;font-size:11px;font-weight:600}.tile .stp .mdi{font-size:12px}.tile .run{height:20px;padding:0 9px;border-radius:10px;background:#eceef1;font-size:11px;font-weight:600;line-height:20px}.tile .remove{position:absolute;top:5px;right:5px;width:22px;height:22px;border-radius:50%;background:#ffffffd9;color:#5a5f66;border:1px solid #dcdcdc;font-size:10px;line-height:1;display:grid;place-items:center;opacity:0;transition:opacity .12s}.tile:hover .remove,.tile:focus-within .remove{opacity:1}.tile .remove:hover{color:var(--danger);border-color:#e7b4b0;background:#fff}@media(hover:none){.tile .remove{opacity:1}}.tile .clockface{width:46px;height:46px;margin:auto}.tile.wide .clockface{margin:0;flex:none}.cell{min-height:0;border:1px dashed #cfd4da;border-radius:12px;background:transparent;color:#a3abb5;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:1px;font-weight:500;cursor:pointer;padding:6px}.cell span{font-size:18px;line-height:1}.cell small{font-size:10.5px;color:inherit}.cell:hover,.cell:focus-visible{border-color:#9eb6cf;color:#4b5563;background:#f6f7f9}.cell.insert-here{border:2px dashed var(--accent);color:var(--accent);background:var(--accent-soft)}.drag-ghost{position:fixed;left:0;top:0;pointer-events:none;z-index:50;opacity:.92;box-shadow:var(--shadow);border-radius:12px}.library{border-left:1px solid var(--line);background:var(--surface);display:flex;flex-direction:column;min-height:0}.lib-head{padding:14px 14px 10px;display:grid;gap:10px;border-bottom:1px solid var(--line)}.lib-title{display:flex;align-items:baseline;justify-content:space-between;font-weight:600;gap:8px}.lib-title small{color:var(--muted);font-weight:400;font-size:11.5px}.filters{display:flex;gap:6px;overflow-x:auto;scrollbar-width:none;padding-bottom:2px}.filters::-webkit-scrollbar{display:none}.filters button{flex:none;display:inline-flex;align-items:center;gap:5px;padding:4px 10px;border-radius:999px;font-size:11.5px;font-weight:500;color:var(--ink-2);background:var(--surface-2);border:1px solid var(--line)}.filters button[aria-pressed=true]{background:var(--ink);color:var(--surface);border-color:var(--ink)}.filters .domain-icon{width:16px;height:16px;font-size:11px;border-radius:50%;display:grid;place-items:center}.lib-list{overflow:auto;padding:8px;display:grid;gap:2px;align-content:start;flex:1}.ent{display:flex;align-items:center;gap:10px;padding:8px;border-radius:9px;width:100%;text-align:left;cursor:grab;touch-action:manipulation}.ent:hover:not(:disabled){background:var(--surface-2)}.ent:disabled{opacity:.55;cursor:default}.ent .av{width:30px;height:30px;border-radius:9px;display:grid;place-items:center;font-size:17px;flex:none}.ent .tx{display:grid;min-width:0;flex:1}.ent .tx b{font-weight:500;font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ent .tx small{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ent .add{width:24px;height:24px;border-radius:50%;display:grid;place-items:center;color:var(--accent);background:var(--accent-soft);font-size:15px;font-weight:600;flex:none}.ent .add.done{color:var(--good);background:var(--good-soft);font-size:12px}.lib-foot{padding:10px 14px;border-top:1px solid var(--line);font-size:11.5px;color:var(--muted)}.domain-icon{display:inline-grid;place-items:center;border-radius:50%;font-weight:500}.drawer{position:absolute;top:0;right:0;bottom:0;width:360px;max-width:100%;background:var(--surface);border-left:1px solid var(--line);box-shadow:var(--shadow);transform:translate(102%);transition:transform .18s cubic-bezier(.2,.7,.2,1);display:flex;flex-direction:column;min-height:0;z-index:5}.drawer.open{transform:none}.dr-head{display:flex;align-items:center;gap:10px;padding:12px 12px 12px 16px;border-bottom:1px solid var(--line)}.dr-head .av{width:34px;height:34px;border-radius:10px;display:grid;place-items:center;font-size:20px;flex:none}.dr-head .tx{flex:1;min-width:0;display:grid}.dr-head .tx b{font-weight:600;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dr-head .tx small{color:var(--muted);font-size:11.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dr-head .tx small.mono{font-family:var(--mono)}.dr-body{overflow:auto;padding:14px 16px 18px;display:grid;gap:16px;align-content:start;flex:1}.f{display:grid;gap:7px}.f>.f-label{font-size:11px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--muted)}.f input,.f select{font-size:13px}.row{display:flex;align-items:center;gap:10px;padding:7px 10px 7px 8px;border:1px solid var(--line-strong);border-radius:9px;background:var(--surface);width:100%;text-align:left}.row .av{width:30px;height:30px;border-radius:8px;background:var(--surface-2);display:grid;place-items:center;font-size:18px;flex:none}.row .tx{flex:1;min-width:0;display:grid}.row .tx b{font-weight:500}.row .tx small{font-size:11px}.row .link{color:var(--accent);font-weight:600;font-size:12px;flex:none}.sw{display:grid;grid-template-columns:repeat(4,1fr);gap:6px}.sw button{display:grid;justify-items:center;gap:4px;padding:6px 4px 5px;border-radius:9px;border:1.5px solid transparent;font-size:10.5px;color:var(--ink-2)}.sw button i{width:30px;height:22px;border-radius:6px;border:1px solid rgba(0,0,0,.08);display:block}.sw button i.auto{background:linear-gradient(135deg,#fff 50%,#e5e7eb 50%)}.sw button i.none{background:linear-gradient(135deg,transparent 46%,#8b95a1 46%,#8b95a1 54%,transparent 54%),#e7e7e7;border-style:dashed}.sw button[aria-pressed=true]{border-color:var(--accent);background:var(--accent-soft);color:var(--ink)}.picker{display:grid;gap:10px;padding:10px;border:1px solid var(--line);border-radius:10px;background:var(--surface-2)}.picker input{background:var(--surface)}.icon-list{display:grid;gap:12px;max-height:300px;overflow:auto;padding-right:2px}.icon-list small{margin-bottom:6px}.icon-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(40px,1fr));gap:6px}.icon-choice{display:grid;place-items:center;height:40px;background:var(--surface);color:var(--ink);border:1px solid var(--line);border-radius:9px}.icon-choice .mdi{font-size:22px}.icon-choice[aria-pressed=true]{background:var(--accent-soft);color:var(--accent);border-color:var(--accent)}.icon-choice.icon-auto{display:flex;justify-content:flex-start;gap:10px;padding:0 12px;font-size:12.5px;font-weight:500;height:38px}.action-list{display:grid;gap:6px;max-height:300px;overflow:auto;padding-right:2px}.action-choice{display:grid;gap:2px;padding:8px 12px;background:var(--surface);color:var(--ink);border:1px solid var(--line);border-radius:9px;font-size:12.5px;text-align:left}.action-choice strong{font-weight:600}.action-choice[aria-pressed=true]{background:var(--accent-soft);border-color:var(--accent)}.action-number{display:flex;align-items:center;gap:8px}.action-number input{flex:1;min-width:0}.items{display:grid;gap:6px}.item{display:flex;align-items:center;gap:8px;padding:7px 8px;border:1px solid var(--line-strong);border-radius:9px;background:var(--surface);width:100%;text-align:left;cursor:pointer;touch-action:pan-y}.item:hover{border-color:var(--accent)}.item.selected{border-color:var(--accent);background:var(--accent-soft)}.item.is-hidden{border-style:dashed}.item.is-hidden .av,.item.is-hidden b{opacity:.55}.item.is-overflow{border-style:dashed;border-color:var(--warn)}.item.is-overflow small{color:var(--warn)}.item.dragging-chip{border:2px dashed var(--accent);background:var(--accent-soft);cursor:grabbing}.item.just-added{animation:chip-added 1.2s ease-out}@keyframes chip-added{0%{background:var(--accent-soft)}to{background:var(--surface)}}.item .grip{color:var(--line-strong);font-size:13px;cursor:grab;letter-spacing:-2px;flex:none}.item .av{width:26px;height:26px;border-radius:7px;background:var(--surface-2);display:grid;place-items:center;font-size:15px;flex:none}.item .tx{flex:1;min-width:0;display:grid}.item .tx b{font-weight:500;font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.item .tx small{color:var(--muted);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.item .x{color:var(--muted);width:22px;height:22px;display:grid;place-items:center;border-radius:6px;flex:none;font-size:11px}.item .x:hover{color:var(--danger);background:var(--danger-soft)}.ghost-btn{border:1.5px dashed var(--line-strong);border-radius:9px;padding:8px;color:var(--accent);font-weight:600;width:100%;background:transparent}.ghost-btn:hover:not(:disabled){border-color:var(--accent);background:var(--accent-soft)}.dr-foot{margin-top:auto;display:flex;align-items:center;gap:8px;padding:10px 12px;border-top:1px solid var(--line);background:var(--surface-2);flex-wrap:wrap}.dr-foot .spacer{flex:1}.live{display:grid;gap:6px;padding:10px;border:1px solid var(--line);border-radius:10px;background:#e7e7e7;color:#1b1b1b;justify-items:start}.live small{color:#5a5f66}.live svg{display:block;height:28px}.option{display:flex;align-items:center;gap:10px;padding:8px 10px;border:1px solid var(--line);border-radius:9px;background:var(--surface);width:100%;text-align:left}.option:hover:not(:disabled){border-color:var(--accent)}.option .mdi{font-size:20px;width:26px;text-align:center;color:var(--ink-2);flex:none}.option .tx{display:grid;min-width:0}.option .tx strong{font-weight:500;font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.option .tx small{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.options{display:grid;gap:6px}.inspection-tile{display:grid;gap:3px;padding:8px 10px;border:1px solid var(--line);border-radius:9px;margin-bottom:6px}.inspection-tile strong{font-size:12.5px}.settings{padding:22px;overflow:auto;display:grid;gap:14px;align-content:start;grid-column:1 / -1}.settings .status{font-size:12px;color:var(--muted)}.set-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:14px}.set-card{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:6px 14px 8px}.set-card h4{display:flex;align-items:center;gap:8px;padding:10px 0 8px}.set-card h4 .mdi{color:var(--muted);font-size:16px}.set-card>.hint{margin:6px 0 4px}.srow{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 0;border-top:1px solid var(--line)}.srow>.s-label{color:var(--ink-2)}.srow.inactive .s-label,.srow.inactive .s-control{opacity:.45}.settings.offline .set-grid{opacity:.6}.step{display:inline-flex;align-items:center;gap:2px}.step button{width:26px;height:26px;border-radius:6px;color:var(--ink-2);background:var(--seg);display:grid;place-items:center}.step button .mdi{font-size:15px}.step button:hover:not(:disabled){color:var(--ink);background:var(--line-strong)}.step output{min-width:56px;text-align:center;font-weight:500}.panel{padding:22px 26px 40px;overflow:auto;display:grid;gap:18px;align-content:start;max-width:960px;width:100%}.panel.wide{max-width:1120px}.panel-head{display:flex;align-items:flex-start;gap:12px}.panel-head .tx{display:grid;gap:2px;flex:1;min-width:0}.panel-head h1{font-size:20px}.panel-head p{color:var(--muted);font-size:13px}.panel p,.panel li{color:var(--ink-2);font-size:13px}.card{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px 18px;display:grid;gap:12px;align-content:start}.card h2{font-size:14px}.card-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:14px}.field{display:grid;gap:6px}.field>.f-label,fieldset legend{font-size:11px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--muted)}fieldset{border:0;padding:0;margin:0;display:grid;gap:8px;min-width:0}fieldset:disabled{opacity:.6}.boards{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:8px}.board{display:flex;align-items:center;gap:10px;padding:10px 12px;border:1px solid var(--line-strong);border-radius:10px;cursor:pointer}.board:has(input:checked){border-color:var(--accent);background:var(--accent-soft)}.board input{width:auto;margin:0;accent-color:var(--accent)}.board b{display:block;font-weight:600}.actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.status-line{font-size:12.5px;color:var(--ink-2)}.status-line.ok{color:var(--good)}.status-line.error{color:var(--danger)}.progress-head{display:flex;align-items:center;gap:10px;font-weight:600;font-size:14px}.outcome{width:22px;height:22px;border-radius:50%;display:grid;place-items:center;color:#fff;font-size:12px;font-weight:700}.outcome.ok{background:var(--good)}.outcome.bad{background:var(--danger)}.steps{margin:0;padding-left:20px;display:grid;gap:8px}.steps li b{color:var(--ink)}.key-box{display:flex;align-items:center;gap:10px;padding:10px 12px;border:1px solid var(--line);border-radius:10px;background:var(--surface-2);flex-wrap:wrap}.key-box code{flex:1;min-width:0;background:none;padding:0;font-size:12px}.log{max-height:320px}details.log-wrap summary{cursor:pointer;font-weight:500;color:var(--ink-2);font-size:12.5px}details.log-wrap pre{margin-top:8px}.check{display:flex;align-items:flex-start;gap:10px;cursor:pointer}.check input{width:18px;height:18px;margin:1px 0 0;accent-color:var(--accent)}.check span{display:grid;gap:2px;font-weight:500}.node-line{display:flex;align-items:center;gap:6px;flex-wrap:wrap}.tools{display:flex;gap:10px;flex-wrap:wrap}.tool{display:flex;align-items:center;gap:10px;padding:10px 12px;border:1px solid var(--line);border-radius:10px;background:var(--surface);text-align:left;min-width:220px}.tool:hover{border-color:var(--accent)}.tool .tool-icon{width:30px;height:30px;border-radius:8px;background:var(--seg);display:grid;place-items:center;font-size:15px;color:var(--ink-2);flex:none}.tool .tx{display:grid}.tool .tx b{font-weight:600}.yaml-editor{display:grid;grid-template-columns:44px minmax(0,1fr);border:1px solid var(--line-strong);border-radius:10px;overflow:hidden;background:var(--code-bg);min-height:280px;max-height:60vh}.yaml-gutter{padding:12px 8px;text-align:right;color:var(--muted);font:12px/1.5 var(--mono);white-space:pre;user-select:none;overflow:hidden;border-right:1px solid var(--line)}.yaml-editor textarea{border:0;border-radius:0;background:transparent;resize:none;font:12px/1.5 var(--mono);padding:12px;white-space:pre;overflow:auto;min-height:280px;tab-size:2}.file-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.file-row code{font-size:12px}.notice{display:flex;gap:12px;padding:12px 14px;border:1px solid var(--line);border-radius:10px;background:var(--surface-2)}.notice .mdi{font-size:20px;color:var(--ink-2)}.notice strong{display:block;font-weight:600;margin-bottom:2px}.alerts section{display:grid;gap:10px}.alerts h2{font-size:15px}.alerts-nav{display:flex;flex-wrap:wrap;gap:6px}.alerts-nav button{background:var(--accent-soft);color:var(--accent);padding:5px 11px;font-size:12px;border-radius:20px;font-weight:500}.alert-hero{display:grid;grid-template-columns:minmax(0,300px) 1fr;gap:20px;align-items:center}.alert-mock{background:#101820;border-radius:16px;padding:22px 18px;display:grid;place-items:center}.alert-mock-card{position:relative;width:100%;background:#ffe1c6;border:1px solid #0001;border-radius:14px;padding:12px 12px 48px 52px;color:#1b1b1b;display:grid;gap:2px}.alert-mock-icon{position:absolute;left:12px;top:12px;font-size:30px}.alert-mock-card strong{font-size:15px;line-height:1.2}.alert-mock-card>span{font-size:12px;color:#3a3a3c}.alert-mock-card .fake{position:absolute;right:10px;bottom:10px;padding:6px 16px;border-radius:999px;background:#1b1b1b;color:#fff;font-size:13px}.alert-screen{border:1px solid var(--line);border-radius:10px;padding:10px 12px;display:grid;gap:8px;background:var(--surface)}.alert-screen-head{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap}.copy-line{display:flex;align-items:flex-start;gap:8px;flex-wrap:wrap}.copy-line .copy-label{flex:none;width:96px;font-size:12px;color:var(--muted);padding-top:2px}.copy-line>code{flex:1;min-width:0;padding:4px 8px}.copy-line pre{flex:1;min-width:0}.table-scroll{overflow-x:auto}table{width:100%;border-collapse:collapse;font-size:12.5px;min-width:560px}th,td{text-align:left;vertical-align:top;padding:8px 12px 8px 0;border-bottom:1px solid var(--line)}th{font-size:10.5px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);font-weight:600}td code{white-space:nowrap}.chips{display:flex;flex-wrap:wrap;gap:6px}.chips .chip .mdi{font-size:16px}.chips .chip code{background:none;padding:0}.alert-icon-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(100px,1fr));gap:8px}.alert-icon{display:grid;justify-items:center;align-content:start;gap:4px;min-height:86px;padding:10px 6px 8px;background:var(--surface);color:var(--ink);border:1px solid var(--line);border-radius:10px;font-weight:500}.alert-icon:hover{border-color:var(--accent)}.alert-icon .mdi{font-size:26px}.alert-icon code{background:none;padding:0;font-size:11px;color:var(--accent);text-align:center;white-space:normal}.alert-icon small{font-size:11px;text-align:center}.swatches{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:8px}.swatch{display:flex;align-items:center;gap:10px;padding:7px;border:1px solid var(--line);border-radius:10px;background:var(--surface);color:var(--ink);font-size:12px;text-align:left;font-weight:500}.swatch:hover{border-color:var(--accent)}.swatch i{width:32px;height:32px;border-radius:8px;border:1px solid #0001;flex:none;display:block}.swatch span{display:grid;gap:2px;min-width:0}.swatch code{background:none;padding:0}.alerts-list{padding-left:18px;margin:0;display:grid;gap:8px}.alerts-list b{color:var(--ink)}.subhead{font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);margin-top:6px}.empty{margin:auto;max-width:420px;text-align:center;display:grid;gap:10px;justify-items:center;padding:60px 20px}.empty-icon{width:48px;height:48px;border-radius:14px;background:var(--seg);color:var(--muted);display:grid;place-items:center;font-size:26px}.empty h2{font-size:18px}.empty p{color:var(--muted)}.toast{position:fixed;left:50%;bottom:20px;transform:translate(-50%);display:flex;align-items:center;gap:14px;padding:10px 16px;background:var(--ink);color:var(--surface);border-radius:10px;box-shadow:var(--shadow);font-size:13px;z-index:60;max-width:min(92vw,560px)}.toast button{color:#9fc4ff;font-weight:650}@media(max-width:960px){.app{grid-template-columns:1fr;min-height:100vh}.side{position:static;height:auto;flex-direction:row;flex-wrap:wrap;align-items:center;border-right:0;border-bottom:1px solid var(--line);padding:10px;gap:4px 6px}.side .label,.side .spacer,.conn{display:none}.side #screens,.screen-item{display:contents}.screen-update{display:none}.side #pending{flex-basis:100%}.nav-item{width:auto}.more{display:contents}.main-head{flex-wrap:wrap;padding:10px 14px;gap:8px;position:static}.head-right{margin-left:0;width:100%}.body{grid-template-columns:1fr;overflow:visible}.canvas{padding:16px;overflow:visible}.pages{overflow-x:auto;padding-bottom:8px}.library{border-left:0;border-top:1px solid var(--line)}.lib-list{flex:none;max-height:360px}.drawer{position:fixed;top:0;width:100%}.panel{padding:16px}.alert-hero{grid-template-columns:1fr}}.progress{position:relative;height:6px;border-radius:3px;background:var(--seg);overflow:hidden}.progress i{position:absolute;inset:0 auto 0 0;background:var(--accent);border-radius:3px;transition:width .4s}.progress-text{display:flex;justify-content:space-between;gap:8px;font-size:11px;color:var(--muted)}.progress-text span:last-child{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;text-align:right}.whatsnew summary{cursor:pointer;font-size:11.5px;color:var(--accent);font-weight:500;list-style:none}.whatsnew summary::-webkit-details-marker{display:none}.whatsnew ul{margin:6px 0 0;padding-left:14px;display:grid;gap:4px;font-size:11px;color:var(--ink-2)}.whatsnew ul li{line-height:1.35}.log-lines{max-height:160px;font-size:11px}.palette-backdrop{position:fixed;inset:0;background:#0a0c1247;z-index:70;display:grid;align-items:start;justify-items:center;padding:12vh 16px 16px}.palette{width:min(640px,100%);background:var(--surface);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow);overflow:hidden;display:flex;flex-direction:column;max-height:70vh}.palette input{border:0;border-radius:0;border-bottom:1px solid var(--line);padding:14px 16px;font-size:15px;background:transparent}.palette-list{overflow:auto;padding:6px;display:grid;gap:2px}.palette-group{font-size:10.5px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);padding:8px 10px 4px}.palette-item{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:8px;width:100%;text-align:left;color:var(--ink)}.palette-item .mdi,.palette-item .glyph{width:22px;text-align:center;color:var(--muted);font-size:16px;flex:none}.palette-item .tx{flex:1;min-width:0;display:grid}.palette-item .tx small{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.palette-item .hint-key{font-size:10.5px;color:var(--muted);border:1px solid var(--line);border-radius:4px;padding:1px 5px}.palette-item.active,.palette-item:hover{background:var(--accent-soft)}.palette-empty{padding:18px;color:var(--muted);font-size:12.5px;text-align:center}.side .search-btn{display:flex;align-items:center;gap:8px;width:100%;margin:4px 0 6px;padding:7px 10px;border:1px solid var(--line);border-radius:8px;background:var(--surface-2);color:var(--muted);font-size:12px;text-align:left}.side .search-btn kbd{margin-left:auto;font:10.5px var(--ui-font);border:1px solid var(--line);border-radius:4px;padding:1px 5px;background:var(--surface)}.side .search-btn:hover{color:var(--ink);border-color:var(--line-strong)}.menu .sub{display:grid;padding:2px 0 4px 10px}.menu .sub button{font-size:12.5px;padding:6px 10px}.menu .sep{height:1px;background:var(--line);margin:4px 6px}.lib-row{display:flex;gap:6px;align-items:center}.lib-row select{padding:5px 8px;font-size:12px;flex:1;min-width:0}.lib-row .chip-toggle{flex:none;padding:5px 10px;border-radius:999px;font-size:11.5px;font-weight:500;color:var(--ink-2);background:var(--surface-2);border:1px solid var(--line)}.lib-row .chip-toggle[aria-pressed=true]{background:var(--ink);color:var(--surface);border-color:var(--ink)}.ent .av.on{background:#fff3d6!important;color:#b7791f!important}.ent .av.gone{background:var(--seg)!important;color:var(--muted)!important}.try-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px}.try-grid .field.wide{grid-column:1 / -1}.tile.full{grid-column:span 2;grid-row:span 3;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:6px}.tile.full .ic{font-size:34px}.tile.full .lead{flex:0 0 auto;align-content:center;justify-items:center}.tile.full .nm{font-size:14px}.tile.full .st{font-size:11px}.tile.full .ctl{margin-top:6px}.tile.full .mini-slider{width:60%}.tile .goto{font-size:10.5px;color:#5a5f66}.device.cyd .tile.full .ic{font-size:26px}
+:root{color-scheme:light dark;--ground: #f5f6f8;--surface: #ffffff;--surface-2: #f9fafb;--line: #e4e7ec;--line-strong: #d3d8e0;--ink: #16181d;--ink-2: #565d6b;--muted: #7a8290;--accent: #2f63f0;--accent-ink: #ffffff;--accent-soft: #edf2ff;--good: #1e9a5f;--good-soft: #e6f6ee;--warn: #b56d00;--warn-soft: #fff3df;--danger: #c6372e;--danger-soft: #fdecea;--seg: #eef0f3;--shadow: 0 12px 40px rgba(20, 24, 40, .12), 0 1px 2px rgba(20, 24, 40, .06);--code-bg: #f1f3f6;--ui-font: -apple-system, BlinkMacSystemFont, system-ui, "SF Pro Text", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace}@media(prefers-color-scheme:dark){:root{--ground: #0e1014;--surface: #161a21;--surface-2: #1b2029;--line: #262c37;--line-strong: #333b49;--ink: #e9ecf1;--ink-2: #b4bbc7;--muted: #8a93a3;--accent: #6d93ff;--accent-ink: #0b1633;--accent-soft: #1b2540;--good: #3fbf7f;--good-soft: #14302a;--warn: #e0a03a;--warn-soft: #302610;--danger: #f07167;--danger-soft: #3a1b19;--seg: #222834;--shadow: 0 16px 48px rgba(0, 0, 0, .55), 0 1px 2px rgba(0, 0, 0, .4);--code-bg: #12151b}}@font-face{font-family:Tile Icons;src:url(./tile-icons-Dqh6fj0Q.woff) format("woff");font-display:block}@font-face{font-family:Bar Roboto;font-weight:400;src:url(./bar-roboto-400-DWGTva7D.woff) format("woff");font-display:block}@font-face{font-family:Bar Roboto;font-weight:500;src:url(./bar-roboto-500-B8TsR_Fs.woff) format("woff");font-display:block}*{box-sizing:border-box}html,body,#app{height:100%}body{margin:0;background:var(--ground);color:var(--ink);font:13px/1.45 var(--ui-font);-webkit-font-smoothing:antialiased;font-variant-numeric:tabular-nums}button,input,select,textarea{font:inherit;color:inherit}button{background:none;border:0;padding:0;cursor:pointer;border-radius:8px}button:disabled{opacity:.45;cursor:default}button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible,[tabindex]:focus-visible,summary:focus-visible{outline:2px solid var(--accent);outline-offset:2px}input,select,textarea{background:var(--surface);border:1px solid var(--line-strong);border-radius:8px;padding:7px 10px;width:100%;color:var(--ink)}input::placeholder,textarea::placeholder{color:var(--muted)}select{appearance:auto}h1,h2,h3,h4{margin:0;font-weight:600;letter-spacing:-.01em}h1{font-size:20px;letter-spacing:-.02em}h2{font-size:15px}h3,h4{font-size:13px}p{margin:0}small,.hint{display:block;color:var(--muted);font-size:11.5px;font-weight:400;line-height:1.4}.hint.warn,small.warn{color:var(--warn)}code{font-family:var(--mono);font-size:.92em;background:var(--code-bg);padding:1px 6px;border-radius:5px;overflow-wrap:anywhere}pre{margin:0;padding:12px 14px;background:var(--code-bg);border:1px solid var(--line);border-radius:10px;overflow:auto;font:12px/1.5 var(--mono);color:var(--ink);white-space:pre-wrap;overflow-wrap:anywhere}a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}.mdi{font-family:Tile Icons;font-style:normal;font-weight:400;line-height:1}[hidden]{display:none!important}.app{display:grid;grid-template-columns:232px minmax(0,1fr);height:100%;min-height:100vh}.app.dragging{user-select:none;-webkit-user-select:none;cursor:grabbing}.main{display:flex;flex-direction:column;min-width:0;min-height:0}.side{background:var(--surface);border-right:1px solid var(--line);display:flex;flex-direction:column;padding:14px 10px;gap:4px;min-height:0;overflow:auto;position:sticky;top:0;height:100vh}.brand{display:flex;align-items:center;gap:9px;padding:4px 8px 6px;font-weight:600;font-size:13px}.brand .mark{width:22px;height:22px;border-radius:6px;background:var(--ink);color:var(--surface);display:grid;place-items:center;font-size:14px;line-height:1}.conn{display:block;padding:0 8px 8px;font-size:11px;color:var(--muted)}.conn:before{content:"●";margin-right:6px;color:var(--warn)}.conn.online:before{color:var(--good)}.side .label{font-size:10.5px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);padding:10px 8px 4px}.nav-item{display:flex;align-items:center;gap:9px;width:100%;padding:7px 8px;border-radius:8px;text-align:left;color:var(--ink-2)}.nav-item:hover{background:var(--surface-2);color:var(--ink)}.nav-item[aria-current=true]{background:var(--seg);color:var(--ink);font-weight:500}.nav-item .dot{width:7px;height:7px;border-radius:50%;background:var(--good);flex:none}.nav-item .dot.off{background:var(--line-strong)}.nav-item .unsaved{width:7px;height:7px;border-radius:50%;background:var(--warn);flex:none}.nav-item .txt{display:grid;min-width:0;flex:1}.nav-item .txt small{font-size:11px;color:var(--muted);font-weight:400;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.nav-item .mdi{font-size:16px;width:18px;text-align:center;color:var(--muted)}.nav-item .plus{width:18px;text-align:center;font-size:14px}.nav-item.ghost{color:var(--muted)}.nav-item.ghost:hover{color:var(--accent);background:var(--accent-soft)}.pill{font-size:10.5px;font-weight:600;padding:2px 7px;border-radius:999px;background:var(--warn-soft);color:var(--warn);flex:none}.side .spacer{flex:1}.screen-item{display:grid;gap:4px}.screen-update{display:grid;gap:6px;padding:2px 8px 8px 24px}.screen-update.available{grid-template-columns:1fr auto;align-items:center}.screen-update.available:has(.screen-host){grid-template-columns:1fr}.screen-update small.failed{color:var(--danger)}.screen-host{display:flex;gap:6px;align-items:center}.screen-host input{padding:5px 8px;font-size:12px}.pending{margin:4px 0;padding:10px;border:1px dashed var(--line-strong);border-radius:10px;display:grid;gap:6px}.pending strong{font-size:12.5px}.pending-actions{display:flex;flex-wrap:wrap;gap:6px}.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:6px 12px;border-radius:8px;font-weight:600;font-size:12.5px;white-space:nowrap}.btn.primary{background:var(--accent);color:var(--accent-ink)}.btn.primary:hover:not(:disabled){filter:brightness(1.06)}.btn.quiet{color:var(--ink-2);border:1px solid var(--line-strong);background:var(--surface)}.btn.quiet:hover:not(:disabled){background:var(--surface-2);color:var(--ink)}.btn.danger{color:var(--danger)}.btn.danger:hover:not(:disabled){background:var(--danger-soft)}.btn.mini{padding:4px 9px;font-size:11.5px}.btn.link{color:var(--accent);padding:0;font-weight:600}.icon-btn{width:30px;height:30px;display:grid;place-items:center;border-radius:8px;color:var(--ink-2);font-size:15px;flex:none}.icon-btn:hover{background:var(--seg);color:var(--ink)}.chip{display:inline-flex;align-items:center;gap:6px;padding:3px 9px;border-radius:999px;font-size:11.5px;font-weight:500;color:var(--ink-2);background:var(--surface-2);border:1px solid var(--line);white-space:nowrap}.chip .dot{width:6px;height:6px;border-radius:50%;background:var(--good);flex:none}.chip.off .dot{background:var(--line-strong)}.chip.dirty{color:var(--warn);background:var(--warn-soft);border-color:transparent}.chip.sent,.chip.good{color:var(--good);background:var(--good-soft);border-color:transparent}.chip.update{color:var(--warn);background:var(--warn-soft);border-color:transparent}.seg{display:inline-flex;padding:2px;background:var(--seg);border-radius:8px;gap:2px}.seg button{padding:4px 11px;border-radius:6px;font-size:12px;font-weight:500;color:var(--ink-2);white-space:nowrap}.seg button[aria-pressed=true]{background:var(--surface);color:var(--ink);box-shadow:0 1px 2px #14182824}.seg.wrap{flex-wrap:wrap}.seg button:hover:not(:disabled):not([aria-pressed=true]){color:var(--ink)}.spin{display:inline-block;width:14px;height:14px;border:2px solid var(--line-strong);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite;flex:none}.spin.small{width:11px;height:11px;border-width:1.5px}@keyframes spin{to{transform:rotate(360deg)}}.switch{width:34px;height:20px;border-radius:10px;background:var(--line-strong);position:relative;flex:none;padding:0}.switch:after{content:"";position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:#fff;box-shadow:0 1px 2px #0003;transition:left .12s}.switch[aria-checked=true]{background:var(--accent)}.switch[aria-checked=true]:after{left:16px}.switch.unknown:after{left:9px;opacity:.6}@media(prefers-reduced-motion:reduce){.switch:after,.drawer,.spin{transition:none;animation:none}}.main-head{display:flex;align-items:center;gap:12px;padding:0 18px;min-height:52px;border-bottom:1px solid var(--line);background:var(--surface);position:sticky;top:0;z-index:4}.main-head h1{font-size:15px}.main-head .eyebrow{font-size:10.5px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--muted)}.head-right{margin-left:auto;display:flex;align-items:center;gap:8px;position:relative}.menu{position:absolute;top:38px;right:0;min-width:210px;background:var(--surface);border:1px solid var(--line);border-radius:10px;box-shadow:var(--shadow);padding:6px;display:grid;z-index:6}.menu button{text-align:left;padding:7px 10px;border-radius:7px;color:var(--ink);display:flex;justify-content:space-between;gap:12px;width:100%}.menu button small{color:var(--muted);display:inline}.menu button:hover:not(:disabled){background:var(--surface-2)}.body{position:relative;display:grid;grid-template-columns:minmax(0,1fr) 300px;flex:1;min-height:0;overflow:hidden}.canvas{overflow:auto;padding:22px 22px 26px;min-width:0}.canvas-head{display:flex;align-items:baseline;gap:12px;margin-bottom:14px;color:var(--muted);font-size:12px;flex-wrap:wrap}.canvas-head b{color:var(--ink);font-weight:600;font-size:13px}.canvas-head .warn{color:var(--warn)}.pages{display:flex;gap:18px;align-items:flex-start}.page{flex:0 0 292px;display:grid;gap:8px}.page-label{display:flex;justify-content:space-between;align-items:center;font-size:11.5px;color:var(--muted);padding:0 4px;min-height:22px}.page-label .btn.mini{color:var(--muted)}.page-label .btn.mini:hover{color:var(--danger);background:var(--danger-soft)}.page.ghost .device{display:grid;place-items:center;border:1.5px dashed var(--line-strong);background:transparent;color:var(--muted);font-weight:500;cursor:pointer}.page.ghost .device:hover,.page.ghost .device:focus-visible{color:var(--accent);border-color:var(--accent)}.page.ghost .device.drop{color:var(--accent);border-color:var(--accent);background:var(--accent-soft)}.page.ghost.disabled .device{opacity:.5;cursor:default}.device{background:#e7e7e7;border-radius:18px;padding:10px;display:grid;grid-template-rows:auto minmax(0,1fr);gap:8px;color:#1b1b1b;aspect-ratio:1 / 1}.device.cyd{aspect-ratio:4 / 3}.bar-wrap{cursor:pointer;border-radius:8px;padding:0 2px}.bar-wrap:hover{background:#dedede}.bar-wrap.selected{outline:2px solid var(--accent);outline-offset:1px}.bar-wrap svg{display:block;width:100%;height:auto}.tiles{display:grid;grid-template-columns:1fr 1fr;grid-auto-rows:minmax(0,1fr);gap:8px;min-height:0}.tile{position:relative;background:#fff;border:1px solid #dcdcdc;border-radius:12px;padding:8px 9px;display:flex;flex-direction:column;justify-content:space-between;gap:4px;min-height:0;cursor:pointer;text-align:left;overflow:hidden;color:#1b1b1b;touch-action:manipulation}.tile:hover{border-color:#b9c4d3}.tile.chosen{outline:2px solid var(--accent);outline-offset:1px}.tile.wide{grid-column:span 2;flex-direction:row;align-items:center;gap:8px}.tile.bare{background:transparent;border-style:dashed;border-color:#c3c9d0}.tile.placeholder{opacity:.55;border:2px dashed var(--accent);background:var(--accent-soft);cursor:default}.tile .ic{font-size:18px;line-height:1;color:#1b1b1b;flex:none}.tile .lead{display:grid;gap:2px;min-width:0;flex:1;align-content:end}.tile.wide .lead{display:flex;align-items:center;gap:8px;align-content:initial}.tile .tx{display:grid;min-width:0;gap:1px}.tile .head{display:flex;align-items:center;gap:8px;min-width:0;flex:1}.tile .head.top{flex:none}.tile .nm{font-size:11.5px;font-weight:600;line-height:1.2;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.tile .st{font-size:10.5px;color:#5a5f66;line-height:1.2;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.device.cyd .tile{padding:6px 7px}.device.cyd .tile .nm{font-size:10.5px}.device.cyd .tile .ic{font-size:15px}.device.cyd .tile .head{gap:6px}.device.cyd .tile:has(>.mini-slider){padding-block:4px;gap:3px}.device.cyd .tile .mini-slider{height:8px}.tile .mini-slider{height:10px;border-radius:5px;background:linear-gradient(to right,#ffbf38 60%,#fff1d3 60%);flex:none;width:100%}.tile.wide .mini-slider{width:70px}.tile .ctl{flex:none;display:flex;align-items:center;gap:4px;color:#1b1b1b}.tile .tog{width:30px;height:17px;border-radius:9px;background:#2196f3;position:relative}.tile .tog:after{content:"";position:absolute;top:2px;right:2px;width:13px;height:13px;border-radius:50%;background:#fff}.tile .tog.off{background:#c9ccd1}.tile .tog.off:after{right:auto;left:2px}.tile .ic.lit{color:#d9930f}.tile .big{font-size:19px;font-weight:600;letter-spacing:-.02em;line-height:1.05;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.tile .big small{display:inline;font-size:10.5px;font-weight:500;color:#5a5f66;margin-left:2px}.device.cyd .tile .big{font-size:16px}.tile .range{width:56px;height:14px;border-radius:7px;background:linear-gradient(to right,#2196f3 35%,#d3e8fb 35%)}.tile .key{height:20px;min-width:22px;padding:0 5px;border-radius:6px;background:#eceef1;display:grid;place-items:center;font-size:12px}.tile .stp{display:flex;align-items:center;gap:5px;height:20px;padding:0 7px;border-radius:10px;background:#eceef1;font-size:11px;font-weight:600}.tile .stp .mdi{font-size:12px}.tile .run{height:20px;padding:0 9px;border-radius:10px;background:#eceef1;font-size:11px;font-weight:600;line-height:20px}.tile .remove{position:absolute;top:5px;right:5px;width:22px;height:22px;border-radius:50%;background:#ffffffd9;color:#5a5f66;border:1px solid #dcdcdc;font-size:10px;line-height:1;display:grid;place-items:center;opacity:0;transition:opacity .12s}.tile:hover .remove,.tile:focus-within .remove{opacity:1}.tile .remove:hover{color:var(--danger);border-color:#e7b4b0;background:#fff}@media(hover:none){.tile .remove{opacity:1}}.tile .clockface{width:46px;height:46px;margin:auto}.tile.wide .clockface{margin:0;flex:none}.cell{min-height:0;border:1px dashed #cfd4da;border-radius:12px;background:transparent;color:#a3abb5;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:1px;font-weight:500;cursor:pointer;padding:6px}.cell span{font-size:18px;line-height:1}.cell small{font-size:10.5px;color:inherit}.cell:hover,.cell:focus-visible{border-color:#9eb6cf;color:#4b5563;background:#f6f7f9}.cell.insert-here{border:2px dashed var(--accent);color:var(--accent);background:var(--accent-soft)}.drag-ghost{position:fixed;left:0;top:0;pointer-events:none;z-index:50;opacity:.92;box-shadow:var(--shadow);border-radius:12px}.library{border-left:1px solid var(--line);background:var(--surface);display:flex;flex-direction:column;min-height:0}.lib-head{padding:14px 14px 10px;display:grid;gap:10px;border-bottom:1px solid var(--line)}.lib-title{display:flex;align-items:baseline;justify-content:space-between;font-weight:600;gap:8px}.lib-title small{color:var(--muted);font-weight:400;font-size:11.5px}.filters{display:flex;gap:6px;overflow-x:auto;scrollbar-width:none;padding-bottom:2px}.filters::-webkit-scrollbar{display:none}.filters button{flex:none;display:inline-flex;align-items:center;gap:5px;padding:4px 10px;border-radius:999px;font-size:11.5px;font-weight:500;color:var(--ink-2);background:var(--surface-2);border:1px solid var(--line)}.filters button[aria-pressed=true]{background:var(--ink);color:var(--surface);border-color:var(--ink)}.filters .domain-icon{width:16px;height:16px;font-size:11px;border-radius:50%;display:grid;place-items:center}.lib-list{overflow:auto;padding:8px;display:grid;gap:2px;align-content:start;flex:1}.ent{display:flex;align-items:center;gap:10px;padding:8px;border-radius:9px;width:100%;text-align:left;cursor:grab;touch-action:manipulation}.ent:hover:not(:disabled){background:var(--surface-2)}.ent:disabled{opacity:.55;cursor:default}.ent .av{width:30px;height:30px;border-radius:9px;display:grid;place-items:center;font-size:17px;flex:none}.ent .tx{display:grid;min-width:0;flex:1}.ent .tx b{font-weight:500;font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ent .tx small{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ent .add{width:24px;height:24px;border-radius:50%;display:grid;place-items:center;color:var(--accent);background:var(--accent-soft);font-size:15px;font-weight:600;flex:none}.ent .add.done{color:var(--good);background:var(--good-soft);font-size:12px}.lib-foot{padding:10px 14px;border-top:1px solid var(--line);font-size:11.5px;color:var(--muted)}.domain-icon{display:inline-grid;place-items:center;border-radius:50%;font-weight:500}.drawer{position:absolute;top:0;right:0;bottom:0;width:360px;max-width:100%;background:var(--surface);border-left:1px solid var(--line);box-shadow:var(--shadow);transform:translate(102%);transition:transform .18s cubic-bezier(.2,.7,.2,1);display:flex;flex-direction:column;min-height:0;z-index:5}.drawer.open{transform:none}.dr-head{display:flex;align-items:center;gap:10px;padding:12px 12px 12px 16px;border-bottom:1px solid var(--line)}.dr-head .av{width:34px;height:34px;border-radius:10px;display:grid;place-items:center;font-size:20px;flex:none}.dr-head .tx{flex:1;min-width:0;display:grid}.dr-head .tx b{font-weight:600;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dr-head .tx small{color:var(--muted);font-size:11.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dr-head .tx small.mono{font-family:var(--mono)}.dr-body{overflow:auto;padding:14px 16px 18px;display:grid;gap:16px;align-content:start;flex:1}.f{display:grid;gap:7px}.f>.f-label{font-size:11px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--muted)}.f input,.f select{font-size:13px}.row{display:flex;align-items:center;gap:10px;padding:7px 10px 7px 8px;border:1px solid var(--line-strong);border-radius:9px;background:var(--surface);width:100%;text-align:left}.row .av{width:30px;height:30px;border-radius:8px;background:var(--surface-2);display:grid;place-items:center;font-size:18px;flex:none}.row .tx{flex:1;min-width:0;display:grid}.row .tx b{font-weight:500}.row .tx small{font-size:11px}.row .link{color:var(--accent);font-weight:600;font-size:12px;flex:none}.sw{display:grid;grid-template-columns:repeat(4,1fr);gap:6px}.sw button{display:grid;justify-items:center;gap:4px;padding:6px 4px 5px;border-radius:9px;border:1.5px solid transparent;font-size:10.5px;color:var(--ink-2)}.sw button i{width:30px;height:22px;border-radius:6px;border:1px solid rgba(0,0,0,.08);display:block}.sw button i.auto{background:linear-gradient(135deg,#fff 50%,#e5e7eb 50%)}.sw button i.none{background:linear-gradient(135deg,transparent 46%,#8b95a1 46%,#8b95a1 54%,transparent 54%),#e7e7e7;border-style:dashed}.sw button[aria-pressed=true]{border-color:var(--accent);background:var(--accent-soft);color:var(--ink)}.picker{display:grid;gap:10px;padding:10px;border:1px solid var(--line);border-radius:10px;background:var(--surface-2)}.picker input{background:var(--surface)}.icon-list{display:grid;gap:12px;max-height:300px;overflow:auto;padding-right:2px}.icon-list small{margin-bottom:6px}.icon-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(40px,1fr));gap:6px}.icon-choice{display:grid;place-items:center;height:40px;background:var(--surface);color:var(--ink);border:1px solid var(--line);border-radius:9px}.icon-choice .mdi{font-size:22px}.icon-choice[aria-pressed=true]{background:var(--accent-soft);color:var(--accent);border-color:var(--accent)}.icon-choice.icon-auto{display:flex;justify-content:flex-start;gap:10px;padding:0 12px;font-size:12.5px;font-weight:500;height:38px}.action-list{display:grid;gap:6px;max-height:300px;overflow:auto;padding-right:2px}.action-choice{display:grid;gap:2px;padding:8px 12px;background:var(--surface);color:var(--ink);border:1px solid var(--line);border-radius:9px;font-size:12.5px;text-align:left}.action-choice strong{font-weight:600}.action-choice[aria-pressed=true]{background:var(--accent-soft);border-color:var(--accent)}.action-number{display:flex;align-items:center;gap:8px}.action-number input{flex:1;min-width:0}.items{display:grid;gap:6px}.item{display:flex;align-items:center;gap:8px;padding:7px 8px;border:1px solid var(--line-strong);border-radius:9px;background:var(--surface);width:100%;text-align:left;cursor:pointer;touch-action:pan-y}.item:hover{border-color:var(--accent)}.item.selected{border-color:var(--accent);background:var(--accent-soft)}.item.is-hidden{border-style:dashed}.item.is-hidden .av,.item.is-hidden b{opacity:.55}.item.is-overflow{border-style:dashed;border-color:var(--warn)}.item.is-overflow small{color:var(--warn)}.item.dragging-chip{border:2px dashed var(--accent);background:var(--accent-soft);cursor:grabbing}.item.just-added{animation:chip-added 1.2s ease-out}@keyframes chip-added{0%{background:var(--accent-soft)}to{background:var(--surface)}}.item .grip{color:var(--line-strong);font-size:13px;cursor:grab;letter-spacing:-2px;flex:none}.item .av{width:26px;height:26px;border-radius:7px;background:var(--surface-2);display:grid;place-items:center;font-size:15px;flex:none}.item .tx{flex:1;min-width:0;display:grid}.item .tx b{font-weight:500;font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.item .tx small{color:var(--muted);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.item .x{color:var(--muted);width:22px;height:22px;display:grid;place-items:center;border-radius:6px;flex:none;font-size:11px}.item .x:hover{color:var(--danger);background:var(--danger-soft)}.ghost-btn{border:1.5px dashed var(--line-strong);border-radius:9px;padding:8px;color:var(--accent);font-weight:600;width:100%;background:transparent}.ghost-btn:hover:not(:disabled){border-color:var(--accent);background:var(--accent-soft)}.dr-foot{margin-top:auto;display:flex;align-items:center;gap:8px;padding:10px 12px;border-top:1px solid var(--line);background:var(--surface-2);flex-wrap:wrap}.dr-foot .spacer{flex:1}.live{display:grid;gap:6px;padding:10px;border:1px solid var(--line);border-radius:10px;background:#e7e7e7;color:#1b1b1b;justify-items:start}.live small{color:#5a5f66}.live svg{display:block;height:28px}.option{display:flex;align-items:center;gap:10px;padding:8px 10px;border:1px solid var(--line);border-radius:9px;background:var(--surface);width:100%;text-align:left}.option:hover:not(:disabled){border-color:var(--accent)}.option .mdi{font-size:20px;width:26px;text-align:center;color:var(--ink-2);flex:none}.option .tx{display:grid;min-width:0}.option .tx strong{font-weight:500;font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.option .tx small{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.options{display:grid;gap:6px}.inspection-tile{display:grid;gap:3px;padding:8px 10px;border:1px solid var(--line);border-radius:9px;margin-bottom:6px}.inspection-tile strong{font-size:12.5px}.settings{padding:22px;overflow:auto;display:grid;gap:14px;align-content:start;grid-column:1 / -1}.settings .status{font-size:12px;color:var(--muted)}.set-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:14px}.set-card{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:6px 14px 8px}.set-card h4{display:flex;align-items:center;gap:8px;padding:10px 0 8px}.set-card h4 .mdi{color:var(--muted);font-size:16px}.set-card>.hint{margin:6px 0 4px}.srow{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 0;border-top:1px solid var(--line)}.srow>.s-label{color:var(--ink-2)}.srow.inactive .s-label,.srow.inactive .s-control{opacity:.45}.settings.offline .set-grid{opacity:.6}.step{display:inline-flex;align-items:center;gap:2px}.step button{width:26px;height:26px;border-radius:6px;color:var(--ink-2);background:var(--seg);display:grid;place-items:center}.step button .mdi{font-size:15px}.step button:hover:not(:disabled){color:var(--ink);background:var(--line-strong)}.step output{min-width:56px;text-align:center;font-weight:500}.panel{padding:22px 26px 40px;overflow:auto;display:grid;gap:18px;align-content:start;max-width:960px;width:100%}.panel.wide{max-width:1120px}.panel-head{display:flex;align-items:flex-start;gap:12px}.panel-head .tx{display:grid;gap:2px;flex:1;min-width:0}.panel-head h1{font-size:20px}.panel-head p{color:var(--muted);font-size:13px}.panel p,.panel li{color:var(--ink-2);font-size:13px}.card{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px 18px;display:grid;gap:12px;align-content:start}.card h2{font-size:14px}.card-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:14px}.field{display:grid;gap:6px}.field>.f-label,fieldset legend{font-size:11px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--muted)}fieldset{border:0;padding:0;margin:0;display:grid;gap:8px;min-width:0}fieldset:disabled{opacity:.6}.boards{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:8px}.board{display:flex;align-items:center;gap:10px;padding:10px 12px;border:1px solid var(--line-strong);border-radius:10px;cursor:pointer}.board:has(input:checked){border-color:var(--accent);background:var(--accent-soft)}.board input{width:auto;margin:0;accent-color:var(--accent)}.board b{display:block;font-weight:600}.actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.status-line{font-size:12.5px;color:var(--ink-2)}.status-line.ok{color:var(--good)}.status-line.error{color:var(--danger)}.progress-head{display:flex;align-items:center;gap:10px;font-weight:600;font-size:14px}.outcome{width:22px;height:22px;border-radius:50%;display:grid;place-items:center;color:#fff;font-size:12px;font-weight:700}.outcome.ok{background:var(--good)}.outcome.bad{background:var(--danger)}.steps{margin:0;padding-left:20px;display:grid;gap:8px}.steps li b{color:var(--ink)}.key-box{display:flex;align-items:center;gap:10px;padding:10px 12px;border:1px solid var(--line);border-radius:10px;background:var(--surface-2);flex-wrap:wrap}.key-box code{flex:1;min-width:0;background:none;padding:0;font-size:12px}.log{max-height:320px}details.log-wrap summary{cursor:pointer;font-weight:500;color:var(--ink-2);font-size:12.5px}details.log-wrap pre{margin-top:8px}.check{display:flex;align-items:flex-start;gap:10px;cursor:pointer}.check input{width:18px;height:18px;margin:1px 0 0;accent-color:var(--accent)}.check span{display:grid;gap:2px;font-weight:500}.node-line{display:flex;align-items:center;gap:6px;flex-wrap:wrap}.tools{display:flex;gap:10px;flex-wrap:wrap}.tool{display:flex;align-items:center;gap:10px;padding:10px 12px;border:1px solid var(--line);border-radius:10px;background:var(--surface);text-align:left;min-width:220px}.tool:hover{border-color:var(--accent)}.tool .tool-icon{width:30px;height:30px;border-radius:8px;background:var(--seg);display:grid;place-items:center;font-size:15px;color:var(--ink-2);flex:none}.tool .tx{display:grid}.tool .tx b{font-weight:600}.yaml-editor{display:grid;grid-template-columns:44px minmax(0,1fr);border:1px solid var(--line-strong);border-radius:10px;overflow:hidden;background:var(--code-bg);min-height:280px;max-height:60vh}.yaml-gutter{padding:12px 8px;text-align:right;color:var(--muted);font:12px/1.5 var(--mono);white-space:pre;user-select:none;overflow:hidden;border-right:1px solid var(--line)}.yaml-editor textarea{border:0;border-radius:0;background:transparent;resize:none;font:12px/1.5 var(--mono);padding:12px;white-space:pre;overflow:auto;min-height:280px;tab-size:2}.file-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.file-row code{font-size:12px}.notice{display:flex;gap:12px;padding:12px 14px;border:1px solid var(--line);border-radius:10px;background:var(--surface-2)}.notice .mdi{font-size:20px;color:var(--ink-2)}.notice strong{display:block;font-weight:600;margin-bottom:2px}.alerts section{display:grid;gap:10px}.alerts h2{font-size:15px}.alerts-nav{display:flex;flex-wrap:wrap;gap:6px}.alerts-nav button{background:var(--accent-soft);color:var(--accent);padding:5px 11px;font-size:12px;border-radius:20px;font-weight:500}.alert-hero{display:grid;grid-template-columns:minmax(0,300px) 1fr;gap:20px;align-items:center}.alert-mock{background:#101820;border-radius:16px;padding:22px 18px;display:grid;place-items:center}.alert-mock-card{position:relative;width:100%;background:#ffe1c6;border:1px solid #0001;border-radius:14px;padding:12px 12px 48px 52px;color:#1b1b1b;display:grid;gap:2px}.alert-mock-icon{position:absolute;left:12px;top:12px;font-size:30px}.alert-mock-card strong{font-size:15px;line-height:1.2}.alert-mock-card>span{font-size:12px;color:#3a3a3c}.alert-mock-card .fake{position:absolute;right:10px;bottom:10px;padding:6px 16px;border-radius:999px;background:#1b1b1b;color:#fff;font-size:13px}.alert-screen{border:1px solid var(--line);border-radius:10px;padding:10px 12px;display:grid;gap:8px;background:var(--surface)}.alert-screen-head{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap}.copy-line{display:flex;align-items:flex-start;gap:8px;flex-wrap:wrap}.copy-line .copy-label{flex:none;width:96px;font-size:12px;color:var(--muted);padding-top:2px}.copy-line>code{flex:1;min-width:0;padding:4px 8px}.copy-line pre{flex:1;min-width:0}.table-scroll{overflow-x:auto}table{width:100%;border-collapse:collapse;font-size:12.5px;min-width:560px}th,td{text-align:left;vertical-align:top;padding:8px 12px 8px 0;border-bottom:1px solid var(--line)}th{font-size:10.5px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);font-weight:600}td code{white-space:nowrap}.chips{display:flex;flex-wrap:wrap;gap:6px}.chips .chip .mdi{font-size:16px}.chips .chip code{background:none;padding:0}.alert-icon-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(100px,1fr));gap:8px}.alert-icon{display:grid;justify-items:center;align-content:start;gap:4px;min-height:86px;padding:10px 6px 8px;background:var(--surface);color:var(--ink);border:1px solid var(--line);border-radius:10px;font-weight:500}.alert-icon:hover{border-color:var(--accent)}.alert-icon .mdi{font-size:26px}.alert-icon code{background:none;padding:0;font-size:11px;color:var(--accent);text-align:center;white-space:normal}.alert-icon small{font-size:11px;text-align:center}.swatches{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:8px}.swatch{display:flex;align-items:center;gap:10px;padding:7px;border:1px solid var(--line);border-radius:10px;background:var(--surface);color:var(--ink);font-size:12px;text-align:left;font-weight:500}.swatch:hover{border-color:var(--accent)}.swatch i{width:32px;height:32px;border-radius:8px;border:1px solid #0001;flex:none;display:block}.swatch span{display:grid;gap:2px;min-width:0}.swatch code{background:none;padding:0}.alerts-list{padding-left:18px;margin:0;display:grid;gap:8px}.alerts-list b{color:var(--ink)}.subhead{font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);margin-top:6px}.empty{margin:auto;max-width:420px;text-align:center;display:grid;gap:10px;justify-items:center;padding:60px 20px}.empty-icon{width:48px;height:48px;border-radius:14px;background:var(--seg);color:var(--muted);display:grid;place-items:center;font-size:26px}.empty h2{font-size:18px}.empty p{color:var(--muted)}.toast{position:fixed;left:50%;bottom:20px;transform:translate(-50%);display:flex;align-items:center;gap:14px;padding:10px 16px;background:var(--ink);color:var(--surface);border-radius:10px;box-shadow:var(--shadow);font-size:13px;z-index:60;max-width:min(92vw,560px)}.toast button{color:#9fc4ff;font-weight:650}@media(max-width:960px){.app{grid-template-columns:1fr;min-height:100vh}.side{position:static;height:auto;flex-direction:row;flex-wrap:wrap;align-items:center;border-right:0;border-bottom:1px solid var(--line);padding:10px;gap:4px 6px}.side .label,.side .spacer,.conn{display:none}.side #screens,.screen-item{display:contents}.screen-update{display:none}.side #pending{flex-basis:100%}.nav-item{width:auto}.more{display:contents}.main-head{flex-wrap:wrap;padding:10px 14px;gap:8px;position:static}.head-right{margin-left:0;width:100%}.body{grid-template-columns:1fr;overflow:visible}.canvas{padding:16px;overflow:visible}.pages{overflow-x:auto;padding-bottom:8px}.library{border-left:0;border-top:1px solid var(--line)}.lib-list{flex:none;max-height:360px}.drawer{position:fixed;top:0;width:100%}.panel{padding:16px}.alert-hero{grid-template-columns:1fr}}.progress{position:relative;height:6px;border-radius:3px;background:var(--seg);overflow:hidden}.progress i{position:absolute;inset:0 auto 0 0;background:var(--accent);border-radius:3px;transition:width .4s}.progress-text{display:flex;justify-content:space-between;gap:8px;font-size:11px;color:var(--muted)}.progress-text span:last-child{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;text-align:right}.whatsnew summary{cursor:pointer;font-size:11.5px;color:var(--accent);font-weight:500;list-style:none}.whatsnew summary::-webkit-details-marker{display:none}.whatsnew ul{margin:6px 0 0;padding-left:14px;display:grid;gap:4px;font-size:11px;color:var(--ink-2)}.whatsnew ul li{line-height:1.35}.log-lines{max-height:160px;font-size:11px}.palette-backdrop{position:fixed;inset:0;background:#0a0c1247;z-index:70;display:grid;align-items:start;justify-items:center;padding:12vh 16px 16px}.palette{width:min(640px,100%);background:var(--surface);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow);overflow:hidden;display:flex;flex-direction:column;max-height:70vh}.palette input{border:0;border-radius:0;border-bottom:1px solid var(--line);padding:14px 16px;font-size:15px;background:transparent}.palette-list{overflow:auto;padding:6px;display:grid;gap:2px}.palette-group{font-size:10.5px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);padding:8px 10px 4px}.palette-item{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:8px;width:100%;text-align:left;color:var(--ink)}.palette-item .mdi,.palette-item .glyph{width:22px;text-align:center;color:var(--muted);font-size:16px;flex:none}.palette-item .tx{flex:1;min-width:0;display:grid}.palette-item .tx small{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.palette-item .hint-key{font-size:10.5px;color:var(--muted);border:1px solid var(--line);border-radius:4px;padding:1px 5px}.palette-item.active,.palette-item:hover{background:var(--accent-soft)}.palette-empty{padding:18px;color:var(--muted);font-size:12.5px;text-align:center}.side .search-btn{display:flex;align-items:center;gap:8px;width:100%;margin:4px 0 6px;padding:7px 10px;border:1px solid var(--line);border-radius:8px;background:var(--surface-2);color:var(--muted);font-size:12px;text-align:left}.side .search-btn kbd{margin-left:auto;font:10.5px var(--ui-font);border:1px solid var(--line);border-radius:4px;padding:1px 5px;background:var(--surface)}.side .search-btn:hover{color:var(--ink);border-color:var(--line-strong)}.menu .sub{display:grid;padding:2px 0 4px 10px}.menu .sub button{font-size:12.5px;padding:6px 10px}.menu .sep{height:1px;background:var(--line);margin:4px 6px}.lib-row{display:flex;gap:6px;align-items:center}.lib-row select{padding:5px 8px;font-size:12px;flex:1;min-width:0}.lib-row .chip-toggle{flex:none;padding:5px 10px;border-radius:999px;font-size:11.5px;font-weight:500;color:var(--ink-2);background:var(--surface-2);border:1px solid var(--line)}.lib-row .chip-toggle[aria-pressed=true]{background:var(--ink);color:var(--surface);border-color:var(--ink)}.ent .av.on{background:#fff3d6!important;color:#b7791f!important}.ent .av.gone{background:var(--seg)!important;color:var(--muted)!important}.try-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px}.try-grid .field.wide{grid-column:1 / -1}.tile.full{grid-column:span 2;grid-row:span 3;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:6px}.tile.full .ic{font-size:34px}.tile.full .lead{flex:0 0 auto;align-content:center;justify-items:center}.tile.full .nm{font-size:14px}.tile.full .st{font-size:11px}.tile.full .ctl{margin-top:6px}.tile.full .mini-slider{width:60%}.tile .goto{font-size:10.5px;color:#5a5f66}.device.cyd .tile.full .ic{font-size:26px}
diff --git a/screen_manager/app/static/assets/index-Cp13kZoB.js b/screen_manager/app/static/assets/index-CteHDGjl.js
similarity index 100%
rename from screen_manager/app/static/assets/index-Cp13kZoB.js
rename to screen_manager/app/static/assets/index-CteHDGjl.js
diff --git a/screen_manager/app/static/assets/tile-icons-BawP5y9D.woff b/screen_manager/app/static/assets/tile-icons-BawP5y9D.woff
deleted file mode 100644
index 7bdb239..0000000
Binary files a/screen_manager/app/static/assets/tile-icons-BawP5y9D.woff and /dev/null differ
diff --git a/screen_manager/app/static/assets/tile-icons-Dqh6fj0Q.woff b/screen_manager/app/static/assets/tile-icons-Dqh6fj0Q.woff
new file mode 100644
index 0000000..90daa2b
Binary files /dev/null and b/screen_manager/app/static/assets/tile-icons-Dqh6fj0Q.woff differ
diff --git a/screen_manager/app/static/index.html b/screen_manager/app/static/index.html
index 9009d1b..73665c8 100644
--- a/screen_manager/app/static/index.html
+++ b/screen_manager/app/static/index.html
@@ -5,8 +5,8 @@
ESP Screens
-
-
+
+
diff --git a/screen_manager/app/tile_icons.py b/screen_manager/app/tile_icons.py
index 02e1874..d6e12c1 100644
--- a/screen_manager/app/tile_icons.py
+++ b/screen_manager/app/tile_icons.py
@@ -338,6 +338,13 @@ HA_DEFAULTS = (
('speaker-pause', 'F1B73'),
('speaker-play', 'F1B72'),
('speedometer', 'F04C5'),
+ # A light's effects page (firmware 0.2.70+): Home Assistant's icons for an effect and WLED's palette, preset,
+ # playlist and intensity; the speedometer above serves its speed.
+ ('creation', 'F0674'),
+ ('palette-outline', 'F0E0C'),
+ ('playlist-play', 'F0411'),
+ ('play-speed', 'F08FF'),
+ ('brightness-6', 'F00DF'),
('spoon-sugar', 'F1429'),
('sprout-outline', 'F0E67'),
('square', 'F0764'),
diff --git a/screen_manager/config.yaml b/screen_manager/config.yaml
index d3da9e5..14fa741 100644
--- a/screen_manager/config.yaml
+++ b/screen_manager/config.yaml
@@ -1,5 +1,5 @@
name: ESP Screen Manager
-version: "0.2.82"
+version: "0.2.83"
slug: esp_screen_manager
description: Choose tiles for your CYD and Guition touchscreen, without reflashing.
url: https://github.com/MaxGramser/homeassistant_espscreen
diff --git a/tests/test_effects_page.cpp b/tests/test_effects_page.cpp
new file mode 100644
index 0000000..30cbb4c
--- /dev/null
+++ b/tests/test_effects_page.cpp
@@ -0,0 +1,73 @@
+// c++ -std=c++17 -Wall -Wextra -pedantic tests/test_effects_page.cpp -o /tmp/test_effects_page && /tmp/test_effects_page
+// The model of a light's effects page (firmware 0.2.70+): what it can show, how a number maps to its slider, the texts.
+#define EFFECTS_PAGE_TEST
+#define THEME_TEST
+#include "../components/smart_display/effects_page.h"
+#include "../components/smart_display/tile_controls.h"
+#include
+#include
+
+using namespace effects_page;
+using runtime_tiles::Tile;
+
+int main() {
+ // A light with the EFFECT feature or with rows from its device has a page; a plain light or a switch has none.
+ Tile wled; wled.entity = "light.vuelta"; wled.supported = 36;
+ assert(available(wled));
+ Tile plain; plain.entity = "light.kitchen"; plain.supported = 32;
+ assert(!available(plain));
+ runtime_tiles::OptionRow palette; palette.entity = "select.vuelta_kleurenpalet"; palette.name = "Color palette"; palette.current = "Aurora"; palette.count = 75;
+ plain.edit_extra().option_rows.push_back(palette);
+ assert(available(plain));
+ Tile sw; sw.entity = "switch.x"; sw.supported = 4;
+ assert(!available(sw));
+
+ // A number's slider: its place in the range, and the value a slider position stands for, on the entity's step.
+ assert(percent_of(128, 0, 255) == 50);
+ assert(percent_of(0, 0, 255) == 0);
+ assert(percent_of(255, 0, 255) == 100);
+ assert(percent_of(NAN, 0, 255) == 0);
+ assert(percent_of(5, 10, 10) == 0);
+ assert(value_at(50, 0, 255, 1) == 128.0f || value_at(50, 0, 255, 1) == 127.0f);
+ assert(value_at(100, 0, 255, 1) == 255.0f);
+ assert(value_at(0, 0, 255, 1) == 0.0f);
+ assert(value_at(101, 0, 255, 1) == 255.0f);
+ assert(value_at(50, 16, 32, 0.5f) == 24.0f);
+ assert(value_at(33, 0, 10, 5) == 5.0f);
+ assert(number_text(128) == "128");
+ assert(number_text(12.5f) == "12.5");
+ assert(number_text(0) == "0");
+ assert(percent_text(50) == "50 %");
+
+ // The roller's text and the row that is chosen in it.
+ std::vector names = {"Solid", "Akemi", "TV Simulator"};
+ assert(joined(names) == "Solid\nAkemi\nTV Simulator");
+ assert(joined({}) == "");
+ assert(index_of(names, "TV Simulator") == 2);
+ assert(index_of(names, "Blink") == -1);
+
+ // A row shows a dash where Home Assistant reports nothing.
+ assert(row_text("Aurora") == "Aurora");
+ assert(row_text("") == "—");
+ assert(row_text("unknown") == "—");
+ assert(row_text("unavailable") == "—");
+
+ // The tile names the effect while one runs; WLED's Solid, Hue's off and Home Assistant's None are none.
+ assert(tile_controls::effect_running("TV Simulator"));
+ assert(tile_controls::effect_running("candle"));
+ assert(!tile_controls::effect_running("Solid"));
+ assert(!tile_controls::effect_running("solid "));
+ assert(!tile_controls::effect_running("off"));
+ assert(!tile_controls::effect_running("Off"));
+ assert(!tile_controls::effect_running("None"));
+ assert(!tile_controls::effect_running(""));
+ assert(!tile_controls::effect_running("unknown"));
+
+ // Both boards' sizes: the CYD's page fits four rows and two sliders in 240 px, the Guition's in 480.
+ Metrics big = metrics(480, 480), small = metrics(320, 240);
+ assert(big.rows_y + 4 * big.row_h + big.gap + big.number_h <= 480);
+ assert(small.rows_y + 4 * small.row_h + small.gap + small.number_h <= 240);
+ assert(big.rows_y + big.roller_rows * big.roller_row_h + 2 * big.roller_pad <= 480);
+ assert(small.rows_y + small.roller_rows * small.roller_row_h + 2 * small.roller_pad <= 240);
+ return 0;
+}
diff --git a/tests/test_light_effects.py b/tests/test_light_effects.py
new file mode 100644
index 0000000..2a35dce
--- /dev/null
+++ b/tests/test_light_effects.py
@@ -0,0 +1,160 @@
+"""A light's effects page (app 0.2.83 / firmware 0.2.70): effects, palettes, presets, speed and intensity of a WLED.
+
+- The tile's state message carries the light's `effect` and the rows of its device: the selects and numbers Home
+ Assistant lists for it, named in English as its frontend names them, with its icons, in a fixed preference order.
+- A picker asks for its names when it opens (`esphome.screen_options`); the app answers one page per message, under
+ the screen's 4096-byte limit, the effects alphabetically with "Solid" first, a select's options as Home Assistant
+ lists them, and only for a light on the screen's layout or a select on such a light's device.
+The values below are what Home Assistant 2026.9 reports for a WLED 16.0.1 in a Dutch installation.
+"""
+import json
+import sys
+import unittest
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT / 'screen_manager/app'))
+import light_effects # noqa: E402
+import tile_icons # noqa: E402
+from core import ATTRS, encode, extras, state_message # noqa: E402
+
+LIGHT = 'light.vuelta'
+EFFECTS = ['Solid', 'Blink', 'Breathe', 'Wipe', 'Wipe Random', 'Random Colors', 'Sweep', 'Dynamic', 'Colorloop', 'Rainbow',
+ 'TV Simulator', 'Dynamic Smooth', 'PS Volcano', 'Color Clouds', 'Slow Transition', 'Akemi', 'Sparkle+', 'Noise 1']
+PALETTES = ['* Color 1', '* Color Gradient', 'Analogous', 'April Night', 'Aurora', 'Default', 'Rainbow']
+
+
+def wled():
+ """(states, device registry entries, registry index) of a WLED as Home Assistant reports it in Dutch."""
+ states = {
+ LIGHT: {'state': 'on', 'attributes': {'friendly_name': 'VUELTA', 'supported_color_modes': ['rgb'], 'effect': 'TV Simulator',
+ 'effect_list': EFFECTS, 'brightness': 255, 'supported_features': 36, 'icon': 'mdi:lamp'}},
+ 'select.vuelta_kleurenpalet': {'state': 'Aurora', 'attributes': {'friendly_name': 'VUELTA Kleurenpalet', 'options': PALETTES}},
+ 'select.vuelta_voorinstelling': {'state': 'unknown', 'attributes': {'friendly_name': 'VUELTA Voorinstelling', 'options': ['Lava lamp', 'Moving blue', 'Simple White', 'nice1']}},
+ 'select.vuelta_afspeellijst': {'state': 'unknown', 'attributes': {'friendly_name': 'VUELTA Afspeellijst', 'options': ['test']}},
+ 'select.vuelta_live_override': {'state': '0', 'attributes': {'friendly_name': 'VUELTA Live override', 'options': ['0', '1', '2']}},
+ 'number.vuelta_snelheid': {'state': '128', 'attributes': {'friendly_name': 'VUELTA Snelheid', 'min': 0, 'max': 255, 'step': 1}},
+ 'number.vuelta_intensiteit': {'state': '64', 'attributes': {'friendly_name': 'VUELTA Intensiteit', 'min': 0, 'max': 255, 'step': 1}},
+ 'switch.vuelta_nachtlampje': {'state': 'off', 'attributes': {'friendly_name': 'VUELTA Nachtlampje'}},
+ 'sensor.vuelta_ip': {'state': '192.168.146.106', 'attributes': {}},
+ }
+ keys = {LIGHT: ('segment', None), 'select.vuelta_kleurenpalet': ('color_palette', 'config'), 'select.vuelta_voorinstelling': ('preset', None),
+ 'select.vuelta_afspeellijst': ('playlist', None), 'select.vuelta_live_override': ('live_override', 'config'),
+ 'number.vuelta_snelheid': ('speed', 'config'), 'number.vuelta_intensiteit': ('intensity', 'config'),
+ 'switch.vuelta_nachtlampje': ('nightlight', 'config'), 'sensor.vuelta_ip': ('ip', 'diagnostic')}
+ device = [{'entity_id': eid, 'platform': 'wled', 'device_id': 'wled1', 'translation_key': key, 'entity_category': category,
+ 'original_name': states[eid]['attributes'].get('friendly_name', '').replace('VUELTA ', '') or None}
+ for eid, (key, category) in keys.items()]
+ return states, device, {item['entity_id']: item for item in device}
+
+
+WORDS = {'component.wled.entity.select.color_palette.name': 'Color palette', 'component.wled.entity.select.preset.name': 'Preset',
+ 'component.wled.entity.select.playlist.name': 'Playlist', 'component.wled.entity.number.speed.name': 'Speed',
+ 'component.wled.entity.number.intensity.name': 'Intensity', 'component.wled.entity.light.segment.name': 'Segment {segment}'}
+ICONS = {'entity': {'wled': {'number': {'speed': {'default': 'mdi:speedometer'}},
+ 'select': {'color_palette': {'default': 'mdi:palette-outline'}, 'playlist': {'default': 'mdi:play-speed'},
+ 'preset': {'default': 'mdi:playlist-play'}}}}}
+
+
+def icon_of(eid, state, attrs, entry):
+ return tile_icons.ha_icon(attrs) or tile_icons.default_glyph(eid, state, attrs, entry, ICONS)
+
+
+class RowsOfTheDevice(unittest.TestCase):
+ def test_the_light_gets_its_effect_and_the_rows_of_its_device(self):
+ states, device, index = wled()
+ self.assertIn('effect', ATTRS)
+ extra = extras({'entity': LIGHT}, states, device=device, entries=index, words=WORDS, icon_of=icon_of, device_name='VUELTA')
+ self.assertEqual([r['n'] for r in extra['rows']], ['Color palette', 'Preset', 'Playlist'])
+ self.assertEqual([r['e'] for r in extra['rows']], ['select.vuelta_kleurenpalet', 'select.vuelta_voorinstelling', 'select.vuelta_afspeellijst'])
+ self.assertEqual([(r['s'], r['c']) for r in extra['rows']], [('Aurora', 7), ('unknown', 4), ('unknown', 1)])
+ self.assertEqual([r['i'] for r in extra['rows']], ['F0E0C', 'F0411', 'F08FF'])
+ self.assertEqual(extra['nums'], [{'e': 'number.vuelta_snelheid', 'n': 'Speed', 'lo': 0, 'hi': 255, 'st': 1, 'v': 128, 'i': 'F04C5'},
+ {'e': 'number.vuelta_intensiteit', 'n': 'Intensity', 'lo': 0, 'hi': 255, 'st': 1, 'v': 64}])
+ message = state_message(0, {'entity': LIGHT, 'name': ''}, states, extra)
+ self.assertEqual(message['a']['effect'], 'TV Simulator')
+ self.assertNotIn('effect_list', message['a'])
+ self.assertLess(len(encode(message).encode()), 1200)
+ # The live override select (options 0/1/2), the switches and the diagnostic sensor stay off the page.
+ self.assertEqual(light_effects.related(LIGHT, device, states), ('select.vuelta_kleurenpalet', 'select.vuelta_voorinstelling',
+ 'select.vuelta_afspeellijst', 'number.vuelta_snelheid', 'number.vuelta_intensiteit'))
+
+ def test_names_fall_back_to_home_assistants_own_without_the_device_name(self):
+ states, device, index = wled()
+ extra = extras({'entity': LIGHT}, states, device=device, entries=index, words={}, device_name='VUELTA')
+ self.assertEqual([r['n'] for r in extra['rows']], ['Kleurenpalet', 'Voorinstelling', 'Afspeellijst'])
+ self.assertEqual([r['n'] for r in extra['nums']], ['Snelheid', 'Intensiteit'])
+ self.assertNotIn('i', extra['rows'][0])
+ # A name with a placeholder is no name ("Segment {segment}").
+ self.assertEqual(light_effects.entity_name(LIGHT, index[LIGHT], states, WORDS), 'VUELTA')
+
+ def test_hidden_disabled_and_empty_entities_are_left_out(self):
+ states, device, index = wled()
+ index['select.vuelta_kleurenpalet']['hidden_by'] = 'user'
+ index['number.vuelta_snelheid']['disabled_by'] = 'integration'
+ states['select.vuelta_afspeellijst']['attributes']['options'] = []
+ extra = extras({'entity': LIGHT}, states, device=device, entries=index, words=WORDS)
+ self.assertEqual([r['e'] for r in extra['rows']], ['select.vuelta_voorinstelling'])
+ self.assertEqual([r['e'] for r in extra['nums']], ['number.vuelta_intensiteit'])
+ # A light without such a device has no rows and no extra block.
+ self.assertIsNone(extras({'entity': 'light.kitchen'}, {'light.kitchen': {'state': 'on', 'attributes': {}}}, device=[]))
+
+ def test_at_most_three_selects_and_two_numbers_in_preference_order(self):
+ states, device, index = wled()
+ for n in range(4):
+ eid = f'select.vuelta_extra_{n}'
+ states[eid] = {'state': 'a', 'attributes': {'friendly_name': f'VUELTA Zzz {n}', 'options': ['a', 'b']}}
+ device.append({'entity_id': eid, 'platform': 'wled', 'device_id': 'wled1', 'translation_key': f'extra_{n}', 'original_name': f'Zzz {n}'})
+ eid = f'number.vuelta_more_{n}'
+ states[eid] = {'state': '1', 'attributes': {'friendly_name': f'VUELTA More {n}', 'min': 0, 'max': 10}}
+ device.append({'entity_id': eid, 'platform': 'wled', 'device_id': 'wled1', 'translation_key': f'more_{n}', 'original_name': f'More {n}'})
+ extra = extras({'entity': LIGHT}, states, device=device, entries=index, words=WORDS)
+ self.assertEqual([r['n'] for r in extra['rows']], ['Color palette', 'Preset', 'Playlist'])
+ self.assertEqual([r['n'] for r in extra['nums']], ['Speed', 'Intensity'])
+
+
+class ThePickersNames(unittest.TestCase):
+ def test_effects_are_alphabetical_with_solid_first(self):
+ states, _, _ = wled()
+ names = light_effects.effect_options(states[LIGHT]['attributes'])
+ self.assertEqual(names[:4], ['Solid', 'Akemi', 'Blink', 'Breathe'])
+ self.assertEqual(names[-3:], ['TV Simulator', 'Wipe', 'Wipe Random'])
+ self.assertEqual(len(names), len(EFFECTS))
+ self.assertEqual(light_effects.effect_options({'effect_list': ['candle', 'off']}), ['candle', 'off'])
+ self.assertEqual(light_effects.effect_options({}), [])
+
+ def test_only_a_light_on_the_layout_or_a_select_of_its_device_is_answered(self):
+ states, device, _ = wled()
+ device_of = lambda light: device if light == LIGHT else [] # noqa: E731
+ self.assertEqual(light_effects.options_for(LIGHT, states, [LIGHT], device_of)[0], 'Solid')
+ self.assertIsNone(light_effects.options_for(LIGHT, states, ['light.other'], device_of))
+ self.assertEqual(light_effects.options_for('select.vuelta_kleurenpalet', states, [LIGHT], device_of), PALETTES)
+ self.assertIsNone(light_effects.options_for('select.vuelta_live_override', states, [LIGHT], device_of))
+ self.assertIsNone(light_effects.options_for('select.vuelta_kleurenpalet', states, ['light.other'], device_of))
+ self.assertIsNone(light_effects.options_for('switch.vuelta_nachtlampje', states, [LIGHT], device_of))
+ # A light without the EFFECT feature answers nothing, whatever its attributes say.
+ states[LIGHT]['attributes']['supported_features'] = 32
+ self.assertIsNone(light_effects.options_for(LIGHT, states, [LIGHT], device_of))
+
+ def test_pages_stay_under_the_message_limit(self):
+ names = [f'Effect number {n} with a longer name' for n in range(400)]
+ pages = light_effects.pages(names)
+ self.assertGreater(len(pages), 1)
+ self.assertEqual([n for page in pages for n in page], names)
+ for i, page in enumerate(pages):
+ self.assertLess(len(encode(light_effects.message(LIGHT, i, pages)).encode()), 4096)
+ message = light_effects.message(LIGHT, 99, pages)
+ self.assertEqual((message['op'], message['i'], message['n']), ('options', len(pages) - 1, len(pages)))
+ # WLED's 216 effects fit in one page.
+ wled_names = [f'Effect {n}' for n in range(216)]
+ self.assertEqual(len(light_effects.pages(wled_names)), 1)
+ self.assertEqual(light_effects.pages([]), [[]])
+ self.assertEqual(light_effects.message(LIGHT, 0, light_effects.pages([]))['o'], [])
+
+ def test_the_answer_is_what_the_firmware_parses(self):
+ message = light_effects.message('select.x', 0, light_effects.pages(['A', 'B']))
+ self.assertEqual(json.loads(encode(message)), {'v': 1, 'op': 'options', 'e': 'select.x', 'i': 0, 'n': 1, 'o': ['A', 'B']})
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/web/src/assets/bar-roboto-400.woff b/web/src/assets/bar-roboto-400.woff
index 6fcdaf3..eeb874d 100644
Binary files a/web/src/assets/bar-roboto-400.woff and b/web/src/assets/bar-roboto-400.woff differ
diff --git a/web/src/assets/bar-roboto-500.woff b/web/src/assets/bar-roboto-500.woff
index e95315b..353464b 100644
Binary files a/web/src/assets/bar-roboto-500.woff and b/web/src/assets/bar-roboto-500.woff differ
diff --git a/web/src/assets/tile-icons.woff b/web/src/assets/tile-icons.woff
index 7bdb239..90daa2b 100644
Binary files a/web/src/assets/tile-icons.woff and b/web/src/assets/tile-icons.woff differ