Styles System¶
How CSS actually reaches pixels: the combiner that stitches styles/*.js into one shadow-root <style>, the separate body-host stylesheet the modal/toast portals need, the runtime --evcc-* token bridge, the typeface chain, and the "all CSS in src/styles/" rule with its CI gates.
Scope boundaries β this doc is the DELTA. It does not re-cover:
- Theme System β the theme editor: token hierarchy, paletteβtoken derivation, import/export, token groups/facets.
- Frontend Module Reference β the per-file "what each styles/*.js is" inventory.
- Event Binding & Modal Host β the body-level modal host node itself (why it's outside the shadow root, z-index, teardown).
- Card Topology & Bundles β the three self-contained ESM bundles.
- Render Cycle β floor-texture rendering (mask-mode:luminance, marble veins, content-hash cache-bust), and the render cycle whose first step calls applyThemeToCard.
1. The combiner¶
One CSS string, built once at module-eval time, injected into the shadow root.
Convention. Each feature owns one module: src/styles/<feature>.js exports a <feature>Styles CSS template-string constant. The combiner imports each and pushes it into the STYLES array.
- Imports:
src/styles/index.js:22-48. - Array + join:
src/styles/index.js:50-82βSTYLES = [ β¦ ].join("\n")(styles/index.js:82).
Order matters β three load-bearing positions:
- fontStyles is FIRST (styles/index.js:54, comment :51-53). The @font-face declarations must be parsed before anything reads --evcc-font-family, and the [data-evcc-font] override rule needs to sit ahead of anything it must outrank. See Β§4 (the typeface chain).
- foundationStyles is second (styles/index.js:55). It owns the canonical :host token block (styles/foundation.js#CNVJMQTE+); every downstream module consumes var(--evcc-*), so foundation must declare those tokens before anything references them. See Β§3(A).
- MOBILE_STYLES is LAST (styles/index.js:81). Mobile rules reach shared elements via .evcc-shell[data-viewport="mobile"] and must win specificity over the desktop defaults declared above β the comment at styles/index.js:76-79 spells this out. externalJobsStyles sits just before it (styles/index.js:80).
Injection (shadow root). STYLES is imported into main.js#CNG921VV, passed into the frame builder in _render() as this._ensureShellFrame(STYLES) (main.js#CNET1PRZ), and written as a single <style data-evcc-style-root>${styles}</style> that is a direct child of the shadow root β the sibling right before <ha-card> (main.js#CND80HAJ), not inside ha-card/.evcc-shell.
Injected ONCE, not per render. _ensureShellFrame only rewrites shadowRoot.innerHTML when missingFrame is true (main.js::_ensureShellFrame) β first mount or a HACS-update frame reset. On later renders the existing [data-evcc-style-root] block is reused as-is unless styles itself changed (styleRoot.textContent !== styles, main.js#CNJEQQXZ); only header / bottom-nav / mobile-overlay / active view-root innerHTML get diffed per render (main.js#CNPA0T4V). So the shadow <style> is stable across the card's life.
Blast radius. Adding a module means: create styles/<feature>.js exporting <feature>Styles, import it (styles/index.js:22-48), and add it to the array (styles/index.js:50-82) at the right specificity position. Forget the array entry and the export exists but never ships. This reaches the command-center panel only β see Cliff 2.
Intentional omissions. Several imported exports are deliberately NOT in the shadow STYLES array β sharedChipStyles (styles/foundation.js::sharedChipStyles), maintenanceModalHostStyles (styles/maintenance.js::maintenanceModalHostStyles), externalWizardModalStyles (styles/external-jobs.js::externalWizardModalStyles), dialogModalStyles (styles/dialog.js::dialogModalStyles), jobSummaryStyles (styles/job-summary.js::jobSummaryStyles). They ride MODAL_HOST_STYLES instead (Β§2). jobSummaryStyles is a partial exception worth flagging: alongside its body-host modal rules it also carries three shadow-root-targeted selectors (.evcc-review-job-card[data-job-summary-open] cursor/hover/focus-visible, styles/job-summary.js:122-133) that, riding only MODAL_HOST_STYLES, never reach the actual review card in the shadow root β see Β§2's note.
2. The body-host style split¶
The modal host is a document.body child, outside the card's shadow root (see event-binding-and-modal-host.md Β§3). The shadow <style> cascade cannot reach it, so it gets its own stylesheet.
MODAL_HOST_STYLES β defined styles/modal-host.js::MODAL_HOST_STYLES. It interpolates the body-host-only exports:
- sharedChipStyles β styles/modal-host.js#CN0EX0CH
- maintenanceModalHostStyles, roomAccessStyles, roomEstimateStyles, jobSummaryStyles, externalWizardModalStyles, dialogModalStyles β the ${β¦} block at styles/modal-host.js#CNE5BJEK.
Injection site β _updateModalHost() (main.js::_updateModalHost):
- Host div created lazily and appended to document.body (main.js#CN5837PS): div.evcc-modal-host.
- Before injection the host is stamped with the resolved language direction (applyDir, main.js#CNQGQBJM) and the typeface attribute (_applyFontAttributeTo, main.js::_updateModalHost) β both are things a body-mounted node cannot inherit from the card (Β§4).
- Styles prepended inline to the modal markup: const modalMarkup = `<style>${MODAL_HOST_STYLES}${runtimeFontTokenRules(β¦)}</style>${html}`; (main.js#CN2W4MTS) β the drop-in-font token rules are appended at injection time (same in _updateToastHost) (main.js::_updateModalHost), written via innerHTML (main.js#CNR1S943).
- Guarded by a dataset.renderedHtml diff (main.js#CNPK07RK) β re-injected only when markup changes; each open modal body's scrollTop is preserved by index across the swap (main.js#CNXZAC3C). Teardown at main.js#CNRBAADE / main.js::disconnectedCallback.
Parallel toast host. TOAST_HOST_STYLES (styles/toast-host.js::TOAST_HOST_STYLES) is injected the same way in _updateToastHost (markup construction main.js::_updateToastHost, written main.js#CN83JR6N), z-index 10000 (styles/toast-host.js#CN3CGTPS) to sit above the modal host's 9999 (styles/modal-host.js#CNJT36DZ).
Why the split is load-bearing. The modal/toast hosts are detached from the card's :host cascade, so they neither receive the shadow <style> nor inherit the canonical --evcc-* seeds. MODAL_HOST_STYLES re-derives the whole --evcc-modal-* family from canonical tokens on .evcc-modal-host (styles/modal-host.js#CNMFXWX4), with a light-scheme companion re-deriving the same family with light floors (styles/modal-host.js#CNR1K8KC, inside the @media (prefers-color-scheme: light) block that runs styles/modal-host.js#CN0M3MW9), precisely to compensate. This is the modal token derivation bridge, matching themes/preloaded.py's _build_release_theme_colors().
Adding a body-host style β you MUST touch two places:
1. Author the rules as a *ModalHostStyles export in the feature module (canonical example styles/maintenance.js maintenanceModalHostStyles; also external-jobs.js externalWizardModalStyles, dialog.js dialogModalStyles, job-summary.js jobSummaryStyles).
2. Interpolate it into MODAL_HOST_STYLES at styles/modal-host.js#CNE5BJEK.
Skip step 2 and the export exists but is never injected β the modal renders unstyled with no error. See Cliff 1 for the dead-modals.js trap.
A variant of the same failure, already shipped: shadow-root rules stranded in a body-host-only module. jobSummaryStyles rides MODAL_HOST_STYLES correctly (step 2 above is done), but three of its rules target .evcc-review-job-card[data-job-summary-open] (cursor: pointer, a :hover border, and a :focus-visible outline β styles/job-summary.js:122-133) β and the review job card that selector matches lives in the shadow root (renderers/review.js, styled by styles/review.js which IS in the STYLES array), not inside document.body. Because those three rules only ship to the modal host, where no such element ever exists, they are dead: the review card gets no pointer cursor and no hover border. The one that matters for Β§4a's keyboard-parity claim is narrower than it sounds β the shadow-root stylesheet never sets outline on .evcc-review-job-card at all (styles/review.js:126-134 is the only base rule and has no outline), so a keyboard user tabbing to the card still gets the browser's default :focus-visible outline. What's actually lost is the styled 2px accent ring (outline: 2px solid var(--evcc-accent)) falling back to that UA default. The fix is the mirror of Cliff 1: a rule meant for the shadow root belongs in a module that's actually in the STYLES array (e.g. alongside reviewStyles), not in a body-host-only module.
3. The theme-token runtime bridge¶
Per-render, resolved theme values are written as inline --evcc-* custom properties on the live hosts; the stylesheets reference var(--evcc-*, default), so an unset token falls back to its default. Three moving parts: apply-theme (writer), the token registry (key inventory), and the default sources (CSS + JS palette).
3.1 The writer β apply-theme.js¶
applyThemeToCard(card) (src/styles/apply-theme.js:32) is the runtime entry point (the first step of _render() β see render-cycle.md):
- Reads the resolved layer: state.resolvedTheme() (apply-theme.js:36, guarded at :34).
- Target 1 (card host): applyDynamicTheme(card, resolved) (apply-theme.js:41) β vars on the <eufy-β¦> instance that carries :host.
- Target 2 (modal host): applyDynamicTheme(card._modalHost, resolved) (apply-theme.js:49-51), only when the modal host is body-attached β because it is detached from :host and needs the token layer bridged separately.
When it runs: the first effectful call in _render() (main.js::_render); once post-library-load (main.js::_loadInitialThemeState); plus 15 event-driven callsites in bindings/theme.js (e.g. preset, mode bindings/theme.js#CNW1D5GX, token, color bindings/theme.js#CNFQSNRH, alpha, colormix, backend refresh bindings/theme.js::_refreshThemeFromBackend) for immediate editor feedback without waiting for a full render.
The actual writer β applyDynamicTheme(card, resolvedTheme) (styles/index.js::applyDynamicTheme):
- Iterates THEME_TOKEN_REGISTRY and removes any prop absent/null/empty in tokens (styles/index.js#CN344YSE) β so a cleared draft value falls back to the foundation default instead of leaving a stale inline value.
- Then host.style.setProperty(property, asComponents ?? value) for every present token (styles/index.js::applyDynamicTheme) β --evcc-animal-* tokens branch through styles/index.js::animalHslComponents first, because they are consumed as bare hsl() components.
Trust boundary: apply-theme does NOT resolve the cascade β it just writes an already-merged tokens map. The defaultβthemeβoverride layering resolves upstream in resolvedTheme() (Β§3.3).
3.2 The token inventory β THEME_TOKEN_REGISTRY¶
A flat array of descriptor entries { key, label, group, type, min?, max?, step? }, exported as a live let binding (theme-tokens/index.js:127), reassigned by rebuild() (:150). rebuild() flattens static group token-sets + dynamically-built animal tokens (:132-161), asserts unique keys (:113-125, :145), and also produces THEME_TOKEN_MAP, THEME_GROUP_MAP, THEME_GROUPS. It rebuilds on the animal-svg-registered document event (:169-177); the live binding means importers see new values with no subscription.
Token definitions come from group-bound factories in theme-tokens/helpers.js: makeTypedGroupToken(group, defaultType) (:173) wrapping makeGroupedToken (:126). Examples: mapToken.color (helpers.js:176, :208), roomToken.number (rangeless on purpose, :180, :207), roomToken.size (:179); range sugar .unit/.blur/.angle/.signed (:191-194) over SCALAR_RANGES (:81-91). min/max/step are editor-only, never persisted (theme-tokens/helpers.js::makeGroupedToken).
Trust boundary: the registry is the key inventory + type/label/range β it does NOT carry a default field. applyDynamicTheme iterates it only for the remove-pass (THEME_TOKEN_REGISTRY imported at styles/index.js::applyDynamicTheme, used styles/index.js::applyDynamicTheme). Defaults live in CSS/JS (Β§3.3). The registry also feeds the editor (THEME_GROUP_MAP/THEME_GROUPS, out of scope β see theme-system.md).
3.3 Where each default actually lives¶
There is no single default source. When applyDynamicTheme removes/never-sets a prop, the stylesheet's var(--evcc-*, fallback) resolves it. Three distinct sources by token family:
(A) Canonical foundation tokens β :host block in styles/foundation.js#CNVJMQTE+. Declares canonical defaults, e.g. --evcc-surface-base: var(--card-background-color, #1c2127) (styles/foundation.js#CNRE7F7B), --evcc-accent: var(--accent-color, #3b82f6) (styles/foundation.js#CNQ4HPFN), text/border/semantic/radius/chip tokens. Each chains to an HA theme var first, then a literal β this is the only place HA fallbacks are mapped. A theme overrides by writing an inline prop on the same host.
(B) Modal-family tokens β derived in MODAL_HOST_STYLES. The body host is detached from :host, so --evcc-modal-* defaults are re-derived from canonical tokens in .evcc-modal-host (styles/modal-host.js#CNMFXWX4, dark) with a light companion (styles/modal-host.js#CNR1K8KC, under the @media (prefers-color-scheme: light) block). See Β§2.
(C) Room-fill tokens β NO CSS default anywhere; default lives in JS + inline var() fallback. --evcc-room-fill-<N> is declared in no :host block. Instead:
- SVG path: roomFillCss(idx, override) (cards/map-room-color.js::roomFillCss) emits var(--evcc-room-fill-<N>, <defaultHex>) (:74), the hex inlined from ROOM_FILL_PALETTE (:19-23) β CSS resolves it live, a theme token overrides via cascade.
- Raster path: roomFillRgb(idx, host) (cards/map-room-color.js::roomFillRgb) reads the computed prop off a mounted node, else roomFillDefault(idx) β canvas can't take CSS vars.
- Editor swatch seed: because the token carries no default anywhere, resolvedTheme() seeds the palette so the picker isn't blank β state/theme.js:385-389 (colorMap['--evcc-room-fill-<i+1>'] = hex, sources=default). Comment state/theme.js#CN1YNYTH notes the seed equals the render's own default, so a themeless card is net-zero.
- Full per-room cascade (override > token > default) documented at map-room-color.js:5-8.
The cascade resolver β resolvedTheme() (state/theme.js::resolvedTheme) produces the {tokens, sources} apply-theme writes:
- 0. default β room-fill palette seed (state/theme.js#CNG4F7SJ; a sibling floor-texture-material seed follows at state/theme.js#CNTV53SE, cross-referenced from floor-texture-map-view.md rather than restated here).
- 1. theme β active theme's colors/alpha/tokens (state/theme.js#CNGDCQF4); activeTheme = library[effectiveActiveThemeId()] (state/theme.js#CNGDHPPD); effectiveActiveThemeId() (state/theme.js::effectiveActiveThemeId) resolves per-device override β backend active fallback.
- 2. draft β working-draft overlay, highest precedence (state/theme.js#CNC3TMHJ).
- 3. combine β flattenThemeBuckets() folds colorMap+alphaMap into 8-char hex via hexWithAlpha() (theme-tokens/flatten.js, called at state/theme.js#CNGE76CN).
- Returns {tokens, sources} (state/theme.js#CNWWDBRT); sources (default|theme|draft) drives editor provenance only. The foundation :host default (A) is NOT in tokens β it is the implicit floor CSS applies whenever resolvedTheme omits a key.
One-line bridge: _render() β applyThemeToCard(this) (main.js::_render) β resolvedTheme() merges default(seed)βthemeβdraft into {tokens} (state/theme.js::resolvedTheme) β applyDynamicTheme writes/removes inline --evcc-* on card + modal host (styles/index.js::applyDynamicTheme) β CSS resolves anything unset via :host (A), modal-derived (B), or var(β¦,defaultHex) (C).
4. The typeface chain¶
The accessibility typeface (OpenDyslexic, the theme/paper default is the other option β see
i18n-system.md for the per-user store) shipped once
already inert: every rule involved was individually valid CSS/JS, and nothing caught that the
rules didn't connect. styles/typeface-wiring.test.mjs (tests TF-1β¦TF-13) now asserts the
connections, not just that each piece parses.
All per-font CSS is GENERATED from FONT_DEFS (styles/fonts.js) β the one table carrying
each font's id, family, stack, and woff2 faces. @font-face blocks, the a11y-token setter rules
(via fontTokenRules(selectorFor), reused by styles/index.js for the modal/toast hosts), the
picker sample classes, and the theme editor's Font Family preset chips all derive from it. Adding
a font = drop the woff2 files in frontend/fonts/, add one FONT_DEFS entry, add its verified
locales to FONT_SUPPORT (i18n/font-store.js β ids must match, TF-11) and a font.<id>
label key. TF-13 proves the new font surfaces as a theme chip automatically. Form controls
(input/textarea/select/button) get an explicit font-family: inherit on both the shadow
side and the modal host (TF-12) β they default to the UA font and silently ignore the typeface
(and the theme font) otherwise. The chain, in order:
@font-faceis registered on the DOCUMENT, not the shadow tree. Chromium does not honour@font-facerules that live only inside a shadow root βdocument.fonts.check()still returnstruefor a family with zero registered faces, so that gap is invisible to the obvious check (this islive:FONT-1, landed 41a9735). The fix,ensureFontFacesInDocument()(styles/fonts.js::ensureFontFacesInDocument), id-guarded (idempotent, so whichever bundle entry loads first wins) and injected intodocument.head, is called from all three bundle-entry files βall-cards.js,cards-standalone.js,cards/vacuum-map-host.js(TF-6) β because any one of them can be the only bundle a given surface loads.FONT_FACE_CSS(fonts.js::FONT_FACE_CSS) also stays part offontStylesfor engines that do read@font-facefrom a shadow sheet; the duplicate registration is a no-op where it isn't needed.:host([data-evcc-font="opendyslexic"])sets--evcc-a11y-font-familyβ a SEPARATE token from the theme's (fonts.js, the[data-evcc-font]rule; mirrored on the modal/toast hosts instyles/index.js). The old claim that being FIRST in theSTYLESarray made a--evcc-font-familysetter beat a theme was wrong: the theme's Font Family token (--evcc-font-familyis inTHEME_TOKEN_REGISTRY) is written as an inline style byapplyDynamicTheme, and inline beats any sheet rule regardless of array position (live:FONT-1 remainder #2, found on-device 2026-08-06 against a theme carrying Segoe UI/Inter). Precedence therefore lives in the read's fallback chain, not the cascade: everyfont-familyread isvar(--evcc-a11y-font-family, var(--evcc-font-family, β¦))β the user's accessibility choice first, the theme's aesthetic choice second, the HA default last, with no!importantanywhere.TF-8pins both halves: setters write only the a11y token, and no read consults the theme token without the a11y token ahead of it..evcc-shellreads the token (styles/shell.js, the.evcc-shellbase rule) βfont-family: var(--evcc-a11y-font-family, var(--evcc-font-family, var(--paper-font-body1_-_font-family, sans-serif)))β the a11y token is the OUTERMOST layer, which is what makes step 2 above effective. This link has now been wrong twice (TF-1): first the rule named a font directly instead of reading the token; then (the live:FONT-1 remainder, found on-device 2026-08-06) the token-read sat onfoundation.js's.evcc-cardblock β a selector no element carries; the shell frame emits.evcc-shell(main.js). Every source-regex assertion passed while the rule matched nothing, and the faces reportedunloadedforever because no rendered text ever requested the family.TF-7now asserts the markup side: the shell frame must emit the class the reading rule targets. (the.evcc-cardblock that used to sit here was DELETED as R2-DEAD-4;styles/foundation.jsnow carries a comment in its place β "CARD SHELL β deliberately absent. Do not re-add .evcc-card.")- The modal and toast hosts re-declare the token, because a
document.bodychild cannot inherit a custom property declared on the card's:host(TF-2):.evcc-modal-host[data-evcc-font="opendyslexic"](styles/modal-host.js#CND49ARX) and.evcc-toast-host[data-evcc-font="opendyslexic"](styles/toast-host.js#CNFC7N35) restate the same declaration for their branch of the document. main.jsstamps the attribute on all three hosts (TF-3):_applyFontAttributeTo(el)(main.js::_applyFontAttributeTo) is the single stamp/clear helper, called for the card itself (_applyFontAttributeβmain.js::_applyFontAttribute), the modal host (main.js::_updateModalHost), and the toast host (main.js::_updateToastHost) β a font selected on the card must reach its modals and toasts, which live outside the shadow tree where step 2's rule is declared.
Selection and persistence are setUiFont(fontId) (main.js::setUiFont, optimistic apply +
fire-and-forget persist, same shape as setLanguageOverride) and _maybeLoadFontChoice()
(main.js::_maybeLoadFontChoice, one-shot load on first hass, in-session pick always wins over a late server
read) β see i18n-system.md for the shared user-data object
and the language-gated offering of the font (fontSupportsLang).
The picker's own sample bypasses the token deliberately (TF-5): .evcc-font-sample-opendyslexic
(fonts.js::fontStyles) hardcodes font-family: "OpenDyslexic" so the option shows the typeface
before it is selected β routing it through --evcc-font-family would only resolve once the
setting is already on, defeating the preview.
Why served, not embedded as data: URIs. The two woff2 files are ~235KB combined (not the
~100KB the design estimated) and the cards bundle loads on every HA page via add_extra_js_url, so
a data:-embedded font would cost every user those bytes on every page load whether or not they
use it. Served from /eufy_vacuum/fonts (registered with cache_headers=True) instead: a browser
only fetches the @font-face src when something renders in that family, so the cost is zero
until the toggle is on and cached after (fonts.js:13-29).
Licence. OpenDyslexic ships under the SIL Open Font License 1.1 with a Reserved Font Name; the
full licence text travels beside the woff2 files at frontend/fonts/OFL.txt and must not be
removed (fonts.js:31-41).
4b. Drop-in fonts (user-supplied, no release)¶
config/eufy_vacuum/fonts/<id>/ holds font.json + its woff2 files + its licence. The backend
owns the trust chain (user_fonts.py, run at setup): descriptor validation, cmap parsing via
fontTools (manifest.json requirements fonttools>=4.47.0 + brotli>=1.1.0 β brotli is a separate pin, not a [woff2] extra), and per-locale verification against
the shipped locale catalogues β letters/marks/digits only; symbols (β β Β·) legitimately ride
the fallback chain. Shaping locales (ar) additionally require a GSUB table. The verdicts land
in catalog.json, the canonical font-library response served at /eufy_vacuum/user_fonts/. The
descriptor cannot claim locales β the font file is the evidence; without fontTools a drop-in
is catalogued unverified with zero locales (present, never offered).
The card consumes the catalog generically (fonts.js runtime section + _maybeLoadUserFonts in
main.js): sanitize every entry (defense in depth β catalog fields become CSS), register faces on
the document, inject the runtime shadow styles (a second <style data-evcc-runtime-fonts> node,
re-injected per _render so frame resets can't drop it), append the modal/toast host setters at
injection time, register per-locale offering (registerRuntimeFontSupport; shipped verified sets
are immutable at runtime), and re-arm the stored-font read so a persisted drop-in choice restores.
The card never render-verifies β rendered-text checks lie through per-glyph fallback (the
live:FONT-1 instrument class). Pins: UF-1β¦UF-6 (frontend), tests/unit/test_user_fonts.py
(backend).
5. Styles-in-styles-only + the CI gates¶
The rule: all CSS lives in src/styles/; renderers emit no inline <style>. Verified β grep for <style across src/renderers/ returns zero matches.
The one allowed escape hatch: dynamic style="--x:β¦" attributes that set only CSS custom properties consumed by rules in src/styles/ (data β CSS, never literal declarations). Sanctioned examples:
- renderers/map.js::_renderMapSegmentPolygon β --seg-color (room-fill per segment); renderers/map.js::_renderComposerShape β --evcc-grp (group color)
- renderers/rooms.js#CNPK5534 β --job-progress; renderers/rooms.js::renderRoomCard β --room-progress
- renderers/maintenance.js:418, :509 β --maintenance-remaining (gauge fill)
- renderers/floor-texture-surface.js:132 β --floor-opacity-card / --floor-position-card
Renderer β styles pairing is by evcc-<feature>-* class convention: a renderer emits class="evcc-<feature>-*", matching rules live in styles/<feature>.js, and the module is registered in the combiner (Β§1). Concrete (saved-zones): renderer classes in renderers/saved-zones.js (.evcc-saved-zones-panel :44/:121/:130/:166, -header :31, -item.is-selected :90, β¦) pair with rules in styles/saved-zones.js (.evcc-saved-zones-panel (styles/saved-zones.js#CNXDKP5B), styles/saved-zones.js#CNHZZ19E, styles/saved-zones.js#CN15KDB5, styles/saved-zones.js#CN8PQBTZ, β¦); wired via import { savedZonesStyles } (styles/index.js:35) + array entry (styles/index.js:67). The export is a plain template-string constant (styles/saved-zones.js::savedZonesStyles β closing backtick :226) β exactly the shape the gate checks.
The gate β scripts/check-styles.mjs. SIX independent fail() producers β but its success line names only five, so a clean run under-reports what it actually checked. The five it names: "import cleanly, CSS exports brace-balanced, no un-tokenized colors, no physical-direction (non-RTL) CSS, and every --evcc-* reference resolves", scripts/check-styles.mjs#CNCVC9M3), all src/styles/*.js (skipping *.test.js) unless noted:
1. Import-clean β import()s each module; a stray backtick / broken template literal throws on import β fail. This catches the original prod bug: truncated CSS that was still valid JS.
2. Brace-balanced β walks each string export counting {/}; nonzero depth = truncated literal β fail. (1 and 2 together: scripts/check-styles.mjs#CN50N9K4.)
3. THEME-LINT (scripts/check-styles.mjs#CNDPYTCV) β no hardcoded color literal (#hex / rgb(a) / hsl(a)) assigned to a color-ish CSS property in a rule body; every color must resolve through var(--evcc-*, fallback). Scans src/styles/* (minus foundation.js, the token-DEFINITION file, whitelisted since its literals ARE the defaults) plus src/room-card.js, cards/dashboard-card.js, cards/profile-card.js, cards/_shared.js β i.e. beyond src/styles/ too. An inline /* theme-lint-ignore */ comment on the line whitelists a deliberately theme-independent color.
4. RTL-LINT (scripts/check-styles.mjs#CND7HKR6) β no physical-direction property (margin-left/-right, padding-left/-right, border-left/-right, text-align: left/right, bare left:/right:) outside an /* rtl-ignore */ line; the card renders RTL (Arabic/Hebrew) and a physical rule won't mirror. Same target list as THEME-LINT plus cards/vacuum-map-host.js, minus styles/map.js β the map is spatial (coordinate math, canvas, CSS-triangle borders) and is force-direction: ltr on .evcc-map-view, so its physical properties are correct by construction and would only be false positives here.
5. TOKEN-LINT (scripts/check-styles.mjs#CNMF2SNK) β every var(--evcc-*) reference must resolve to something real: a CSS declaration anywhere in the card (including the token-definition file), an entry in the theme-token inventory (src/theme-tokens/* β tokens a THEME may supply that the card itself never declares), or a runtime-set inline style="--evcc-x:β¦". A dangling reference still renders (its fallback always applies) and never appears in the theme editor, so it silently advertises a knob that doesn't exist β three shipped that way in one sitting before this check existed. KNOWN_DANGLING (scripts/check-styles.mjs::KNOWN_DANGLING) is a shrink-only allowlist of pre-existing dangling references recorded 2026-08-06; it currently lists none (all 11 recorded that day were fixed same-day), and the gate itself fails the build if a listed entry stops dangling and the entry isn't deleted β so the list can never silently regrow permission.
6. STRAY-BACKTICK (scripts/check-styles.mjs::strayBacktickLines) β the sixth producer, and the one the success line omits. It walks each src/styles/*.js for a backtick inside a CSS comment and fails with "BACKTICK INSIDE A CSS COMMENT β it ends the template literal, truncating every rule after it." This is the guard for the dab1af7e class: a comment form that is legal in JS but not in CSS silently eats every rule below it.
- Exits 1 on any failure (scripts/check-styles.mjs#CNN7APJJ). Header (:1-17) documents the original incident all of this guards (dropped nav / header-padding / view-stage overflow:auto).
Runs FIRST in the build (package.json:6-7): both build and build:deploy are node scripts/check-styles.mjs && β¦ β &&-chained first, so a new module that fails any of the five checks blocks the entire build before the card is bundled. Also standalone check:styles (package.json:9).
Gate blind spot β narrower than it looks. Checks 1β2 (import-clean, brace-balance) scan only src/styles/. Checks 3β5 (theme-lint, RTL-lint, token-lint) additionally reach into room-card.js, dashboard-card.js, profile-card.js, cards/_shared.js, and (RTL-lint only) vacuum-map-host.js β but none of the five checks look at the standalone cards' own inline CARD_CSS block beyond those specific files (Cliff 2). A backtick-in-a-comment truncation inside dashboard-card.js's CARD_CSS, for instance, would still ship silently β it's covered by 3/4 for un-tokenized colors and physical CSS, but not by 1/2 for a truncated template literal.
6. Cliffs¶
Cliff 1 β new modal CSS must go in the BODY host, not the shadow bundle. Add a modal's CSS to any shadow-bundled module (the STYLES array, styles/index.js:50-82) and the body-portal modal renders completely unstyled β no error, just naked markup on document.body. The live modal stylesheet is MODAL_HOST_STYLES (styles/modal-host.js::MODAL_HOST_STYLES), injected at main.js::_updateModalHost. Trap: styles/modals.js:3 is verbatim β οΈ DEPRECATED β DO NOT EDIT ("its rules never match anythingβ¦ edit MODAL_HOST_STYLES in src/styles/index.js, not this one"), yet it is still in the array (styles/index.js:69) β it looks authoritative but is inert (slated for deletion v0.10.0+). (Its banner's own _renderModals() reference is itself stale β the live method is _updateModalHost().) Correct add = both places (Β§2). Token gotcha: miss the light companion (styles/modal-host.js#CNR1K8KC, inside the styles/modal-host.js#CN0M3MW9 media block) and a themeless light-OS Follow-HA user gets a dark modal. A live example of the inverse mistake β shadow-root CSS stranded in a body-host-only module, so it never reaches the shadow root at all β is jobSummaryStyles; see Β§2's note.
Cliff 2 β the three-bundle boundary: styles/ reaches ONLY the command-center. scripts/build-card.mjs:74-76 builds three self-contained esbuild bundles, no code-splitting β entry points src/all-cards.js β eufy-vacuum-command-center.js, src/cards-standalone.js β the room/dashboard cards, src/cards/vacuum-map-host.js β the map host. main.js#CNG921VV is the only importer of STYLES/MODAL_HOST_STYLES, and it is reachable only from the command-center entry. The standalone cards carry their OWN inline CSS and do NOT import src/styles/ (though check-styles.mjs's theme-lint/RTL-lint/token-lint checks do reach some of them β see Β§5's gate blind-spot note):
- cards/dashboard-card.js:989 const CARD_CSS with its own :host; header :986 says verbatim "own shadow root β sibling cards carry their own CSS".
- src/room-card.js:80-81 / src/room-card.js::EufyRoomCard._render (note: at src/, not src/cards/) write their own shadow <style> (imports i18n, cards/_shared.js and cards/card-suggestions.js β notably not styles/).
- cards/ imports from styles/ exactly twice, both in vacuum-map-host.js: mapStyles (styles/map.js::mapStyles) and ensureFontFacesInDocument (styles/fonts.js::ensureFontFacesInDocument, called during host setup).
This is the #1 "I changed the CSS but the room card didn't update" trap. Editing src/styles/ changes the command-center panel only; the room/dashboard cards have hand-duplicated token maps (e.g. dashboard-card.js:992-997 maps --evcc-accentβ--accent) you must edit too. Duplication is the deliberate price of independently-cacheable lazy bundles (build-card.mjs:63-73). The three bundle entries also each independently call ensureFontFacesInDocument() (Β§4) for the same reason β a standalone card can be the only bundle a page loads.
Cliff 3 β where a token's default lives is per-family; room-fill is seeded elsewhere. Canonical defaults are in styles/foundation.js#CNVJMQTE+ (:host). Room-fill is the exception with two independent default sources β the map renderer's own var() fallback (roomFillCss/roomFillRgb in cards/map-room-color.js, keeps a themeless card correct) and the resolvedTheme seed (state/theme.js:385-389, only so the editor swatch opens). state/theme.js#CN1YNYTH and theme-tokens/map.js:52-56 both state this verbatim. Bites: looking in foundation.js/index.js :host for a room-fill default finds nothing; changing one palette requires syncing both, and count = ROOM_FILL_N in cards/map-room-color.js (theme-tokens/map.js:56: "keep them in sync"). Full trace in Β§3(C).
Cliff 4 β build-time style handling.
- Texture cache-bust: build-card.mjs:27-42 hashDir() sha1's each texture's name+bytes β 10-char assetVer, injected as the esbuild define __ASSET_VER__ (:60) and appended ?v=<hash> to texture URLs. Same scheme mints __LOCALE_VER__. Bites: these are compile-time define constants β a raw __ASSET_VER__ is undefined under build:dev/watch (package.json:10-11), which don't set the defines. (Rendering of the texture itself is render-cycle.md.)
- External dynamic-import boundary: build-card.mjs:59 external: ["/eufy_vacuum/frontend/*"] leaves the dashboard card's runtime import() of the ~1MB map host as a literal URL β loads only when show_map is on (same pattern main.js uses for animal-svg).
- CSS-literal guard blind spot: the check-styles.mjs import-clean/brace-balance checks (Β§5) scan only src/styles/, not the standalone cards' inline CARD_CSS β a truncating backtick there ships silently even though the lint checks partially cover those same files.