Frontend Module Reference¶
Per-file navigation map for src/: where each responsibility lives, who imports it,
and what breaks if you touch it. Same spirit as state-management.md
(which covers state/) β this document covers everything else. Entries are navigation entries,
not one-liners: what a file reads/writes, its blast radius (who imports it), the contract it must
not break, and the cliffs β view-mode forks, layer ordering, intentional omissions, trust
boundaries. When a file is too big for a table cell, its detail drops into a ### subsection
below the table.
Source tree: src/ β the authoring source. The build emits bundles to
custom_components/eufy_vacuum/frontend/*.js; those are artifacts and are not documented
here. state/ (26 files) is deferred to state-management.md.
Read the room before you touch it. The frontend isn't one dragon β it's several habitats with different rules.
theme-tokens/has the highest blast radius;renderers/map.jsis a pipeline unto itself;bindings/is DOMβaction wiring bound after every render;styles/apply-theme.jsis a directmain.jsdependency;i18n/sanitize-locale.jsis an XSS trust boundary. This map exists so a one-line change doesn't become an hour in the wrong cave.
1. Entry points & cards¶
The src/ root holds the command-center card and the two standalone cards; src/cards/ holds the standalone-card implementations plus a handful of pure helper modules shared across cards. This section is the topology map: which custom element owns which shadow root, which subsystem stack each one instantiates, and where the trust boundaries between them sit.
Card topology. Three distinct custom elements ship from this tree, each with its own shadow root and its own state/renderers/bindings/actions stack:
eufy-vacuum-command-center(main.js) β the full sidebar panel. Owns the shell frame (header + nav + per-view roots), the render loop, all data-refresh schedulers, and two body-level hosts appended todocument.body(the modal host and the toast host) that deliberately escape its shadow root's stacking context.eufy-vacuum-map(cards/vacuum-map-host.js) β an embedded map host that mounts the same map subsystem classes the panel uses, on a minimal element. The dashboard card lazy-loads it via a dynamic import of a separate served bundle.vacuum-agent-dashboard(cards/dashboard-card.js),eufy-room-card(room-card.js), andvacuum-agent-profile-card(cards/profile-card.js) β the drop-in Lovelace cards, each fully self-contained with its own CSS and i18n shims.
The load-bearing seam: the panel (main.js) and the embedded map-host (vacuum-map-host.js) each instantiate a full VacuumCardState/Renderers/Bindings/Actions stack, but the map-host forces isMapViewActive()/useVaRender() instance-locally and neuters the setters so the embedded map never writes the panel's shared localStorage keys (evcc_va_render_<vac>, per-vacuum pan/zoom, active-view). The dashboard card owns the map element on its JS instance (this._mapEl) so its zoom/pan/zone state survives the card's innerHTML wipes; it re-parents rather than rebuilds. The modal + toast hosts live only on the panel β they are document.body children (z-index 9999 / 10000) because a shadow-root sibling could never out-stack them; the toast host must sit above the modal host so save/dock feedback shows through an open modal. See the four-layer contract architecture-overview.md / state-management.md and the map layer stack map-render-layers.md.
| Module | File | What it owns |
|---|---|---|
| Command-center card | main.js |
The eufy-vacuum-command-center custom element β the panel core. Owns the shell frame, the render scheduler, every data-refresh timer, the body-level modal/toast hosts, the header language globe, viewport (mobile/desktop) detection, and the set hass/setConfig lifecycle. Directly imports (not via a barrel): constants.js, state/index.js, renderers/index.js, bindings/index.js, actions/index.js, bindings/core.js (applyCardDomHelpers), render-cycle.js, styles/index.js, styles/apply-theme.js, i18n/index.js, i18n/lang-store.js, controllers/learning-controller.js. Full trail below. |
| Render-cycle contract | render-cycle.js |
The VIEWS enum, VIEW_ORDER (the fixed list of view roots the shell builds), isViewAvailable(view, state) (the single capability-gate shared by desktop header, mobile shell, and the active/persisted-view fallback), buildRenderContext(card) (the ctx object every renderer receives), renderHeader(ctx), and renderView(ctx) (the view router β a switch that dispatches to renderers.render<View>View, each with an i18n empty-state fallback). Imports renderers/language-control.js. Imported by main.js, cards/vacuum-map-host.js (for VIEWS), and renderers/mobile-shell.js. Contract: VIEW_ORDER.length must equal the number of view roots the shell frame builds β main.js._ensureShellFrame treats a mismatch as a missing frame and rebuilds. MAPPING_ARCHIVE is legacy and always rerouted to ROOMS. |
| Constants | constants.js |
Single source of truth for CARD_NAME/CARD_VERSION, DOMAIN ("eufy_vacuum"), every SERVICE_* name (must match what the integration registers), the ENTITY id-pattern builders, vacuumObjectId(), and INVALID_STATES. No runtime state, computed values, DOM, or CSS (enforced by the header comment). Imported by main.js and ~16 actions/* and state/* modules β the widest blast radius in the tree, so a renamed constant ripples everywhere. Note the standalone cards do not import this β they hardcode DOMAIN = "eufy_vacuum" locally. |
| Panel bundle registrar | all-cards.js |
Side-effect-only barrel: imports main.js, room-card.js, cards/dashboard-card.js, cards/profile-card.js. This is the panel bundle entry β loaded when the sidebar panel opens; defines the panel element and all three standalone cards. |
| Global cards registrar | cards-standalone.js |
Side-effect-only barrel: imports room-card.js + cards/dashboard-card.js + cards/profile-card.js only, no panel. Loaded on every HA page via add_extra_module_url so the standalone cards are defined on a cold dashboard (wall tablet, hard refresh). Double-registration with the panel bundle is a no-op via defineCard's guard. |
| Room card | room-card.js |
The eufy-room-card custom element (single-room settings + save + exclusive quick-start) and its config editor. Reads room switches live from hass.states, holds unsaved field drafts locally, and dispatches update_room_fields / start_selected_rooms directly. Imports i18n/index.js and everything from cards/_shared.js. Its own local CSS + a local chipRow/tVocab. Contract: start is exclusive β it turns off all other room switches before turning its own on. |
| Dashboard card | cards/dashboard-card.js |
The vacuum-agent-dashboard element (compact multi-room control) + its editor. Full trail below. |
| Profile card | cards/profile-card.js |
The vacuum-agent-profile-card element (stripped inspect-and-run surface for ONE saved run profile) and its vacuumβmapβprofile config editor. Fetches the run-profile library (which carries steps) via get_saved_run_profiles, renders name + metadata + a read-only Runs As manifest + a Run button (start_run_profile). Its manifest is the shared pure renderStepsManifest (state/steps-manifest.js) β the same helper the panel's renderers/run-profiles.js calls, so card + panel render the step sequence byte-identically (anti-drift seam; unit-tested in state/steps-manifest.test.mjs). Editor maps are derived client-side from room switches (attrs.map_id/map_name). Imports i18n/index.js, cards/_shared.js, state/steps-manifest.js. Contract: reads library (full, has steps), never the profiles summary array (no steps); map_id is required. |
| Embedded map host | cards/vacuum-map-host.js |
The eufy-vacuum-map element. Full trail below. |
| Run-launcher logic | cards/dashboard-dispatch.js |
Pure, DOM-free, unit-tested run-launcher state machine for the dashboard card: emptyArmed(), nextArmed(state, action) (mutual-exclusivity reducer β arming a profile/scene clears rooms and vice-versa; never returns a service call, which is what structurally guarantees "arming is inert"), armedIsValid(armed, live) (re-validation gate β a vanished scene must never fire select_option, since selecting is the run), planStart(armed, ctx) (returns the ordered {domain,service,data} plan; [] when no source is armed β Start no-ops). Imported by dashboard-card.js only. |
| Zone geometry | cards/zone-geometry.js |
Pure, DOM-free, unit-tested coordinate math lifted verbatim from the panel's state/map.js: normRotation, unrotatePct/unrotateRectPct (90/180/270 un-rotation), rectToNormalized/draftsToNormalizedRects (object-fit:contain letterbox correction β normalized [x0,y0,x1,y1] for start_zone_clean), rectToPolygon (β β₯3-point polygon for create_saved_zone), ZONE_MAX_FALLBACK. Imported by dashboard-card.js (draw-a-box zone clean) and bindings/saved-zones.js (rectToPolygon). Contract: these transforms are on-device-verified β reimplementing them elsewhere is the anti-pattern this module exists to prevent. |
| Room-color resolver | cards/map-room-color.js |
The shared room-fill palette resolver β the single source both render paths resolve through (the SVG segment fill in renderers/map.js and the VA raster in bindings/map.js, which were once two byte-identical hardcoded copies). Cascade, defined here once: per-room override (room.color) β theme token --evcc-room-fill-<N> β default ROOM_FILL_PALETTE. roomFillCss stays pure (SVG rides the live CSS cascade); roomFillRgb is the only DOM-touching export (reads a computed custom property because a canvas can't take CSS vars β resolve once per render, never per pixel). normalizeHex is the shared gate so a bad override falls straight through. Imported by bindings/map.js, renderers/map.js, renderers/room-editor.js, actions/rooms.js, theme-tokens/map.js, state/theme.js. See [project themeable map palette / map-render-layers.md]. Despite living under cards/, this is core map infrastructure, not a standalone-card helper. |
| Standalone-card shared helpers | cards/_shared.js |
The small, pure, DOM-light helper set both standalone cards share: esc, vocab, roomSwitchesFor (live room-switch reader), adapterOptions, committedRoomFields, isMopMode, stripNull, chipRow, callResponse (response-capable service call that never throws into a render), the renderLangControl/wireLangControl/LANG_CSS language globe (persists per-user via getStoredLang/setStoredLang β the same store the panel uses, so language stays in sync across panel + cards), and the defineCard/registerCard idempotent registration guards. Re-exports i18n primitives. Imported by room-card.js, cards/dashboard-card.js, and cards/profile-card.js (the last uses esc, callResponse, defineCard, getStoredLang). Contract: defineCard must guard every standalone element β they ship in two bundles and a plain customElements.define throws "already defined" on the second load. |
main.js β the command-center card¶
Owns: the eufy-vacuum-command-center shadow root and its full subsystem stack (_state, _renderers, _bindings, _actions, _learningController), plus two document.body hosts (_modalHost, _toastHost) that live outside the shadow root.
Reads / writes:
- setConfig β on an empty/vacuum_entity_id-less config it renders a static "no vacuum" onboarding placeholder and returns before instantiating any state machinery (fresh-install fallback panel). With a real config it constructs the state stack once, syncs on subsequent calls, and seeds viewport from the card's own rendered width (not window.innerWidth β Lovelace can embed it in a narrow grid cell).
- set hass β the hot path: syncs state + actions, mirrors the theme sensor into state, detects active-map-id changes (clears stale map-derived slices via setMapSegmentsData(null)), then fans out to ~13 debounced refresh schedulers (_scheduleStartStatusRefresh, _scheduleDashboardSnapshotRefresh, _scheduleLiveMapRefresh, _scheduleLivePosePoll, β¦). Most schedulers are view-gated β metrics/learning/setup refreshes early-return unless their view is active.
- _render β re-applies theme, re-measures viewport, falls the active view back to ROOMS if isViewAvailable says its tab is hidden for the current adapter, then diff-renders header/view-roots/bottom-nav by comparing dataset.renderedHtml (skips untouched regions). Wraps renderView in try/catch so a throwing view degrades to an i18n error box instead of a blank card.
Cliffs & trust boundaries:
- Render suppression during gestures. _scheduleRender early-returns while _furnishedGestureActive is set β a furnished-art alignment drag mutates the art element's transform inline, and a mid-gesture re-render would detach the dragged node and lose its pointer listeners. The gesture's finish handler clears the flag and settles with one render.
- Two frame pollers, both self-disabling. _scheduleLiveMapRefresh (2 s) exists only because a camera.* live entity pushes frames without a state change, so the <img> freezes without a cache-bust tick. _scheduleLivePosePoll (2 s) overrides the moving overlays with the fork's fresh in-memory pose and latches off for the session on the first not_configured response (Roborock is frame-fresh via the snapshot). Both are idempotent (never reset a running timer β no starvation) and skip the fetch when document.hidden.
- Modal host bind-once discipline. _updateModalHost composes all modal fragments in a fixed order (dialog rendered last so it stacks above whatever opened it) and only re-binds events after an actual innerHTML swap β re-binding on every background status push while a modal sits open with unchanged markup would stack duplicate click listeners and double-fire save/rename/delete. Modal-body scroll is preserved by index across the swap.
- Viewport lies. All width decisions use the card's own getBoundingClientRect().width via a ResizeObserver on this, never the window β with a mobile_shell: true|false config override that suppresses width detection entirely.
- Language override precedence. _langOverride (per-user, server-stored via lang-store.js) is the highest-priority source in resolveLang, bypassing the draft-gate; a fresh in-session pick (_langUserPicked) blocks a late server read from clobbering it. See i18n-system.md.
- The config editor (EufyVacuumCardEditor, registered as ${CARD_NAME}-editor at the bottom of the file) is a minimal entity + display-language picker that writes config.i18n.locale; advanced settings live in the in-card Setup tab, not Lovelace config.
cards/dashboard-card.js β the drop-in dashboard element¶
Owns: the vacuum-agent-dashboard shadow root, the armed-run state (_armed, delegated to dashboard-dispatch.js), per-room draft fields, the zone-draw drafts (_zoneDrafts), and (by reference) the lazy-loaded embedded map element (_mapEl).
Cliffs & contract:
- Arm-only (load-bearing). Toggling rooms / picking a profile or scene mutates local state only β nothing reaches the vacuum until Start. This is structural, not incidental: the Eufy scene's select_option is the run, so a blind dispatch on pick would clean immediately. Exactly one run source is ever armed (nextArmed), and Start re-validates via armedIsValid before firing (a scene/profile can vanish between arm and Start).
- Render-gating. _shouldRender(prev, hass) short-circuits irrelevant hass ticks β a blind rebuild would slam shut an open profile/scene <select> mid-pick β and never rebuilds while _drawing (an in-progress zone box) is live.
- Two map surfaces. _renderMapSection shows either the full <eufy-vacuum-map> host (when there's a real map / VA render β _wantsFullMap) or the lightweight W2a zone-draw <img> fallback. The full map element is persistent on the JS instance: collapsing the section drops its DOM body but _maybeMountMap re-parents the surviving element (with its zoom/pan/zone state) on expand. It loads via a dynamic import() of a separate served bundle (/eufy_vacuum/frontend/eufy-vacuum-map.js) so the always-loaded cards bundle stays lean.
- The card never builds service calls for a run β dashboard-dispatch.planStart produces the ordered plan and the card only executes it (_handleStart). Zone dispatch and dock go direct.
- Fetches get_dashboard_snapshot + get_saved_run_profiles once per vacuum (_ensureData, storm-proofed: claims the fetch flag before the await, doesn't retry a failed fetch every tick), and forwards the snapshot into the embedded map so it doesn't re-fetch.
cards/vacuum-map-host.js β the embedded <eufy-vacuum-map> host¶
Owns: its own shadow root and a full duplicate of the map subsystem stack (VacuumCardState/Renderers/Bindings/Actions), instantiated via _initStack. Statically imports the heavy class graph β which is why it lives in its own bundle that the dashboard card dynamic-imports on demand.
Cliffs & trust boundaries:
- Never writes the panel's persisted state. isMapViewActive is forced true instance-locally (so the map gates fire) without calling setMapViewActive (which writes the panel's per-vacuum localStorage). useVaRender is forced on and setUseVaRender is neutered to a no-op so the card can't overwrite the shared evcc_va_render_<vac> key the panel owns (issue #35). Pan/zoom is pinned under a separate _mapCtx = "card" key.
- Host shim only. It implements exactly the surface the map renderer/bindings/actions reach β shadowRoot, applyCardDomHelpers DOM helpers, _scheduleRender, _view, a no-op setView (the embedded map never opens the composer), showToast, the transient scratch flags, and its own copies of the two frame pollers (copied from main.js, self-contained on _state/_actions). renderMapRoomView renders only the map; the host manually brings in the zone panel + collapsible layers panel (same gates/signatures as renderers/rooms.js) since those normally render in the panel's sidebar column.
- Separate i18n registry. Being its own bundle, it must call ensureLocalesLoaded itself or it renders English even when the parent card's language is German. disconnectedCallback flushes the map transform and tears down the pollers + map ResizeObserver so timers die with the element.
2. Actions¶
src/actions/ β action modules translate UI intent into HA service calls. Every module is a apply<Domain>Actions(proto) mixin that grafts methods onto VacuumCardActions.prototype; the composed class holds this.hass + this.state (refreshed via sync() on every HA update) and is the sole write-path to the backend. Actions read this.state freely but never write another action module β the Β§4.4 cross-module rule applies symmetrically here. The whole layer is imported only through the barrel (actions/index.js); no file is pulled in directly. The single instance (main.js this._actions) flows into bindings and renderers via the render context, so a broken method signature blasts every binding that calls it.
Universal contract (all 14 modules): response-capable services are called with returnResponse=true and the result is unwrapped result?.response ?? result (HA wraps supports_response=True payloads in a {response} envelope). Skipping the opt-in makes modern HA silently no-op the call β the load-bearing footgun that recurs across map.js (upload/analyze/adjust) and every fetch. callService swallows failures to null so a backend error never propagates into the render cycle.
| Module | File | What it owns |
|---|---|---|
| Barrel | actions/index.js |
Defines class VacuumCardActions (holds hass/state, sync() re-binds both) and mixes all 14 domain modules onto its prototype. CLIFF β apply ORDER is load-bearing: applyCoreActions MUST be first because every other module calls callService(). Re-exports only VacuumCardActions; imported by main.js and cards/vacuum-map-host.js. |
| Core | actions/core.js |
The three primitives every module builds on: callService(domain, service, data, returnResponse) (centralised try/catch β null on failure, the trust boundary keeping backend errors out of render), callHA(service, entityId) (homeassistant domain), callNamedService("domain.service", β¦) (splits + validates a fully-qualified string). CONTRACT: returns undefined on non-response success, unwrapped response otherwise; warns (not throws) when hass isn't ready. Blast radius = everything. |
| Rooms | actions/rooms.js |
Side-effecting Rooms-view flow β see subsection below. |
| Map | actions/map.js |
Segment reads, image analyze/upload/delete, segment adjust, and the backend-persisted UI overlays β see subsection below. |
| Learning | actions/learning.js |
The learning read-model surface: getDashboardSnapshot (the primary card read model), runLearningEstimate, reanchorLearningTimeline, getNextLearningRoom, getRoomLearningEstimates, getIncompleteRunLog, getTroubleRoomsLog. CLIFF: runLearningEstimate OMITS started_at for pre-start calls (sending it changes backend semantics); log fetches return null unless the payload carries a record_type (empty {} = "no log", not an error). Consumed by main.js snapshot pollers + bindings/*. |
| Dock | actions/dock.js |
Dock cycles (washMop/dryMop/stopDryMop/emptyDust via the private _runDockAction) + getDockActionStatus + pause-timeout get/set. All resolve vacuum_entity_id+map_id from state (opt args override) and bail null if either is missing. Locally-scoped service-name consts (not from constants.js). |
| Metrics | actions/metrics.js |
Single read: getMetricsSnapshot({room_slug, profile_key, status, used_for_learning}) β optional filters only appended when truthy (used_for_learning only when strictly boolean, so false is sendable). Powers the Metrics view. |
| Review | actions/review.js |
Learning Review view: getLearningHistorySnapshot (filtered, limit-capped) + excludeLearningJob/restoreLearningJob. CONTRACT: rebuild_csv defaults true and is sent as !== false (an explicit false suppresses the model-CSV rebuild). |
| External jobs | actions/external-jobs.js |
External Jobs review subtab: fetchExternalPendingRuns (returns {pending, brand}, defaults to empty/null β never throws so the subtab renders on a brand with no external capture), resegmentExternalRun (send expectedRooms XOR activeBoundaries; server re-runs the real segmenter on saved samples), confirmExternalRun (with room_assignments + rebuild_stats), discardExternalRun. CONTRACT: guarded calls return {ok:false, error:"missing_args"} rather than null, so the review UI can branch on failure. |
| Order | actions/order.js |
Brand/scope-agnostic reorder persistence via the order-adapter pattern: confirmOrderedPositionChange (selector move-to-position) + confirmDraggedOrderChange(scope, targetId) (drag-drop). CLIFF β trust boundary: persistence + state cleanup ONLY; the FLIP animation and visual feedback live in bindings/. Delegates the actual write to adapter.persist.call({_actions, state, hass}, nextItems, meta) β it does NOT know how any one scope (e.g. "rooms") persists; returns reorder metadata for the binding's animation. |
| Room profiles | actions/room-profiles.js |
Room-profile CRUD (get/save/save-from-room/overwrite/overwrite-from-room/rename/delete/apply). CONTRACT: optional strings (profile_name, label, new_profile_name) are trimmed and omitted when blank β sending empty strings trips backend schema validation. applyRoomProfile numeric-coerces each room_id where possible (keeps non-numeric slugs as strings). |
| Run profiles | actions/run-profiles.js |
Saved run profile CRUD (get/save/overwrite/apply/rename/delete) β a run profile = a snapshot of the current room selection + settings, optionally expose_as_button. Distinct from room-profiles: run = whole-queue template, room = per-room setting preset. All args come from callers (no state fallback). Two extras drive the native charge/wait steps feature: setRunProfileSteps({steps}) persists a profile's ordered step list ({type:"room_group", rooms:[β¦]} / {type:"charge_wait", target_battery_percent} / {type:"wait", wait_minutes}), and startRunProfile (start_run_profile) is the apply+start-in-one-shot call β the only path that dispatches a stepped profile's charge/wait phases (the flat start_selected_rooms path would drop them). Two backend-enriched flags on a fetched profile drive the card's stepped UI, and they are DISTINCT: has_charge_steps is charge-only (any charge_wait step) and gates only the run-profiles panel's read-only "Runs as" summary; has_stops is the sequenced-run flag β any break step (charge_wait/wait) OR more than one room_group β and is what the stepped preview/chips + Start-routing gate on (state/run-profiles.js mirrors the rule via _deriveHasStops/pendingStepRunProfileId, backend flag wins). A WAIT-only or multi-group profile has has_stops true but has_charge_steps false, so gating on the charge-only flag would flatten it. |
| Saved zones | actions/saved-zones.js |
Saved-zone CRUD + filing + clean (Wave 3b): getSavedZones, create/rename/delete, setSavedZoneRoom (file under a room, null = Unassigned), cleanSavedZone (one) + cleanSavedZones (multi-select, one run). CLIFF: getSavedZones reads off get_map_segments but plucks ONLY saved_zones into an isolated state slot β deliberately NOT touching _mapSegmentsData so a background refresh can't clobber the map's optimistic overlays. Clean results carry a reason (map_not_active/zone_not_found/bad_geometry/no_zones); per-brand count+size caps are enforced service-side. |
| Setup | actions/setup.js |
Setup-tab lifecycle: getSetupStatus, addVacuum, importActiveMap, getSetupMapRooms, saveSetupRooms, deleteSetupMap, rejectSetupRooms, forceRemoveSetupRoom, renamePanel, setMapCamera. CLIFFS: deleteSetupMap sends a confirmation_token (high-protection maps require the exact display name); renamePanel/setMapCamera send an empty string to clear (the backend re-registers the sidebar panel / falls back to the adapter-pattern live backdrop); rejectSetupRooms marks phantoms so discovery never re-surfaces them. |
| Theme | actions/theme.js |
Theme library/active/working-draft + import/export wrappers, all funneled through the private _callThemeService. Covers getThemeLibrary, setActiveTheme, updateWorkingDraft (only sends non-empty tokens/colors/alpha), revertDraft, save-as-new/overwrite, rename/delete/setThemeTags, export/importTheme. CLIFF: notifyThemeExport is the odd one out β it hits persistent_notification.create (not the eufy domain) as a fenced-JSON escape hatch when clipboard + download are both blocked (keyed by theme id so re-export replaces). importTheme with a vacuumEntityId is a SCOPED import (targets that vacuum's active theme); without it, a full library add. |
actions/rooms.js β the start/queue orchestrator¶
The one action module that runs a multi-step flow with ordering rules, not thin wrappers. Imports normalizeHex from cards/map-room-color.js (the ONE shared color resolver) for the per-room fill override.
startCleaning(options)is a 4-phase sequence: (1)get_start_statusβ ifblocked, clear confirmation and bail with the status; (2)start_selected_roomsβ CONTRACT: this call must NOT usereturnResponse=true(HA rejects it for this non-response-capable action) and re-surfaces aconfirmation_requiredreason back to state for the reduced-run confirm dialog; opt-instrict_orderis appended only whenstate.strictOrder()is set (no-op on order-honoring brands, gated backend-side onhonors_clean_order); (3)runLearningEstimate(calls intolearning.js) + reset stale live-job fields; (4)beginLearningJoband resolve the first banner room. This is the only place that writes learning-job lifecycle state from an action.updateRoomFields(roomId, fields)is the shared persist path (allsaveRoom*/applyRoomProfilehelpers funnel through it). CONTRACT β null optional fields (water_level,profile_name) MUST be omitted, not sent as null, or HA schema validation fails. On success it refreshes the room-estimate cache (refreshRoomLearningEstimates).saveRoomEditoralways sendscolor: normalizeHex(fields.color)(canonical hex ornullto clear) β the editor is the source of truth for a room's fill, so a reset must persist as an explicit clear; the backend only writes when the key is present, so it never clobbers other callers.- Toggle/bulk helpers (
toggleRoomEnabled,retryMissedRooms,clearQueue,selectAllRooms) operate the per-room switch entities (not the eufy domain);persistRoomOrderingwrites the per-room number entities (value = index+1).clearQueuealsoclearLearningJobContext()(queue-derived state) while preserving queue-independent chips.cancelActiveRunuses the stockvacuum.return_to_base.
actions/map.js β map pipeline + persisted overlays¶
The heaviest module; pulls ~25 service names from constants.js. Splits into three concerns:
- Segments + migration:
getMapSegments(mapId)fetches intostate.setMapSegmentsData, rebuilds the custom-composer draft (maybeLoadComposeDraft), then runs_migrateLegacyMapOverlaysβ a one-time push of legacylocalStoragesegment-room-links + companion-anchors into backend storage (the reason overlays now follow the user across browsers/devices). CLIFF β this migration lives in the ACTION not state, because state has no back-reference to call services; it bails fast iflocalStorageis empty and only pushes entries the backend doesn't already know. - Direct
hass.callServiceescape hatches:analyzeMapImage,uploadMapImage,deleteMapImage,adjustMapSegmentbypass the wrappedcallServiceand callhass.callServicedirectly withnotifyOnError=true+returnResponse=true. WHY β trust boundary: errors must propagate to the binding's own try/catch for status feedback (the wrapper would swallow them tonull), AND these services are registeredsupports_response=Trueso without the opt-in HA silently no-ops β the file cited above (upload never writes) is exactly this trap. - Optimistic-write overlays + adapter reads:
rotateLiveMap,setHiddenRegions,setMapOverlayVisibility,setAreaLabelAnchor,setFurnishedRenderModeall flip an optimistic state overlay synchronously (instant UI) then persist and let the backend snapshot reconcile on its next push.getMapRenderData/getMapLivePoseare the two adapter-driven reads feeding the client-side render β the fresh ~2s live-pose poll overrides the lagged snapshot while cleaning and may return{present:false, reason:"not_configured"}for a frame-fresh brand, which the caller latches off so it never re-polls. CLIFF β furnished custom render:setFurnishedArtPlacementsendsscope:"home"in Wave 1 (per-room is Wave 2) and passes all-null transforms to CLEAR the placement.cleanZoneis fire-and-forget (bypasses the room-id job pipeline). Related: map-render-layers.md,bindings/map.js,renderers/map.js,theme-tokens/map.js.
3. Bindings¶
src/bindings/ β wire DOM events to actions/state. Every module is a prototype mixin: apply<Name>Bindings(proto) hangs _bind<Name> (and helpers) onto VacuumCardBindings.prototype. The class instance holds this.card β the host VacuumCard element β through which all four layers are reached (card._state, card._actions, card._renderers, card._scheduleRender); a binding must not own state or render, it only translates a DOM event into an action/state call and schedules a render. See the four-layer contract in architecture-overview.md and the layer boundaries in state-management.md.
The two cliffs that govern this whole directory:
bindEvents()runs after EVERY render, and the DOM is wiped + rebuilt each render. All previously-attached listeners die with the old DOM, so every_bind*re-attaches from scratch. The safety net iscard._on/card._onAll(fromcore.js): idempotent binding keyed by a per-eventdatasetmarker, so when a render doesn't actually swap an element (viewHtml unchanged), re-binding is a no-op instead of stacking N duplicate listeners. Miss this and clicks fire N-times or file-pickers open N-in-a-row.- The body-level modal host is a SEPARATE binding path. Modals render into
card._modalHost(a node indocument.body, outside the shadow root β event-binding-and-modal-host.md). It is bound bybindModalHostEvents(host)(inindex.js), called frommain.json every modal render, usinghost.querySelectorAll(...).forEach(el => el.addEventListener(...))β raw, non-idempotentaddEventListener, NOT the shadow-root_onAll. Correctness there relies on the render replacing the host's innerHTML (fresh elements, no stale listeners) plus idempotent state resolvers; a no-swap re-render can double-bind, which is why those handlers are written to tolerate it.
| Module | File | What it owns |
|---|---|---|
| core β οΈmain | core.js |
The low-level DOM helper install: applyCardDomHelpers(card) bolts $/$all (shadow-root query shorthands) and _on/_onAll (idempotent listener attach) directly onto the card element. Imported by main.js directly (not via the barrel) and called once in the constructor; every other bindings module depends on _on/_onAll. Idempotency uses a per-event dataset marker on normal elements and a module-level WeakMap for dataset-less hosts (ShadowRoot/Document/Window). Blast radius: everything. |
| index (barrel) β οΈmain | index.js |
Defines the VacuumCardBindings class and mixes all 19 feature modules onto its prototype (see composition order below). Owns bindEvents() (the after-every-render entry point calling every _bind* + _bindToasts), bindModalHostEvents(host) (the body-level modal path β see subsection), t/tRaw (delegates to card._renderers.t, key-fallback never throws β i18n-system.md), sync(card), and the inline _bindDialogHost (card-native confirm/alert/prompt). Imported by main.js directly. |
| nav | nav.js |
Wires [data-view] tab buttons β card.setView(). The single smallest module; the whole tab strip. |
| language | language.js |
The header globe control (toggle-/close-/set-language). ONE selector set covers BOTH desktop and mobile headers. stopPropagation on the toggle so it doesn't reach the outside-close handler. i18n-system.md. |
| mobile-shell | mobile-shell.js |
The mobile bottom-nav overflow ("More") sheet open/close only β view switching rides the shared nav.js [data-view] path. State = card._mobileMoreOpen; sheet-item tap lets nav fire first (setView), then closes the sheet. |
| base-station | base-station.js |
Base Station view: dock action buttons + pause-timeout selector ([data-pause-timeout-minutes]), writing through setPauseTimeoutSettings and mirroring into card._state. |
| maintenance | maintenance.js |
Maintenance view: inner-tab chips, item modal open/close, reset flow, per-component interval editor. Dual path β shadow-root _bindMaintenance + body-level _bindMaintenanceModalHost(host). Contract: interval writes go through set_maintenance_interval, the SAME storage slot as the number.* HA entities (two-way sync). |
| metrics | metrics.js |
Metrics view: filter chips, tab switch, per-profile save buttons; save dispatches the candidate's own save_service via callNamedService (guarded on save_supported). |
| order | order.js |
Shared generic ordering engine (rooms + queue + zones reuse it): mobile position selector, desktop drag-and-drop, FLIP animation, moved-item highlight. Cliff: drag handlers must NOT trigger a full re-render during dragstart/dragover β replacing the DOM mid-drag collapses the browser drag session. _bindOrder() delegates to bindOrderEvents(shadowRoot); the modal-host order actions live in bindModalHostEvents. |
| review | review.js |
Learning Review view: filter/sort/reason chips, profile-matcher fields, job exclude/restore; refreshes the learning-history snapshot on filter change. |
| external-jobs | external-jobs.js |
External-run capture: dual path β subtab (_bindExternalJobs, shadow root) + review-wizard modal (_bindExternalWizardHost(host), body-level). Subtab switches source, opens/discards pending runs. |
| rooms | rooms.js |
Rooms view (see file header's own owns-list): room toggle, start-clean, clear-queue, select-all, strict-order toggle, pause/resume/locate, learning-summary + incomplete-run dismissals, queue-missed-rooms, and queue-chip tap-to-open-settings / long-press include-exclude. Also the launch point for the room-estimate modal (openRoomEstimateModal) β the close lives in room-estimate.js. CLIFF: when an applied stepped profile is pending (pendingStepRunProfileId), the Start button dispatches startRunProfile instead of startCleaning β the flat start_selected_rooms path would drop the profile's charge/wait phases and extra passes. |
| room-access | room-access.js |
Room-access modal β body-level only. _bindRoomAccess is an intentional no-op (nothing in the shadow root); the real wiring is _bindRoomAccessHost(host). |
| room-estimate | room-estimate.js |
Room-estimate modal β body-level only; owns just the close button (_bindRoomEstimateHost). Launch is in rooms.js; _bindRoomEstimate is a no-op. |
| room-editor | room-editor.js |
Room-editor modal open/close/field/save/transition (_bindRoomEditor on the shadow root) plus _refreshRoomEditorEstimates. Imports bindings/index.js. The heavy modal-host wiring (fields, per-room color input, profile save/overwrite/rename/delete) is inline in bindModalHostEvents β see subsection. |
| room-rules | room-rules.js |
Room Rules view (shadow root, direct _on): sub-tab per room, rule create/edit/delete, field inputs, save flow. |
| run-profiles | run-profiles.js |
Saved run-profile side panel: create/apply/edit/delete + name input, the "Run" button (run-run-profile β setAppliedRunProfile + startRunProfile), and the ordered-steps editor (add-charge / add-wait / capture-current-rooms-as-group, per-step move/remove, and inline charge-%/wait-minute inputs). Imports the pure step helpers from state/steps-order.js (sanitizeStepsForSave/stepsHaveRoomGroup/setChargeTarget/setWaitMinutes); overwrite persists steps via setRunProfileSteps only when the editor is engaged and refuses a step list with no room group. Also handles the editable charge/wait queue chips (data-chip-charge-index/data-chip-wait-index) that write straight back to the applied profile's steps. |
| saved-zones | saved-zones.js |
Saved-zones side panel: collapse (click + Enter/Space keydown), per-row multi-select, shared clean-setting selects, "Clean N selected" plural dispatch, delete. All delegated (_onAll) to survive panel re-renders. Imports zone-geometry.js (rectToPolygon). |
| setup | setup.js |
Setup tab: add-vacuum / import-map / refresh (steps 1-2) and configure-map / toggle-room / set-floor-type / save-rooms (step 3). |
| theme | theme.js |
Theme editor: tabs, preset/token/alpha editing, per-token & per-group reset, group collapse, filter chips, global + group-local search, alpha-double-tapβpicker. Dual path β shadow-root _bindThemeEditor + body-level _bindThemeJsonModalHost(host). Backend is the source of truth; live preview may update optimistically but persistence flows through theme services. Theme System. |
| map | map.js |
The largest, most-coupled module (~2600 lines) β full trail in the subsection below. |
Prototype composition order (index.js, both the import/bindEvents list and the apply* calls at file end): nav β language β base-station β maintenance β metrics β order β run-profiles β saved-zones β review β external-jobs β rooms β room-access β room-estimate β room-editor β room-rules β theme β map β setup β mobile-shell (then _bindToasts).
3.1 The body-level modal-host path (bindModalHostEvents)¶
Called from main.js after each modal render against card._modalHost (a document.body node, outside the shadow root). It binds via raw host.querySelector(All) + addEventListener β deliberately NOT the idempotent _onAll, because the shadow-root marker scheme keys off a DOM that's a different tree. It:
- installs backdrop
stopPropagationon[data-stop-propagation](and separately on the stacked[data-evcc-dialog], since the generic guard only catches the first modal); - wires the shared order-selector actions (open/close/set-position/confirm-with-flip) and room include/exclude toggle;
- then fans out to every
_bind*Hostsibling:_bindMaintenanceModalHost,_bindRoomAccessHost,_bindRoomEstimateHost,_bindExternalWizardHost,_bindThemeJsonModalHost, and_bindDialogHost; - inlines the room-editor modal wiring: close, field chips,
apply-profile, and the per-room color picker cliff β capture oninputWITHOUT re-render (a render swaps the<input>and drops the pick while the native OS picker is open), commit + re-render onchange; reset-color βnull(fall back to themeable palette); save βsaveRoomEditorthen refresh estimates + close; profile save-as-new / overwrite / rename / delete. _bindDialogHostwires the card-native confirm/alert/prompt: Enter/Escape on the prompt inputstopPropagationso they never bubble to the document-level keydown (which would close the modal beneath), and a once-per-mount autofocus guarded by adatasetflag.
3.2 map.js β the map view binding trail¶
Wires every map interaction. _bindMap() re-runs after every render (rooms/map view active) and calls ~18 sub-binders. Cross-refs: [renderers/map.js], [actions/map.js], [styles/map.js], [theme-tokens/map.js], and the layer stack in map-render-layers.md. Imports VIEWS from render-cycle.js and the shared room-fill resolver roomFillRgb/roomOverrideRgb/ROOM_FILL_N from cards/map-room-color.js (the ONE resolver both the SVG and raster paths use).
What it owns / the cliffs:
- Room selection (
_bindMapPolygons): single-click (220 ms debounce) toggles a segment + syncs the linked room's enabled state viatoggleRoomEnabled; double-click opens the room editor. Guarded against zone-draw mode and against a tap that was actually a pan (card._mapDragOccurred). - Tooltip (
_bindMapTooltip): builds viatextContent, neverinnerHTMLβ reading.datasetdecodes the escaped attribute back to raw, so a malicious room name would execute if re-injected. Trust boundary. - VA-render backdrop + raster decode (
_bindMapRenderToggle/_bindMapRenderCanvas/_drawVaRender): fetches the room-id raster ONCE (cached by version), decodesrid = byte>>rid_shift, appliesro_dx/dyoffset + Y-flip, paints the themeable palette (resolved RGBs, notvar()). Per-room color override bridges ridβroom by DEVICE-AUTHORITATIVErd.room_names[rid]name-match, NOTroom.idβ the raster rid and storedroom.idare different id spaces on real devices; a name miss falls through to the palette (never miscolors). Cache busted bypaletteSig/overrideSig/version; a map switch invalidates stale render data. - Selection scrim (
_bindSelectionScrim): subtractive per-pixel dark scrim over un-selected rooms (exact raster shapes, no bbox overlap), cached by version+selection. - Config view (
_bindMapConfig, ~750 lines): image-variant upload (transient in-memory<input>β a render would orphan a rendered input and silently drop the picked file; CV variants pass-through-or-hard-fail to keep dark/light alignment, custom backdrops downscale), analyze, delete-variant (two-tap arm), segmentation-mode toggle, custom-layout CRUD + live-layout, and the full composer (add/select/move/scale/resize/rotate/merge/split/assign-room/save). Composer tap-to-place unrotates pointerβcontent% viaunrotatePct. - Gestures & handles:
_bindMapZoomPan(pan/zoom, pinch, and the pointerdown that owns zone-draw + hide-area rubber-band rectangles β these MUST live in the one container pointerdown),_bindMapAnimal/_bindMapAnimalSelect(companion drag/scale/species),_bindAreaLabelDrag,_bindRoomNameDrag,_bindFurnishedArt. Drag handlers setcard._mapDragOccurredto suppress the click-as-toggle, and bail early in zone-draw/hide-draw mode so the rubber-band owns the press. - Layers panel (
_bindMapLayersPanel): overlay-visibility toggles with optimistic flip + rollback on service failure (clearOverlayVisibilityOptimistic); hide-area draw enter/exit + per-region delete + clear-all. - Layer ordering & mode forks (per map-render-layers.md): robot layer sits above room fills but below interaction handles; furnished mode diverges after transform application and intentionally omits the dock marker.
_ensureMapSegments/_syncSegmentsFromRoomskeep segment-selection β room-enabled in sync. The file-tail_imageFileToFittedBase64enforces HA's ~4 MiB websocket frame cap (_WS_SAFE_BASE64_BYTES = 3_400_000).
4. Renderers¶
src/renderers/ β the presentation layer (layer 2 of the four-layer contract architecture-overview.md / state-management.md). Every renderer is a mixin: it exports one apply<Name>Renderers(proto) that hangs render* methods off VacuumCardRenderers.prototype via index.js. Inside a method, this is the renderers instance β it holds this.card, reads state through ctx.state / this.card._state, and must not write state (mutation goes through actions/ invoked by bindings/). All entity/user data crosses the innerHTML sink through this.escapeHtml and all UI text through this.t β both defined in shared.js and available on every renderer via prototype composition, so no renderer imports shared.js by name. index.js mix-in order is load-bearing: shared first (base utilities), rooms/modals next, learning + shell (setup, toasts, dialog) LAST so overlays stack above the views they augment.
Two files are not mixins and are imported directly (blast-radius flags): theme-preview-registry.js (declarative routing table β theme-preview.js) and language-control.js (a bare renderLanguageControl() function imported by render-cycle.js and mobile-shell.js, NOT reached through the index.js barrel). badge-marks.js is a third non-mixin export file, but its src/ importers (the Map Bounds Review renderer + styles/mapping-review.js) were deleted in the mapping shelve β only the render harness still consumes it (see its row below). main.js touches renderers only through the barrel (import { VacuumCardRenderers } from "./renderers/index.js", instantiated once as this._renderers); the dashboard's map host cards/vacuum-map-host.js imports the same barrel.
| Module | File | What it owns |
|---|---|---|
| Combiner (barrel) | index.js |
Defines class VacuumCardRenderers { card } and its sync(card) hot-reload hook, then mixes all 23 apply* fns onto the prototype in a deliberate order (shared β rooms β modals β map/floor-texture β learning β shell). Re-exports nothing but the class. CONTRACT: order is the composition β reordering can let an overlay render before the view it augments. Blast radius: imported by main.js and cards/vacuum-map-host.js. |
| Shared helpers | shared.js |
The cross-renderer utility floor β escapeHtml (the XSS boundary; all HA entity data + user config must pass through it), t/tRaw i18n (Trust-Model-B: t() HTML-escapes by default, tRaw is the audited markup-carrying exception), plus select/chip controls, status badge, timestamp formatter. WHO USES IT: every renderer (via applySharedRenderers first in the mix-in order) β highest blast radius in the directory. CONTRACT: must be applied first; t() output must never be double-escaped (never esc() a t() result). See i18n-system.md. |
| Map (exemplar) | map.js |
The live-map rendering pipeline. See Β§4.1 below. |
| Floor-texture surface | floor-texture-surface.js |
Consumer of the textures/ registry β mixes _renderFloorTextureLayer (room-card CSS mask overlay), _buildFloorTextureDefs / _renderFloorTexturePolygon (map SVG <pattern> + polygon), and the shared _resolveSegmentFloorType. Imports textures/floor-texture-registry.js + textures/floor-texture-resolver.js. WHO CALLS ITS METHODS (cross-renderer, via prototype): map.js (defs + polygons) and rooms.js (_renderFloorTextureLayer). CLIFF: masks are authored with no alpha channel, so mask-mode:luminance MUST be set explicitly β the default alpha mode would flood the whole field. |
| Rooms view | rooms.js |
The primary daily view β action bar, active-job strip, room grid + tiles. Reads state.getRoomsForActiveMap(), canStartCleaning, startBlockedReason. Calls _renderFloorTextureLayer (floor-texture-surface) per tile and _withCurrentRoomPinned to float the currently-cleaned room. Augmented in place by learning.js overlays. When a stepped (charge/wait) profile is applied but not yet running, renderSteppedRunPreview shows a collapsible "This run" panel (rooms.run_plan_title + rooms.charge_time_varies note) with the true Cleanββ‘ChargeβClean sequence, and the queue strip swaps its flat chips for _renderSteppedRunChips β the charge/wait stops appear as editable "fake-room" chips (type a new % or minutes inline) so the multi-phase run isn't misrepresented as a plain N-room queue. |
| Learning overlays | learning.js |
Read-only augmentations layered on top of the Rooms view β pre-job estimate panel, live-run banner, progress list, the reusable confidence chip, and the incomplete-run re-queue banner. Reads state.learningJobActive, hasIncompleteRunLog, incompleteRunMissedRooms. CLIFF: applied LAST in the barrel (overlay, must not break existing layout); banner is hidden while a job is active. Also owns the mid-run charge/wait phase banners for stepped runs: a charge banner (learning.charging_to "Charging to {target}%" / charging_to_eta with a learned ETA, plus a live learning.charging_delta "{delta}% to go" the user watches shrink) and a learning.waiting "Waiting Β· ~{remaining} left" banner. |
| Learning review | review.js |
The Learning-Review view β filterable job history + profile matcher + per-job room cards for excluding bad runs. Dispatches its inner body between _renderLearningHistoryView and renderExternalJobsSubtab (external-jobs) off state.reviewSubtab(); reuses _renderReviewSubtabStrip defined in external-jobs.js. |
| External jobs | external-jobs.js |
The "External Jobs" subtab (list of app-started runs) + the two-step review-wizard modal (Step 1 confirm room count / split-merge cuts, Step 2 name rooms + correct settings). Owns _renderReviewSubtabStrip, which review.js reuses. Reuses the room-editor's shared modal + chip/field classes. |
| Badge marks | badge-marks.js |
Not a mixin β a retained primitive (no src/ importer today). Exports MARK_VIEWBOX + BADGE_MARK_PATHS + badgeMark(): six per-state SVG shapes (ok/outlier/warn/likely/excluded/baseline) that stay distinguishable in flat grayscale so a badge reads without color. WHO IMPORTS: no src/ module β its former consumers (the deleted Map Bounds Review renderer + styles/mapping-review.js) are gone; the only remaining importer is the render harness (harness/mount-entry.js, which exposes it as window.__evcc.badgeMarks for harness/tests/shape-marks.spec.mjs). It is kept deliberately as a latent CVD-safe badge primitive β the harness shape-contract test (shape-marks.spec.mjs) is its sole consumer since the mapping-review view was removed; no card renderer imports it today. CONTRACT: shape topology is the redundant channel β the six must survive the grayscale check (harness/tests/shape-marks.spec.mjs); no ASCII/symbol-font substitution. |
| Metrics | metrics.js |
The Metrics view β tabbed panels (learning quality, room stats, profiles, water usage, dock events). Reads state.metricsSnapshot(); renders loading / available === false placeholders (escaped message/reason). |
| Maintenance | maintenance.js |
The Maintenance tab β upkeep/replacement summary cards, attention list, water card, and the maintenance-item modal. Imports i18n/guide-translations.js; _localizedGuide overlays step/note/frequency text in the card's per-user language (the globe), per-field, falling back Englishβbackend value β so a Russian card shows Russian guide text on an English HA instance. |
| Base station | base-station.js |
The Base-Station view β dock status, water-level panel, dock-action cards, pause-timeout controls. Reads dock accessors (dockStatusLabel, dockLifecycleState, isDocked, dashboardPlannedWaterEstimate) with labelβrawβupkeep fallback chains. |
| Room rules | room-rules.js |
The Room-Rules view β per-room blocker/modifier entity-rule editor (subtab strip β rule list β inline editor form). CLIFF: modifier option lists are not hardcoded β read at render time from state.adapterOptionsFor so each brand renders only the fan-speeds/modes its hardware declares. NO_VALUE_OPERATORS gates value-less operators (is_on/exists/β¦). |
| Run profiles | run-profiles.js |
The saved-run-profile side panel β save/recall/manage named room-queue setups alongside Rooms. Reads savedRunProfiles, selectedRunProfile, runProfileDraft, isRunProfileEditorOpen. Companion panel, not a full view. In edit mode _renderRunProfileStepsEditor renders the ordered-steps editor (progressive disclosure: an "Add a charge step" / "Add a wait" / "Add current rooms as a group" affordance, then a per-step list with move/remove + inline charge-% / wait-minute inputs). The selected-profile card carries a "Run" button (run_profiles.run = apply+start, dispatches steps) and, for a profile whose has_charge_steps is set, a read-only "Runs as" step sequence (_renderRunProfileStepsSummary). |
| Saved zones | saved-zones.js |
The saved-zones side panel β named reusable clean regions grouped by filed-under room; collapsible, each row a multi-select checkbox, shared Suction/Mode/Intensity/Water selects up top drive "Clean N selected". Reads savedZonesGrouped, selectedSavedZoneCount, zoneMax; flags overCap when selection exceeds the per-side zone cap. The selected set is mirrored on the map by map.js. |
| Room editor modal | room-editor.js |
The room-editor modal β profile selector, clean mode, suction, water, intensity, passes, edge-mop, and per-room map-color fields. Imports normalizeHex + roomFillDefault from cards/map-room-color.js (the ONE room-color resolver both map + editor share). CLIFF: carpet-locked rooms (isEditorRoomCarpet) suppress mop fields and show a notice. CLIFF: _renderMopStateIndicator renders a read-only mop indicator ONLY for observe-only tank brands (Roborock S6: mopActive non-null AND !state.supportsSettableMop()); brands with a per-room clean_mode β Eufy AND settable-mop Roborock (S7+, mop_settable/supports_water_control) β show the editable clean-mode picker instead, so the two never stack. Rendered from main.js _updateModalHost. |
| Room access modal | room-access.js |
The room-access-graph editor modal β outbound links editable, inbound read-only, validation surfaced inline before save. Reads activeAccessRoom, accessEditableRooms, accessInboundRooms, roomAccessValidation; returns "" when closed. Trust boundary: dock-room flag (is_dock_room) is displayed but not user-editable here. |
| Room estimate modal | room-estimate.js |
The room-estimate detail modal β full time/water breakdown split out to keep the main estimate panel compact. Reads activeRoomEstimateDetails and (read-only) card._learningController.getRoomProgressSnapshot(room.id). Returns "" when closed. |
| Order modal | order-modal.js |
The generic position-selector reorder modal β the canonical reorder UX on mobile and the fallback wherever drag is awkward/disabled. Scope-driven via state.getOrderAdapter(scope); adapter supplies getLabel. Returns "" when no reorder is active. |
| Theme editor | theme.js |
The theme-editor view β preset selector, palette, grouped token editor (alpha/color rail + numeric slider). Imports theme-tokens/index.js (THEME_TOKEN_REGISTRY, THEME_GROUPS), floor-scope.js, floor-presets.js, and theme-tags/index.mjs (facets/vibe-tags). Owns a local _parseColorMix for color-mix() round-tripping. Links out to the public gallery URL (no auto-download). |
| Theme preview | theme-preview.js |
The contextual theme-preview pane β mounts only the surfaces affected by the focused token group (not a full-card mirror on every keystroke). Reads THEME_PREVIEW_REGISTRY (live binding) at call time and card._state.currentThemePreviewGroup(); dispatches to the entry's method with methodArgs. Also imported by styles/index.js. |
| Theme preview registry | theme-preview-registry.js |
Not a mixin β exports THEME_PREVIEW_REGISTRY, the declarative groupβpreview-method routing table read by theme-preview.js. Imports theme-tokens/index.js (animalEditorGroupLabel, ANIMAL_PARENT_GROUP). CLIFF: animal sub-groups are not enumerated here β they're rebuilt live from the AnimalSVG registry on the animal-svg-registered document event; consumers must read the binding at call time so dynamic rebuilds are picked up. |
| Setup | setup.js |
The Setup tab β a data-driven step list; the backend adapter declares setup.steps, the card renders per step ID (add_vacuum / import_active_map (Eufy-conditional) / save_rooms). CLIFF: save_rooms can re-open after completion on room drift (new/missing rooms) with Configure/Reject/Force-Remove actions. FLOOR_TYPE_OPTIONS is a local table. |
| Language control | language-control.js |
Not a mixin, not in the barrel β exports renderLanguageControl(renderers, ctx): the header globe dropdown of bundled locales + "Auto (system)", persisted per-user server-side (i18n/lang-store.js). Imported directly by render-cycle.js (desktop header) AND mobile-shell.js (mobile header) so both share one control. Ships its own defence-in-depth esc(). CLIFF: open-state lives on the CARD (ctx.languageMenuOpen), NOT the DOM β a DOM flag would snap shut on every status/battery re-render. |
| Mobile shell | mobile-shell.js |
The mobile chrome (<600px) β compact header, bottom-tab nav, overflow "More" sheet. Imports language-control.js. CLIFF: does not replace the shell β it swaps header content + adds bottom regions, sharing the same per-view DOM roots as desktop so scroll/focus survive tab switches. Tab split: Rooms/Upkeep/Dock/Stats primary; Learning/Rules/Theme/Map/Setup in the overflow sheet. |
| Toasts | toasts.js |
The transient feedback-pill stack ("Interval saved", β¦). Reads ctx.state.activeToasts(); returns "" when empty so the DOM stays clean. Trust boundary: TTL/expiry is the state module's job β the renderer never reasons about time, only escapes kind/id/message. |
| Dialog | dialog.js |
The card-native confirm/alert/prompt replacement, rendered into the body-level modal host by main.js _updateModalHost. CLIFF: composed LAST so it stacks above whatever modal triggered it (e.g. the run-profile editor). Reads state.pendingDialog(); the message is already t()-localized at the call site but escaped again here because the sink is innerHTML. Reuses shared .evcc-modal*/.evcc-btn*; dialog-only CSS in styles/dialog.js. |
4.1 renderers/map.js (the exemplar)¶
Owns the live map rendering pipeline. Reads state.map(), state.rooms(), state.learning() (concretely mapSegments/mapSegmentsData, mapImageUrl, mapZoom/mapTranslateX/mapTranslateY, mapRenderData, mapOverlayData/mapStateSource). Uses theme tokens from theme-tokens/map.js; resolves room fills through the shared themeable palette roomFillCss/normalizeHex in cards/map-room-color.js (the same resolver the room editor uses β the old hardcoded _SEGMENT_COLORS array is gone). Invokes the floor-texture resolver (_buildFloorTextureDefs / _renderFloorTexturePolygon / _resolveSegmentFloorType from floor-texture-surface.js). Must not write state directly. Consumes actions only through bindings/map.js (data-action attributes). Shares dialog helpers from renderers/shared.js.
Exports applyMapRenderers(proto), hanging two top-level views: renderMapRoomView (the map + overlay stack) and renderMapConfigView (source picker, furnished-art composer toolbar, layer toggles). Layer ordering (authoritative: map-render-layers.md): the base image/canvas is bottom; floor-texture polygons and room fills sit above it; the robot layer is above room fills but below interaction handles; device-overlay map_state_source layers (no-go/walls/path/robot/dock) render at z5/z6 so the robot + dock markers stay on top of static room labels and area chips. Furnished mode diverges after transform application: when isFurnishedLayoutActive() and furnishedRenderMode !== "live", _renderFurnishedArt draws the to-scale home art and the live raster fades (evcc-map-image--furnished-*); the dock marker is intentionally omitted in furnished mode. Related: bindings/map.js, actions/map.js, styles/map.js, theme-tokens/map.js.
5. Styles¶
src/styles/ β 26 CSS-in-JS modules. Each exports a template-literal string of CSS; the barrel styles/index.js assembles them into three injectable bundles that main.js writes into DOM: STYLES (the card shadow-root stylesheet, injected via _ensureShellFrame(STYLES)), MODAL_HOST_STYLES (injected into the body-level modal host <style>), and TOAST_HOST_STYLES (the body-level toast host). styles/apply-theme.js is imported directly by main.js (not via the barrel) and applies the resolved theme layer as inline CSS custom properties. There is no other <style> anywhere in the frontend: all CSS lives in styles/ only β renderers emit class names, never inline <style> blocks or (beyond dynamic CSS-var attrs) inline style= rules (feedback_styles_in_styles_only). Feature modules are pure data β they export a string and are consumed only by the barrel; their blast radius is exactly styles/index.js. The interesting coupling lives in the barrel (three-bundle split, modal-host token derivation) and the four host-target modules that route into MODAL_HOST_STYLES instead of STYLES.
Foundation β semantic β component token cascade and the four-layer render contract: see architecture-overview.md / state-management.md. Map layer stack (room fills β texture β robot β labels): see map-render-layers.md. See the modal-host token derivation in the theme system.
| Module | File | What it owns |
|---|---|---|
| Barrel + theme writer | index.js |
See Β§5.1 below. Imported directly by main.js. Assembles STYLES from 23 modules, defines MODAL_HOST_STYLES / TOAST_HOST_STYLES inline, and exports applyDynamicTheme(host, resolvedTheme) (the sole writer of --evcc-* inline props). |
| Live-theme bridge | apply-theme.js |
See Β§5.2 below. Imported directly by main.js (and bindings/theme.js). Exports applyThemeToCard(card) β reads card._state.resolvedTheme(), applies it to the card host AND, when mounted, the detached card._modalHost. Trust boundary: writes CSS vars only, never markup. |
| Foundation | foundation.js |
The design-system floor. Exports foundationStyles + sharedChipStyles. Owns the canonical --evcc-* token declarations on :host, the only HA-fallback mapping in the codebase (--evcc-surface-base: var(--card-background-color, β¦), --evcc-text-primary: var(--primary-text-color, β¦), --paper-font-*), back-compat aliases, shell/header/nav/chip/badge primitives. Contract: every other module resolves through these tokens β no module re-declares HA fallbacks. First in the STYLES array so downstream modules override it. |
| Shell | shell.js |
Outer .evcc-shell container, .evcc-header (name/status/battery), .evcc-nav tab strip, .evcc-view-stage. Header comment forbids room/maintenance/modal/theme rules here. Fully token-first (no HA colors). The shell DOM is built once and persisted across renders β mobile.js reshapes it via [data-viewport="mobile"], not a rebuild. |
| Layout | layout.js |
Reusable content-grid primitives (--evcc-grid-gap, room-grid auto-fit vs explicit --evcc-room-grid-columns). Header explicitly excludes room-card / modal / shell / chip styling β shared helpers only. |
| Rooms | rooms.js |
The Rooms-view room cards (three-row header, labeled detail rows, status pills, gradient-enabled glow state). Declares a :host token-bridge block. Fully committed to the EVCC token system β header bars direct HA/semantic colors and ad-hoc surface tokens. |
| Room access | room-access.js |
Room-Access modal (chip grid, issue list, save-error states). Routed into MODAL_HOST_STYLES, not STYLES (imported by both the barrel's array and its modal-host string β see cliff below). |
| Room estimate | room-estimate.js |
Room-Estimate modal (section layout, estimate rows, notes). Uses --evcc-modal-* tokens with canonical fallbacks. Routed into MODAL_HOST_STYLES. |
| Room rules | room-rules.js |
Room-Rules tab: sub-tab strip, rule-list cards, rule-editor form, footer. Card-shadow-root only. |
| Run profiles | run-profiles.js |
Run-Profiles sidebar panel + editor form. Establishes the .evcc-rooms-workspace / .evcc-rooms-main two-column split that saved-zones shares. |
| Saved zones | saved-zones.js |
Saved-Zones sidebar panel (Wave 3b): collapse header, per-row multi-select, shared setting selects, actions bar. Deliberately mirrors run-profiles panel tokens so the two side-column panels read as a set. |
| Order | order.js |
Ordered-list interaction language: order chip, drag handle, move button, drag/reorder feedback. Note: the reorder modal's preview-chip rows live in MODAL_HOST_STYLES (body-attached host), not here. |
| Map | map.js |
See Β§5.3 below β the live/furnished map view, config editor, selection bar, nudge pad, vertex controls, animal companion. Pairs with renderers/map.js + theme-tokens/map.js. |
| Floor textures | floor-texture-styles.js |
Floor-texture overlays in two DOM contexts (a cliff): card layer = absolutely-positioned .evcc-room-texture-layer > .evcc-ftx-layer spans using mask-image + mask-mode:luminance on 512px PNGs; map layer = SVG <polygon> filled via <pattern>. Gates on both the global enabled token and per-layer inline --layer-opacity; enabled=0 collapses every span to opacity:0. |
| Learning | learning.js |
Learning-driven surfaces: pre-job estimate panel, live banner, progress list, confidence chips, reanchor motion. Declares a :host token-bridge (--evcc-learning-*). Confidence styling is token-first; motion is subtle to survive frequent ETA re-renders. |
| Base station | base-station.js |
Base-Station tab panels, stats, activity, action cards. Standard .evcc-{feature}-view/grid/panel shape shared with metrics/review. |
| Metrics | metrics.js |
Metrics tab panels, stats, filter chips, card grids. |
| Review | review.js |
Review tab panels, job cards, matcher, stat rows, badges. |
| Maintenance | maintenance.js |
Two exports (a host-split cliff): maintenanceStyles β STYLES (the tab); maintenanceModalHostStyles β MODAL_HOST_STYLES (the maintenance modal, status-tinted heroes). |
| External jobs | external-jobs.js |
Two exports for two targets: externalJobsStyles β the card shadow root (subtab strip + list); externalWizardModalStyles β the modal host (the wizard + a shared .evcc-btn* block that dialog.js relies on). Uses ONLY canonical foundation tokens. Placed near the end of the STYLES array (see Β§5.1 ordering cliff). |
| Dialog | dialog.js |
Confirm / alert / prompt dialog. Routed into MODAL_HOST_STYLES. Only the dialog-specific bits β shell/footer/buttons reuse the shared .evcc-modal* / .evcc-btn* classes that externalWizardModalStyles already put on the modal host (a dependency, so its ${...} must follow externalWizardModalStyles in the barrel). |
| Modals | modals.js |
β οΈ DEAD β DO NOT EDIT. Exports modalStyles and IS bundled into STYLES, but every rule is inert: modal markup mounts to document.body, outside the shadow root these rules live in. Live modal styles are MODAL_HOST_STYLES in index.js. Kept one release cycle as an override-target safety net; header says delete in v0.10.0+ along with its barrel import. |
| Setup | setup.js |
First-run Setup view (step cards, headers, descriptions). |
| Theme editor | theme.js |
Theme-editor view: preset grid, token-editor groups, color controls, sliders, search box. |
| Theme preview | theme-preview.js |
Live theme-preview pane β sample elements whose appearance is driven purely by the tokens being edited, so authors see changes before commit. |
| Mobile | mobile.js |
Exports MOBILE_STYLES. Reshapes the shared shell to bottom-nav via [data-viewport="mobile"] attribute selectors (declarative, no class churn β the shell persists across renders). Placed LAST in the STYLES array to win specificity over desktop defaults. Cliff: it can't reach the modal host (body-attached), so the modal's mobile bottom-sheet @media rule lives in MODAL_HOST_STYLES instead. |
5.1 styles/index.js¶
Imported directly by main.js. Three exports, three DOM destinations:
STYLESβ[foundation, base-station, metrics, review, shell, layout, order, rooms, room-access, room-estimate, room-rules, run-profiles, saved-zones, maintenance, modals, learning, theme, theme-preview, map, floor-texture, setup, external-jobs, MOBILE].join("\n"), injected into the card shadow root. Ordering is load-bearing, not alphabetical:foundationfirst (everything overrides it);externalJobsStylesandMOBILE_STYLESlast so mobile's[data-viewport="mobile"]shell selectors out-specify the desktop rules declared above.modalsis included but inert (see modals.js). Noteroom-access/room-estimateappear here too and inMODAL_HOST_STYLESβ they carry both shadow-root and modal-host rules.MODAL_HOST_STYLESβ defined inline in this file, applied to thedocument.bodymodal host div. This host is DETACHED from the:hostcascade, so it can't inherit foundation's canonical seeds. The big.evcc-modal-hostderivation block re-derives the entire--evcc-modal-*family from canonical tokens (same mappingthemes/preloaded.py::_build_release_theme_colors()bakes in), with a@media (prefers-color-scheme: light)companion supplying light floors for a themeless light-OS host. Layering contract: an inline--evcc-modal-*(written byapply-theme.js) outranks these rules β explicit override wins; else it chains to the canonical token β canonical-only themes flow through; else the hard literal is the themeless floor. It interpolates${maintenanceModalHostStyles},${roomAccessStyles},${roomEstimateStyles},${externalWizardModalStyles},${dialogModalStyles}(dialog after external-wizard, since dialog reuses the latter's.evcc-btn*), plus a mobile bottom-sheet@mediaand the reorder-modal preview chips β both here rather than in mobile.js/order.js because the host is body-attached.TOAST_HOST_STYLESβ inline, applied to the body-level toast host.z-index:10000sits above the modal host (9999) so feedback shows over an open modal; the stack haspointer-events:noneso toasts don't block modal clicks, re-enabled per-toast on the dismiss button.
Also exports applyDynamicTheme(host, resolvedTheme) β iterates THEME_TOKEN_REGISTRY (from ../theme-tokens/index.js), removes any token absent/empty in the resolved theme (so foundation defaults reclaim it rather than leaving a stale draft value), then sets every present token as an inline property. This is the single write-path for live theme CSS vars; apply-theme.js is its only caller.
5.2 styles/apply-theme.js¶
Imported directly by main.js (called at two render points) and by bindings/theme.js. applyThemeToCard(card) reads card._state.resolvedTheme() and pushes it via applyDynamicTheme to (1) the card host and (2) card._modalHost only if it's currently in document.body β because modals render outside the shadow root, they need the same resolved token layer bridged onto their external node or they'd fall to the derivation floors. Trust boundary: it is a stateβCSS-var bridge and writes no markup; it early-returns if state.resolvedTheme isn't a function.
5.3 styles/map.js¶
Owns the CSS surface for the map view: the view-toggle strip, config editor, selection/action bar, nudge pad, vertex + room-assignment controls, and the animal companion. It is the styling counterpart to the render trail in renderers/map.js and consumes the map theme tokens defined in theme-tokens/map.js (label pills --evcc-map-label-*, tooltips, compose-mode strokes/fills, and the Wave-3c --evcc-map-ov-* overlay layers). Those same map tokens are also re-declared inside .evcc-modal in MODAL_HOST_STYLES so a map-bearing modal (compose/editor) resolves them off the modal host rather than the (unreachable) shadow root β the one place map tokens leave this file. View-mode divergence (live vs furnished) and layer z-order are properties of renderers/map.js, not this stylesheet; see map-render-layers.md.
6. Theme tokens¶
src/theme-tokens/ β the helper-driven theme token registry: the single authoring surface for every --evcc-* custom property the theme editor exposes. HIGHEST BLAST RADIUS in the frontend. Two coupling hubs dominate: helpers.js (the factory layer, imported by 13 sibling group files β a shape change to its entry factory or range vocabulary ripples across the whole registry) and index.js (the combiner barrel, 2nd-highest fan-out β imported by bindings/theme.js, renderers/theme.js, renderers/theme-preview-registry.js, state/theme.js, styles/index.js, styles/apply-theme.js, i18n/lang-store.js). No theme-tokens file is imported directly by main.js β all consumption routes through the bindings/renderers/state/styles/theme.js layer per the four-layer contract architecture-overview.md / state-management.md.
The load-bearing contract shared by every file here: backend persistence is a flat token dictionary β only token VALUES persist, flat. Everything else (group, label, type, min/max/step) is editor-only metadata and must never leak into what gets saved. Break that split and saved themes drift or reject their own emitted values.
| Module | File | What it owns |
|---|---|---|
| Helpers (factory) | helpers.js |
The authoring vocabulary all group files build on. See ### 6.1. |
| Combiner barrel | index.js |
Assembles the live registry from all group files + the dynamic animal set. See ### 6.2. |
| Group order | groups.js |
Owns the stable editor group ORDER (STATIC_GROUPS_BEFORE_ANIMALS, STATIC_GROUPS_AFTER_ANIMALS, buildThemeGroups(animalGroupLabels)). Group names are editor metadata only. Animal sub-groups are deliberately absent β spliced in at runtime by index.js from the AnimalSVG registry, so adding a mascot needs no edit here. CLIFF: the module-level THEME_GROUPS export is a back-compat shim built with the bundled-animals fallback (Cat/Dog/Raccoon/Parrot/Snake) β a stale snapshot; the live order is the THEME_GROUPS re-exported by index.js. Imported by index.js. |
| Animals (dynamic) | animals.js |
Smart registry β exports BUILD FUNCTIONS, not static arrays. See ### 6.3. Note the co-located test animals.test.mjs (node --test), which stubs window.AnimalSVG.get() to assert the per-animal token shape (memorial Mittens surfaces only its 6 real tokens, not 8 inert palette no-ops). |
| Map tokens | map.js |
Owns the themeable surfaces of the map view: label pill bg/text, tooltip, composer outlines/cutouts, the map_state_source overlay layers (--evcc-map-ov-*: current/no-go/wall/path/robot/dock/obstacle/area-text), and the 12-slot room-fill palette (--evcc-room-fill-1..12). CLIFF: unlike the other map tokens, the room-fill defaults are NOT seeded in the styles/index.js :host block β state/theme.js resolvedTheme seeds the palette, and cards/map-room-color.js (roomFillCss/roomFillRgb) supplies the default rainbow as its OWN fallback so a themeless card is unchanged. Count 12 must stay in sync with ROOM_FILL_N in cards/map-room-color.js. Consumed via var() in styles/map.js map-render-layers.md. Imported by index.js (as MAP_TOKENS). |
| Floor textures | floor-textures.js |
Owns floor-overlay enable/opacity/per-material tuning: global master controls (Floor Textures) plus seven material subgroups (Tile/Wood/Marble/Concrete/Carpet-Low/Carpet-High/Granite). Densest use of the semantic-range methods (.unit/.blur/.angle/.signed) from helpers.js β marble veins carry master+offset scalars whose bounds feed both slider and importer. Imported by index.js (as FLOOR_TEXTURE_TOKENS). |
| Floor presets | floor-presets.js |
Not a registry file β data, not tokens. Owns three named MARBLE looks (MARBLE_PRESETS: Carrara/Portoro/Calacatta), each a scoped theme envelope (scope:["marble"], same shape Download Floor emits) applied via the targeted-import path β it REPLACES the marble namespace, it is NOT a standalone theme to switch to. Out-of-range edits here can't invert behavior β imports clamp to token ranges. Bypasses the barrel: imported directly by bindings/theme.js and renderers/theme.js. |
| Floor scope | floor-scope.js |
Registry-driven slicing of a theme by FLOOR TYPE β the machinery behind targeted per-floor-type export/import (floorTypeNames, detectFloorScope, sliceThemeByTypes, clampThemeScalars, themeKeyCount). CLIFFS: valid type list is derived from FLOOR_TEXTURE_REGISTRY (an upward cross-directory import of ../textures/floor-texture-registry.js) so new floor types are auto-supported; names are OPAQUE β matched whole by prefix, never dash-split (else carpet-low mis-buckets to carpet+low), longest match wins; colors/rangeless are NEVER clamped (only bounded scalars), so legibility stays decoupled. Bypasses the barrel: imported directly by bindings/theme.js and renderers/theme.js. |
| App shell | shell.js |
Owns top-level shell + primary text color tokens (SHELL_TOKENS: accent, text-primary/secondary/strong/muted/on-accent). Built via shellToken from helpers.js. Imported by index.js. |
| Surfaces | surfaces.js |
Owns card/panel/surface bg + elevation tokens (SURFACE_TOKENS: --evcc-surface-*, card bg/gap/padding). Built via surfaceToken. Imported by index.js. |
| Borders | borders.js |
Owns border-strength + shadow-depth tokens (BORDER_TOKENS: border default/strong/subtle/warning, shadow card/hover). Built via borderToken. Imported by index.js. |
| Chips | chips.js |
Owns the shared chip system (CHIP_TOKENS: base/active/hover/excluded bg-border-text, font/gap/height/icon sizing) used for tabs, filters, action badges. Built via chipToken. Imported by index.js. |
| Room cards | room-cards.js |
Owns room-card surface + profile-chip + grid tokens (ROOM_CARD_TOKENS: profile/room chip bg-border-text, --evcc-room-fill-opacity, room-grid columns/gap/min). Built via roomToken. Imported by index.js. Note: this is the room-CARD grid, distinct from the map room-fill palette in map.js. |
| Queue & ordering | queue-ordering.js |
Owns queue-strip / drag-feedback / progress tokens (QUEUE_ORDERING_TOKENS: drag opacity/scale/shadow, order & queue chip colors, completed-state, progress fill). Built via queueToken. Imported by index.js. |
| Status | status.js |
Owns semantic status / confidence / alert tokens (STATUS_TOKENS: color cleaning/docked/error/idle, confidence high/medium/low bands, --evcc-sem-*, status-* surfaces). Built via statusToken. Imported by index.js. |
| Learning | learning.js |
Owns learning-panel / estimate / metrics tokens (LEARNING_TOKENS: estimate default vs learned, learning-anim durations/ease, learning chip + confidence bands). Built via learningToken. Imported by index.js. |
| Modals | modals.js |
Owns modal / overlay / modal-chip tokens (MODAL_TOKENS: modal accent, backdrop bg/blur, border tiers, modal-chip states). Built via modalToken. Note: modal tokens live on a body-level host, derived from the canonical tokens (the modal host escapes the shadow root, so it can't inherit them). Imported by index.js. |
| Foundations | foundations.js |
Owns shared primitives reused across systems (FOUNDATION_TOKENS: font-family, gaps, radii, spacing, motion/transition, press-scale). Built via foundationToken. Ordering CLIFF: this is the sole entry in STATIC_GROUPS_AFTER_ANIMALS, so in the editor it renders after the Animal Companion section, not with the other static groups. Imported by index.js. |
6.1 helpers.js β the factory layer (13 dependents)¶
The maintainable authoring surface every static group file builds on. Any change to its exports ripples across all 13 importers, so treat its shape as a contract.
THEME_TOKEN_TYPESβ the frozen type vocabulary (color/text/shadow/size/number/duration/motion/typography/easing). This is the persisted-type universe; the semantic methods are sugar overtype:"number"and do not add types.SCALAR_RANGESβ default{min,max,step}per bounded-scalar KIND. The central cliff: range is a property of the token KIND, declared ONCE β 277 tokens = 277 chances to fumble a hand-authored bound.bluris deliberately not routed throughunit(that would cap blur at 1px and clip a soft vein).makeTokenLabel(key)β derives a human label from a--evcc-*key (strip prefix, hyphensβspaces, title-case).makeGroupedToken(group, defaultType)β the entry factory:(key, label?, type?, range?) β {key,label,group,type}plus editor-only{min,max,step}when a range is supplied. Only VALUES persist; min/max/step are editor-only and never saved.makeTypedGroupToken(group, defaultType)β augments the factory with explicit type methods (.color/.text/.shadow/β¦) and range-carrying semantic methods (.unit/.blur/.angle/.signed, each accepting a per-token override like{ max: 2 }). Bare.numberstays intentionally RANGELESS so nothing inherits a wrong bound by accident.- Bound group helpers β
shellToken/surfaceToken/borderToken/chipToken/roomToken/mapToken/floorTextureToken/queueToken/statusToken/learningToken/modalToken/foundationToken/animalToken, each pre-bound to its editor group. These are the exact symbols the 13 group files import; the single source guarantee (editor slider min/max/step == importer clamp) lives here β the editor can't emit a value its own importer would reject [seefloor-scope.jsclampThemeScalars].
6.2 index.js β the combiner (2nd-highest fan-out)¶
Central assembly point. Exports are live let bindings, not static β THEME_TOKEN_REGISTRY, THEME_TOKEN_MAP, THEME_GROUP_MAP, THEME_GROUPS, plus re-exported animal-label helpers (ANIMAL_PARENT_GROUP, animalSubGroupLabel, animalEditorGroupLabel). Also re-exports these so consumers resolve animal group labels without importing animals.js directly.
- Statically imports all group-file token arrays +
groups.jsorder +animals.jsbuild functions, thenrebuild()splices:STATIC_BEFORE_ANIMALS β animalParent β per-animal β STATIC_AFTER_ANIMALS. - CLIFF β dynamic rebuild: the initial build uses a
BUNDLED_ANIMAL_FALLBACK(cat/dog/raccoon/parrot/snake) because the bundle is parsed beforemain.js's dynamicanimal-svgimport completes; on theanimal-svg-registereddocument event it rebuilds in place and consumers reading the ESM exports see the new values via live bindings, with no subscription. - CLIFF β uniqueness gate:
assertUniqueTokenKeysthrows on a missing or duplicate key at build time; a rebuild failure is caught and warned (registry keeps the last good build). DownstreamTHEME_TOKEN_MAP/THEME_GROUP_MAPareObject.freezed.
6.3 animals.js β smart per-animal registry¶
Theme overrides for the map's <animal-svg> companion, structured like floor-textures (parent group + per-animal subgroups). Exports build functions consumed by index.js: buildAnimalTokenSets(animals) β {parent, perAnimal}, buildAnimalTokenRegistry, buildAnimalGroupOrder, plus label helpers animalSubGroupLabel, memorialSubGroupLabel, animalEditorGroupLabel, and the constants ANIMAL_PARENT_GROUP / MEMORIAL_PARENT_GROUP ("Rainbow Bridge").
- CLIFF β derived from the LIVE registry: per-animal tokens are read from
window.AnimalSVG.get(name).colors; an animal exposes a palette token iff itscolorsblock declares the matching--animal-<suffix>key (memorial Mittens β 6 real tokens, not 8 inert no-ops). Falls back to the full 14-suffix catalog when the registry isn't loaded yet; theanimal-svg-registeredrebuild then refines it. This is exactly whatanimals.test.mjspins. - CLIFF β HSL-triplet value format: token VALUES must be HSL components only (
"142 71% 45%"), never a fullhsl(...)expression, because the SVG references them ashsl(var(--animal-X)). - CLIFF β override priority (highβlow): per-animal token β global animal token β animal's own baked default; the two-level fallback is wrapped in the animal-svg shadow root, not here.
- CLIFF β memorial ordering:
buildAnimalGroupOrderplaces memorials (def.memorial) under a separate Rainbow Bridge section after the everyday companions; the flag is orthogonal to body-plantype[seeproject_mittens_mascot].
Cross-refs: four-layer contract architecture-overview.md / state-management.md; map layer stack map-render-layers.md; editor labels route through i18n i18n-system.md; room-fill palette resolver cards/map-room-color.js and docs/dev/themeable-map-palette.md.
7. i18n¶
src/i18n/ β the per-file complement to the runtime contract in i18n-system.md, which owns the t()/tRaw()/tVocab() call-site surface (mixed in via renderers/shared.js) and the resolution semantics. This directory holds the machinery those helpers delegate to: the translate() engine + locale registry, the untrusted-locale intake gate, the nestedβflat authoring transform, the per-user language store, and the two generated catalogs. English (en.js) is the ONLY catalog bundled; the seven AI-draft locales (de/fr/es/nl/it/pt/ru) were ripped out of the bundle and load at runtime as served JSON β so most of this directory is the load/validate/register pipeline that pulls them in safely. Every string is escaped at translate() time (Trust Model B, i18n-system.md); sanitize-locale.js is the second, independent layer for the ~13 tRaw keys that bypass that escape.
| Module | File | What it owns |
|---|---|---|
| i18n barrel + engine | i18n/index.js |
The runtime translation engine and locale registry β NOT a thin barrel; see Β§7.1. Owns CATALOGS ({ en } at load; non-English registered at runtime), translate/resolveLang, the load pipeline (loadLocale/loadDroppedLocales/ensureLocalesLoaded), the picker/status surface (listLocales/listBundledLocales/localeStatus), and the quarantine record. Imported directly by main.js (translate, resolveLang, loadLocale, ensureLocalesLoaded, localeSource, listBundledLocales, localeStatus), by renderers/shared.js (translate, resolveLang β the two the t() mixin wraps), cards/_shared.js, room-card.js, cards/vacuum-map-host.js (ensureLocalesLoaded only), renderers/language-control.js (listLocales), and β internally β by lang-store.js (listBundledLocales, for its allow-list). Contract that must not break: translate() NEVER throws and always returns a string (falls back locale β base-language β English β the raw key, which is the deliberate "visible miss"); every catalog value is esc()-escaped unless options.raw. |
| Locale intake gate (TRUST BOUNDARY) | i18n/sanitize-locale.js |
The XSS trust boundary for community/user-contributed locale JSON β see Β§7.2. Exports sanitizeOrQuarantineLocale(catalog) β { outcome, catalog, report } and the OUTCOME enum (LOAD / REJECT_MALFORMED / QUARANTINE_HOSTILE). Imported ONLY by index.js, invoked ONLY on the untrusted drop-in path (opts.status === "custom" AND a DOM is present). Pure: no fetch/hash/persist (the loader owns those). Contract that must not break: this is the SECOND layer, never a replacement for the translate()-time escape β both must stand. Cliff: detection PARSES with DOMPurify + the browser template parser (never regex), because the innerHTML sink is where mutation-XSS hides; a single hostile value quarantines the WHOLE file (shared-source siblings are tainted). |
| Per-user language store | i18n/lang-store.js |
The globe control's persistence: getStoredLang(hass) / setStoredLang(hass, code) over HA's frontend/{get,set}_user_data websocket (key eufy_vacuum_card, field ui_language) β per-user, server-side, cross-device. Imported directly by main.js (line 13) and by cards/_shared.js; the override it returns is fed into resolveLang(hass, config, override) as layer 0. Contract that must not break: FAILS SOFT β any WS error resolves to "no override" (returns null), so a language toggle can never break rendering. Validates the code against listBundledLocales() + "auto" on BOTH read and write (defence-in-depth against a tampered stored value). setStoredLang merges into the existing user-data object so sibling prefs survive. |
| Nestedβflat authoring transform | i18n/flatten.js |
flattenLocale(nested, enManifest) β { flat, coverage } and the isPluralLeaf(v) predicate. Turns a context-scoped nested locale (commons + per-tab/subtab sections, mirroring the theme-token fallback chain) into the flat dotted-key catalog the runtime consumes β organizational dedup lives in the FILE, never on the render hot path. Imported ONLY by index.js (both loadLocale paths run it before validateLocale). Contract: resolution is most-specific-first (literal flat key β deepest existing scope walking up β commons[leaf] β omit so translate() falls to English); a flat file is the degenerate valid case and passes through unchanged. coverage classifies EVERY manifest key (explicit/scoped/commons/untranslated) so fall-through is visible to a translator. |
| English base catalog (source of truth) | i18n/en.js |
The single bundled catalog: export const en = every user-facing string key, dot-namespaced <view>.<name> (shared actions under common.*). Doubles as the key MANIFEST (flattenLocale/validateLocale classify other locales against it) and the universal fallback. Imported ONLY by index.js (as CATALOGS.en). Contract: a missing English key renders the raw key (a visible miss); {name} placeholders interpolate at translate() time; // plural keys carry a VALUE that is an object of CLDR forms ({ one, other } shipped), selected at runtime via Intl.PluralRules β one documented exception, badge_runs_samples (two independent counts), stays a plain string. |
| Generated upkeep-guide catalog | i18n/guide-translations.js |
export const GUIDE_TRANSLATIONS[lang][family][component] = { steps[], notes[], clean_frequency, replace_frequency } β per-language maintenance-guide content so the upkeep guide follows the CARD (globe) language, not the HA instance language. Imported ONLY by renderers/maintenance.js. GENERATED β do not hand-edit: run python scripts/sync-guide-translations.py (source: adapters/eufy/upkeep_guides{,_i18n}.py + scripts/data/guide-frequency-translations.json). Distinct from the en.js/served-JSON catalog system β a separate data channel keyed by device family, not by dotted UI string key. |
7.1 i18n/index.js¶
Too central for one cell β it is the engine, the registry, the load pipeline, and the picker surface at once. What it owns and the cliffs:
-
Registry state:
CATALOGS(starts{ en }; runtime locales added byregisterLocale),LOCALE_STATUS(bundleden:stable+ the sevendrafts),LOCALE_ENDONYMS/DRAFT_WORD(metadata stays bundled so the picker + draft-gate work BEFORE catalogs arrive), andDYNAMIC_LOCALES(runtime drop-ins taggedcustom). A drop-incustomstatus WINS over a bundleddraftfor the same code inlocaleStatus, because the drop-in is the catalogtranslate()actually uses. -
translate(lang, key, vars, options)β the core. Resolution:CATALOGS[code]β base-language (code.split("-")[0]) β English base β the raw key. Plural entries pick a form viapluralForm, dropping to the English plural object then to the key if a partial locale lacks the category. Cliff: the catalog string isesc()-escaped UNLESSoptions.raw(thetRawpath β the exact surfacesanitize-locale.jsexists to defend); interpolatedvarsare inserted RAW (the CALLER escapes user data at the sink β unchanged Trust Model B). -
resolveLang(hass, config, override)β the SINGLE resolvermain.js,renderers/shared.js, androom-card.jsall share (each formerly inlined it). Order:override(globe, fromlang-store.js) βconfig.i18n.locale(dashboard pin) βhass.locale.language/hass.languageβ"en". Draft-gate cliff: the override and the pin BYPASS the gate (deliberate opt-ins can reach an AI-draft locale); ONLY the auto/system step is gated (isDraftLocale(auto)β"en"), so an unreviewed draft never auto-activates from the system language. -
Load pipeline (all NEVER throw β always resolve to a soft report so the card keeps rendering English):
loadLocale(url, lang, opts)β fetch βflattenLocaleβvalidateLocaleβregisterLocale. Two-path cliff: thesanitize-locale.jsgate runs ONLY whenopts.status === "custom"ANDtypeof document !== "undefined"β the untrusted drop-in path in a browser, where theinnerHTMLsink it defends actually exists; shipped first-party locales (build-time vetted) and DOM-less contexts (Node tests/SSR) take the trusted path.loadDroppedLocales(baseUrl, opts)discovers${baseUrl}/index.jsonand loads each<code>.json(en.jsonrefused);opts.vercache-busts SHIPPED catalogs (a stale served catalog manifests as new keys falling back to English).ensureLocalesLoaded(onLoaded)is module-guarded to run ONCE across the WHOLE bundle (CATALOGS is shared by the main card AND the standalone room-card, so one load covers both) β shipped locales first, thencustomdrop-ins so a drop-in overrides a shipped locale of the same code. -
Quarantine record:
LOCALE_QUARANTINEis keyed on the file's FNV-1a content hash (not filename) β a fixed replacement gets a fresh hash and is re-evaluated; identical hostile bytes stay quarantined silently.getLocaleQuarantineReport()exposes a read-only snapshot for the diagnostics surface. -
Picker surface:
listBundledLocales()(bundled only β alsolang-store.js's allow-list) andlistLocales()(bundled + dynamic), each returning{ code, status, endonym, label }with drafts labelled in their own language (Deutsch (Entwurf)).localeSource(config, lang)resolves an author-suppliedi18n.url/url_map[lang]to a one-shot load identity.
7.2 i18n/sanitize-locale.js¶
The security-critical boundary β worth the extra detail because getting it wrong reintroduces XSS on the tRaw keys.
-
Why it exists:
translate()escapes every value except the ~13tRawkeys, whose values are emitted RAW intoinnerHTMLby the call site (e.g.external_jobs.detected_roomskeeps a<strong>). A loaded locale supplying a value for atRawkey is the injection surface. The gate scrubs each parsed locale BEFOREregisterLocale, so by translate-time provenance no longer matters β but it is a SECOND layer, deliberately not a replacement for the translate-time escape. -
Three bright-line outcomes (no runtime judgement):
REJECT_MALFORMED(not a plain object of string/plural-of-string values β honest mistake, soft skip, NOT hash-locked β retried);QUARANTINE_HOSTILE(any value carries active content β positive tamper evidence taints the file's ~1,353 shared-source siblings β reject the WHOLE file, hash-locked);LOAD(clean, or only inert-disallowed markup that was scrubbed to visible text). -
Parse, never regex β the load-bearing design choice:
detectHostilebuilds a<template>and walks the real parsed DOM tree, andurlVerdict/hrefSurvivesresolve URLs through theURLparser. Encoded/padded/split names (onerror,java	script:,on\nerror) collapse to their true identity the same way the eventualinnerHTMLsink will parse them, defeating evasion a raw regex would miss. Allowlists: inert tagsstrong/em/code/asurvive as markup (attributes dropped; an<a href>only if http(s) AND host βgithub.com/kingchddg901.github.io);script/iframe/object/embed/link/meta/base/formand anyon*handler are hostile. A disallowed-but-inert element is escaped to VISIBLE literal text (not silently stripped) so a translator sees their mistake.domHardenruns the scrub output through DOMPurify as a belt-and-suspenders final pass so the string reaching the sink is one DOMPurify itself certifies. Same untrusted-intake posture as the community animal-submission system (data-not-code, reload-not-rebuild).
8. Misc (controllers, textures, theme-tags)¶
Three small no-barrel dirs plus the theme-tags barrel. controllers/ holds the one event-driven side-effect owner; textures/ is the floor-material data + normalizer pair consumed only by the floor-texture renderer; theme-tags/ is the pure, dependency-free tagging core shared by the card and the build-time gallery.
| Module | File | What it owns |
|---|---|---|
| LearningController | src/controllers/learning-controller.js |
Imported directly by main.js (import { LearningController }, constructed once as card._learningController, connect()/disconnect() on the card's connect/disconnect lifecycle). Owns the four+one live-job HA event subscriptions, ETA reanchoring, the 1 s progress ticker, and the bounds-exit poll. Never a renderer β it WRITES state (via card._state.* setters) and calls card._scheduleRender(); the map/room renderers READ what it stored. See Β§8.1. |
| Floor texture registry | src/textures/floor-texture-registry.js |
Static data: FLOOR_TEXTURE_REGISTRY (canonical floor-key β ordered layers[] with colorToken/opacityToken theme-token bindings) plus getPrimaryTextureUrl(). Self-contained (no imports). Layer array order = DOM bottomβtop (last renders on top) β a load-bearing cliff; reordering restacks the material. Cache-bust ?v=<__ASSET_VER__> is appended to every URL once at module load (falls back to "dev" unbundled). masks[] and baseTexture are kept only for backward-compat with the SVG map <pattern> path. See Β§8.2. |
| Floor texture resolver | src/textures/floor-texture-resolver.js |
Pure resolveFloorType(room) β the SINGLE normalization point from raw room floor_type/carpet_type (spec form, legacy combined, direct keys, aliases hardwood/laminateβwood, graniteβgranite_light) to a registry key, "default" for anything unrecognized. No imports; consumed with the registry by renderers/floor-texture-surface.js. |
| Theme-tags barrel | src/theme-tags/index.mjs |
The public entry point. Owns effectiveThemeTags(theme) (the ONE function every surface calls) and themeAttribution(theme), plus the SYSTEM_VOCAB guard set, and re-exports the sub-module API. Trust boundary: system-owned words are STRIPPED from author free-text tags, and colorblind-safe is added only when verifyColorblindSafe() actually passes β an author can request it but can't assert it. See Β§8.3. |
| Tag derivation | src/theme-tags/derive.mjs |
Pure, dependency-free color math + deriveThemeTags(tokens, opts)/themeMetrics(tokens). Derives mode/accent/temperature/surface/contrast facets from a resolved --evcc-* token map (falls back to foundation DEFAULTS for omitted tokens). Cliff: colorblind-safe is intentionally NOT auto-derived here β the crude palette metric over-claims, so cvdMin is exposed for audit only and the verified tag is owned by colorblind.mjs. Imported by index.mjs; some symbols re-exported through it. |
| Colorblind verification | src/theme-tags/colorblind.mjs |
Pure verifyColorblindSafe(tokens) β the colorblind-safe GATE. Simulates the four status semantics under Machado (2009) deutan/protan/tritan matrices in linear RGB, measures pairwise CIELab ΞE76, fails if any pair drops below CVD_DELTA_E (19). Returns {pass, minDeltaE, weakest, buckets, bestBucket, perCvd, reasons} so the UI can say exactly WHY it failed. Imported by index.mjs; imports only parseColor from derive.mjs. |
| Facet grouping | src/theme-tags/facets.mjs |
FACETS (ordered filter-bar vocabulary), TAG_FACET/facetOf, orderTags, SUGGESTED_VIBE_TAGS. The ONE source of truth for facet order/labels shared by the Pages gallery AND the card's theme picker so both filter identically (OR within facet, AND across facets; vibe tags un-faceted). Imported by index.mjs and directly by renderers/theme.js. |
8.1 controllers/learning-controller.js β live-job side-effect owner¶
Constructed once by main.js and driven by the card lifecycle: connect() subscribes to five HA events (eufy_vacuum_room_completed, _room_started, _room_finished, _job_finished, _run_incomplete) and is idempotent (early-exits if already subscribed); disconnect() tears down every subscription plus all three timers (bounds-exit poll, progress ticker, job-reset timeout).
Trust boundaries and cliffs the code enforces:
- Vacuum gating β every handler drops events whose data.vacuum_entity_id isn't this card's configured vacuum; _handleRoomCompleted additionally requires an active learning job. Do not remove these guards β one card must not react to another vacuum's run.
- Backend value beats local ticker. getJobProgressPercent() / getRoomProgressSnapshot() prefer the backend dashboardJobProgress().progress_percent / learningTimelineEntryForRoom() and fall back to the local model only when absent. _handleJobFinished deliberately passes the event's authoritative duration_minutes/room_count into endLearningJob() because the live tracker misses events on short 1-room jobs.
- Progress is honest by construction. _computeProgressPercent() caps in-room elapsed at the current room's estimated share and floors the result at 99% until the job actually ends β it never overshoots or hits 100 early. endJobProgress() snaps to 100 then clears after 3 s.
- Bounds-exit poll (see the block comment): when the backend reports awaiting_bounds_exit, no room event will fire until the robot leaves the room, so it polls the snapshot every 5 s and stops the instant the room rolls over or a room/job event fires.
- Stale-response guard in loadRoomEstimates() β a monotonic _roomEstimateRequestSeq plus request/response vacuum+map context validation discards responses from a previous vacuum or map; estimates are cleared immediately on context change.
It is a pure WRITE-side actor within the four-layer contract (architecture-overview.md / state-management.md): it reaches state through card._state setters and dispatches backend calls through card._actions.* (reanchorLearningTimeline, getNextLearningRoom, getRoomLearningEstimates) β it renders nothing itself.
8.2 textures/floor-texture-registry.js β floor-material data table¶
Blast radius: three importers β renderers/floor-texture-surface.js (the card-side layered surface, the primary consumer, pairs it with the resolver), theme-tokens/floor-scope.js (derives the valid per-floor-type token/export vocabulary from Object.keys(FLOOR_TEXTURE_REGISTRY), so adding a floor key here automatically widens the floor-scoped theme export), and re-exports via renderers/index.js. The registry itself imports nothing.
Contract that must not break:
- layers[] order is DOM z-order, bottomβtop. Each layer's paint is a colorToken/opacityToken pair (theme-editor-tunable, *Default is the fallback when the token is unset); the marble entry additionally carries computed blurToken/effective-opacity tokens whose defaults are calc()/oklch() expressions (master vein rides both tiers, minor recedes for atmospheric depth). These *-eff tokens are computed, not exposed to the editor.
- masks[] / baseTexture are the legacy SVG-map-renderer path (<pattern> fill via getPrimaryTextureUrl(), preference baseTexture β first layer β first mask β null); the card renderer uses layers[]. Keep both in sync when editing an entry.
- The module-load loop that appends ?v=__ASSET_VER__ to every layer.url/mask.url/baseTexture is the single cache-bust point β a regenerated mask is only refetched fresh because of it (textures ship cache_headers=True, 7-day cache). See the floor-render layer stack in map-render-layers.md.
8.3 theme-tags/index.mjs β effective-tag entry point¶
effectiveThemeTags(theme) is what the card picker, the Pages gallery, and the submission bot all call. It merges DERIVED facet tags (derive.mjs, authoritative β author can't override), the core status from source:"core", the author's free-text VIBE tags with every SYSTEM_VOCAB word stripped (anti-spoof), and colorblind-safe only when verifyColorblindSafe() passes (adding the bestBucket red-green/blue-yellow tag it's strongest for). It returns the tag list plus the full colour-vision verdict (requested/verified/reasons/minDeltaEβ¦) so a form or card can show exactly why a requested safety tag was withheld. themeAttribution(theme) is the sibling one-read-point for provenance (author, url, source validated against {core, community, generated, manual}, submittedBy).
Blast radius: consumed by state/theme.js (effectiveThemeTags, cached per theme library β the runtime tag source computed from state.resolvedTheme().tokens) and renderers/theme.js (the facet API for the picker filter bar). Because the whole core is pure and dependency-free, the identical logic runs at card runtime and at gallery build time β tags are computed on the fly and never stored, so a taxonomy change updates every surface at once and they can't go stale. Tag display strings route through the i18n system at the render layer, not here (this core emits stable tag ids).
Cross-references¶
- architecture-overview.md β the four layers + cross-module rule; render-cycle.md β the render cycle; state-management.md β the
state/inventory. - map-render-layers.md β the map layer stack, the
rid == room.id == room_namesidentity, and the furnished-art / floor-texture fill precedence. - i18n System β the
t()/tRaw()/tVocab()+ locale-loading contract this section complements per-file.