Skip to content

ADR-0029: Themes — one C++ palette, role styles in the YAML, games stay dark

Status: Accepted (2026-10-04, lot 1 « fondation » asked by the author: no visible change). Lot 2 (2026-10-04): theme catalogue, light mode, live switch, Auto mode — see « Decision (lot 2) ». Lot 3 (2026-10-05): shapes, dark zones and display fonts per theme — see « Decision (lot 3) ». Date: 2026-10-04

Context

The author wants themes, with a dark and a light mode. Before this lot the interface could not change colour at run time, and not for a lack of tokens:

  • ESPHome writes a YAML colour into the generated C++ as a literal. text_color: color_text_dim becomes lv_color_make(148, 163, 184) in main.cpp; the color: component is fixed at compile time. 483 colours were set that way, widget by widget (as local style properties), against 12 shared styles.
  • The C++ read constexpr tokens (UIColor::TEXT_DIM, 282 uses): fine for one palette, impossible to switch.
  • The colours were written twice, in tab5-styles.yaml (color:) and in tab5_tokens.h, with a comment asking to keep them « in sync by hand ».

LVGL 9.5 already has the right tool: a shared lv_style_t changed in place, then lv_obj_report_style_change(), repaints every widget that uses it. ESPHome 2026.9 wraps it (lvgl.style.update, lvgl.theme.update) and accepts a lambda wherever it accepts a colour, style_definitions and theme: included.

Three ways were weighed with the author:

  • A — one firmware per theme (a substitution picks the hex values). No code change, but no switch from Home Assistant, no automatic day/night, two binaries to publish (ADR-0022).
  • B — recolour after creation by value (walk every widget, map each dark value to its light one). Small diff, but two roles that share a value (INFO and TEMP_MIN are both sky-400) could never differ, and the games would be recoloured by accident. Rejected.
  • C — one palette, shared role styles, the C++ reading the active palette. Chosen, in three lots: 1) foundation, the dark render unchanged; 2) the light palette and a « Thème » select, a change restarting the tablet like the language does; 3) optional, a live switch (the C++ colours through shared styles too) and an automatic day/night mode.

Decision (lot 1)

  • struct Palette (Tab5/socle/tab5_tokens.h, still dependency-free): one field per colour role (background, glass, text, semantic, vigilance, climate, rain, weather icons), 55 roles. PALETTE_SOMBRE holds today's values, unchanged. Two roles with the same value stay two roles (INFO / TEMP_MIN, INACTIVE / BAR_INACTIVE): another theme may split them.
  • UIColor is the active palette, a variable (inline Palette UIColor = PALETTE_SOMBRE;, constant-initialised, no static-init order issue). The C++ and the YAML lambdas read UIColor.TEXT_DIM instead of UIColor::TEXT_DIM. A constexpr table that must follow the theme keeps a pointer to member (&Palette::TEXT_PRIMARY, read through UIColor.*field) instead of a value: the weather icon table does.
  • The styles read the palette. Every colour of style_definitions, of the theme: (labels) and the pages' background is a lambda return lv_color_hex(UIColor.X);, evaluated when LVGL creates the style. The interface colours left the color: section: only the games' and the arcade selector's remain.
  • 33 role styles (style_<property>_<role>: style_text_dim, style_bg_accent, style_border_error, style_arc_info…), one colour on one property each. A widget of the interface takes its colour through styles: instead of setting text_color: on itself; in a styles: list the role style comes last, so it wins over the styles before it, exactly like the local colour it replaces (a local property beats every shared style; among shared styles the last added wins). Templates that received a colour id now receive a role style (modal_header.yaml and tv_transport_btn.yaml: icon_style; tv_app_btn.yaml: style). A bulb's own colour (light_popup presets, light_white_btn) is data, not theme: it stays as it is.
  • The games stay dark (ADR-0014): they read PALETTE_SOMBRE.X, never UIColor., and keep their own palettes and their color: entries.
  • Guards. tools/check_tab5_code_rules.py rule 8: outside the games, a LVGL colour property (text_color:, bg_color:…) is only a lambda or a template variable; a colour declared in color: is used by the games only; a game does not read UIColor.. tests/test_themes.py: every palette gives every role, in the order of struct Palette (an omitted field would be 0x000000 without a word from the compiler); the Météo-France vigilance colours are the official ones in every palette; every colour of the styles reads a field of Palette; each role style has one property and reads its own field; no role style is dead.

Proof

  • The migration of the 33 YAML files was checked tree against tree: the HEAD tree, transformed by the rule above, equals the new tree, file by file.
  • The off-device render (ADR-0021) compares every screen of the pull request with the last run of main, in the seven languages: the dark render must be identical to the pixel.

Decision (lot 2) — catalogue, live switch, Auto mode

The plan of option C was revised with the author on 2026-10-04: the switch is live (no restart, what C left for a lot 3), an Auto mode follows day and night, lot 3 becomes fonts for a few themes (display texts only, the seven languages covered) and lot 4 the twelve themes, by batches of four, chosen by the author on a render gallery. Themes stay compiled into the firmware (option A of the second discussion, against downloadable themes): no parser, no file system, every palette checked by the tests.

  • Catalogue. One file per theme, Tab5/themes/<name>.yaml: nom, ordre, a sombre: and a clair: mode, each giving every role of struct Palette (herite: <file> starts from another theme; the glass pre-mixes are computed when omitted, like lv_color_mix(); a role may name another role of the same mode, CONSOLE_VALUE: TEXT_PRIMARY, resolved after inheritance, so each theme gets its own value). tools/gen_themes.py writes THEMES[] (struct Theme {nom, sombre, clair}) into tab5_themes_data.h (in tab5_tokens.h until 2026-10-08: every unit that includes it, games too, was rebuilt for a theme change; now only tab5_theme.cpp and tab5_reglages.cpp include the catalogue), the options of the « Thème » select and the repaint of the shared styles into tab5-themes.yaml, between >>> / <<< marks; --check runs in pytest. PALETTE_SOMBRE, the games' palette, is the first theme's dark palette, written into tab5_tokens.h; THEMES[0].sombre takes it. Adding a theme is one YAML file and one command, no C++.
  • Entities (Tab5/paquets/tab5-themes.yaml): the « Thème » select (its INDEX is kept, so a theme is only ever added at the end, ordre:), the « Clair ou sombre » select (Sombre, Clair, Auto) and the « Nuit (thème auto) » switch. In Auto the screen is light unless the switch is on; Home Assistant turns it on and off with sun.sun (automation « Tab5 — thème jour/nuit », packages/tab5_push.yaml; the switch is found through the theme_nuit attribute of sensor.tab5_tablette). Without Home Assistant, Auto keeps the last known state. Home Assistant reads the options, so they are never translated; on screen the mode is translated, the theme name is a proper noun written as in Home Assistant.
  • On the tablet: the GESTION card of the system console gets a « Thème » row: one button moves to the next theme, the other to the next mode (select.next, the same path as Home Assistant). The four existing buttons are 68 px high instead of 86 to make room.
  • Live switch. theme_selectionner() (tab5_theme.cpp) copies the chosen palette into UIColor and tells whether it changed (no repaint otherwise). tab5_theme_repeindre then (1) sets every colour of the shared styles again from UIColor and calls lv_obj_report_style_change() on each (what lvgl.style.update does; since 2026-10-08 one redraw instead, see « Switch cost » below), updates the label theme (lvgl.theme.update), the main page background and the calendar's C++ styles; (2) waits for App.is_setup_complete(); (3) calls theme_rejouer_ui(): every C++ unit repaints, from its last state, the colours it set itself as local properties (central card, vigilance, rain, tiles and light popup, measures, climate and plants, energy, zones, voice assistant, calendar detail, weather tiles), and the YAML repaints the icons its sensors colour. A unit that sets a palette colour itself must have such a replay ([AI-CONTEXT] of tab5_theme.cpp).
  • Boot order, found while building lot 2. ESPHome 2026.9 creates the styles, pages and widgets in main(), before App.setup(); the selects and the switch restore their value during setup (HARDWARE priority). A saved light theme is therefore applied to an interface already built in the dark palette — exactly a live switch, hence step (2): a unit replays only once it has its widgets. The on_boot sequence is not touched. The games' pages keep the bg_color of lvgl:, evaluated at creation: they stay dark.
  • Twelve new roles (67 in all, 268 bytes per palette), for colours that were still literals in the C++ or that a light palette must split: TEXT_ON_ACCENT, ALERT_ORANGE (the orange vigilance level had borrowed WARNING), METEO_CLOUD, and the stops of the temperature and humidity gradients (TEMP_GRAD_*, HUM_GRAD_*). ICON_MUTED, read by nothing, was removed on 2026-10-08 (66 roles, 264 bytes per palette); tests/test_themes.py now fails on a role that nothing reads.
  • Contrast. Every mode keeps the text at 7:1 at least, the secondary text at 4.5:1 and the semantic colours at 3:1 on the four card surfaces (tests/test_themes.py, WCAG). A TEXT_ON_ACCENT role (style style_text_on_accent) colours the text of filled accent buttons (« Tester », « Parler », « OK »): unchanged in dark, 4.5:1 at least in light. In a light palette, couleur_lisible() darkens a too-light tile colour towards black instead of lightening it, and a vigilance icon is drawn in the text colour on a pastille of its official colour.

Proof (lot 2)

  • The render task « clair » (.github/workflows/rendu-host.yml) captures every scene and screen of the interface in French twice (the consoles, which stay dark, are left out to fit the task's 30 minutes; the Arcade selector stays): painted in dark then switched live to light just before the capture (capturer.py --bascule Clair), and after a cold start in light (--puis-mode-theme Clair, then a restart with the same preferences). The two series must be identical to the pixel, or the task fails: a difference means a colour that the switch does not repaint.
  • The dark render of the seven languages stays identical to main, except the system console (GESTION card re-laid out).

Decision (lot 3) — shapes, dark zones, display fonts

Asked by the author on 2026-10-04 after a gallery of sixteen sketches: the themes differ by more than their colours (« Relief doux » has soft shadows and no border, « Almanach » a serif clock). Lot 3 adds the mechanisms, with Ardoise only: its render is unchanged. The sixteen themes come in the next pull request.

  • Shapes (formes:). A theme may change the geometry and the material of nine shared styles of the dashboard and the popups (STYLES_FORMES in tools/gen_themes.py): radius, border (width, sides, opacity, colour), gradient (colours, direction, stops), shadow (width, offsets, spread, colour, opacity), outline. A colour is 0xRRGGBB or a palette role, read from the style's own palette when the theme is applied. Five new dashboard styles are exact copies of style_meteo_card_page (style_horloge_page, style_bandeau_page, style_clim_carte_page, style_onglet_jour_page, style_onglet_temp_page, the tabs without border as their templates had): a theme that says nothing about them sees them as the weather cards, a theme may draw them apart. A style that follows a parent (SUIT) takes the parent's shapes except what tells it apart in tab5-styles.yaml.
  • Applied in C++ from generated tables (theme_formes(), tab5_theme.cpp, between // >>> formes marks): before a theme's shapes the compiled state of every property is set back (a property absent from tab5-styles.yaml is removed), so a switch from one theme to another leaves nothing behind. Values are written as ESPHome compiles them (an opacity of 35 % is 89, truncated). The first theme (ordre: 1, Ardoise) is the compiled state: no shapes, no fonts, no zones, refused by the generator.
  • Dark zones (zones_sombres: [bandeau, horloge]). A light theme may keep the central banner and the clock dark. Two more palettes, UIBandeau and UIHorloge (tab5_tokens.h), equal UIColor except in the light mode of such a theme, where they are the theme's dark palette. Six zone role styles (style_bandeau_text_dim…, style_horloge_text_soft) and the C++ of these zones (vigilance, rain, alerts, voice answer, humidity colour of the forecast) read them. The vigilance pastille is drawn for the banner's palette.
  • Display fonts (polices: {horloge, date, titre}). Google Fonts, Famille@graisse; three shared styles (style_police_horloge, style_police_date, style_police_titre) carry the time (rollers, colon, alarm ring), the date under the clock and the titles (popup headers). Since 2026-10-05 (asked by the author) the date font also carries the 45 px texts of the home screen (temperatures and setpoint of the climate card, « Ok Nabu », central card, forecast titles) and a few popup values, so its glyph set covers ASCII and the characters of the seven languages. The rest stays Roboto. tools/police_theme.py downloads each font once, measures it with fontTools and writes Tab5/themes/_polices.yaml: the largest size whose digits fit the clock frames, the y of the rollers and the place of the colon, the glyphs it lacks in the texts of the seven languages. gen_themes.py declares one font per (family, size) with only the glyphs that role shows; ESPHome refuses a missing glyph, so they are left out and drawn by the Roboto of the role (fallback of the LVGL font, set at run time). The clock geometry of a theme is set by theme_polices() on the labels and the roller frames, minus the theme's tile border (0 to 4 px, sometimes on one side only): the date's baseline stays 32 px above the tile's outer bottom edge, the digits' ink sits as far below its top edge as the bottom of the date's descenders (g, j, p, q, y) sits above its bottom edge (23 px in Roboto, 12 to 27 px across the theme fonts), and HH:MM is centred (2026-10-05, asked by the author).
  • Glass buttons. apply_pressed_scale_to_tree() (on_boot, 2 s after setup) recognises a glass button by its radius of 18, which a theme restored during setup may already have changed. tab5_theme_repeindre marks those buttons once (LV_OBJ_FLAG_USER_1, boutons_verre_marquer()) before any shape; the scan keeps the marked ones. The on_boot sequence is unchanged.
  • Calendar. cal_fond_case() reads the gradient of the popup card to blend its cells; with bg_grad_dir: NONE it uses the plain background.

Catalogue (lot 3, second pull request)

  • The seventeen sketches of the gallery (2026-10-04, audit_tab5/themes_2026-10-04, outside the repository) became seventeen theme files, converted by a script kept outside the repository: sketch keys mapped to palette roles (the rest inherited from Ardoise, same mode), sketch styles mapped onto STYLES_FORMES (each sketch named its own new styles: « creux », « plaque », « cadran », « bandeau central »…), every shape that changes nothing on screen removed (checked with formes_resolues()), zones_sombres where the light banner or clock is dark. The surfaces of the contrast test are the real backgrounds of the cards (page and popup), and every mode passed without a correction. Three more themes followed the same day (Bonbon, Sorbet, Ultraviolet, #324): 21 in all with Ardoise.
  • Display fonts were chosen per theme among the sketch's fonts (the clock and date font, the title font); the sketches that kept Roboto have none. Relief doux is the default of a new tablet (initial_option); a saved choice is kept.
  • Not taken: the « active button » state drawn by three sketches (inset background or ring): highlight_button_border() sets local border properties, a later lot can give it a shared style.
  • Shadows are clipped by the parent of the object: the five cells of the daily forecast are overflow_visible.
  • The render task « galerie » (rendu-host.yml) captures the home screen and the climate popup of every theme in both modes, switched live one after the other, then after a cold start in each; the two series must be identical to the pixel (a difference = a shape, a font or a colour that a theme leaves to the next).

Proof (lot 3)

  • Ardoise is the compiled state: the dark render of the seven languages and the light render stay identical to main (job comparer of rendu-host.yml).
  • tests/test_formes_themes.py runs the generator on a test theme (inheritance of shapes, values as compiled, refusals); tests/test_polices_themes.py recomputes the clock geometry of every measured font from the metrics kept in _polices.yaml and checks that Roboto gives back the values written in the YAML.

Switch cost (2026-10-08)

  • Measured on the tablet on 2026-10-07: a switch of theme or mode blocked the loop 3.2 s, whatever the theme. Each of the 69 lv_obj_report_style_change() calls (53 colour styles, the label theme, 12 shape styles, 3 fonts) walks the ~1,600 objects and, on every object using the style, refreshes every property: LV_EVENT_STYLE_CHANGED to all its descendants, whose labels lay out their text again. Shadows and gradients only cost 25 to 32 ms per full redraw on the two « Relief » themes.
  • Now: the colours (and lvgl.theme.update) are set with the style refresh turned off (lv_obj_enable_style_refresh(false)), then the whole screen is invalidated once: a colour changes neither size nor drawing area, and LVGL reads it when drawing (LV_OBJ_STYLE_CACHE is 0; tab5_theme.cpp refuses to compile otherwise). theme_formes() walks the tree once and refreshes only the objects of its styles, for BORDER_WIDTH (layout, also covers border_side) and SHADOW_WIDTH (whole extra drawing area); no shape property is inherited. The fonts keep their three reports. Prototype measured on the tablet: 0.18-0.19 s, same geometry as before on five themes (hash of the coordinates in the same firmware). tests/test_themes.py refuses anything but a colour while the refresh is off; the « galerie » render task still compares the live switch with a cold start, to the pixel.
  • Second step (2026-10-17, #501): on the Waveshare 7B a switch still blocked the loop 731 ms (median) once the 20 screens had been opened (3,294 objects; 291 ms right after boot): 389 ms in theme_rejouer_ui() and the lambdas after the wait_until, 147 ms fonts, 129 ms shapes, 60 ms calendar. Profiled on the virtual tablet (callgrind, and variants that remove one remedy at a time): in LVGL 9.5 every visible object that is invalidated walks the active screen and the layers for a blurred widget over its area (blur_walk_cb, lv_obj_pos.c), visiting every object that overlaps the area — hidden popups included — so the cost of each invalidation grows with every popup built; the switch invalidated thousands of objects although the whole screen already was (97 % of the module repaint's instructions in lv_obj_invalidate, 68 % in that walk). Each invalidation or layout request also sends an event to the display, whose dispatch walks its callback list twice, and every label to lay out again subscribes to that list until the next layout (the quadratic walk #478 removed from the boot). theme_bascule_debut() / theme_bascule_fin() now bracket each run of the switch script that has no wait: one full-screen invalidation, then lv_display_enable_invalidation(false) and the label subscriptions drained every 64 (lvgl_vidage_abonnes(), shared with the boot), with a guard on LV_EVENT_REFR_START so the screen cannot stay frozen; tests/test_themes.py refuses any action but a lambda inside a bracket. The calendar sets its colours only, the fonts are reported only when a role's font changes, and theme_formes() refreshes only the carriers of a style whose geometric properties (LAYOUT_UPDATE / EXT_DRAW_UPDATE flags, plus border_side) changed — none for light ↔ dark, the geometry being the same in both modes for 20 themes of 21. Virtual tablet, native: whole script 32.6 → 3.1 ms (247 → 22 M instructions), light ↔ dark ~29 → 0.6 ms, next frame 50.0 → 43.9 ms. On the 7B (2026-10-17, measuring builds before / after, switch script only): theme change 761 → 65 ms and light ↔ dark 675 → 10 ms after opening the 20 screens, 310 → 43 ms and 271 → 6 ms right after a restart. The walk itself remains for every other invalidation (layout of the next frame, normal updates): 22 % of that frame's instructions.

Consequences

  • One source for the colours of the interface (the palette), and one shared style per role that a theme can repaint in one call.
  • Lot 2 adds a palette: a struct instance, plus the glass pre-mix recomputed for its background (formula in tab5_tokens.h), and contrast choices (the vigilance yellow on a light background, couleur_lisible() darkening instead of lightening, the pressed opacity).
  • What lot 2 still has to repaint on a switch without a restart (lot 3): the colours set by the C++ (local properties, set at each push), the calendar's C++ styles, and the pages' background (local). With a restart, nothing: everything is created from the active palette. Also for lot 3: ui_text_color() (tab5_internal.h) compares the new colour with the local property only, so the first push of a colour equal to the role style's sets a local property and the widget stops following its role style; compare the effective colour (lv_obj_get_style_text_color) instead.
  • UIColor.X costs a load instead of an immediate; the palette is 220 bytes of RAM.
  • Lot 3: shapes cost a table of a few hundred bytes per theme; a display font costs its glyphs in flash. Shadows cost drawing time: measured on the tablet on 2026-10-04 with the shadows of Relief plat (day cards 30 px, buttons 15 px, popup frame 50 px; median of 5), a full-screen redraw went from 134 to 169 ms and the climate popup from 163 to 203 ms; the rotating central card did not change.
  • Every theme font is compiled into the image, whatever the active theme (checked on 2026-10-08, audit of 2026-10-07, PERF-2). ESPHome's font: turns each font into constant data of the program, and the read-only segment lives in PSRAM (External RAM .rodata in the build's size table); loading only the active theme's fonts would need fonts outside the image (a data partition read by LVGL's binary font loader), a partition table change and a font path ESPHome does not offer: not done. No glyph was trimmed either: besides the date, the date font draws the free 45 px texts (voice answer, alerts, planning, page title, central card, popup values), so its set (printable ASCII, ° and the characters of the seven languages, tools/police_theme.py) is already the smallest that keeps them out of the Roboto fallback. The font files themselves are pinned in Tab5/fonts/ since the same day (DO-15).
  • Lot 2: each theme adds two palettes of 268 bytes to the flash (THEMES[], constexpr); the active palette stays the only one in RAM. ui_text_color() was left as it is: the replays set the local colours again from the active palette, so a widget that left its role style still follows the theme.