Themeable map room-fill palette — design¶
Status: SHIPPED 2026-07-03 (Phases 1 & 2). Per-room color override + theme-palette fallback
("2 with 1 as a fallback"). The palette tokens (--evcc-room-fill-N) + resolver
(cards/map-room-color.js) + per-room room.color override are live on both render paths.
Learned the hard way: the room's visible fill is the VA raster canvas, and anything opaque
above it (the furnished-art image in non-live mode, or a floor texture) hides a recolor — so
room colors are a live-map feature. The full layer stack + the raster-rid → room_names →
room-name override bridge (and the unresolved rid-vs-room.id disagreement elsewhere in the
codebase — never assume they're identical without checking) is now documented in
map-render-layers.md — read that first.
Phase 3 (label ink by luminance) DROPPED 2026-07-03 — the label pills carry their own
contrast; validated readable at the #ffffff + #000000 room-color extremes, so per-luminance
ink switching buys nothing.
Problem¶
Almost every color surface in the card is themeable — map overlay colors (--evcc-map-ov-*, in theme-tokens/map.js), the room-card chips (--evcc-room-chip-*, theme-tokens/room-cards.js), shell/surfaces/status, etc. The one forced surface — and the biggest visual area — is the map room fills:
- SVG segments —
_SEGMENT_COLORS(was a hardcoded rainbow inrenderers/map.js; nowROOM_FILL_PALETTEincards/map-room-color.js), consumed at_renderMapSegmentPolygonvia an inline--seg-color(renderers/map.js::_renderMapSegmentPolygon, read by.evcc-map-polygon--selected { fill: var(--seg-color) }instyles/map.js::mapStyles) and at_renderConfigPolygon— the map-config path,renderers/map.js::_renderConfigPolygon, called fromrenderers/map.js::renderMapConfigView— via inlinefill:/stroke:(NOT--seg-color). - VA raster —
_VA_ROOM_COLORS(a separate hardcoded copy inbindings/map.js; now resolved throughroomFillRgb/roomOverrideRgbincards/map-room-color.js), painting canvas pixels in_drawVaRender(bindings/map.js::_drawVaRender).
A user can't recolor their rooms, and (surfaced 2026-07-03) the fixed palette can collide with UI drawn over it. The zone-select collision is already fixed independently (color-independent casing on .evcc-zone-rect + drop-shadow on .evcc-map-ov-savedzone), so this doc is only about the room fills.
Design — the cascade¶
Per room, resolve the fill color in this order (the same override > theme > default cascade the rest of the theme system uses):
- Per-room override — a color the user assigned to this specific room (stable, keyed by
room_id). If set → use it. - Theme palette — the theme's color for this room's index (
--evcc-room-fill-N). If the theme sets it → use it. - Default palette —
ROOM_FILL_PALETTE[index](cards/map-room-color.js; shipped — was_SEGMENT_COLORSat design time).
Degrades gracefully: touch nothing → today's rainbow; set a theme palette → whole map recolors; override one room → just that room changes.
One cascade, two consumption modes¶
The SVG path can ride the CSS cascade; the raster can't (canvas takes no CSS vars). Both consume one shared source of truth (cards/map-room-color.js — the default array, the index rule, the per-room lookup) so they can't drift:
- SVG —
roomFillCss(idx, override)returns a CSS string: a concrete override hex if the room is overridden, elsevar(--evcc-room-fill-<idx>, <default hex>). CSS resolves theme-token-or-default and picks up a live theme change with no re-render. - Raster —
roomFillRgb(idx, host)reads the computed value of--evcc-room-fill-<idx>off the host as[r,g,b](onegetComputedStyle(host)read per palette slot —ROOM_FILL_N= 12 reads per raster render, resolved into an array once and then indexed per pixel), androomOverrideRgb(value)resolves a per-room override hex to[r,g,b](ornull) when set. A theme change busts the_vaImageCacheso the canvas repaints.
Index vs id (the split that makes it coherent)¶
- The theme palette slot is derived differently on the two paths — and on neither is it the
room_idthe override uses. SVG: by render order (idx = segIndex % N, the segment's index instate.mapSegments()—renderers/map.js:216and:1237passiintoroomFillCssat:937/:1290), matchingROOM_FILL_PALETTE[idx](the shipped default array; was_SEGMENT_COLORSat design time); not stable across a re-segment. VA raster: by the device's raster rid —palette[(rid - 1) mod ROOM_FILL_N](bindings/map.js:392, and:459on the floor path), withriddecoded per pixel at:385; theCNYVDQ9Sanchor at:378marks it explicitly. So the raster slot moves only when a room's rid moves, not when draw order does. Either way the palette is a base look, not a per-room promise — that promise is the override below. - The per-room override is keyed by
room_id— stable across re-segment/re-render, because it's a promise about a specific room.
Contracts¶
- Storage:
room.color— a#rrggbbstring ornull, on the per-map room bucket (alongside name/settings;room_idstorage key per the locked room-identity model). Shipped as the fold-in:coloris an optional field onupdate_room_fields(services/rooms.py::_UPDATE_ROOM_FIELDS_SCHEMA, validated by_hex_color_or_none(services/rooms.py::_hex_color_or_none)); there is no separateset_room_colorservice (grep confirms none exists). The value survives a room re-save (rooms/room_manager.py::build_managed_roomsandmaps/map_manager.py::rebuild_map_bucketboth preservecolor) and is surfaced on the room-switch attributes (room_entities.py::extra_state_attributes). - Tokens:
--evcc-room-fill-1…--evcc-room-fill-Nwhere N =ROOM_FILL_N(cards/map-room-color.js::ROOM_FILL_N, = 12), added toMAP_TOKENS(theme-tokens/map.js::MAP_TOKENS) — defaults =ROOM_FILL_PALETTE(cards/map-room-color.js::ROOM_FILL_PALETTE; was_SEGMENT_COLORSat design time), so a themeless card is byte-for-byte today's render. They auto-surface in the theme editor (registry is array-driven) and resolve viaapplyDynamicTheme. - Resolver:
cards/map-room-color.js— the single definition ofperRoom ?? token ?? default, exposed as three thin functions:roomFillCss(idx, override)→ a CSS string ("var(--evcc-room-fill-N, #default)", or a concrete override hex) for the SVG path;roomFillRgb(idx, host)→[r,g,b]reading the computed--evcc-room-fill-Ntoken for the raster palette; androomOverrideRgb(value)→[r,g,b] | nullfor the raster per-room override. - Label ink: ~~
labelInk(fillHex)→"#000" | "#fff"by WCAG relative luminance~~ — dropped with Phase 3; nolabelInkexists anywhere insrc/(grep confirms). The label pills carry their own background + ink, validated readable at the#ffffff/#000000color extremes — see the Phases section below.
Phases (each shippable, gated check:i18n/check:styles/build/tests)¶
- Phase 1 — resolver + theme palette (NET-ZERO visual): add the
--evcc-room-fill-Ntokens (defaults = today's rainbow) +cards/map-room-color.js; wire both render paths through it. Themeless render is unchanged; a theme can now recolor the whole map. Raster cache-busts on palette change. - Phase 2 — per-room override:
room.colorstorage + service + a color picker in the room editor (it already holds per-room name/settings) + cascade so per-room wins. (map-tap → coloris a later nicety, not this phase.) - ~~Phase 3 — label contrast by luminance:~~ DROPPED — the label pills already carry
contrast (own background + light ink); validated at
#ffffff/#000000, solabelInkis moot. - Phase 4 (optional): per-room "reset to default", a few palette presets, an editor contrast hint.
Reuse (this is a delta, not a rebuild)¶
- Room storage already holds per-map per-room data → add one
colorfield. - The theme registry is array-driven → the palette is N more
mapToken.color(...)lines in an existing group; editor surfacing + resolution are automatic. --evcc-room-fill-opacityalready exists (room-cards) — the palette is its natural sibling.- The color-independent select/overlays (2026-07-03 casing fix) mean UI drawn over recolored rooms already survives.
Design-care / open questions¶
- Raster cache invalidation — key
_vaImageCacheby a palette/override version (or the resolved-colors hash) so a theme or per-room change repaints. Cheapest: include a palette signature in the existing version key. getComputedStylecost — read the N palette tokens once per raster render (they're few); cache within the render.- Editor grouping — palette tokens under
MAP_TOKENS, or a dedicated "Map Rooms" group for clarity? (Cosmetic; either surfaces.) - Color picker placement — room editor (has room context) is the Phase-2 home; map-tap is deferred.
- Contrast is advisory, not enforced — we recolor and adapt the label ink, but don't block a low-contrast choice (match
feedback_map_render_aesthetics: let the user see the LOOK; warn, don't nanny).
Out of scope¶
Map overlay colors (already tokens), room-card chips (already tokens), the animal/mascot colors, and the CV/segmentor internals. The mapping/theme systems this rides on: docs/dev/11-mapping-system.md, docs/dev/frontend/theme-system.md, docs/dev/map-state-source.md.