From e7e173d3b7e37912dc5d9816fdbe7de5b3556f81 Mon Sep 17 00:00:00 2001 From: Max Gramser Date: Fri, 18 Sep 2026 21:38:20 +0200 Subject: [PATCH] Give a lamp's modes a page: the effects, palettes, presets, speed and intensity of a WLED behind its colour card, with a drum picker and the effect's name on the tile (0.2.83) A light that offers effects gets a sparkles key at the top right of its colour card. It opens a page with a row per thing the lamp offers, all from Home Assistant: the light's effect and the select entities of its device (a WLED's colour palette, preset and playlist) with what they are set to, and a slider per number entity of the device (speed, intensity). Names are Home Assistant's own English entity translations, icons its frontend's; nothing about a brand is hardcoded beyond a preferred order of translation keys. A row opens LVGL's roller with every name Home Assistant lists at that moment, asked for when the picker opens (esphome.screen_options, answered with op: options, one page of names per message), the effects alphabetically with Solid on top. The check key at the top right sends the choice as one action; the back key sends nothing. A slider sends number.set_value when let go. While an effect runs, the tile names it instead of the brightness. Firmware 0.2.70: effects_page.h (built when it opens, gone when it closes), Extra.effect/option_rows/number_rows, options_request, a hidden roller widget so ESPHome compiles LVGL's roller, five firmware-only glyphs. App 0.2.83: light_effects.py, the effect attribute, the device rows in the state message, card_options_loop. Checked on the host with a virtual finger (35/35 on both boards) and compiled for both ESP32 boards; CYD 1,675,424 B (91.3 %). Co-Authored-By: Claude Fable 5.1 --- README.md | 7 +- README_EXTENDED.md | 8 + components/smart_display/effects_page.h | 473 ++++++++++++++++++ components/smart_display/runtime_model.h | 10 +- components/smart_display/runtime_tiles.h | 56 +++ components/smart_display/tile_controls.h | 8 + components/smart_display/tile_icon_names.h | 5 + docs/TEST_RESULTS_0283.md | 64 +++ docs/images/cyd-effects.png | Bin 0 -> 13740 bytes docs/images/guition-effects-picker.png | Bin 0 -> 16701 bytes docs/images/guition-effects.png | Bin 0 -> 21738 bytes guition-4848s040.yaml | 63 ++- home-like-2432s028.yaml | 63 ++- packages/cyd.yaml | 63 ++- packages/guition.yaml | 63 ++- screen_manager/CHANGELOG.md | 9 + screen_manager/app/core.py | 14 +- screen_manager/app/light_effects.py | 210 ++++++++ screen_manager/app/server.py | 62 ++- ...-5Ia.woff => bar-roboto-400-DWGTva7D.woff} | Bin 14120 -> 14120 bytes ...L9yA.woff => bar-roboto-500-B8TsR_Fs.woff} | Bin 14240 -> 14240 bytes ...{index-CKR1N3aP.css => index-C6Sfd-UM.css} | 2 +- .../{index-Cp13kZoB.js => index-CteHDGjl.js} | 0 .../static/assets/tile-icons-BawP5y9D.woff | Bin 24044 -> 0 bytes .../static/assets/tile-icons-Dqh6fj0Q.woff | Bin 0 -> 24396 bytes screen_manager/app/static/index.html | 4 +- screen_manager/app/tile_icons.py | 7 + screen_manager/config.yaml | 2 +- tests/test_effects_page.cpp | 73 +++ tests/test_light_effects.py | 160 ++++++ web/src/assets/bar-roboto-400.woff | Bin 14120 -> 14120 bytes web/src/assets/bar-roboto-500.woff | Bin 14240 -> 14240 bytes web/src/assets/tile-icons.woff | Bin 24044 -> 24396 bytes 33 files changed, 1409 insertions(+), 17 deletions(-) create mode 100644 components/smart_display/effects_page.h create mode 100644 docs/TEST_RESULTS_0283.md create mode 100644 docs/images/cyd-effects.png create mode 100644 docs/images/guition-effects-picker.png create mode 100644 docs/images/guition-effects.png create mode 100644 screen_manager/app/light_effects.py rename screen_manager/app/static/assets/{bar-roboto-400-H0q_-5Ia.woff => bar-roboto-400-DWGTva7D.woff} (99%) rename screen_manager/app/static/assets/{bar-roboto-500-Bn7ZL9yA.woff => bar-roboto-500-B8TsR_Fs.woff} (99%) rename screen_manager/app/static/assets/{index-CKR1N3aP.css => index-C6Sfd-UM.css} (99%) rename screen_manager/app/static/assets/{index-Cp13kZoB.js => index-CteHDGjl.js} (100%) delete mode 100644 screen_manager/app/static/assets/tile-icons-BawP5y9D.woff create mode 100644 screen_manager/app/static/assets/tile-icons-Dqh6fj0Q.woff create mode 100644 tests/test_effects_page.cpp create mode 100644 tests/test_light_effects.py 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 Cover card for a venetian blind: its battery, the position slider with the blind hanging from the top, the tilt slider over slats, and open, stop and close A finger on the history graph: the top of the card shows the average of that hour and its time, the graph stays as it is

+

+ The effects page of a WLED lamp: the effect, colour palette, preset and playlist rows with what they are set to, and the speed and intensity sliders + The effect picker: a drum with every effect Home Assistant lists, TV Simulator in the middle, and the check key at the top right that sends it + The same effects page on the CYD: four rows and the two sliders in 320 by 240 pixels +

The media card on the Guition: the album cover, the title, artist and album, a progress bar with the elapsed and total time, previous, pause and next keys and the volume slider A media player over the whole page: the cover at the left, the track, the bar and the keys beside it, the volume row along the bottom The media card in dark mode: the same cover and keys on a black page

-

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.

CYD 2.8-inch screen: the weather forecast, a kitchen timer, the coffee machine, a lamp and power usage as a large value Second CYD page: Sonos volume, presence, a scene and an energy graph 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 0000000000000000000000000000000000000000..0fbd6130f88720f481460d000e10e10cfd4e735e GIT binary patch literal 13740 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 literal 0 HcmV?d00001 diff --git a/docs/images/guition-effects-picker.png b/docs/images/guition-effects-picker.png new file mode 100644 index 0000000000000000000000000000000000000000..019faba9638fe6c4160d799edfb05d4f7c18958c GIT binary patch literal 16701 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 ZUUz)@{=|}I0eA^P{9u2GbinJ<{{a5HO`ZS% literal 0 HcmV?d00001 diff --git a/docs/images/guition-effects.png b/docs/images/guition-effects.png new file mode 100644 index 0000000000000000000000000000000000000000..6efbbb24827591fa0ab4ed2d8b7fd93e726cc2ea GIT binary patch literal 21738 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 literal 0 HcmV?d00001 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 6fcdaf33fb25abcbf8bbb4078bdfbdcfff344949..eeb874ddfbc639cc6ea1b2c84d298974ec0b4d62 100644 GIT binary patch delta 32 qcmV+*0N?+pZm4ds1pxz_ozs&D0hI%tUt^P?0Tlz&KBu#|0dO_dT@0B3 delta 32 qcmV+*0N?+pZm4ds1pxz>Io^{90hI%$Cry)}0Tlzz+Oo5_0dO_VWek}B 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 e95315bd2e48ff48b3072150fb7467623b0a1726..353464bf371cb2c954cc0f1667524cb4030efad7 100644 GIT binary patch delta 32 qcmV+*0N?+hZ=i3m1pxzqo#2xQ0hI%Yqid6(0Tlz&KBu#|0q8Z>OARal delta 32 qcmV+*0N?+hZ=i3m1pxzmIqQ=M0hI%hYgLn=0Tlzz+Oo5_0q8Z(Qw=Nt 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 7bdb2399d5b71aeb46b635ab402f1c0bc4fd24ec..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 24044 zcmY&wQbwBZF}lYZQGu9s;O$H*4%)YGG<*^3NCQ zpC8A67{G9jwD>3f(}ezUyni4A_JQEE~xlpSP3HulE<`azNX<97c+ z1&KD)*2v@EdqH&n`4RpD5vUTN#?Hvr^k2-S|2V||#lOlcWHE?;cP9$lyN;1^8bxz$4Sx(8SP?@8%l- z1fl|=0MZf=YCT{8*%r`<|JTN5tTjQ@0|1p1ng9=hgZu9FfdCLXgM$3e_orcIPtRCS z&q-hEP*2YeLQyx16iWjW6BBbS3#%bGh-Ec39Kv^Cz-`Yr5Y8(~5<4@XFN%m8@Rt{! z@?RhxnZC-*vcb$~d|;H}3VA=nF_gtf#9{RC$TCDRAw_Q~rOckMDO+A>KR`acxAgRF zC_TwKI@;YmTLC@TO3F837g82c{98XRS05D7?s}_-=!}O}m{iqicR_fU{2g`}_?c-* zncsUjf9Pn$Pm^Cw3y`KxzL+dt=rJp+dI&^#lg0)rYFGwVc_v?Jp0J&{Z<#WobEZl@ z?J+Rd@QG`d)xCv?EE*UWAFb=RX5X4*my1l-Mb0~}!M-P-2C}(7coM1nm~Fi>Lf{;H zdl2Bm`q=Y~L?4R?!;@Z6d}1_yJ+{B~UwkPW;GEb;yqZY#cAz+TIm+vZ@b*@8$IuD^ z?r(M;^JG@Eh`)jJZcBfAWRfpI$LWfLgx}V|wXqCyr<#1coihK1URhy+CuR~o>zS6-fdwIr!+43tQJAa1M_m8WIec5%NoQEChkoF#hguu`>Cn^k^hUJp z?%VV9G!MeNq63<3O$g_a?aMau$d6H*@G0Ii^%N`Id9}x$$dl2&77?&{nOKZ}ZDbC5 zw8{^fZU2n0GEYz`ig#@qi%?oaSh0`SJ(G=98>@#|Tl4AE1N!dJBa&X-6@a-q8}NA% z6!3Z33j(>40*+^44n+8?3D&WqkFO-yEaCd~nbqEf?@;c;&98|p0mG7a^Hrrmgl$Ii6Op`4$8$Mq>c0Nu^C$MPvyd;jI}Np)@K1@3({fb|j+bn36Z_q4>Y zeZw}- z@Sju`-#fdm)~pxL%WG;D8)F#j&V$-G8w8LYS~roD1l?too}-q6LB77|ug{nEW3sxJ z3_t}8ztJ6%d?|V!x7KlY@Sfj3y%=3C)~@xiZ;0C&cC@{N?OZOh7recDT%Tk?9y<*X zUrIY^OW!rF6(4@>$F7!u+7((pL;7sA2{uXMZJSU61uWfRTUN8N7Cvh(y{vN1R$ONV z_)EQw-Y){CSCWJ`G1pTdTin)gLG2oGfp+zZU>?4g29O)V^T#+R71O_`SF=F7ES_N< z5Z(|F9k$POP;St@O?oshr$M{Sa$p>$-Y9`xM$HH<^H;b#x=-GUkAyofcI|XK1l}qE z=biqZJ-$zUfMvh$cVL%k4UB{Rc{jxl@6vWlw}5q*bq#FG_7>iaZ+f6O$Wa+G(~ys?oDV`h`)vLE!oCm)j8G1YgaS-GjZ7Dh2!v-Znr`X==RZAc zd+aSFj=(>b7!Y2v_-S^Ymp+XS*oOUUk0D;(V{PDG5;_HUrr9P3LAHh`lR*3%pTRyR z{TO$ypGE`xXQE=jKjsx6`D|mM9R#uTFmCMI`JD7|^iXdI-~R-B&qYnb-@I}($@iGG zEB4esMctH|+QUX3SJ&|d?S1hyQB!5N4`m}ncK5&Z<$7G<7~r5im)xEQ@3mTg6z9Ht z5c;!FT@mhQ1n<>bSGYqyez4SmXB($S=mqZ)urdo$k-CX1ZCq1sVWUl>IkyGv=}uw8 zk8w>NObM@!RU@3eTHKxGzD#85;wB!iL<`@3WpAJ@ur3@N_0+4evSTI2WWk)RT3My! zay_eNvlb&bUu-eCiYf7-D&XUyO^3{rQwbClAA=7%4?koJ-^#752JNNQ?z4983usvR z7Q6Fxqu12bQ&CO*IDW}(fpk}ZlJUL9J@D0@_RYEv+S|UEfEaY%nJ`iMcxRP_95g?Q z@*a#j@9ya7Vx-Cj9#_MReS8Bkm%|?aoBaMWQb0Cf2m~5L2*d&;93&m25~LSo3FHjq z0~7(26jT({0yG-*FX%Gp2ap&j0kj6j0o#H5U{GLkV6I?I;H2Q@;8EaR;HMB+5ULQK z5M>azki?LRkbfYzq1>Q;LDfL@K+QnIKr2IALzhF3!yv-&!Z^V!!^*(wz;?ra!7;=6 zz)ix-z`Mg2!MDP1Bj6&KBD5jwAfg~DBSs;1B90+GAi*QCAlV|-A@w8OBU2#zAjcwS zAlD%8ps=GPqhg{eq5eUwLVZV*MYBWOLwiFfMdv`5LU%#$!+^uk!>Gc<#+1ZV#~jDJ z#lpsN$7;da#fHTe#dgB3$6mz&$C1Vf$N9ju!tKVx!^^>^!_On2A_yedAmkuSC0rna zBw{DBBq|^pCi)^)APy#;B>72FOo~8SN;*k;K?Y04Nv22^Ojbpi$CAmi z!%EL;$C}Q%&PK-O$=1%E%>K=x!V${}%^Aqq%0M#%OsY&8N?J(T zU%F6wSo%%IOr}>BTvkoCOb${`@V_-pLnmw|>GBUsZOK>~{&KP;8Fg~kQ@!}|_Hvy~ z`bM?Ov|G7JkD(_%zWo#vUboB$RLa|t2zeCVfj}ZR>0~4V;u&BdIddGCMIB#qh$fUp zU?*Q6ZMzF_JS-!$vzE8hef0z5skujg^0UiXCiV)OGR)LmTfL}!u1xj#*7y6`vjwoc zuf{?GWay*DlhoD@tKn#B^OBucYn)p>bAvW-(spmwHoKQ`{=42G0#W6yebZc{)3)Ce zw%?cO?kqQSVBmbdk+orYh4+j!OsywBujAD`yU4D$?@3H*p(nos-ZfiWj!|=~(QoC_ zewDnwgKNjRV%JML83RpN8nAZNV%zJoUS8H$vUbw5YCmeX^cqqs4$odfy{Dn4rCkFi zD$YkQEvXoO;WAa1rMvdkwy0V`iuhIJ%zej*UZ#iXgga08c6e&(Sb@pMl>clNb&BTY|I%68ve7+k)=OhylX>lcb~%lb#f+&2V?v$rV#@6XP|_;vuhb zPZSYad%tqa^Tc@2z?UkW(mtAgkl<}`Z_AB1A$~(@?b23Lgyxi71%_&YH0KMSbAGqUH;zQt6fg{x&n@e zMosJevX-~t9WLJ-rxP_hF*+XFai}eHe3KVK_U>!pIeBY0iu}3{7A@CEk5YleYnYHq z`hYdoG3gt6Eod5a%%KDTVIO|nXYh5-#~!C>go!x|(bj?C#aV!E?_(dE+2d^X@ z$k5Mr4Qh`|O?L6aW`2uW zxE2z-z-W4^Pgz{LRjmJM#yN;?G@@r1zcmPl$6jfoQ}9!eD-v^xrE;FpoZp7z&Jw7& z+^SC8y0UZ1BZ_sH`jc-*U3OnCbB2#ce?q7|=kO7$J~wqjSW!4TC8#02zx%I%?5t5k z@k}B|o_dEV*ila@>n>h zP%McIWYrB}Jr&ND2;9I#kDU~)U5zf?vPi)2WtBt=ye56N;9KH0Esl5zN9#pTm$rAP zd3+5u&a>8eY{z!nFqHpI5Ef6 zn9VKlJMQQKyWtz6C%#)1JW>ToWAo?*( zB%^qg9VuzpLuH0am!svxEo7A4yjW1?;}bEE<>7J(7+*IGdS};9PlBw|Xh2!<%@VEu z@TR1srlfWtU8c-+ecMHKd546i7!(n5b;Nnd7N>9N!Sc;HxyZR-#g z{vF!6gDl-FIj|Go`8F}pkq{vh}ad?z!;JXdmIS{_>; zmhPzNj9Z&)8G=vCTU<4W9jfO?-;x5NTZsH1s%a-o{NNQ8r=U@On9T?8k&L5x1A?k; zt+6TeU-X1J6?#P1j9rQ6G~aZ{@%}|Vc}-7s%^@kb@O)H8auNqB!xj0Ov@jk zvn;4c$N(Wg#QVUL8-9|46>f7m4;B|^wJ77!T*ALJKQ;_-2}{}zz7w}7kRQ;r^@Yfu zJPm}c$IYgKDpwTXOdT zVNtm1hG;(P5TYaBfB9>UU&v8tzLhJ*#J;k$Socp_A-URuOje}UB|==L0@f3M?1gv# z7Xr3I7@#grdgCC>mNKd{~e-O>K{pQErHLUtgi zA0_$;e<@!_>KQAk>v>(1^Vtu?le~g;yV;5aM0uq+(*7*`lLjCp8Ts|9sxR&pRa?{2 zeH72FljU^A1#hKdAd1|1Tv#(}AZ63;dle$=G>t|LU~qo@k@OLlQNYUyvjSI&@V!Jo z<@IdTAuqmPlzrva`ZVni-6BW*WPO`bvz5U;vOUEcbwLi0wS?Ex!?A!h@p- znl((V3_0(WsZ4acIJTwKi&PJKyn%u4ZxlniLV1a7EEMd8dV^fKiq8r$%90v(KIKx1 zmgCF%U4qIq7J)NDJLthuWDXlzOEy3ze3*bOZdsZZwWgFP7q1CUO(C@_LR?W2f14jK zIBFvQbd0_iPg3OklaAtO&H)RO`jBLV&)A9JEs+##Jupgn!s|dWqW=jtlMW8*S3z`< z&hNh`Ta9}~gj82}$>QEXt{*rANp{ejL9LDiM-Y5+R39NN{9J}3QI!o zGlUh_>f&t8PuW`Hd6@0n1?G<1wR?9g^KhneFV5kc3FNG4b-QSlSMWh#!L3;%w$LXM zANmT+*b@3;BR~b&?lrUKH&=7!Hd9n|#~Kw`H2kiO)p-dB!m}4AH%mG#Y5HsQ<)cse z%*X-f-FQmM{U9o*ME3Hz_f~!behCY=ar=!Fl0{(Jn?I5JbT^(=Bc4+kV}W=Y<_Uon zw?!Rqwil&cP_<6x7rqt$H;+viY;zA5CCsZKY6NBzQ`TB3_ z=xty$!(Kmb3R;Nqj0s4;QEa3>>b$Q!;RL@jw@Ps6?S*p=n_T2YVaiT78OGT7Voi{9 zpo4LHIASlU8g-(D67n3#aI&&^5Y! zv`MKA6Z_}}gdD~0@5g5Dl=~NUEl%xDNalh-t`lz~-Ac#jo(#b45KeFdpauktFreTY zy-e^UT+k!j4)a>n4A9P?;#;wc0rUulffw4sj|6`eqlu3$O1`57CyzS-l1=`WeH9`? ziyU`Pv_@yul%otLzO~U!oR?;<3J>yOLPH)Xav+nE;(K~^pu>XTsmh_f(|**ZMT-j9 zK$}s-TKl0IJq}nG-IWsKys8K{zI)$|@X>-ZPsmRmF;juYxA38&!!GsMcgyXDm@(k{ znbcICdKKf|i5T!alXxbeeJ zg)~lu6n>+if@DJyE<;Bzwe%Iny(Xl~-Xul<{X2>v`U_=!lT%RC>uIJ*u!@fQhSQ{7 zaosH(Uzj$(p#gY_SEN`iXNsiujtXW6N0Ltl3YB)M=pOJ>{>fPkV$~Rm1hURvN!&_- z0B&dO(M2KDzxClI-S(R3Qv&X|SNGY_AWwzS7iOObe!Q?9;*iuCt$h0EK6()qN?7!VSca+7-4QX>)jb) zLkVOEZUO8aseiU#yBdBDMLVk3l z3rdfNX&bLH=StWNXfa7Vy)Aci@+;yaNbcW4S#l)|d+7$9Qhe@e1QG_IF)tp*W$9sY zn4C0Fd=qFHKv|+v{4x-+Dvdes6fi2d%0Pw=Oa#^~<>!^?>c31=?0LlZbU6 zEA;9fWsXL#2Z;G|Vk0V3A=2xs! z*nS_vA)f7n(z_7QhCpS)CEp1yGa)Qa1aJ>G&>Q>aQMYJ{4Rogj$lV%kF+ z{pI`Bzxj+gpLp5&^q)s}tMAPk92Lbzsf&|IRqxXbtgU8Xhu6aID)?ygtkE`wNH-Ti zo(ko2tv|bGYJN)c92q{`Kl!lpCBO}whPN7(8<_y{*$KCU%9215aLVHu5y;pO?=ta3 zP{OdN8Itr_dA5`1ksHBZ7VDhP-YcMTvFr!&Bedwk1}{lhB;?AE!_jr$`zcXbTSzBK z%tt@4A5-As&-l%!+3n=g%eG1St!~UJMNLnXd?}RZs3`q04C17v2vpG-EU1)?SyQry zvrKA-*@-Z-5-3|0dwkk~_z>A43V#r_(h}_3g#-VYre8&S z?8=!R$;~UkD1GMx4%6`6v^JqvjMvkG&~E|XljUxQJpaHA{{v+5%R$T;FM@3TQ0d?YCOu}W$lkAsYdNv za=0+-1iYNZS4c7lU&oxV=P4N$%=?B7D)jUZviP}rNCE?=j}n~(QU_d^WnX64r9Ht? z_2{ekn?#5TPwoikWM_%N?o>i#yrSMv{-hglt!3_$ah5L;LfkBeQx@v!W0yp!x!JFe zMIxaHCo%+5tfpP#_CPYjEkUd0)g0tD`?BpX=Fz zyVq8=q|XGzGu-=Uo^pC+S=ZrPnWiD!Ku1pR=HP0@r;>wKub!i ze4Mv}lev-|se122n!fi?BSwtRkOfCb>rWMSa~7W!cSMp-uiJX58FniaoVltn%DEyQ z*599yNJN2XM-#escLvDDsE7w>L|HV4&Nkx;iML+~6{bigavy;OCEH}AZ>U2g_6cx^&W@`EOMaGTx5+$K7ygjX-4L&Y&gcRND1&aO4t?N=u z19yWtk7d*dFvn{BM#78CKQjK5vaXM#0ZosF#|+ZhKG)Vo@miXr=M7aL(mlGFDra0b zPv~~hQ&KzBBZ=Vn+scoKff91^#^v@n!O9Uw{+r)%gemOLEfu3O&c^&d>d&BmJT|0W zUQAw?evdmctUQUwnNN+}Or>WkluU}zfV-D7K6s&#%})lbCKfOGi)CULP7%$3M!52- zJ(dAeAQt|jdfvQDO|?8)G{0L|`z*7b8u*+}c=>x=tjb0V6q&~oKW+xhzIi8d=xgtY z0oLe>QLUjw`u{eS;wywQo3JZ+gyE2|?0A-z9ktyomPl9#%+8?Z8wCbHiJ7{(N{wj8 zf#IP*yUv|L5uSy=Nbzh!i~m9;>TO)t(>ORi#H{_5P@$JvW~KzC2L%DN5?MhA?a#9% z&VvZ*g(W7o*t&8OOV^hO*=@$K8o|sMvBzW4wn+|MidMBpHk1}Yy>fpf`kKfmkF)HL z8D45l%F0S|_O2&+`y8dZ;$QPQnn*KGkM&x}3B3=<@LJHGYALfuP965n`C|Je$Jx4j zliaS`O5a}1=Q1|0W9OvC8Z~zt@EcTvTFD>xEQ_KcbRF+c@NVaG-?l{?uRnpCaUoG> z-e&XVR~*5-6E}^kY(P!ZZYn4|(L|{}6Fp7Ej8Sgi#DFupfh3e~4a;dJyf4c1DCRsj z`FkIv!AiXTpiN=gX zGcO(d$RD)?;}?P#4@&Qz{8@Wm0ViafPEsESmqYVRx~=Pvg0WK`9`p`v>)n<)3AEM`}crGTkVjNBbhQ{G*h;$#MC66O0-RhG&rL z>neSQo1f{^*=*y3i%&sz5SwxIeC4Meg(4St+4+=a&lcgGhTH9) zr`hzx*eE2F49`uW4s7V~(jE8J$wUyIUoV_CVWNYEbUwEgeX~S_U+Lj}y(Ng&?q9h? zA*vw6Nl=BJc_{HkI~;^?I1y?ADFXqSbl@oZTu3$y$O0JfSeHVXm4uC!VJ%0KjDNN% z2#ZI4NWkoYZuKI9%*@{el6wiqCLObS9&(%hb7kp-NGR2?qHs>k8jPdbc z(KG={K`S1|ac9Vt27+LFoAJ)%_2lhy1BDVgOHE{JO)ciuB_9#F9ig0j0gr4K`^I&# zeX;I*aeonn$47hBcoi00rZUR-j4Ganc!6#nn*}@~N6tMTuL#zFwdRB`$aby>uK*3K zOp&voHcYB>(HXmG(RT7O|3<|nkL)o5_}_u50kQ;2VLlS;0_|0%*}o#2xYoocN2wEOu((exX!fKR zYR@A#RT~8=+`X*5@oSCDbyEELYu-2P$ec`bamFR-`XgZj{t8nM%dw#f9k?m;>D<)% zTQjH89Vuv}7JM4pUZ&WzbK)_)Ez6SspfPnhGX1tFaXRJ=U&cS99)2`L{U_3fZ4)f!^wGfl75+uSbfEY$wm z;bIZO`4=oQH)zgr1i6ria2?1W1tjt(?0eEe=ucU@4h@1hDxYFlZ-@LnS*#Z36f{ra zxuBcYY%T$>R?Rw^>uq#MC7yd?^8(gC%=bfWnwVaH+A|Sd|B%=9+)o;?s`P$kPiYrS zvyUwBxR2Nkc);ZQi-b&bLjf-)9q`xnM7^`1-?&sKDjTHb_f_JJ4G3YGJ9Kl?6ZHOR ziFfdD{f}W?_95Qezh+sD8I(Ms7Z}9Bjg7}qCE>w{{Mi^9#^5czR9NWm5#yU!3#H(X zTH7$n-yTBjq8@@t(Ohg){r)LGEY-2R)Yg-v4_PfVMh;Zd&K%LF&@Rtp@=_-;U}@Fc zwfOD&cunlTm|sQjI%hO~H2h=&;`}vE(b8c`;{XS{I;gKvtie?hI|M?!4ixCOW~vYZbQpU1uA>eYvTg>qZLkZ~f5WZ7QtiPb5xf-)7i5I!Rk}2P5?C95&<|$Ef zC0%N_nm?atd0VRWcM*$aCV}U8L4hNvPP;-~^rO}%6LVh*Iqu{L@2quAD_o}G$_^fx zs<8@5j$r`J*?>`Oh_b~S+dXf((5hmr&(SwedxoS+Nn}Aw{pVy9}yH3fsUYyCv@(VPtF)5AfOF_hQ>;NEx&7eJvM7=w{C^eqwjDr;G zMykfSSGP;jY_v5aH{3C}Il4Ki7x?^;-hmM5lins|E3KZ%@yw{APUGn#KcnV{s5Uc?olz%7{M%al3Tterl~n2FZz(7Ol(t&??s?We|4s*dGe!b9^AD(IYQU!ZxiErUW zhs4iviXz_}qTYZvU5sChjky2{u+U^EMe_!H;xc~F62KH4Al3Yu2)0!);FA%z7uDOS zk(yd0jJEBpIK_UcXOa6Emanfe)XY^AWgUUP02+n0vWh|_7 z6(SnS;AZY{l{NmEYe7l#5E!XyFe>lC=QO*?D#FdQ;MgM0@|!*)7+5CP2Oh5nApJ>O zP*J09AN|YwD^84?9qseiFpKQ27k@`0aVvxkp)XpOza;OnzIMn6LhuOhalvdPQ4P+u zx1lBdCnX{syQ%@wL1WQ$kEdCRp)yt^<1n4GJJIqp{TQrEr3iEaZv}IeO&o*-G|_85 zzw)xSeyl);BsSXt+!v=6KcS8~McsE!>Em@~uT1m0)+sA!zK=oBvXNNkJ!#HLRO#PH zCScxFrRF_q)b9{lXEgO9)UX&L?L%a%I5Dg^5rlrZRD4*1YL__E3sP%}vydT4B(@iK zv{E#gGf10CS*>9_7HA)Ak6|`AZz|AU^tb@F4klNH^q~v5yh(bq?-3}qKQ;25D7D;w z4vluEHpa(n2HU0i7ob?#by|?;5;i@?AJ`2| zi2Ezd@ajlN*X)U8P^;Q@UgckmJUlMwJat)hd*wX$nHe9JSu0ktaTwfO(4trH+vp_G z+G}f*$c{g{m8noSo{x)?!hW=7u^(jU_&s?b6MyfG1!F;&kpKM|J*g)LB7VAJp3uq_=O(y6Xj5DQIh>;H=hLh89QiWeg3>K2{o**bs z3NUMRnTQRQEg$}L2O+#^Sq{Wicu-@gTK9-}EmSc8p{JB?ry;Cb_sw(ckhsbq-+7NvT$fjh*TxzBZzC z?&Ig`l*ehvWv(fz&m%}!mFoIi9l!P;_x?h2o(+o0P?w-fD#U{JnF8QlYo?UPDfeYh zM%|LVVWalp%h_*Sd5f=qvVV(&1~+LJedV0=*cn9!?y*MWg{pdCU0W(I-(Y~wu7CCr zo3-U~CND%fn5*H_P+!li<(ajb?0kEzy*m zG_rJFFRh1@Q4jPBnk~4BN0!s9fBP}v!$QX(sfdDZN+H|Y@LB~RiWl7&TZ!$7YX2(1 z_4d;w$#;Tf6_EZcS^j*f3{~nPDgvl3gZ_TSEHyLrijO)%BYReS$wT@xC2PP%EYa^f zcYUvX%jLy2eJyOAgoYnmFel)1a%(iFyK!z&ZZZN@kT%S)xp1&sV%)Vx%?)6!M*@qAT^6Ou{ytSOIP^5p@SOxC)dGXMPz|wkVag zOs`3Lw(Qnp(mLd;Ku)3#tbO{gLeO|>fEyEMKESL1_a{@j!e3{fNkK@!vuLQMRZn%_ zI7vqC zXpdXey!IIWGEefV^-Z*Y7tz;*4+lXK_RH@@XWJa#5^S;}*lZ$uvsra$&>9oo#p~=b zY5^9L|9TIWIucmSBk|`5WT!6~K(5slkNgUPQKnN|o-o~&+U3Ulp`pftL;W5|_+hkO z-A!ezJbSVpHM-BMWK#2d+il6kYM^j1>3HxhBOcBvoG-9;spwX-9-OfeaItZ)5NJ!- zq^60m+63N!by6Nm2!ao^Ao`xzNzNiszr?4J(eI>00rc0Q* zcy$+s;b!)Z>MZ&X`VX+KM|ndD!JcWvb^zlbQ(NR#w8!WhNpP<#WtQ0ekc*Y~r>UuE zy*EYtSVS-WY2{tPM=X7+{hRCK?u@UnTVlL$AOnaovEnNedsTTCqZFyd-w$XW*I?FJ z@(^vt5v8d^PJb(?7f+k#aPLk6>>eNPks@Wg)C(i5+CUAw()mx_^bf9AYK$t#(zV!$ zJ$lT3ET*u$z-m@uA}XF(8##cP0e_jMOXp85SK8$3@*h8@iaGxV_sY_$0oas%5dZ)?nw$6`Vt2%xgniO&wF5c zvicT;XgFiKO3DT=T1$U$+YwR6M`R3JNXM85=A^6VyhQeB(%AAx300ekz-P)*Ehi%G zw;Wj7$F6nO?s!bf_&RuJImFVohrp{bPNM~WaPm951@{8T&#t_fYq3~KHhooeI>8Z@ z#{&A>UvF#uoeySS5g#Jsj@q{p6Ui3}ovG89=XyHY5sUj7G4w@*8kJKnjP$cfwEqM2z8bl{?pF^S=*zp&okqRqVmqe&?o9sh1{us)Euxpn;_BfJAWb(#w=K3 zU`(gvc&@D>)MLTI%NmX;EXLAF)(M)uGB{3jK75D3ql2dGNUQJD@FTPVUMap}26 zGvr?4i_Gw1FG(s0YOt-PqEiA-(X^qX;mgpm${n3L&ImsFl@ZX_F9)$%UTGcEum1G$ zX_AkGWBdVLk1SEcjB^)gatazk2nJzoc~}t|F1=3uC-N{=7(>X~t7kv4xLmb(u(7Jq zm$yv6S!^hC!mIB(Mll`v6x^H%hi2Uqg7YMq&<@Lw`2p?MZg|J0$w45H8&?eYHmVo5 z9HrvuN^pE^8>b9ylapEdS~g3|>zl9xvW4P0F%rLLwa+8my8 zI`*-;O(_1>X_se3is!_rsoJq%EVBMFKF#cZUCU#BC0pK8m^)c2Uxdr$cE%`XzYO~U zH^erCF5Ho}J4+Cs*M^i@WwWB@$?{8LK#c}@azvS$;g~bl{wV9P9x+RAsmMiF`nFDK zVnzjqM(3+kt89@*yg#>SgolmHh}gxxEQRPSEA_G&+)jiSr~P_atqCqG?oz2!kn{x3H}UxsL>V z&_ZlTMZq%E<62%WQVxBysSDY~Wq=m{>GjbZulEyy2!#t(0B50zRbC-jio}8ZObFTh z3qu-51spiAFL1QBAYKoBEYQCxdXxiWY^||mHHt~!C&Cg4hIhdOUmB7PkRH5ahDIu) zX_Rs?BFPOz2t8vU;zco#Eg4L)QfaUn8jOCg)S~%Wc&MfQ32Q7XTgbt%n??>ky|L-h zL&R%lx?dy}lU=%mE3CekvKnh!P+)<C_OpQf568_rM#DF|v866P0!zIPdDhL}zywJ##aR zzdTR7sDjxj#Um|+G7s?jrd*gwqyrBQWTRD66xb;^^-rozMoJjXG};dGuM=?>HRh?o z^%i*H$&>UK^S>3UthM!Q6WLiSHF2UN2{;<5MFetheL$lG@rIeK3NDG z+TJeC9{slOg_eS=cph>36S1;=;W_L5@MkWPSQdMKQvdSH8-Vc{>eom7aP%^Yq+nEP{JnV7Mpe ziWw7LgOJDHj(1?(I3fepR8zDL#qX?z`fwrF-o}n8(u^vde45XkQ49Aq+o@rFJ~{$eTP)3?^j3m@79}nBJe^Ys6*JX+HI-@;S61z~N_FBn-zCh4x|3#E5+r z=Bwjs)AM`(@{7fmq4-AE3bcI=G-Tx{8QQx|9cy|R0GK3N&LDi~cr2ss!@?3YMuI2J z$AuDk?dl7L<8x-`RvY$q84M>cUViG0Lag`(u7Yj78Sd@YOew4L`w;Jw2gB?+AmeL^Er)9!y>D#})UW3y2SY{{ zZCZ{uOXAkG`6C3ZZ=pt2GK7coYc5JSM}KRm%THTS-%8)E&w!7>>0H}W>FsF4i{6-^ zgxP-1^c8J&RX^r1K}NYkHY5m?zo(i{0=f#mMh4 zjm|(bEv8RoKO$gqF8AvPs7AQ-?Sl=gko2#qg%9WFrrxhINkw>^5*#b?F5hprj{Lsc zEPDZ?6j_P)YVT_++@im}dz0=gEAAgx?YlenxFrmlFy&nxot>2xp<{=t(F7Y#*L6P5 z?GW7ogiB2sug1?J+@u&(QT0~nYtP%tHI7+NXE2NA6;{jv+Fp+)0A*(FH(e|h+m)Z! z+B2;@wbaP~8QW~m{y*U{_})LW5M2rU_8!dYy1w$zw=nLWZ)pcbR%n|#jt(ozjzcYXoyU!=x8wjpK{!PSzHFnwhHQmeB=(v;aXc}M{l+?+1R>{--AfDd{XS_qL z*n)ID)8{MIE;OL)&5Ox7HAc*&2XqBcbD2LX`-Py&jI!I5|5$T!t4H9@p+|NvXCN3`7?;$83RUQMiJ!=ci;3);NcYhJ*Y)cYtELg6Xxisq! zYxW~OtWJ^rw-JY!s0e8sNi~tCmU_ za^x9P9wzLTwafagUUx9m5|=)T=Ozo=Cy#29msp7Qa{;}|>-viZRCY3r+O?&n-=1Ri zqao(E!qy8kZiLTV;wRDNOhvueG^lQ+v9=kWNYFGmZK*(O(utk+^||#_a{ICHf7fI2 zExp>;C3;nkM;#WK6tIS@dS~JzB#6I89CP_@ThJm>Vg*qc*-)e>Q$IvV2+#&a*s@7N zqaM79$GtJv`A22bK&1x@z?I>KMudX=C*PiqPypL&W;YRXG{^|?LR5INrrA(-OMU4L z4Xt|iU+ack+og8lph^O~bw`}aB!tRECR@&eCX7$3 zt;=zK&uf%^KG!gG`kjAmS8(b#XK3K)e~NCg3#DS>#@b_l6v=kijW2hGv3QO<@ac}& z4v|1I9V0B*ohiqVT(i|rdE_c5mcob8IZi3LGKGPnU&ioY#SSa2!77a-s(3CHrp;ma zBp7kPt{hwF&8=0_s97G|=f^BPQ?GfMtjiDIWf%>ssGEDf|DoSzG!7rkyQ)muRvoX# zEQ+HT540sQx4x#Xq4zMSTf4-S^vT*FjZt*|@usx&wXS_9J>3n5Yi~CtNG2X0yK~5+ z5>|jQ^W)`#?hL%qF9;%h0adSG33sib<&zGU#BKGeX6AN}KxMPEk0=Gz-3J>i>hFZ$ za>b&Vne;Nj&ooG;l<5GOGEW@(ZH-M&pHv}v=o9*V4P297eG>{*;;d3av_1^gH$#A( zlIHH(6ER{O2NluTE|_o-@rPya#9` zORXn0)VYuagD-&pX<9NsOjH5FhX4E(NW8EXkrlI5%M-O=w#;-ovIZj+_}frb$71v# zDw3D0Mx<3MG==Z}ZlPJIwIW7!ymJlyb(wdduk}TZfk(Nv+jjlzeW+ZWA8HzN{jg?- zE-)C~_w#nGMuVlx)&(6(_}=xPQ-jSne#J z=9jNc$xmQ}f3IApJLhSRn#ez>eAuHp>^zGond&|()0|6~eR!tfK>>97r8jfOkddYU z1@ag%dY9Al6OQ%fdIWoapV1$*t55A=AxeApXfz>8+L&n{GwY$Z8L*l9;Vd}b+{4gUGB#ym~_np}mtBv_Vy zn$>sC`%nueQ2kg7HIqnOXigQzeaK&5yI6KE?D^a;m58d-WSSANSktp%--)(&?LyhU z@IgOpIGojEgr(KYDCuY-HFeINuK+Zr+Pfmu$vqOqJWojWiZRU4WT@I{GY?H@?Rg^m z0!!5JHPd02oBS#^9p>C-8oJRuEj^irg`i^@WoZC5Czy*1tyW>~5oo&~rhbSDaLXEE zSH;2<-ImFqG|Y##?h2-53Ag|e62~^oleQ)NWW)HB(MS$|`>MPxU(H*|KTkH+R>K!A zgjY|lMsm5x>IEhD_MAd4Dc!D8>U$n+OUhp<&poHy$Tu4KUg7H1!UA4+jC(Kl0}RJA z($z4hw!GUNO#@ps-^FICc`yOVnpLag>l*<0(wuTFU8C8Z*;SQYooOiaP3#u(t^~A%mP-hH62IsPJ-5G~k%Z+O94c`+ zo#cf?4sHo#VlwGX(QYh9tp-_@(UE-8$ijJKF3$5W|+qSP5MobX!dV_Q3CZI2)2F40-dc@v6s@cvaUa4-|Jwa zPNQiQSMo3ie=*Fzqp|i-9nM-!rN`)jwdZFcvior!jQ5$EfYV8`$u{ z!|U*m^y!uj>prG!>NA4d@9$%Jp#A>r$;?i!LyzAF!+?4!cPfHxc5q_Q^y#W8>zg|I ztl;Wq=w#7Fy_>E9H=qC;zX+RMt3$7%L5asi!!FsI^vN!MY%i6`7#l_=lk&b85h7B& zP|OP9K#+(cAB>P7AC=N^Q3%BqElL70ArKA)NFWf4gcA|fCXUy;$?fudi2ea*`FJ8# z6=OnB76qWy#c14!1p>ifP+Lz%gHkb;x(g;nGzya>8t0>8JP;03R6X#ei5ShzOk)ET zUe|=SuSmb$p^(9B^J<1_Jl(dR%nO0I>1r)k@4A?KcP51VD4$F5YKWJU;qv^j0*8V| z5C7H1axWMSMWnF$jR4eu7cXfg;aNLa4yuwW*4j2x0fB(Flrm&PN$2nY03tg`8JTo$ zxOP7M^;kwQT2-=*twvTkO-LmjQBn_Bd%jwT(PH+#IhxamIn{S)a&Uc~P7%1k9FTM7 zo$BX};_}%)GforCr}$FeIG}Ke>=cd9t9P0p+1bYFaExVD>>0@!3TI>LSwJNBa-5D? zWxA9ct3hh%Mg-3OyHvZ(l*!a@kCJwmYB@Cji#sLoPHEZNesbGdF8#;ol3LPpY9PQ1 zVoF`AhSSkVCMgQxV8AAUuplP2xEP7Al$U^j@IjKvW-CkOjIP?K)`-K%vx$b0@C&ez42j8`1mF zn)-Jp1MI~Bk7Auc@}Ua!4t*7&%H|W??Kc3;!j03#=S7Ss34T%dhGErC+SPROARY?@@%1xdo{xluU?f@1D)DGA7>$c| z!IlKiKA}hi+D%Gj^Yx?}k1eMnR} zr%0pY&|n8`?u$7pCVPY%_C|~AeN3|s%-?KYG+=9)uIGp6yd$MghUigv^a;*QZaf^) z^%SP-TUBVbyoK`pq{8i)Z-)Q0Ly6}oo>b8Z!$xlm)z4dnx}sd4uE@g@Z{}pjfu{4* zZ=Pa|`EzA|_G6Y>rh%r~k0Z&oOy8f9DxMoNxJ)nPv{&XKI9 z4%%05a(>2~Fju0K=i7aG2-h>gcw^U^ebBAP6=roIpMCh4{e$-L75Lac2<;S%zU3`! zRFH2^U5a)i7b9*IKA}({y^_hWhcD_5lk=WDs*wr>Mp z{uc7K#A&Wtge2(RELUXaNy@S>QgY}Y!V#u^bw-%Wec!>HK0?sA(+CNXC?AUj;4mn_ z$uJCBIt?-9`^UnekPtH9rce-W!F?$FP7aOhN5 zWysqzlh3cjR_^sb=k55)*+jM;Jc#Gbj$ zxfhme%Zr-pD9*$%?UOd9XRTOFRqreGp$F+ooG6)~R=NsjSo#5l`IIpst^t(@7QPP^2NQhcm0Zu&jv~*UAE9rRR3gBLrd#iV(#n74x z(;ahyBD%#-$48ZTTyN`bm*vtP_IhL6_RtxN?rw5I!bu1{;y9B7!fB3*XFX>Jjn?XX z?SiQkv52^5K~=9LLxkrc(a8J`d`;VPNMN${dePMT8-vL_63g&P%~TnBlz`z~3eUXyIxG+(WT?m^G+M^H8>? zN#*ey75SiBPp__q*DtKvs~6V8^TVH?n*&ZX8cJ!kp_Wx4B&PUyBNEQ@(bdtBU$Ddd zxshPde;y^Vc+>BMiME>hskPWc1V6vHc|q zdearZpkL|Y1-*WM^t5^8lU zQ{B6t&xpI}+}Fx_&dffu$YtMRkWUzIS}ksq_4{LDPX3FpCAn%&zq!b1|HWI3$>YY; zPM}z(`6>QA`2+ILeVse@9j>9kg@NI{Qo>ms7x#ol(5Zt8{JE+=HEHTIH%amblPB{1 z>>)pu?3Kx{Leo5I2cVGxYUUeKT2B7F_a=r6;^c?&y~ExrS3VAu%Axk@f&9KeR)&k7 zI<+T-XKPi?X%bh*HQN9(V?Het$G*QR1znqx?DvPeL(Dp?A_nq2D&3Az!lig^no*}f z{dqeTwu}NxV#*Q-%~sj~WYw@#j4VC2PSct`ms~|n+<<8dKkx!c1 zH($$aWSXOAsP`Jn@~gt=T49`co`H>JVPgu>sd{>Pa zZ``;62Up|qkRUt;Tb{P}Q_~$C6V9jkODgY`n-EUFjaky|$sua5!Ko*rm+pWP!SX|ll zP#&(^tQl)k*DXpHkFNE)KBWI{UGnuNrq}bz-l7#V!`2rf%q73Wd;xWpMz|={$UCucLOUib=zO5|9t*qIa{IsM*ACrap7>CL0g6pAVjNFxz0g~gg?1bdfBRRK# z@FUdXwy@sQEzKpjr^^%E#j+vO&F%pM%)5(#6J!gdaYL?$K+N~%eigplquaS{-`-W5 z102NN90xcuhIt=@XQURhuMtZS0G+!P3eh^49;ht{bbUJ4vM>XqU=euG7}p4y^oE*B z$+dRuaqUX{m*MkWa7;LQ%h+?A(NG%?HP87fxD!skh z1++}>!ri@*=k0i2BBBJ$mfDO94#w>RFG0OzkY*UfS-p+cOT=WZ>1!ihR7RT9Z;jc? z4`|UsZHYcpP!g9=k2F*N@AoynsW8<{4hu|Gd7Z6+`(dM1Y8cXGs;CT+h%xzZ;Z!Wl zlOB;9MU!kMl1H*m54O?Evm6hPX4Dso{Gc*s5_i;peXcX84&I<#(iK6 zRyw++Q+Y%O9J1w3gYKE$ou!4Nab*iQkg}a)GVxCQIgpeLd`-5JccWp^sAQMrPQON6E3REyEjfuTQ(q!tLQtKxGjYCo`9;3j&>j5NsJKd? zd0%tooY_s5&RdcYuPw;PQJ6hdu7TXrmw{k3xaQ2*W6siLT2!7yhaMQvpUKz$OF;fi zEq{Yquk-8Qw6ilDj<2b;yvir5Pr>=A`T9r0l@9~884f*)E&emb7Pw*nuBmcqA*VW3 zajGC;(QL=F<)SKc3B^Lp0J1BOA7BL9k!99go@Nq-zag&b8Bga9{6z1^eXE|P$kkFz zw&hr$kw{V!1SuJb1k|jt zfy`jT$f^O_$2}_jGCvrHtsckw7z2P^=hpSF=8GwgyqMS*W8NNHK;#Q-_d8-n%V4t6 z=lM)rRpS}{d1t)n>EWo*_99%Pv&W;eM+AzsaU^>OrXHrD?M#40UnHuJpbH$bFj9Uo zokt^^$!^0Zy8PsF^y;QvTh-Oj`8S>qsrqUSI7qqcb;{+A*DZVD6c7JW5ndGee=5e! zR6G~YR;yXqrOY^67n`m~>Cn7}2KtjS?HOIijN&h*UbD~%TcwNpVpev{Wla(PKDCLA z0BG;Qc?aXvn7&2z+Zxw(JApZUj@rR*v#{*!%q^YKzKxCiV7`73?0zLerN}1_dDva@ zMN1I)=}%B;$yhl1(-s$oUHrrMX!-IT@)?cVz*ebDe#nFCc1fyCOsuN5P6n*m(ZPIO z^+G$>$-e(Fbtwd>GN+PkBV;HqJGs`$s(txnxo=C^vW(#d#G6~L2j`>FDyC2RhWnS+ zJeehfxo?|2_jnt#9;@?M()k1}JS8O(w7g_u zm7f2OBr9rzmh`XacFiw+ah3WA=E|B`Zo?g#zzf-&P95ecrZpSBrVibgVLe z(Fz~SXHtOukcI&qIh609c-TLgu1!jP32E-fmNxT99w^(}X+5xNhJ}C-)M_dpJmix6 z>v}S!=g)P7NI0|F$cEzcOeGj)nxV1cxc*d|CXek-4NGQaYKLJ9NrOyrFtN%0#Or~m zQY&PYWIPfIVK_)A6p1I5Y@voAq=y?va`c}xpKk#@2F3e@8Yiu4fK4ZhGJ_qHqqQ9r81xJpY3|7nspW<*Y4hyBj&nI8#~iztUhqoA4EcyK zXs!oWH<2T<#9oIT-VrMkZpYg@o>H=&+NTCfl*x;Ibu{kOgi1y&m!-%7o;)vWJ@o}e8R6ZhykU*{0C{Ves0+GFc{QH3R;OM-ajvAd6%baUI)zP@#M zZ3noL1oG1X$WO0C8K2?adX%#NAEJ))mt$0t2DkuqJoeN(aD!A5P(xiTN=ap1xyC0Y z38NP*vP3&`N+^@aNOYq=d8* zw$lTLp8w8d-pfP5oA?m@SLk+)ovQvcZO#;D-26lG$K<0!cYa+4U9?Av?fOM`)`ZfAAAwz(QR!IX54 zf7#zn{yX`E@!;WQq#pV#dT6Q22udYAS}f_&SS`%XF{08SM|{oxOdTOCM4sTpBp;Aw z#yl?`8=~eAHsqtyk~|s8OG`2t%5Oq&Z{r3r-a`3k1M}D7@XM9A^dm;%;8eiy?|9e3 z1F^_mx_PBX%VKo0OY(Pe+_$^Ot1m87O6W2vx3wI|NaKc>%>{Mdp5 z^@hjwjr^y#>2r`fbZ`}!HVRj5s#BO(rai60e}kt!&nht5V;1t!$o6ZIQq`2R%dF7XW%<}%swKuDNgygM$`M_K))#}1j=n8G2p*JkoorR)wblwFS+j2D>mxr8X z1^O-{vtC}$*v&Jc(3xhc2VYYRcnR$6$!03VLffamc_fr-o`HKC>&Ef*%t`o`W{~RP z*-2xbg#p(Sr95M{qG-DV2(yF06XYSQnlf9A(h{eccjEI;}OLV2p`AF5~BNW5d zct6eXd_Yb{16eC;^1vU4Lrbb?S}GI6u@^it_Ku9V%Hp{C2^d#D0j1f4g`q?T&)j~@ zG=Anhn#x}%!k60^zoH2ZFh&Lh9*rqIK**;DKTDUlqizB$CBNsf6*E;8cy{&X7s#KH zkHYL&rIWb|pPImE+HQi*}vWYd;69r5sL3aX2$iSz>W1Mw``wUge z{E5Vg%Akoy#_2;V@n-LAONUD9$rMzDBD3fZnM+qle>i1oj!BQ0 zGrazZ0j0NmAxRTVBif4H_$@_@WeLGi+ZJm8D13SPQem;6LyN|O#)C)EY@p1Powu%6 zcy5+ILfFw9V;t>q0Ent&kXL zkiGe5!@eRH%oNP{tHmZCWM@AWm@~Iy)Myn_fp3PNQ@9^!2FRVZ}hFT#%aYkpgopWH>G-y|1Ay|@*2QzTjG1R!n@m48Bu}}skC3p zNY@mVJjU^%f1@ZWA}|+`INcm6v(oVeOf^_R;3tfKeJxFs!g`fVLz`AUd4rsaEt;Y^ zA?=7swif11gSSMR!wBGtW+NbkrlT-eGCtr8E(OB!^xZUyrXcI5^0iL@ghu%Y*Fc z0a81?N!P;=g6QI!-1(ekSGzlIry5mlv)OQp0Z#^=lVzp9+WnLi0000000sa6c${Nk zW`F=z2CM)80Pp|_c${NkWME+6Vo+nyU|@1_^9f<#%SkNBV~}8A0g5yH|IYvi0Bx5A HT>t<8@YQeB 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 0000000000000000000000000000000000000000..90daa2b9e91dd344ea8d1ae6b80dd75abc531d21 GIT binary patch literal 24396 zcmY&zx%&mZlCj1*HhJ1-Df`Z z%=FZ_D@aHHKmgxOUlsuSUtc-%KmMQo|2Ih$HIeUeMBi5GZ_u>7w%jYIFtPvuAU@w* z_#5g9_1g2swuTM>0B`{S05=8z-~jleP(X7VPcr}jrUn2|RR;jl9_MwYXDv((O}=BH zzT-Il!vGfEiN&|^+a~nQ@xDO@+6U5aVe8`YZR`04+ILD3-Ui4 z$I}VwBl0}}V1eHT0KkVlv**A5a9*Go0Ql+w0zd}8DHPy;Zh%LIv4M$!0pIl(02CAz z6bkS|0z$P13?S124F7+=2D6_NL_GkYtk48_5FFfBuMY%(@D&L0zgQ!KjGmsco}S~r zl%bxU9fZPe7AcknCMG848WvUqa1hHXYB+?i-~Kl}U!ZVaQIgmh{(VtI+<-b>c*^gE zcx3!i>XZ%?M#2N5g)Jw~3nmr|r~rdr`8ncBZKLcF#3$0S_UR3Vo81R_{lLQ8yj{#u zw^1utU+?V*zHNkyzjFv3Uydt3X0@WA}=YSqmf(aanaDhy}@#{S0vhM7cN zh)Ec1!NUNl01bRWjV(u=n7SGXO;k*Z*aIn_iew80!hS{sIh=$_f*!e%@0#TGAzSt1 z*3~q&<7By79~87F$IOB`N&cC0AMy4$Thij)E8&XTjp1~f{T$;t0X`P~w(||F;i3yf z)a}dP(7Pdto1k_a^y@2RzYn&i99Uhl%how^1LUoC=gO+Pg8pf-4B1*m3v_EHL|Ez9 zLp|_n20OPDM6XHiBsQ@3*67(k?`)fP{P5|9xqbP>sv(rSX_Ltm7PL-<=IVK5JA3}& z#;`N`=eBr8-ujI+uApbOjv3?u&v=jeXsi*#Y8Z@T18^NRL z7un9wv?S-nGUtC^+iSE2!Mb=0>~puTT9P~hTLLf{#Y>(bl;5e2RwF^x*Ib2l^($o> zdu#93+SqqYyv=)H&lQ03weFByPnD1XZ5)upKs^iI-)D{38|*uSZrT2ekKrJUwUIzQ zt4_>78T{@E0t|;XU4m0eM<%`d%R_XOqsrIgH%1`=H^^-iXMS`(2M$pa+NSEmKu;P9K{?Vk~H3G zihTO67!ZTM8tT3OQ?m%rjvAfS|5NSYBHk2_igG-b@%!p0>2w<_rgfzu$rzkPcON9;G zYj<4!ox?XI4?cL*_!g9tmEML6oK}!~y3M)2D9XtyZd(u7dv|Sy95nB2XQi4lwdbSC z0`>09@jy7gz(w1Z;h(1JG{*D3JjTaFoA&KY2-xHGX#yMkW$mV<*s-+=8n}0Lc7>02 zlz%mSzquXpX1D$1yM3p}|MZXyKDa$A$W4_6{Zg<$9=QOy8Mz;M3;7m>6vYE&6;%s00d)cO8chH#2CWvI9GxHC4BZnw61^D% z976#k2jd!32QvaQ1q%U-4NC{B4eJe?3|k&M1iKOY5eE-P3MUF@3|A1h5)X){kGF?! zkAFa5O)x-6Na#tpPJ~G$P2@pTL3BtALo7@jPdr3|L{dmHPl`$EM}|!%K&DIPLsml8 zLv~M2KrTgYMIJ!jK>k3XLGg#;hEjskhVnOMEM+O>1r;}y4^ChI91Et>{g3fnF_2fGJ{3P%SgBBw0pIF~qAC)WeF z8TT^}8jm_pAWs9&JueHdId2Z{J|8Kc7heb81HTl1FnUl$tJvRf5O8PJPrmZ!twWn zklM*)pkk3oL_G%MAZLtr>&R-5fLaEOvDae^n>}?7^)>FRnOp5~+kKxHE9I@*?(!$I z{QCnAt9GRX#nQ#{nZr*X-NP>km^)q>(nBUa;#|S|QZnP$i$iW2_Lga7>OAY447(HR zXP4^ZHhQ-5?&%t%Ij#z@0&i`Fya+a23=^K5lqMpd?9HQ_qnW!?&WMf~bBk=9h3>ez zy7TN=m@ENHi(q;?xbWpxH#qj}6%x&7cJ1(8yKaFO7<_Z*Dc8Zkl8cyi4B6(&G#a1s zXoLb^Yq#<#N-=t?5^g!<=Ci&rGjc2%qdGEtIfT`d3}fEbhL4A{;^kq2_d;jxTSl}} zT}-E8Gr{}2`T0FJ`T#*VjQUEP>^N1~tWP@zewPel6L7!%+W9~@?-M*#j3*|{z-Hgy zD!XFiPyq}-pBr^H2ko21eJOB$S>!*@hz2Zi6}uC>{=Cs3h&UY8Hm0o&^(}m^4Q+mK zhUdTC=^u;^%~On=ngp$r>`F&%#naf6o!V>hY6O{_4L%&X_Nf`W!Fxb~ZEaBL=R+=P0)@lGH%nEZ&AXs_0P!X0zCX5Xy zXi#K?krrBhIWO3>f6zH^V+1@1(a zf8y^kANZpiQ7(#}X9tgkgx|wG<|pue&~2D3e=mE@ds}*AFWdJl>(>Fp;G*Vl$WS|y z4N{$$cPf01jJYJ;V~i?agKow5Swi|QAqdXaqnl&Ti3?`=P~>N}ElmyDdx_ss=I-Y& z-Mzft4o8Ufyt<7fLX4>Q1l(TxxwnhpGKFfV-GjlJFG-h?LRR+Q~98~E)hnLMefq6*^B_T;_9c9gxVa1G5Qx*+$dSy zLhho0FII+x1B;d;Bm|ad1wZ*eDvnOXzQ=Ot4DN5psOKp{b%*P6-aNmzf_?p z#eA-_5o%VW=N^v@zFd81WjVBP$VTwM;u4aFE5jtQxj!D_J09)ETJpFW@=ib@jFEZKL=;f%RSdllBwV;20_1v?C`X5bp(TMkTSnmE*_} z)Bkm9rL1vcQ`dk`J>O6r{I>=xPapBPnw@v~VxC;WEJWeMgG!7FjSV`C_HgspyR{XH(7oPrm&<40pI^U2tpG{&b3UA>BEx!L%ZwDM{xsI86@|C?b+F z>Dk;rmF{PnrdZ(jlF5c%!x&3+PTXw;0{@6%=Gx?hU61mmW1%WF$^!J(mg$|Gec}=)%0$Iy0QCHZAPPcD+yX4$*p2l5WxnFJH~A~i=EnYIMdE_Q)9+?bN8`mj zJnnt`Uu0u+Y$tg~#ILD9JPH#yNr)V`D!mg`I@eSVbu3#p5JQk0u$fi`8~bG?>i8PR z^0L6+&imxY=&koIeo2>jaB+6E@7{6UP_7NZ9+j2Ty)E3l4!^Z}P{D;CMjeY;1abb_ z{~iC6hjnuQC0j5blJ{P%NaIVI1ydQRL{EbfbNN`M+a7}%&9;8ITY?wkSZ>*ZufhXU zg5&{)dGzNz8h!CEEla{iP=r>gN_^!e2+9(4f7em`FZ&h3_+1yMIQY~lIXkJ4Etgm> zqQCHuLn$G*iK1uoflm*|Z-?<|Bk1lF$y(QaRJv^8f&FxMfjct$a zSqWw|B%3$$G$;wv@(iRx@{!rreuMr=ewsMxf_F#L)jso-$n+ib@SfmvaesKGcdZ8GP&Dt6qgP5E6JXtpHGUL&qIk;AYmhh$KSNWBB zM30GnYL375_%y;^HJ*8R`ELu1992k)6B*1OO`#Tm#82i!G~c{Y#dwlOX0NH+9NA8@ z#b#aecrIkoPt@;jJuhHwd(#rgDjNinxvWtE!wA;jYe0epA2O=dLQpXvZ8_{aUB>pzZ{!d>{1V?Y;22IgBmctAQcN`7}JM^UPH zH7P>Lh{&R-3ZyFi4M`gF1K-PZkPEi-Vmh!Xy5PXcAzwPUndp zmW_{w9@P%ccpn%A?_snlauciLaXY%_@XXUT$mi^C#6A*i z2o*y4SDiWT2Uw1-zh*YfG0liK5i@5sN4iFKkK8(#7@y&U z;hUke=}p57T#@S2&5Rz_VHZA)R(R=u$!OS7L+OiVfCss2)g20|at+mGV3p zetS)?SQYH-T1O8usF9wG55$q1$OO^GBvFP3(*~AExJ^-ZNOrg3gsTC$8P~}A>D;xu z>VC5p7ub7Z15iaH8~)(jw<)C+{*~=*NgaRO(3Q{?sM`{P1EvtX8t|pqrS35VUVIO1 z59WuFms{)i-Mo%*^5sf7nd0=^;{Et3H$+07{C`-&f?PRmh3FobLv8+e8%{!A{`I@8 zd=2RZj_ERlYr0n%+rp+=(R>^&@fvj4%2q|+KOB~&L6r{ z?w{MWIJNsAP4dHY9ed~M+c-S+qyuh-Py+8kszJaA{Y(5J76?CgL9b>m_I{1P87zyJ z*|TpL0qhlXfg|mrwM5xc;&rtP(!P~BV1Io43p^dk388{S9A9FbDvb^!PE44{vLYj$ zc&LjW6yp#>NCH|=V!)uohr5ts!25-YAMV8U%JuAn1CZnTfv-PAdCkilu?PGWxlm@K z-%fW^-hDkt2r>Fs3rfiAQdLDDaR^~#AZTt|kQvaTf+nJT*Aklw4u_v+Y?(*sc!EqL zd}Waz7?rA8jEP%HOUlq(QUwil8B!yra!EU?e^yT*ueXNRjGy~v$4#FHy!FKb9k#Q%0lC{8QA9*oHr z39|x)cZP^aq&fWKQU%<=(5&0CNm)i%M3D@`eg)E)Io}!pH1wp8RG-HSDVJ@%gYI#e zGa!vO1Ahfe&)!o<1+vj4T;lwVrBf^sAk421ob`PGlSX_|*OFnUy8uXTTP|9JNilRMH{scb!b z>%OO`YBMchgJ^+G*4btIbPRF>@gV>}F#m)kYTqV=gUJ6pN0i~(H>4KIcon({g8nkamP&cVzW<~sZEY%+4kI|5$QmY z89Ql87wl=>D}yQO`BMY=n$j)7p%g6>V=hVdeP|LRc`VFMm3c8iH<`k6pP61yLzeSB zK!tA}^G)FTZweEKR)q1Kd>64f)%60%CRSTtI2DUU7Z8H5F9u|D1y5aMS^@8#3x)tO&VY~L*O!(?zchD&U(su80Cskr_&d- zMES0WKJ|YZD>Jp7ay9W3Ii-X%xTX;4@YmQrs0ST4Z&+fDXRvlU;>I0c6(taliK%jO zLips1(l!cx?);^(rb0aq9Zee^U?$1QF>za~c4!S{8VTf(d}hT%Se$hQgSV9JlZg`> zS_n8|63v>&MZhz6;0oterU;EW)}Tx9O~1d!gVYM}P7kC)rY&6lq>q?2RHggxJ(6DHP03M8qWoy-)SN}Tgl%OkqqJXl z9%S)Gi4A5NOLS5~2*;rWu8*hG8}}b3ZZ1YbYo!0+kRh)`Q{Za1<&; zkmJ6hzlH;ZYrmt=Krm+pf9F7@a^%yjncBpd9+<@Q>j*do3q{K|<*7qg{%m^M*VEQ%<6Ey2PW9={?#?DP zcKH4ZyGq+_KfAy^C~AHiDbIqwvse!yLe){w)3+tv7ICUqn%=pN#&fbC7})B8 zoW}4pqJgK*zm0yZzA)Z^rv@D`tL^?7LS^TGJ+bU#id~# zk&T5&u$}pFsaAyUM~jKg>yc2fR~oW{+!thJz%>C2iqS%3Q=9L+SX&`v6{{%8*^(eY8#+ks}-HDv$tt%^epqh zV*agIpHYgJai6@1;`e_Ryr!$=01-(h)lJ-e0{OT$314`&_{;9lTDTmX zrfga3`Cq#h)RuYuyJy2n`a%6iMb~kk5Ug2F|6aLMRgmT9@&`?0af;dL69g4 z$b0gvHJQx`gt3-n`ihn+=Jx0Ofeet>d^}zEkJM)v9iHBY;`Y;FFlFTW`;RjdHu)*3 z))eeHWRe{lk}~wT4LS~tSR>s8paeV=4uJhtSQxlsk!vY_^^*q*+++aBV4>v6&Z)Ca zc;R_|SM2$ur5iU(;MXf@R`VG$;YlxlzptOgLUHz=XI_z9UXK&*_-%yXCQs(6oGP^Y0u5M_-XmckQzmEk@g88`xSetxpGXw zP#dBAkW%7JtCTo~#Z<>jLH`wKFgEi;r}?jtkqi%_O+c?2wY!OSlH$;tLS?e>JM#@> zOqmtl!gWOzcuoP&OgDxUPIBuol^i-<*&S~O<Ph79>AH>%Q@>54)CDPxar`=U3+2mVTI!pH;S3b8TMe>A}-8 zDKaW$LY~hkqwZ(ddw>83s-PpMQ4a{8KP2Hr0-u=v!9qr9TR(*lWE$@|YjsWf$Qx$X zE1an`xnXrCz<0v85gN0BS%JQq6}W&I`~Du`Hzjm-k`mIIk~uI*b=(8JP+@?#$8b{d zF+aZwM_)SSrY)m$lEOl;@o?xX!5QMozP_3VN7t+RsO`}0i+LeonEABSOEph?V+tEn z5Q@Xhp%8A{eQ#NK{|ZZH>@9kGpWnjv1@&r$(LThP6};j(;h*Zz?L;9Kac_axAsG!@ zQOMZ_o-rDWC9uCReY%_$>}^$%Ydq(>K7l^DsG2``GN(o-DJ!5-3E-5ug)6T*D)lye zY(;;z&Y(+)m3KeHc-=fakw?4p$4em{t+BuLPHC3jyVt2|=wr?_5uR_5kIrBHs#HZe zqw(x);wOT}ie!ByfE244vYkPO&VbTv&DrNI`y`$TOBI%TfVY~VorM;D>OP<}ndINK zs+;3$O0Cd*2&(YXns9qORB!tF$AxM8h9V_wZt7t!Cto~oMv@r7yLjZy4^w$!)q8br;73z<_C`qn75B{#`2NL0rV%}URM<7{?WBYgnU;8_snWI%FEa@T~ zOm1Yafsh;&Z>|GZ11Z^p5R(Tp8QA2yZj4m@c$m!lnn>rX6Z%V{=+O9Looc(I)endy zGPYe`4aB`^x-`&KwR01rOnuzwrKzjQ`s1hK{gx+-yOVnJY&(HdrD~ihP=a%%bpHL` zDscn6t6zt#L>8L|A^qByPXC(D+iqv5r|nutm42~|Er2q>Kotvh`j*6I%wX6#iy?$O zZNX;W*tqWk&ncvYy+qsX*FT8)i(Z^m)sq2<7d_0ultL*=)P`EBeYu-L!SUgi==$|g zw$&`DS+2iOrbmHh!m61^6yX@?;QzjD6 zz!z!8WIMN>Mh|4U#H&OekSAcN!cIu ztsj9g!g_4dMkoY=ET^R;7~>4+iJ=CBua7kpz2M#yYIHq zF3oMSAw3ZZ3Eg)h1%J0JZPCX3E8ukF=6rI2EnjAZJ;maBoCAq`BehP>5Dq6?)jxT_KZ6b&MUM-~rV2>_10L%_ zD8q)JF({C{7KVdXyql@{` zE9oMAclfzaj4m>_TtB6X-r#1@*TQ9`RW}LZk_K6S3V1@gmjm|_)#Pj^ zInIZSZtK_)`TAG);XHM5k=6XEpyWuL9LsiV^6UJ~{CnTx7Xm*ig0etb4wzb6uP=&K?5xYhOHb1W8Gh0`IH@9`$bBY-f4G;MhCPFrLViQu?ygL zMir8Zmc?txbEidkpgi2R89k|1PLE}7ZLNaYtgOx!>OYNl)o_ICV3!P?B74{E0ds5= z$CxoE!H^bo(WaB$-?z17DI!}{&<`)aermsouV2E4j_Z9Y|45R?&U|?lTr1spnc9^> zcv#-|nu)|;!Wr;GOZ+=a#Me({Qlr2R4*O|W#HE)aJ{*;#qWM=d+z@B@d-}0e+J{O+2ZftOb-rcMAoVW)SVK1&?S9ctavd`!=h!2 zP@?S}kS)#-mGfeQk4UOH-q9flO!^mYXI9$D;CpNeSvG4&bZ0spag!KD`seTv2mEK@ z<@OzEKVx01I}W9vDDUJP@$3bd@!~%J5Mq*ufNvH2rh-;%LZ~crckzI8Et+KoxX+fN zX3_7EJ}~TZa0poPJ*e7WdvKhkoDG?vFvIae=XvT`Nvc0!YG?j}i=kwIb(qs!8i&12csVbDj zDL0f`2ODu{(^aTl7(E0*U&VF4NFE@LJc;O<&OY`|*p0;K8rk+6tf3&aBusY_cUlTz zBqCxRB*c3CbCQn#J}B9a8}h-W6f}?}{QCLSn9eRf?VC2Nm6nLC-4UhN7F0~}H#iH( zA~|-sWi&~qLwA^rL+|>}NfI{?NLyH_4BA9`g*^D8Ed+f%OBm1|;-Ms?Ys_n==18SH z?!fUdJ9x8dCV`*H!=Bq_nvS4S^pSjD5q;T4y!n+7HWL2pSf75>otrtZqE|iz< zT=HdOj0!W`*3K@Lv(O~M)Pkf!$2Wk4oqcd=x)CgpK+{emGyG6!BpiKA@xZD>lbkjy z_v-|*1j?Z9)@^wml$k$5&$mFT@vbHH^Hup~5~KG;B@z?LwqkPd#vNr$r3f-^++p=F zL<(BJNUQM9HA5{x#3dR zqj~))hw)C@)-7}$Lb#vZe!kT13nCfJUr6*I?Qe70>S-PmSQMTwdl zhKDdb*x1~8(d*+YCoRT;0-ZS3Eny?zRE11MvpSFXG&=u?JOmmt1~wi& z7)l(+I<%-8Ye!o??tXS@ zNAVXJ2Wsozwq@z&yIjS5l~7t*bD7|@8hXo@zp1Lu$csK?C==V%;(Or)MufJ~@1)|f-ykig_uk(UseRV8z8!97b2 z!-JR1Gh*O}E+Dawkq{X@PA7)tdLhf`Ny?n4u9)OuiV)6qVL_9o<7rK8+{;yzM!1O> zdEdggAIw+1=U5p0h*$__mZY(1>&w7yq@o2%JX!sa)d|YF>om*-gV#XWzY*iZM|L!j zusVq+lW{Q$ZnPwTtn*ZWMe#40X|^?hgoa_3?B8HJGGrzm=N7tIfdvfPm;YUR@A@jL zehXDrS{B&|O_IXf03tZLJQe;a|L2ZFgZ|kWcB-3Bf%wNp4bBKNo`bYhk|+qz^Wb zimqp7_KXV=U?IGWPFML4#cot)X4#>^dA;>XFi8`7`D`~4Zj{~N?Kt1zGxI{n+Z3CG z7mg&BK`b9#SdVk-sb@^$Re|8AWzpx)I8Jo|*pmr{-opT@F1-4&&EGoP&7zF6wJ_we zmmLEQHNrgnGJsLc%DA?rTd(_F{;{0ePAN2)-N{Hf2IFu-E?TL&89UfIvJ7-()TN>* zQPR})bo5scx~u!AOA)#LM-D5j%e~Z1^VhHZg~%>7Q)+JaK}2h#x@+)CUeuF!jd0{fP*5 zQNK(k#WLqK(ZM#scwo?A$R9wLmdWXi5mLdr;5v+P+W1X=@4!e2;pnBbu}zvGz~@fU zn|+Nysm51N`e4`a#Q)p9&keRuT8(f|@G3zzvubi6Eud__NjkCa3K0@UC%>7sL4tH- zdYcW(Gnq{42O!L;{EhfT70$N9Chv@ZbuF4`cxa2PBb{GXAzXhe`#Alzb*CNONLek| zy0qf8RbQ=M@mOQ4R@G%`8eCDav|cD(xKLG%5KVV|ZD;4{VOd)IpFemB9Obe}ME#>4?*p}C zR_@~Q%B2-n7Kz5%vJTh$3D-X`{;94gG_kzajl%vKEwQI~VA;s)m$f)q@Deh19neO! zO_Y{xEMD7cn-}nvrE?!oSED>iMJ{zsR(Tph!m3cy+iVN^c$h1L<~$t~lcp|4mz0kM z?L!B^yH-yrjZ^N+9DljRc*EZB8>?FHoVm$vGg~`~1P3;073$?5KiV0F|K4Mb#tT;Q z!n(3l3cbYu&aQv-5Sz8-aH=mvIGC&A&HTuw%>QF_VnmTT5teRLR1r2TMy}RWmmY)* z2NCWfJ5Fiul&)Exk+(c{-=%F&_@@8~@vB8?8J)VU%;w9x*M*LZOGFAD+J#iSA^f5Q zL;x+ZFSZmp9MO_E%kF$oH#}^thT;y>#Le@V5-3EyEDdoi|D-TF!sWZcB%kp-J_XC@ zvh!BwCqG09=Y>@?nmKjHw#w8(ZuO^tU^D$Qs#6wtzi2oe-HY;Rc|9OiHVvgRjlyW^W1*F$17N7yN|bLulral{FoSV zf3oER+j$=bS55OP*1{`NN8XKxR5d^KA|CeOZW>ve&NY~knrxu^I}vSHN|^NgHk4TYu&l7M zhYCyq5(;5tQC~_3)9T-VQ8qc5t%t-jNM`WtzuGePX<-GxKhyvQ=2&qMD#_UJUJIVl zx#zTOh@BVFV2!e#s=jfO?1rTCCT5;|+sl*EBSz1y!Xp(l{pm#gSXYn5 z?To8|=^Or0c21`~O^DjPw4vvRKrK?2#lC8Pc}OLYHNcNN0JLOz5q-<$STd#u=gU#=aL8O+L;NQx-TYdX&*DL%k^^;cO#hq^$jfrfh=7^4 zWy>UF6A2C;A_9$`jv{W7B2j4_)WtzJodSduT?jM_077|Q($&XagEgn>blYn6bs2Jm z;|LIpRX>8Av}lT4N@XEvz5dNgj*I_*;}s@P;C0rJSI@%J$?j@+j8R|p$%%{LnL|ND z7RGV2TyH#%a?g=fZ-z}7ANE)%W=GDMXV&$T2m@!cW+pXZUd;bFk;&uEqfMvxQKX!- z*Jd+%-+Mx^hB7y}vDs?G1IlKKF`lbKXQrXP!}-s8kR1YYIRf9FqjBMGXKRDGHDw(A zpE38yK&bJ`zI?9*c6@TK>6y%DuyFF=@C;i9njNw4j$DQ=*loMe*e#4SC*=aXxu~*& zTI4W_l?d!m;51M4={&MicsS?0phqRE&+3hnXM2vuV$UHiBblF0{!-n(qH{M4d+4ip zRLR24=Ic$^6pz&|DnAcZ-=e?O%_`A=zqt=bYMAEr@XUa6@nXa~0p;X1|u!8(;mUPK#3ZQel46BfQfI}K@GuhYAiO2IgU3T}1BE|hkW!^`7 zr>6#|>qv4%L&oy2H)&hvU`<1H4r`b@N8@|2*#y3H!5D_zNMn{Y5^4K z9~g4u=|}&({9$;b=~|vh*8P_{ZUyUg!zGJcLulFfvRCKK+3ccDwhRX&{+C?02X-SI zwbbBub>aGmQq|cxV7%FHXrwFotRPM|y#x$V2N_lrAwI z%Xa)>&s6Z>9)#w08Qyg`?+$orC2%xQM0oeMaRyJ=VO|dHbKLVw@$a1=k6to=(1QM9 zf7GG@uaZK8G*##Il~IrHNny(|L!TLS^(XzJ{9vAfco{Y4_mM&B$c@X?uFvFrkWYlQ zWAU-idf6gePN|E4qs-G(O7~o8%dFJxP?TFv?F)7 zs}b#0MV9yRMj1olzH~%vuu({ovSR#Q=%93^IyFvkpZ4mzGW&q_nG;=V7};DQ%X~p) zW^=_IVx{T)P0+wFYOt1$5IVA_WbH=x5+i1Gd-cYLI)`NNgFE7ZJLD_&)Qe79>!KOL zNqv3ci!uZJ$w>KKE2W2_P_c0hQM6?QoXvXIWg_{%G!6NJd1y9qqlrl3^*74pF;nqd z2T*s0acM5=oB-yEL-k%yCYUqzvE~_vj){;p``XRzxN+&N5m`a_^=xe_&aRQ6G(l*N zYFWWd=3AK74CZt4M7Tu)CM%=@0U8~Ae&rHLB=F#8n^yY#1S5MelDCHLbeY)4bN(RN z;|hSi)aZh;JTyb}NP3Q^bP?;6iB=_x2^E5 zJR8)?sw>YRsuqw8eSDZq=x^D=+HCM|{~59vt`(rv76?D%E+Yj z!*i$Exa(RZuIDU86;vrs?)xBtQ4?den55ikij|xJWdqf9gzFNLvvg@>Kj|#sU^W5>GcTQ18+FSU|)IW+rW82$kUXm!hyvT z$*)KI(YL6Rbhub<{=&Waul1+EKMmKX+l~v_;72FAc}g&48$438l*vX_c_ffWF|4@V zjAB5R`nKIwn+6!5QnEQ@Qiy=$1n4t3b^$lFvMlHx*%7i^Q6%yY7_wr0tBfw6Hs;P7 zA9s3D0cYy^CGupS(Cw0@EP3kV&n-);()%`y4|glw`BSU>id4hSISbYMn$k(6TaZjc zDxt4_U%1YtAbUm0>|?i;Jkhk0v+0WNg)47@5w99Ga{^1Og8usIeY349P@HU)qF3Ds z@L9%#pTDn6R?d3OO%l2~k9w>5aQ6GB;a;z7Bm@)d!tB~q$3zS+xo4JbGw^_&e(r=p z71uspfOPw?yZYZ09k1M>ZG?CBv8|9kkfMSvS&-ySTn{ymgi7vj=tqQ?@K5;X&}WKy zev+Lmt~qHG%@X-eC4&H(-USs96F6bHD>k~;GdH5qyBEF5c%N0A+p}IRs99Zpay2i& zA86NA34@ia>9L-bM)?jGIDf4}&=VFQv0fI$1+&(-At_;O3s3{X(FSyR2)cL`2-E)r6dm{~nD=i%r*MCj^yeh(_{(D;Ys3Bu%X53k;u1NjaJ5AD^f6dC0+teL+n&a&%$;i23*+ zU`3Go!mXiNzXT;UJlBXG+-FX-wd@0!6t9YrKWlp}qwQysXW7 ze$6vx5DIwvP~e|~a;_Y-NGFzUHk*|hWz?L#(wIJLP#g6H$*0TrP%cNb{Q*vqT3K-zL7h{9hNF}BLS{0N;k-(G-UlnU$Lmj1)Cc3=rQ~wn6$78pnu&iX%#!&D zT)FqQ?;aPjR{LBO$x|-WwdV4%h6_`In7tmEJ?bbUl!e+vvM=j&Jn{8VR zX`mRgoEBt_6()EcN7=ou&-v`xXh2!h%CtZn@ii(&5DMx{6J~269GM-hUq-+ng2OqZS?}QuyrM#Az>&1c%T1%uZR+ zLF%w2&!%bUc^6`rG@sXDL|XnE3=3?(=r!y5#QIz7b5{+r-VL`;JM9AJns_2l$A$&{ zlOFywfx^Hs#qDB5}T?;i!q+q6n_xHpe`>7NLuIo++UVV@0ci&F)C z84p;^RLblX5|pN^2q?xZHeJBUhN}E4t%di2sp>yVDo9OUpmT;FCJ~8$Ol^mfai63A zR@DElYmA8+QJTZsTb2gzf&pv&)GHX7bVrL*O+bUqXOH#SbHKkuIAt7e#rC5KB2=o^ zJy8W~vM;9>tQ&NqoANwIFW|m=!3QNS2=P4Sk*L}}P-TY6r>7}n6aUw5B_#w_%0o6Z z@=(Y$9crw+5E&5@;*7`6T*gC^kzbCXa~lU7z;xus)RO1f|0>CHR+spqPhq#^*!3&A zSvadTq4@;bLY)DM}C+8CZp%SAJ4ym5Ku};X#0x_z%WO=}^rE@ka*di1SbC#ww zmwRS&-G!_gRn-v{{fj5lQanmw>e+o zRwno6)tsO|8yO_}QqhKHlp##v3WNIRBTytjA3XB?y$w{I;-L1@y&Vpjr87v+ zUzD+KFH_>!Kp%cyen{@>QU3*Zli`0-qjW z;X;}$2X=^@t-YtbvE-4m?5of?;@{;SarRos_=na@xUKgB>G_vo<1Jhm4oUanBY3%v zl;Nmlc0Of%KGO%ak%9(?)can;=|!|^ar-$oaoT0Ozd~n|dr0+I6*{?`xZ}$pV3e`G zPp?*+nH61oh{b5Ys7C`TJ?CkJfVS!`=EMoq#DN|{ZnZ&%C}fRlEgEYBYT@&7g*OHO zKfV2>AN`8H0JJN%@18*FuL#C z?ploo3skNPI^^)Z^>=0o)KMa?7L))zDAh$74vc~tPe8Si?1Q-g>=W_K$*>s@RD#xInb8@ijku=nw#Ba80fpiv*;`aQm@At2*pmy$%CCOwO?m9oi z763yPAA2IulNmZermoIb;P_ghtnTG&B*a%#J72R!MO0(rjaq&*8mfw3%d1M33}L5Y zOHU8PsCFY?W3kTIh~M+QWE?t6m73V+AsXgF5FSC7_gfOs4qzVY1tkypj-9%)hDL{cdMvUI& z^!(6ly@h_m-rr~R2krecJ6(vlPCQOSGI#_- z?e*;OPoVE_V`V}|^xyZ*!msMhrhhfQ{^ec`{`^;C9x#7Ru0dH6Y<&b8|9HXsP&+43 zy91hbVRUk)IUFw*1R%4iFz8H2SsH-NDMl?rYg(9l1nTZbsPCl$+_Hw)Rk1Lbxn(ja z4dbD$yMk$10xtc8#IX(Iq-_a5*)TqBG?K&Lx+d?)*YZ~KKP4L*>*0$R!|SKkBe`5; z{i2e4drl#jm2Oul^*s-^CFQS_XP;GWJVF@oh#=V#OA%^1_>1voW8{_Sc zW|Mg}-}Psvc`yOVnpLag>l*<0(&&3FU88ZZ*;SQYoogucEo>Z%MpAsmo96c*(}(UT zuOKtYDo6M9;%b@~Q;|qaN~>u(t^~A%mP-hH5>NDmp4;EgNWy9k4wbl^PVzz`2e$+= zF&Ta!#UhcE$Vbw}_1oT2)Z@H=!yPpo99IPx3!yb0#zuIJhd~nBWYGoE=xQAb1Edbc z#E?L2!iS<_DB?_aonYYqh=k*@AWtw5MV!DRQ%w^CFP`vP1Gh2a_;;I&97h+f_i zL0qiHFrYS_HIRNUK+lP$s`5 z=WaODN$bzjiHH$F^V4m1gk#{y$D=Q`YO2aKoKBC}wbo12KhU8LgUqdvZGDV?Kz^R$ zdjlvL)93i=)C}`vpyAQ!5si}$FiN0a4Z*hW@Sw9*LiSRcR@U{W?Ry;zX=*f$;#wZY z;4g&vcQiI0YTVgsbW-|T?sLK^^7FXdM_HGhd+kE?gbDW+)`~Ei&jf1EUQJAY_MhK% z{hQu!XQOdXB(@}p7{09O99rF@?R#vsXZi;$70$m6Lo!ZR?lEdR@CG(~@$e=1BYnDM z-MWvVs``xJ_WS!75^2A`6j3Vg$D)6Ldku61-)_#lQh1XAITgbW2j@Y*K=9}^*np*! zcMiaBwF53q9bJlS4%N~K^8qPP)LE1(JSl0lTB~{W<6hBsc(9x zBFJV3$pQ_tubQ&HrK2YiuBwNwFJ06+>KgDuDqi4;u+@bc!K-NI<1rw$OZKLHvWs8a zOJy>~F(Z>nd0&hO5h-3MW`%GdNJNnjMo5s4O6j;LgyM=8C4ra_2!{eB5Qs&>iHK?w z$LrnXc6mNTKfqT%kw{g=m=Kgj0XS7L8aHBrKrk58Hj~kyRE(wW0+JAo0*Q#m`KTBV zgu_&(82GYOjK=V$vq2SJ*MzojM!4Ofkik_6)C|@4x@|w`8;T*OtF>Ic>tc-Nc@Ow8 zK9}Ov5HBag<;7tI4h4-K{;OlFyYuii#2IA>T z%8(5uox=lw$Lt_wWYW3e#)b4ZU>?C}RmnEC8(HNHA(eDQNj+fg`ARXyiP-z*XcQ|p zje_=AbkmrH-2@)E5Jx!kPW1~$arNAv8D|K_-F&fc98j=GMn&Tb>YXOYwYG649AnWp zdq#3DgRnmJ4B)YQIZnqoLS4#?)FAagK>36HyHvN%RO!^4k}@`z>UcE&#GSD>r?hJA zyl%%@E&a#nidxchY9PQ1VoF`9hSSkVCMgQxV8AAUuplP2xEP7Al~;g#@#@Jl*y^~kDH=<9dHS@Yn2H1-M8pkq&+(*p^9r`Llm5nF3+pho`tsRGp&xsjF z7W{eWCN(u|VP&Y~z?M^IUZ3AMD@n1{0Fjb~T07M48Zz0<7=~3lWmnV5gLo_u#MjS+ zc|H;rf{|o3tHh(hU^Fh;1zQq4`=lZfs5dE<&DWD^JhqyOkf$4AQmf_(5o%UrGhe+| zNJRzPjzoo6_#6RJzxg-sf_h0s(Kh-s(GYPJx~iXB(ZBim=Zj}Z>r4^%)89UG`JL}9o;g#bTcF1?i>or{ z1e*+dFX2cNnSn|zu`C+lOrJE@EBRgu$`}RjbhWh)enV!Kd4Ej~V}Ihe8cfG_9g*jEz1rsvoHg zHD$RzT?vW>XwAuv16AjTX+6zg$>+=d=*QU!O#e=GKS#o;nZB1RRl7Il!UDm`=Mxqh zkCuW3zx_3amI#J%a*jm5bAxF^XHe3l~h#A!@p1Z3!RE>~pc-^=1{QgY}Y!V!iDcE%XB@N$~@^y!B% zqY!rMKxp)(Z}>K8b)ZG3Gvw`==@-^wYxnx!3wC^M`o&qYeecqx>7TsmLvJF(hhFlZDtvPWv>;sp7UeRuX|&MU zhWNG`8jXso!lrL$68 zNyigc0rj(JYrP{ihPGrF?if23u|0k}KBmUwdRuS1Eav*K*PGb3hc0q-caswmPD1Dr z$C(}wPIFXD^_&r!?A65@7c&`ZS>BjWqkAO@D!iBkkIe7D*Aw4XU?L_T26m2<*W&q9dfw9z0+5gvtG<5eet{==%7`FWBM!!bCFtyZUF>OCX1);t>G#(Ug{m3pv0urS%1Wbrx&J`D-fU$!Xji((MX%o{66_U-{OF6hih$cVPJT#6k=G%MP;E9bn2c1 zA6M0923>tFw36SSK9TQd4|(ZiuTFmjs^(EU0F@L_GvAofa`G3vH!)-oCqJC;9ro3^ z`U#*^4zDz9I`CtA?fGT;k)KG?4AH$#v924w$a= zlh4Idqsbjl9(NR7R}?+J^;%{j)EqrSeF|AzXBB$a8so%^n0qYl9#aR;l=HJAp9KQ| zrE&8}(V-3xY)d>K9ye_J#*G_ra4jAW3BqHrz!u!e84u&y|?mMJWY*5WB z$fC0bXXfE{8%5VdVCGuhXoTtfeQ0t^6L5-vzt%#dfyMz4?q(73w)#}*|B@|6b0M1~ z^R}pEAt|E~E|iOki6qf7a6?uT?GdvM_<{4Tt22XH{PUGku;#WC6#x~P?zgJ(I9xa= zRf^pVAd5<(?be_tEUzehDDv8E)+#os;~J%lN7oqM9MZpAmm0o_0TsQnw`{}Buyv>i zn<&v?9-6vJ=N&0j-9u%vw(bK}^w3x3x!+rdh8U+Be-CqT6E6Ps2zN4(ft*qIa{|&j_FGBfFptL;rfN9qPBwvgVA6F?>?UHF;8S~$+A>re6bC!j?wFNsuVM6T~I}O zy8|?-=Xtj0?X_&Z6r8kxmg!x%yEpc{k>@2MO2BNX&A8xTqz2$6sFe-Un36cEchGi= zn9MbOU95|$SaWvPn6D6m_B_;<=yUZfaS8RuGxh&|U*nq#GrjDvz!bGFv9*^!Y_v)Z zL%KrMv>_5Prhgeu#lk%45xFs$D`=U*HiwGdvbJutV$WWUNYme>`r735e;VY;NG#BY zdYf^eD70f45bklteV`9kI=ZD(c|->svhB{oEHJAB3j|1K`z_%>%65*+#5?&HC&gNB zbR*=6iB?Unh-XPWBF0d4qowxk@1P(MSP9ml9Sjr^%Wu}1l8F% z8|Rx>Uf`Pz-NCa)#Z>~$`eo zqVgoVGeL*`Y`*qi1M+8U`5Vl3pWpoE(P%iFTvuy(l}}cmg!Gy5{>Q?V594|jLyuyM z|3a|^t`UK2?_6GrqEA(vDo9u~zV&=rvC2Y1u@EzW?8@U87=dD+-I+W&-a)6^8XT8hcG94l32MH6E3F~hC^_OIB+F*)|5?6!Xc9rBSt;E`Nf{Uru4 zd`F~6Bq<4kl#E0IYSuW0%;1=jRRgq*dsO;mUO*08WsvtV1^~Oxt?Q&M7Tp|sF|jYk zygjzi%pbGeZ;Kf%gW+YL<1=wpjc54hoXN7Wz)_*@MYu*sk4Hz32o&qsk$5c_Y?=lf zU;-?9Vo`krT?ms+?&X&QxHPhv><)aRtFK#)UfZ&3>$)1c@Wu-vRbQ_G2Pt>GPPyFi zx@9k%;^8k9;YE@Er()bp#dGm&wVH)p%8ax1*y+lp4vl1Kpf@ekp3(L0DE?yVH49zP zRk{c?#^J~404C4u5taplNiP6s29L(79gId+|n5B+js~Z zEY_KV-7iO|6#2SC{)Lx)(GmoH`V&-IG8WE0+VTRz%O8A?mM`BSpV6oVaGlEJhy2%W z7naP#P`YaCRKS`a9Ta>6JO*pXb+Ye&OkD{9s?4b*+XxxT%TBI!s%l?3RqoqTwk+dh z3*s&8_Ja%2XcYryedGNr8}c7Nu+o&jzj}Hr=alcfYRl!UWGl5}sO4GmwX6Dde-6g+ z!`u^8<9?{@hnAR~n`mihsH(ui+**PrU;y_F&T7FK=2{ETgvPGT?)1|!rZ?XC3XAF< zXH8O*@`+@onHlg&QB3kfwtFIxmr{^=XEL2mW%{(O~5;VVLVx6XcXOd-gjOO&O=yt=;om=u8j$m}Ox!pS4p&{Fl&FOTy z0L8Rs!`IZI^EPy7zfWVg9{DO)tJabYJ}kyh2mvFUjD0v7E%1t{MmR3~I3KO>v3w>4*biwKz>z~yD2j)@v*{YP)RU4%`)p}*f9!#> zvy;{X>ty2zEzK9=#bAK~*20yM-)~4ZtyEDU* zS(({k*n;CAQyfffvOo2DAga^~StS{dghDtOCKQUqlS;NwLlDwK4Uo55S7O;ld96}k zSy9!Mm3n2Z+{j9DCZ9k2e1Xm!qtBP&k@Ea06U=!+GeePfx%~>%-wfqRCc)BxUTxIv zc35a50DXS+R)6VHYK|m(_64cqb)*ZneKFOrckM>%qPGtuCE{te=D`675(U#NIAf=xB9U+d_wl1^8ugnEmL< zSRj>^7S)Me*CahNvYkkVKsHD{MPYkZnFW3}oh-@>c1(`eMkp}o88p(|lOIyc4PVk0 z@t_^&N<@x1b}zl;lZ$I2Ai|&#I9%OCj>tl}9d`FetW3C(w|63?WIeS{4VEaA7yIgX z(y0lRj9M;BkpoOWh)9m-EyYM?YrxrTp6{z!2M+yUJ18dZ(e>cYBWU}V)F*6@tp`UH zmV_<>=a3u-krvs3mUWqb3&As&~W&htq9TzXhs3e`<1Jv>O zlkdO{Qb|A!b+ITVl}+V3pOhq^k^=68C#0f?si%^%oYX&SoH3Hs2P^N#edWRGCm<21 zT|xfUe1VHuL;j2k(FQ```KpgD*WyI8AO6qmq4l zk9=t`P}QBR&eyiqV<(xC&han%yXk)?pEMpkyl~h!G8xKmLU3>61~J}J{%8a9 zS6}h-mACaHM&jU1!0_*Q*U|$q%U!y8rANzRbh=9;X!fJxbZ=3Rc?{LzlJ;_Sp}(}+ zsuSp-0;`RLTZR^&h4DpG3$FmeoiEmC)0%g zfIO64fTG>`1KL%QRe=EQ(%EELbmd>@BFnD2Ezm!3I&(Rel&oApBse5U4aBRNd+(fk z&M@2`lH0uHmM+!(`N?p>jUjYrVa}K<&*r;)cq{8^#QKI5wi7=bs*H|ChvQUTRnzg| z=%_wf4@169&d$)w{K4qfL$bG3t#0j+hqp!#@^=+^{a(7vG0G%I%REv1elo4rEwccZ zxe@r{sm~`sU+}M;@!bzJQX9;5SwoFJ5eqrug=9Su&&6+h*~Qz)=N?N)NT^}ck3f-i zVX1Usp`vI{k;KvqgS>b>bLcvCX3`8#6Jvu?n>2;()jqJj5A1DY*{%vTRC3=&4j2B;sFmWlO8~5Y6;(a6NdtHCa;J5BOxPy0i+vWE^(d(MExyp9Dr}z9kIQWkGF1FV0k5NOF zDpmh-TUzJ-;TUSMFTscf;Lm_kE6*6qxQ|#OyxxnT-Y~sUM@X}Ek3Z^1oPEZ& z38=|-emmzb+}*p&UTq8N8OfE?>oU3inqWw$D|-C~cPiRnYIr%F&X07%)4q0;-nkga zPpZS3qoF(V#Ce%EmyEpUQ0*r8l19SAF4Rl*a-pO?U$cQ(WPwa!fZmlsKAO({uyBc6 z&&+U57E1i;#U+6KbH_s^cfS{%o=%cF;OC>ZF&BoU=vf)J|LJVC>;1jHe_7#m4hYyD z=o5}bwafqQ9FxUGNV#ekzlRaNeU)CO0${{@+OJHx*OXQQ!1##OD3KLWf!if3=AQxx zj$)OG3bZ7Nb3mV8D~eo_?Q%1uqUXEM%~p+SL?gT09U(llARl>(uhBX)5vw`WM8?Iy zsQZSxQkxBIW}OUh)i#?pN?r3f4phPoM{H^kADiyr*z~RDt<8ih(9Z literal 0 HcmV?d00001 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 6fcdaf33fb25abcbf8bbb4078bdfbdcfff344949..eeb874ddfbc639cc6ea1b2c84d298974ec0b4d62 100644 GIT binary patch delta 32 qcmV+*0N?+pZm4ds1pxz_ozs&D0hI%tUt^P?0Tlz&KBu#|0dO_dT@0B3 delta 32 qcmV+*0N?+pZm4ds1pxz>Io^{90hI%$Cry)}0Tlzz+Oo5_0dO_VWek}B diff --git a/web/src/assets/bar-roboto-500.woff b/web/src/assets/bar-roboto-500.woff index e95315bd2e48ff48b3072150fb7467623b0a1726..353464bf371cb2c954cc0f1667524cb4030efad7 100644 GIT binary patch delta 32 qcmV+*0N?+hZ=i3m1pxzqo#2xQ0hI%Yqid6(0Tlz&KBu#|0q8Z>OARal delta 32 qcmV+*0N?+hZ=i3m1pxzmIqQ=M0hI%hYgLn=0Tlzz+Oo5_0q8Z(Qw=Nt diff --git a/web/src/assets/tile-icons.woff b/web/src/assets/tile-icons.woff index 7bdb2399d5b71aeb46b635ab402f1c0bc4fd24ec..90daa2b9e91dd344ea8d1ae6b80dd75abc531d21 100644 GIT binary patch delta 24272 zcmY&fV|1WBu&!;}=GM0Dc5B<__U+cjc5BUp5D;}u5Rml8dA;dbD|2Hr5D-w9e-xbmVE~8VBq5#{zyJXO^P%3Q zSh908H3k8J;r?fP{fDW)_gMDEUQYj<^!_;z{{sn_21t#AvAy}fQU4=?`d|E$PL8f_ z|JqRg+5dqAX7&D8AI=MwfPf@2fpXw9T6~`% z=>-9k7oLC&MnL%L^Me8*eg%U{^aYgxx@3byPzWIC;VUTfLrBE~D4I6Xz+Cfae`9E=sIR+p6ioJX(m6R{9Ol4}4l6&aH(oYg4*NjU0WodiT$}+NYgA{CePS zUp{eai4|_z<+4PCY|~%??%qfCvu7C(rk&BpfB^6dHE^VQsS~SVr8;T|{>kjZBR&H= zB}DEO2c+*NJp{E2TZB$9lfBZtc^-5--#RFEoP5=LQ6G6grPf{oELWZ&7p}VaOP^eg z3QjYb8@Az3y9l3EdK=EC!DH)ski43IQ0#n9Pj+1_cm4ady+&^Ygy<72aLnDl>PYbl zZVAF=mMnRLQvc&RT8#qNTyq!EH>{Ft>Z`k3Yv zwT1}#a7Mf!Icy26D%n)*H(XeP8QAxrT;rYbBRIJF#eQh^wmtHJPTlN02HahAf!35* zBSUodahBVj$NXC=1&`?fuJs@=rdnIDEt73LWnYzEoj0b%qYu$DUc2S#zef&^b4jt= z_FT{lH2{C9ub8iGz*FZBv4FdUZu%Wo-<(U&cmKB(kY&K@EeCYU{B0Yw`YA6j_mWHO z2-1sD%s(`*DOSjA8B))j3tG>D&BF3Yeft50P@u(^A0M`LtWye%x9Z z*n4Gv0ccL`Wm11)Y)#81$ko^NC+O7hb(zTKSEyn2JRz`_jk|+~>u=v{dC?w#f8h(> z`M6sJ$_1?2gXXj#Lu}Xq_&YlI&H~X3p2OG|`{3Os$cWG70-QUx9ufg}EG~Y%T<6SS z4t2jTy=pusL{wIJSS49Z@ST$l;EczXpb?q36l=YeQOc&8p*KI+n|oVsPm_k}6wR^%9c zv>hFsf6hc5l)aX2e&zvhm(Q!9Q>)LAB0oxE9ATe}x+t#&mgOtqTDPl_PJJCsOatDY z1|~tbjw1NL{lyxLz8Qu6Bl$3N67RjL%<=4FBmCN`Mt%<3#JDDzJP=pju1kyp>zqym z#=$?$a`3M$&xlyPLrrrwM_s``ZCW7TC$~_qJ<|_=1pq5@;6%+Wkk1=jEzs}dmoQGq zsizR{!&6Z2%h@x=-zpBd^}4`bC@p$5aD6jBTxX`XC>Rv~H^F^Er8df##BUo-dHq+VdaJ zkCYb3LDzu^FXha*w2wemdu(WP><+3@%ltI>puG;~<)69yLyC}tM@?_Rx!D=*_+ZmY z3QxDW_ZP*v*(L4kfqU=nEzpCOT^(#RQ|6BRG}++3UAbOJ=NI@G+j4-wG)5}`tc}eFU_eMALGdPGB~%@;BbTMQ15H-hr?x- z$jv9S9`SFnPvo1d2#-W^7-c{h$`>jRY8Dz7S_aw^x(x;zCL5*}W*O!f<{6e1)*Chv zb`JIqjtkBdE*G3Y71&V>K5uP8abL5+A6vZdLsG)`Za(dh!KlXhe?Sk zfN6p0jTwd6f(41Cgq4eRjje|riJgjrgu{-bhtrPphD(8~h#QLAg!_m`fG3R?jW>ob zgkMDfMqo&=N9ag+Kx9iaKuk>RO}tKmO(IL;MN&y}ND4XQvyVk(v&uofs~Du4^&!Izo>4gC8_PGe^SR$mr-BP@X+|tG}DsO zTGQ6jF4DfyDbfYdHPfTh^U~|myU=$qKr?tSq%gEHtT8e%YA|LpE;3Owg)l8KvoX6c zCo!*n!~B;0ZS3143n7aUOAgBgD?Y0OYXch|n>Je(+bKIeyB5Hn%D&6N$>GJR#@We* z%q7n?&Mm>+#r?oz!Sl?E!K=v|#M{Vw&&SGV$(PHw&ri0Z zELbW8EyN^bCX_9VAS^0eBs?zyE+Q@RT_jB8L{v-^5Umw`5#tcc7uyqO7XL2ZDS;(X zF9|NmCFv>oOA=U+qLb2-Dv*|yzL6o7v5?7^`70|f>nGbGyCVlK$0?UD_aQGLA20t` zK}^BdE3>f+K9hV|n_5RIj!vMQB1u+*()~pLS9wRdt^i|``bFA}{G`{=BR~IsiW#5B zuZW05uY&=q2*N#Klnx3xm^c(NF|WaR=oypUdWu>UFde{%IqrIlX|uQ9siD?$HEXLQ ze*4cS)=EX&wx{CBtib+&)2c%mQHgAcV%G4}N6+vJ684TyrtFYeuLO6x<_2Q6+ zmZNogxhC)WCe!YO=Gmp@xSfH$qGyKIXs){wyx?1V5g(ErH`9bS7qyw_Cr8WZ=4jUL zlq<4x<{Yrd-c{s@udhGPk&Vq7xU>jiu!E0SVRM7$$WbZTa^}#1*uCo!bb-Y`cbO_y@eh(wI__kD)ogb-W`m z=cTI8_tg8cxOj)0sZ0K&b-&Kzjit3ph`(*_wdF7L__cR9{A&@8NL+jyT{1}aY9KY- z3QKJeL`8&fF^*LhoE;ZWP=xlNii zh`He`rv&c;mJ(8uMrd`R_;$^vj{mvPuN@cmhNKc_hLdma4Dte*D_D>TVc93ET-G|1 z1M!S@8E{VgzH^V=4e3Hra1!7(A0*R*tPst}yFu1 z*vs)h%l>h|G`Oh!6FSU+Y=c}c_ML`MD|0Sc{}`*<->66VeU_N9TNsL~?day%d*Xsc zF%0#YeM?)5{$BEDw58|yOHUu4uhS871D}2qnJ_c@JrR%3e%|dOq+F54Y0qGY_Dk}m zv%coZACQ`l6UfdhC7xF#y3YO#YeAq`9^OJkxAv&i?1V6MCQ_`_y^z!mM$21c%Idx=e9$l;CP`}i(o~|wg$MCzipD(C$hauc+!2s z85|=gn0Dqu1|xXEm{Ch=XyZI`#twL$S}AXu*wi=T*DNsBg#4++#`}kKT*JY)VliJK zaTcoR;Xy4{jm{1ePItKF_N7Wm$isn=K}xNz;^w|9#Sx-T%qIzlY_C@T?DM%hsPk zwJxkbr!$ydEIcLUI)@}4rXXQ~zwH$sJ)E;e6Pt<)^q zXLCe7xERBHbiOV%xv(+Z$^_)E*X+?J+~by_6t>>DIx3KQ@-1I4YNM^a{dFPgG;z?g ztKzxvbr0Q9@5hcM$;qbBGgSJm*p+`E=^FUjSKy3;3XTT%BPsMQCr>&|uVnMZ`#d=h z(Cd*2E*zt@aSI_mpc7OV*Oa^u7milDwC2C4@g1{iJv_uX^Wx#J9`W0<8+OabM#h`j z??lk3tzMuT3SVmK!K!*RqerY;+#gNZ>=VY{O&{I|6GRI1HSw|;bS@^4&B&wnE&RoqpfHIQr7x_Ipb!RAJ)@0w9nh0o9a}VSkB6|Y$wd+s?{~k ztn59CdQ(BY)63J=@dmHwbx1;chf#;?%kWnF)*rCaCP5Sr+hzK$uYhgD-quRFZRn?h zMERLEH~Ayq?#A(DMe>5o8@L(N(|WOth<_jd8`TsO*G1VG`C}@GfXWO(3M$v5+TcW; z!99&r6UUw%)EG1uVx~>W&T(0VHlfzJqCDuQ>ptZ%X4}0RkbFsi5bsd;?i=3&MTZo57mbA@#)p&Bn-DT)^a_R+8N7|g}r^sI>+!I3&;Y6(@FplD05 z{oO|iKO9$x6L#HT;t|uP6da^Ox7^~mNqz%}k3*@Uw@G4W^FdD!$8U!T=_8n)R4F>w ze{A5bU0XJPlI|I2qvyhG`h0w5G8{cRi*;sfKqR)iV}>G9^@V20L5o>EAw;*k56m)3 zp5g}r3kWb?igYtR%3fY9iG?*|p#`YyYd^t%r91%> zC*6pi82Y+r-jZ2fZH-3?PlQXw!rB(*(*5gcy{79<{%8Gv?gAI-g+(4bKBQl$#^bWL z2FIZ0CO%J=ExRpv_2>?+m0>0QX$91NWF0YLW1d@FSUT*{Ehd$)|AC)^7o#I_S38ZEGLTg{}HY`aNyu1#RtbT7%f+gF&;F zwJPD5A^Q7_$Z!xtM|E0>DhFh(hyABK=8C>ken+lJ_@*ZZvCH(c<~#i>dbEgNzy;H~ z%<)o$8-GeH_~OXGe47_9Xjf+G&ra27YBir`Wf(b8c{FvwG?m|>>7%8H5~CD=w?@24 zAO;ggy$>BOGu?}tyoWh)4N3|IZ5FyI3rk99Kex0%ig0LpJp4>C0qJ*Svp|z|azK`# z*>Eq~SXiB)ieXbH+5V{-oB2!G+3kDt1tLiuJ3k#Gx&wmgK3FuOm&vB+O`M+B?dYD< zGjIDKpFh;)aUGwYU*NITazVT1|nBKKsyKSm~OIQ#LY6PxqVkZ!i_co=nBCxW( zEu|NLAGQ*<0&`nRbnp!bC8&j5id*U#L*gU!!u4W#7=V$i%f{cBQRti@N4zG|O{WEw?h}(AeoLPR zV?_hHJu&EM!ry;g$7%ZtyQlT9EJ|7h;Kd@vfPH3sw765&SKM3K00I3 z>JJ(P$4cou{TQ44kCi>_~jQ+=k5H-FZ>{UjL&$8SZcOXES8*Mh`?Q|DNefRewBgX1qEi9$1Pg56# z#v_K4gQB}_MQvOLS&NdYoYTadC-i2102SU&MR7IW3VjTV>h|a?G)(Aw)K=w%edAx{v+1@w!9-k!>+H^DMM~Lj~J#BOlJ450n z-tRaD-bh=Os_pQt=bo~<-L#+`vK1~xSGWDs zG3X7{had=&r$2KtnRKe#tvK;rmF|BYG-v;g_nU3`cE0%~Nf~i5S5A*!j^h$OS5~W^A(2}8B3gJf||~p6ay`i zloZd8;3S7a->73dGXEPg>zDC;5H81*g~sG)wteqSI*A?(g{h0SY~h~Hy()yVK>#fn ze{+U4B#g3kQtTz!z8_su6t9)VsVW~f_~y6pyytJPr=iOQUf?1(j|FB3{WnEPLn|Tx zfs6klE|;c3AjQOL+Y6U+iP!>CFz&^Ge4fy$n_L^nJ9@R-mJ_Q_nQkZ&^hebO;|cCC zH}iqg-{p#A$fH$xD)?nkp_(J5+0ML$R8NFb)H4>W`-HAfcSpx*wXpshGge8wse?MA z6r*;B9poWVyL!9&0sDZDHR=f;gp~*$9vpXWbzXt>yp7#fuLu1Ri}8%bv#vkO(ZD zxFh&fslsB9wHOlpGw!bmpmhR$GXgJ|Q*2(zi!2%%>qJ`i*D;)5*fs4OFt~Q+890lG z?m=SJzzJ}_T`;S@)f$G5skZqP3T;x#@fH%j53L6=Jk?V8rV5xSJ^#^xi~S4-z2 z)SzZ&r7v85XN;URR%iI{jL8T{zNt7%OI92$om#T$ma?ymWtR2p&x0=BsIbFLM}mIpNZr-t6gW*5E+wuXL!g-v%6# zMmbDJ0OZq@)7;c-Ydi9g088Q-l^C6gNCYj*+aR0z*@JP^#1j+_y60cyzRQw}e?X*t z6o;^-2ob|EGBd@Zi1`7u%OWgII6oX#QFvQPu33>>sn9PGaYInoo-mHy`|n~*5uK{2 z_eF*$OsZ%az((q`kpC>sL#S{~H0<Sd9>DoC*Ir;=m`%iMRlA*vI3?g3E44;=fpv z&L%Tg7*4P3wH5rj2<(jg7*xjivd_6(i?gkDs`7mcRxorukJ8I^gNb$+Z&c$+X}L@t znMsXjS&Fo%}ghK$IbW|mUFX*bM?{o&}gC~wY98wO6o;cZM@V^}sas<;~u4cph z`|q&dutz_nh*ahbyojo9c3;#|8?9W1kz+Co;AYxn7t96nV#Kh+-2VL~-}27~7#Mfd z+}tp(cZGmG*vrTip}XU>ug59kG@ng2<<(x)HNv{rD7ygHIoou02G;qIu|Zq1O^q{5 z?g1Vm&uAsf_)k703H#p*Uo$jv!H~%&HO)Nzg7~>NiC=iP1j_F)TDhHErtH}o1YWxr zG?w`SdS=5*`@sW7#n$nkkZf5`e_wghR8kb=2?S5$aEUn_?z5SBK#?g6Dthy-HCxOH zhO?Ds`HKN%%6a{{zn}vZwI5H{1ELI>Mu(^OVR!&NCUa))-+#HX;8UKG>&zjZLnk?K zp{c`;+hOA&NVPJ|KvcjEMFQb}6cq)nSmjwuTz%(-fixRHF0o-xYs8 zY3;$!7X0x_p51bWN_^5M(Esnuvrv-r>zPk9kIxG@;Yrv=3TgIc$*IxYw(?n=FR376 zq+P&Wx~FS_oSUP)WRUeOq)Qd7u$%VYoJ*KioD8j1wiNA{5O!Q~q?xP077DWyE(k3n z-Ly%KXIe~iz7z^rfrVhVJan1=3LVMxBH0A#)1dV<(@j<$dQ+-O5qW31fsQS=Ay~Ms ztcC<~3wdXHu%z))+J*h*VUzT&?;;0sP4`@N`euJ98)w!lU1_v= z;PoaTcOtftnsUIhgZ#BC@j+%B`+G&+R4_TnO378)|rQcE4(l+7I2nSQZh7TTV;A)bJ)WrLunuMsosY z4u$dC@B7Ll`d3)9;%+fJ{s0Tx7qqLDCi_rlHi*jS#J_67wv&WeC42?rhUB#D#h_;! zdB^CimLPt^{n6*L;%KjqTH`(6^$YULL)Vt!&6*mWq^^WXBSKK+5vjWBtTNc}vlsi` zHiIcGUeWUq>vQw)L>c2LkRXk6w8jCv^-XD)-Fw!nYZ+qCG!vh1P>#-D{isq$JEQaN zY8D`Y#ff5jC4v^O9kQQ6h0TP~Zp+>0EB_>&2~QJIctEt7p`V2ndFnZ!Hk%aKwW*)u zZ%(V!eh9Ag(V6ggJk)Id`pb=N|Ar9{Sz%yrWo+HNzx$-2mzE3z8 zb`R&ad@>cqq@+A!3kyV9&*25m)K=QtbgAVbYa?Q`!J7iWiOy9r1or!? zC5#BJejKuqSZyAJ_Um4{{B1sOzn!6-c4!+_`N2B20L}ylQzG2uUmBM=gXQ2VjuiT| z1)pLE#5+9)4D1>y~U*b&?v*jqCr>yPb; z>?!p0&BdJX8(_(qpAzfmRj&+yOT$hN#q$u$m$rb8`Z+kykoM>8^MDH{c9ojR)tXz) ztxFZ5@_56z`~Yd`j7u=>Y=w*#79;pgI`TRx;(j^Yd zdOP8e&k)yI=5Y}`oKYS?s=jfDEI@K_#xIBEZe<7g$f+_r=2YJBCkfzTftP@!@aH1D zL^r!SNR9KOV%j@*M!o*le>hKDTx7F+Dl9#cpv1A?n*2I{v;4P#2?#@;6vJ4dtOt_| zk_-biMHPq5n8D}nuaNCk&ECRScdQuTC1Bwzhaouk*H?b!N7q1U0KiE4JxjvhPh(cABmfEj=}^pVkSZ}8ovfz)TRZiRVEIxx zZ$m;h{JLJimA-Z%NZrQ01Z7owa#fZ4OnvSoGEt$3=*W|ZeJ*X0PyifOK^j>gY6HNH zI$iH_KeaWR>eNMMBU(@`h%&=@{H}3)f|nDJ$-HZ^`19wt2d6U;x1kNHV96Qs$$GO*Z9?CvZ-zfmj1df6%1o5B0h_T5;A-4*D(!eS=BUP1qx_KeE z7tgYRc+QrgXEW}QKQQfbatd1WKd3uidvTtno(-8{FxxKiba>aRjX0pQuB>=1dD(D3 z&+Jvjsn?X~&7NvA=grG*^f&7w`i0@^m>O zrs7mNW9mw!@v4m#wjm~*x(t;X7bXwEuvhV2FH#4{BTu6G=ChA|6AmM>`X=`MMr&v& zt%=iJq+Qm+SV_n@2Z?b$e_dn~{)JMuiNC%-HDz!}O#7z~>!c^4>UKsO zvHaXSkMr*_ool&9--Ni03Xei+*cGQDfjAK*7y9xHR7g5ljT=I_TtvABs#w zVveaE*!1X9(q|QZoIsbt7}ei;EU$yJ2t*qA7fLtXwWfW(s@_au^}VP?VZ+#0P7dC9 zqK&B)L&uLhtsaI-!x~mg>J(?r2Y5B&=e488A*Qq;-i=zMpV(h2UOHUTb(Yl3bu_hW zpOmRi^Q+$75!GI!XA%O+!+W|J!7O5C+T$^Idg*X`hLh@nPqL+FYLdOO$Q;<2Vr-0S zl}8npNM$f7jhFgfE$dIY%y+W(9%1WHBK;hW^JNZSP@$2Gyda{UKsnoU{b1|S(cbwN0 zn*XW@6MAR}uI}QDB2QfPrb%?SucgagAu{Aez40ae?yqF7bMrf5{^n)Pu4P1bL5dto33IEW4gx9xY)2bDu8{cZ8_)wb=ZDt!gw|eKa6Uj~DIFw`4 z<)!vy{U;W9M&T(UX=2Q*O*YZa1f)R>W#J=jos%AhG{q(p2+RnTc@aE26D5?A<)>}D zV@+6b56+IRV*LH=(vI>E2u}32-|fq?Eq8g!1!`gR^p=2JNO~=!^~>)xbyw6yKMJ&o zZCZ)Fh(wvst~7OTHgv6n+u}!9=p3XfnTvJq$j@Jo?DvYcPBmd$P*-u6QiuV4;X8)A z0|N3i%Cex&0kX7V?d7tNSQyaFsl($GXov^kGCX+fV57?t1%2-Pu$9n>HKtl~s2h^l z0&0qqqO*WHg=Z`NSz0&&qEx;KlK^ZXnPaS^=;(0;{1fD&PI|pCkYgCZYCj3)%wSE%r_+lVLr6XxW%RT4V zo^J=5YW>}g97K}0O8>wI(J=JR%${*0gII|yV=`3zMRORHn^|^hbX{+I5=z#FT|V1Q zLKx*RdOI#~`pmk}^EJmM<3k{eV-hdG6fxl1dg>jMd{rX)Ze9HOJ)TQb5dLI>sqZk5 zrkkK)Z1bnyc8eJEY#kh>{AK4rW333UfE)-gid_}ozI5wzzbi17Th}Fx0k=CDrNCqw zLCj4rT|eUhUr&*VsfxZ-94$tkww{6c3d(SG|8yy;(ErG3gLAo;wrTnLRj?4%tznM# z_7$u8^{CC@))5mTNKZ2MMCnE7GvEz;V&bD}XrfqXE~Gu!B=ojQx7G`*Xz&_YA?E`U zL<8XpgEV zpI=rZUVkh9IQ_A8ryJ8mT_e=CwBobXP@`G-SZl9Q-ED0iQdzmQUL;$zP+fx*!*G4% zvGn~pEV`6&q4!gKGlyizHg6OnK;Ylo%x}}nUN$iN?xtUkn(3>|DEA5Nmdf%}W64iBe zogS8_C;a+FkjPmfpG?v}>h(TQH)i80k)T>uX=9aSsw?kwEs%IEgY{Q^MX8zfwSE-- z$7rb|)dTBBe!slc$%2otsr!H~vVD@Qd{fEVR{Ol5zdVEIc!mb`Q5tHQdy3lA2ntT6 zhQVfg@W;bkIV{)dptvkRTY@R27zh3b69mz{W=dt8dSCAN%Olno{(j$7-FD~9Lw=jZ z)>$+ps9C4Tpy2q?!8qdQ9$O4Sh?)=1m9Cr8>ckOb&Iy zFOw4!sBnAV$)kxH8lTWNXrFhx0-Gi14vmsyl2m5AS1Y zFk#wdS*T;hC#BI5ZvTyMikZ*jQ}E1gJ8$)X;t(~04^HuD*3=#Q>bF)(n_q=Qn;DI{Ee13 zO}yyG^W1*F*DFL;ho7%^pXXQ`;+QyTe~R@3!hY2y)?>JB3p%n62SJV$NKvNQp8ZqQ z%c7qqMv=Js`_F=3y(;(NbAj-4)n!<4wh!$0?%I}DY(-Z9xijC!Lz)JlSk>ZJx(h=N8ORs1J4^xNw1z)^Muxvht!GiVmb zoZq@~j_KirV83WVm{{T@Kxw4nBKoX&N9Uf?bD(x!#6q;nd#nG9ljSrfpEtAc7T8~= zZfLr>TMP&QwE~vhZv2Za+p%3|L4EFfgmobQu4v)G$mZ z?o-HO+B(K3A0jz3i%w;KX03>(xCZrkb(M9s zVdsZnI^=GP ze`*30q1n~@Hqr$n!l(I7?Hur?`N@guE2#%YiaMfCB{&kL%@(3G_N(PkZ{nZKvBv)L zK};F}v7&^vJ3L!> zC7}V6)E+?V@>v3CbV`sOp82m6B1L)58BqxH_8hsy91@|yLu4?Mr=!T5=4zP`7ah7{w=#JsdY`|uQsWYU z3<94BWg?%emZD}hfnH8`<72Gms$Xt=B<~y=GO7rkhxK~XakOWyyk-l0>iDqNLJ0?I z?mUY=AQ=wHZp%V$#~H|BI6)g>Z=YhK8! zip_8J#>KlMS8K8N5TBU>&?{J~-&c0+f#V2!m544~xY>NY37;a-fE>?xEPDm+gl@up zzWTfFEqo&0{l=YjZX_p?&lS5;r?JoUb#%B# zE%imFP5H(cw!^a86P6c`Da|G^E9-JaYDH++*=iJx^tqj_;8((F%GcqA1>39{QZ-ay zt{rl0!is@T>3l5|U*9Jl&+7qw4$sPB<^3pCzDGxwr$(3SC`x5x=8CU3S$o$IZDUPN zTev%C(|d^7ME(q+Sf;!vQ`S6E#e;+F9_s!R!}(IGKg;&-U=MW|pM$LS`Jb&gV!{dZ ztcchfGu$1j3yQG7gKL_TRQrP^-%pdTA)g)rp5;3!R}9|Q6Wh7kdi={nvhw*8lS z9wpm#<0Y#+V_5lw@>kca*_`4o_Dm-eftNgw2M!ZFjkJ(=O_7GkGWFRxump>ruqaoG z*}+^M28mc=PI7E$!u-mO1g&*Oujn_IVZJlIya)uBjw9502iz(Bc?sGOS%2IEC8bi1 zF*SyJF?O}!0+ksL@{s_ZrxmU$%wpm#3jQhj%S}AU-9&Q*c`xc>;9z0=;K$ULX~G2A zRBxUYOlbqN81POOlhSAA1ZnoJL4zMzYL$o0z!jZcu54?3o|kmNq$po9rCb8mAG0^q zac&rRaSuPEp3U_^_BedvaM{1|=HN2(0HYY&cIb`Vd)Yxa>Uu>Yn4DUrPMj5%L+O3I?qhANEJB8VRbXw8+!+USF9F_@9)v zoHGqs&{w}RE-DV@D@l~oa{UV_q>bFTP3`(k&IkKN+B%mU`>mHRBIK622|C*?A>3(` zHfXoN>`fxXH3P{Aiw(V2=9|T1i6ZP|#i|ijlCiSRJU#9v^jDSHzQ-HoOhx;$k#Qj= zp~o&LJ$7Q!hx(*lx=J+s$ z@bB`4wb_uL0YgFAPQr4f2QSDQUYo=@!Zl zKY7E_wkQ*!N9l58Dpt*WekodLl)Ll9MO3^zg}kJu$`}1IiAFZ98Y&ZiW927RYWjI> zC=G>ZK8j_-?_m}PV{K^PUA1e315-({ zgia0>l$rp4hQux8p;eU!-=jD}^(c-)l>y)=N(^l>yZzc(x^DbD8N~!$X&aU(Q~biV zOPjM5X^%g*tZB;b+p#`8ZS?0)Z3-&WjJxKn)bDG{CQ)udvy5qkzxw?Vx|V_+m8Eiy zJy!C?(o4^#D|;5Me2GSUYBem0tZ@qa8*26~wyMGLa@2}n^(P=_nGe4IyfRxk>jNy! z61%&O`fB*`_WP$1Ua#yVg_7#S9op5$M2#+aW|r+U3BbAlPvYRpYrk%g49D=hhM!cO zuRLMx#CMKyZP0$uVnS}&(3H*G54DfPDxPncN5q$iPlV^NXUh42R2QpzZaP(qWPwZR zAP8OGf*Pn9f{4NuJ44%<2g&GO2;iahE#c9$60OO*4?&8E*Z)02L2$vl_2CC&&1$J* z_9|WVO>-~byH4HpZjsEE6jh{&O@oJuX9^gBV3ae|lE*ppRBq=}$n1l^K`S8E9Pzpw7$*L(37_ zW1P6<^qd%DQUF6dC>A#&MH0s14UXOKuUkWd!-HVYoWz+WEHN}s{1$ZDl*IZHdeW%W z*eKS%>X@)5A&9ZZW^ODa1rQe$z(}a!Y7hp{9if7PEONU405__T7^Yw`cKSlA# zBgLTjA(W0F6_F>^@CQXqrKX-t^pDR|`#t1h#l4`Xm^iy}e8hfy5V0XCeBsy9tY3nY z8=q^%4DPce*<1esF)LXWr+n7+S;pAck~0{HgbbOFgM#ONw0l{b1-!rJnKOw6ef_8i z&%wD?4q9cC$~Rjq%1ttB&tB=wpS5UB{sb##DE3k>N4Eb0n&TlV6Mow6;#2TJ zTrpMm|6toPY`T_# zZxL>3%XvLkl=a`i@Su*1K8x;8oZoeRcQv5vJqY{s({2dvNhgX7>^QLB84*ttsf?Uc zJ$@E*g8vRao@lIdI>A5B@xQ;%b#Bko+~a$?8la~m*nT-o^k_3{ylQ`{ZdRdNr{4CX z4l1C&?8Zm>1e%>+Ws7o8|2;*hXxAy#rJyCZZa%+H`}Y7^)7avK83}OVj*bT1js90-HPhFo{emGqoK?!E=uJQ`zueretjN zh{_zn-m)xYHylLUr$OP!q$ftadLjmV0Y{wQo)h6E5^%~q+=dI#1rw_@=%1)VG&@!> z3e^v~Fid%$V;1tjy6)@73vrGK#w~-cxDC4CV8hI%CHXUZF zx)2o^8|q5H!BWmkmRV4NrFR<-Hh}HSgRP^;yZ=?1?W!sH#hA)r&$$Z}-z=QfnbCcM z+2ynt*t}L2T&EdPW(4lI8Mo%R%Bb7FQ{}R7I8(~ z;ZFX@ip`!4iA#LvdLklLVK%`dH*hr73w>E2MVFAO2>h{h&Q1egjHYGD+MMndxNgwq zb4o`mdEuvYX0~8>^`I_wfrIQgEAwl=xvN^paO0OvlaY$b*LdpCChYW)xV^I6&X0|K zb-=|+Jp>nVN?E%}>?3l0!d6y!wsknu1K*fRvz4(c<_5yjuT+3G5*vXr%?e{LE)!%7Jx(sc>t%raMwC2ZA^K#RdQU`!L42Q9bwt|HhT6yv7^aYDMPQz_i)&Q7bf6D?;9GX{+}vl_{eITrw`of=--1BhUqs_%*9;*^9XL1H zd8~qwGf0w-z#eJP)E6IugIA6;g)aggP=G|02z&6z|NAybeTtLTNB?#>be6#=qhL|a zwxe9RsG%#xeYl%=@sXU*fj4;@4&X~J=dIw2vwQ2NA?JMZXjJi6V(ifVEt<{%?Gc_? z)m742caTtjUNOVHxBZvM`B7)&Ic!O;wdx7%9wLLgMw!ka<={{&SYs^RHHv}$qtIW}?HWxKyZXOnwK^;i`;xtzG;%OGHsvA$1# zuU4Cx6AN`8H0JJN%@19FU@*Gx z+wNM81`AZK3p(WRz4doy3Di*{t`?L4Jt);h84iqs8c#sAk?e!H0PGX-%;TaAmGfA= z{>FO!z_a5}t=V*-)+)!Ko^x`r>yb3npTuvrYJqeXj^g(GuJ8A+t)O=9kR{1v8tytj z!xjKT6(4&d(32TDL8h+GR^a%5TA{4&^ zr(#P_55%Z;BVS{&&e(|G^SxvoI!l$B*ykY{=0Xr2L6`Si63`A{9_j@p5D&8K&!(fw zeJv`EMl^8IXgb-StkfP{c?Noxy@-rtOHmuUnmyRt`zp9ze&$Fy+`ngkIV^V`dGyPd zrsRiu!p|$0>CQz!s3!6cDj)W!ZavRt(adzRm05HujMF^VJfQ$OJL%2cL}a8XK!H3) zjNaw+{LpN@g?_`{-)Hm(?fo-5U5L`2MLZK+;{an2qK6%@nfl>)LEgf%ZagMVJWfM0 zcmzZ3_3ZIapzm*EWkN@P^xyZ*!msMhrhhfQ{^ec`{`^;C9x#7Ru0dH6Y<&b8|9HXs zP&+43yRc_=PjeM_g?OI_8FFeM*m-``x;~D8{ zm@^yW?T%)Xc{ShlXQp{D0m+(GtK;h%0Ql1Ado5j~aj@A{m0g`{DD*9C9E(O$e8rpQ z_aM`U?kKMyGs!AP_w?dwnio@%NK8trX*sS0w1k#R2z(NMPxOSI+uzSf!fFlv9W@*rR|Oagp*0@HMtF^fK@!?z(FM}z zY8?s#qz=WzkU(t0hoWLA;!Jm)VBr6VgyXRwPcRTgoWM~i7z&}cQdAK80$dA);Tjy^ zwMZz4UfvOZL0qiHFrYS_HIRNUK+lP$s`5=WaODN$bzjiHH$F^V4m1gk#{y$D=Q`YO2aKoKBC}wbo12KhU8LgUqdv zZGDV?Kz^R$djlvL)93i=)C}`vpyAQ!5si}$FiN0*UJb#v@9?0rRYLYsnpW2Jr|o+k z3~6dKjpAA!#^5i6`FAun9%|g#YIIWiTkdnhDf08U+(%iLoqO#<^@Iuc7S@U|n$HAk z&t6STfA*i>b^V*(aA%`&Pb9V^i5R}D=^R?!qwRZawP*SVEEUeb4MQ?cSMD)tJMacJ zeDUyqCHNzKx@FzEkD;pijNtbB`xp{wzrPewD(}ale}H=pbOhgS&b(51lL0vu!wv`M zLBBxo=rq`XrIdFLz;Cq!E=?U>ifj(m(g^bbDNxi|lq)zRjtVG&HFwNlGmJHI-_pXV}%plgq%EhIqZ zy`V#XzYh|rZ+fR9$Yux00u8gTnzFv7qbCurs)w#GUDP`28t_6YUf_wa)rA_tt7zuq zF(9=|_NINZi(lJIWirMwBa=yaUyKM5DPAaMg>WE9M3E0hNRW?8>9{C_;))g}ftV0~ z2!{eB5Qs&>iHK?w$LrnXc6mNTKfqT%kw{g=m=Kgj0XS7L8aHBrKrk58Hj~kyRE(wW z0+JAo0*Q#m`KTBVgu_&(82GYOjK=V$vq2SJ*MzojM!4Ofkik_6)C|@4x@|w`8;T*O ztF>Ic>tc-Nc@Ow8K9}Ov5HBag<;7ut1r7y`9{#IitG!?}6p_N}HvvoqB(kEFglFtv zIjBmiSZmu%0S4mfO3IK8C7r_qfXD10Wn|L1;l_pZH((yYXjREJwi{XH3?Y?tL`gkh z?fFVE#);Vb=4ccvHjRSzSaj2vh1~=mxDZD;^G@{(MsfArpBZNe#@&3eZyZp6ut-Kl z;|uDYCdjq6aV8vN(KmZWaxR0gKJ^UXv3ogA$2dY=%8b+?^*=!QgZ;Zyx6V}Q)SHqr zHkaynH2=h%u{Wo*YVEvk$677@$LNY$(sODczzbqZU8#oC(MTpK3gKYDCV{XZCbhU2 ziLaGcfPC^nlF4Q(E9H!?+Njiji9^q`iISJ4aEJtC737v|OcboF7>Y+zq9i`~8c9y5 ziL{W`GKLaLCri0%Hm8vQ@EVoI*j)3ylUKPnqEDzb^SVt2*oy%g$1;Q5N6iNv`YJ+| zjVHL3rLbicdo&I2t6T|qmoSb2siAF zm)C`v#hF;V*&=I@EM&TlB&K;sa-R;-&-3ULoSWQaIHc=t%+^(_(D;Z;`TN1A+jEZ@ z|7nLp4N^3%qHBzeJ~FBwsSGt`xjtP9iUnxR$&Le6=Z9%M&0xvr%l_!c*$GVlPIW&= z!l{|Qmn&7fH|D~B0>R1W6BZhemVyPp{WXS`2!?TTjzqt8(CU4Y^J651xe}#3-{DJx zxb6+kFn6u_7oDwKVO|#UxrdM2AGc4e!Povc)Kf6}mbb88LB1n(Iogd}invkuhC+q( zS|-CDzN|S-E?V-aJ}wM$h3d9R9auz1Fv+2r5z~)4Pk-cpcb=a98tKymvU9jo_MDHL z_?_vmNw_0B%!hsO_h0#J4q0E-%4ZQsIkNJZDn*>AxF^XHe3l~h#A!@p1Z3!RE>~pc z-^=1{QgY}Y!V!iDcE%Xp*DqrEmB)X?37Qr!(a3ndukSVr%#M-wSqp zZTiJovVHH;rRkr%=|gWK!-rn`P%LK0PRG|`r($*-zOkoollJ^-ZFO01AH|Uvrft&3 zFuoOw87h2p2DBhu0T$&lv}v@^*@$>Bz=pZc5oZ{GiOK4TOgb8kimKzN<#d9Nx8uCZ z)-Kq6;;NF?#6&{W(h6|miKnHrQd~*L6ITKCvuJC*BQ=J$WEk!kI~K7$emg#<#^ZWh zZ@Vn!`mooV*tUl*a&&i-6B15B=n==69uQ7*R7~}p5t{7P#Tpkg8ERSHm`|g7B?&6L zm;{f1%-~A|@XOc8-(RtG<5eet{==%7`FWBM!!bCFtyZUF>OCX1T zrs5F*_0g1;hzmKuGo|$fe{~jX#`$Ys&>n`qAoay688UjQjBI}?i{5NyIA~Y8$VIQ; zA3Oc&9xih+?k~;cNkKhrp+?D%|EF4A>f@!))(>Yyc_yt|Xa}}BmZ|RD&u7Hlbnfe9 zJ!fX0UgolIF~}#4H?0?U$mabqF(?0j`PY$LHK*TP=CuF(Eynb5<0&UlEYpZ2|DOCl z`99y`fNh6sZE#^=c&`*eJ-?;-=98_?`IEr>13}?e+8=M zQ9A&Y6i_qYn9_3c7rZwyWDqAmobMg>)w%i!pi~aE&JX1G1+p?+^wgO(Ej(X;BXvQO zxH_)w2ap*fsF~RJ{nbn8ny6&IKinN+oM{y?kmpg^euDBn#cQ(|N(Jg=-KntUHdxqN z7BXtSA`2j^hNa?M;^Uh%knOX{b<{%+n6C7b&&5-t$sJD~cNAS$6g|K7T4o{C96dvQ z3Rzrd6?)eiK9ye_J#*G_ra4jAW z3BqHrz!u!e84u&y|?mMJWY*5WB$fC0bXXfE{8%5VdVCGuhXoTtfeQ0t^ z6L5-vzt%#dfyMz4?q(73w)#}*|B@|6b0M1~^R}pEAt|E~E|iOki6qf~GH^py6YUYR z4)}rduB$VHS^V>rQn2Q>6BPgzneMl$@i<&KC{>Ex3?Pe2qV3k8CoHchd?@nTZPqF_ zspA@@i$~WO-W<}uTbCNXi2)V8vbSu*&9HT-2%9L;VIG>gO6MIZRNX^mvbOF6RrJtT z<+w?`{K);bA|2zMV7u?H%Yjz(L&2 zNq{30822%FMrt|U92ZYW$br`(1EMIC{&(bDZ%|n+!G2`D(ZnhfAZYwQ?Db=}Pi|BZ2SX`h}*Vwu1+Q z(O?AcKA!F|Ph@X@$+A>re6bC!j?wFNsuVM6T~I}Oy8|?-=Xtj0?X_&Z6r8kxmg!x% zyEpc{k>@2MO2BNX&A8xTqz2$6sFe-Un36cEchGi=n9MbOU95|$SaWvPn6D6m_B_;< z=yUZfaS8RuGxh&|U*nq#GrjDvz!bGFv9*^!Y_v)ZL%KqL)wCfJF{XbRPQ}7J=@Gdx znJZ|S!ZwGB-m#^v>rlh`)(6(S}C)!8^3=bKkv;F}HI!LvriRRYcX znrr8O&2F-E!IFe{ZAnIs!sw}T4dj--3eoqVgoVGeL*`Y`*qi1M+8U z`5Vl3pWpoE(P%iFTvuy(l}}cmg!Gy5{>Q?V594|jLyuyM|3a|^t`UK2?_6GrqEA(v zDo9u~zV&=rvC2Y1u@EzW?8@U87=d)4TaEf{Q?1{`1lEP7&5eFR+ylTGgB zmjk#ovYG4-e50$cTa8}ZvTN(Q8oKbt3n5isuK@=scfC%z-0`|)FP!4xFBRcMk^iS+ z+)TxD@ocr4g^q{$lDi3tiAvx(GDJ;m7FZ6!Gs< zi_jQ=_8z1=ID3%kTU5WUas9uO7{%$R7r<>6AfBh((irXAcnBOU)|rCcFGr{p`MN{? zg_nKN5(Iww6I5C<7S2A}@&dxkAAFCNFW(`b(WnJ*oyz2g{MT+5mdwOZx@zlxRKS`a z9Ta>6JO*pXb+Ye&OkD{9s?4b*+XxxT%TBI!s%l?3RqoqTwk+dh3*s&8_Ja%2XcYry zedGNr8}c7Nu+o&jzj}Hr=alcfYRl!UWGl5}sO4GmwX6Dde-6g+!`u^8<9?{@hnAR~ zn`mihsH(ui+**PrU;y_F&T7Gb8Rl9G(1gaW&F=KmF{U@(`3j5b9%oHblk$mVrkNS= zNl{GlL$-S&k(W}CdS^17PG&!Act*1FVC8-O{^OXKC4+@$pS|$+o3IwE^O)0x1kF4p zB@#5hWMZABe`k_qb&TfpujqEe&z)QH9FAagw7K0n+@T@ckj?3Ix&Xz0v}VKC)S>e> zbZEa%W49jpDp#x4k_|pA#!m)(m5{R-6I4CP5C!P0<#UTxIvc35a50DXS+ zR)6VHYK|m(_64cqb)*ZneKFOrckM>%qPGtuCE{te=D`675(U#NIAf=xB9U+d_wl1^8ugnEmL^7S)Me z*CahNvYkkVKsHE!Jw;)AR+$BUHk~ZW40cS8);+t%MD-B7V)4R=SoD5 zId(6-%x6M-`TYE&}73 z$L~IF(#>sGh5NSQ^$~C-3FM~(ke^j0;VXoOgVXNCM2AD0qv+V~Y>@wpAJy5=q zDsDD$+b#*harEghVx@wUgNG(l84QOi*e`)Z4{pr%h;vGA&^{skX&z?+axi`-) zXJ!X)pFVCpn4K*N7LkThB>T|xfUe1VHuL;j2k(FQ```KpgD*WyI8AO6qmq4lk9=t` zP}QBR&eyiqV<(xC&han%yXk)?pEMpkyl~h50%Z$fZy;|4L_QvPTI^H*Q- z^Od*tBSzxjOu+E(c-PVcG0R=Ld8J3oVsyGoBWU)c;&g9Oka-N%;ga@pb)mns+Nu-i zpaQFngj`&?dV>Twr;O2I=vCIHi`=NCcJ8)3M|1(dD7->Hg3Xm87O6 zW2vx3wI|NaKcdf-{FxO6Iu`&}wDO0nD(p;e+f^0jul|M z$1LRIvF(>4rK%}sms!o7F)a=fX%1J!m`7t;Xm8g2`JuPYN&x0+sa6+;LRV-D4ZUH1 zx$b--PDkfmkg+XS({UNmd90@IGBTUx&5YeV8w#Cmrh0IiV!%sasi&H$5DPdk`^_Vv zRP!v{+xY(~yO!OyjVP+ogQUgbdpIMJwrGn|=!mw3kg{3TNh$;`+(m(;XjC`_Y8Xv` zJd|C4qTTre+EtNNfdK8&*<@LCjUjYrVa}K<&*r;)cq{8^#QKI5wi7=bs*H|ChvQUTRnzg| z=%_wf4@169&d$)w{K4qfL$bG3t#0j+hqp!#@^=+^{a(7vG0G%I%REv1elo3p)-AID zm$?!6;;GLkKwt2$o$=idG*TPPby-7=J`oE!;)P^A5zobMd)dX?$mbqQNJyw*(~m%r zbz!M=VWFaEPm#pZ3xm9PJ#*+fb!O5GPZMKt_tNK2l zv%By9LVu%I{NFJIuFnbJ095`?V=-rmBT1eFjPqj0Paww4knWZ8<3ec9_IC7!8szcK>%67b`_xwCK_>TH6w$|>C zQA3p~RsV8ZTIc@Z7;3RE!H5Om&wx@Z<iAm=nT-Y~sUM@X}Ek3Z^1 zoPEZ&38=|-emmzb+}*p&UTq8N8OfE?>oU3inqWw$D|-C~cPiRnYIr%F&X07%)4q0; z-nkgaPpZS3qoF(V#Ce%EmyEpUQ0*r8l19SAF4Rl*a-pO?U$cRKS!97sVSwJ1K|Y$! z{;+U~ThGjJO%_W0>cu61{d31dC3n9Uot{pTI^gG{wlNomr07{0xBuyEwd?)8zJFQa zbq)yF9_SN}MYYTS?HrTEMM$}77r%!QzI~NmrUGEZd)lu|x!06d0>Jo))hLk_QGwef zE9Rd92##Wvi3+rTB#CoCpII>a^EeJv!VO1kY7ifr?%>$;t>)v$?WS&i^9b9n zUOPE4T^qyrBPyG3gX(;x$kik-Q?G#6QyP#CPD<`rKW4=*>16uV#I&Q{ZZ}{UqjYi1 mue_X2H@er`Q6p_ovKqEzL*b2}HJw)e1CQ$hF8~0Os9i*N+9#_3 delta 23897 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