Render Harness¶
The render harness is a headless tool that renders the real card outside
Home Assistant โ for visual-regression, colorblind validation, and theme
previews. It lives in harness/ (plus src/renderers/badge-marks.js, which
ships) and is wired to CI. This document explains how it works and the one
architectural property the whole thing rides on.
It is one tool with several consumers, not several tools. Each consumer (iteration, regression, colorblind, sharing) is fed the same real render and differs only in the token bundle supplied and what's done with the output.
1. The load-bearing property¶
Two facts about the card (see card-architecture) make a headless harness cheap:
- Renderers are pure. Every renderer is
render(ctx) โ HTML string, reading state through one narrow accessor (state.foo?.() ?? fallback), with no reach into HA, the backend, or globals. A stubbedstatedrives any tab. - Color lives in tokens. Renderers emit structure +
--evcc-*custom- property references; the actual colors live in the tokens. Structure and color are therefore orthogonal inputs to one render path.
Two consequences the design rides on:
- One fixture ร N bundles = the whole matrix, free. Author a tab's fixture once; feed it default / protanopia / deuteranopia / colorblind-safe bundles and the same render re-colors itself. Nothing is ever hand-colored.
- No test/prod skew. The harness renders the identical path that ships, differing only in the token values fed in. A green check is a guarantee about the card that ships, not a proxy for it.
The one place the card breaks purity is src/renderers/rooms.js, which reads
window.AnimalSVG directly at render time. The harness stubs that global;
routing it through ctx is a tracked follow-up.
2. Headless mount¶
harness/mount-entry.js is bundled by esbuild (harness/build.mjs) into
harness/dist/mount.js (IIFE) and loaded into a Playwright Chromium page. It
exposes window.__evcc.
It recreates the exact ship path, not an approximation:
- Composes
renderHeader(ctx) + renderView(ctx)(src/render-cycle.js) into the same shadow-DOM frame assrc/main.js_ensureShellFrame()โ<ha-card><div class="evcc-shell">โฆ</div></ha-card>with the realSTYLES(src/styles/index.js) injected. - Applies a flat
--evcc-*bundle as inline custom properties on the host, mirroringapplyDynamicTheme(src/styles/apply-theme.js). - Supplies a stub
stateand a stubcard(the renderers read_config/_state/_renderers/_view/_mobileMoreOpenand, for rooms,_learningController).
Two harness-only shims stand in for the HA host: an <ha-card> chrome style
(HA provides the card background/border at runtime) and an optional animation
freeze for deterministic capture. Both are clearly marked and never shipped.
window.__evcc surface:
| Member | Purpose |
|---|---|
render(view, opts) |
Render one tab. opts: bundle, overrides, controller, width, freeze, modal (a renderer name to mount as a body-level modal โ ยง3). Returns {ok, error?, misses} โ never throws to the page. |
renderGallery(id, opts) |
Render an all-states gallery entry (ยง3). |
renderThemePresets(themes, opts) |
Render the Themes-tab presets grid driven by real state โ a VacuumCardState seeded with the theme library, the facet filter / search / Browse-gallery UI, so the picker is captured on genuine state (the null-object stub can't exercise the filter). Backs shoot-theme-picker.mjs. |
ingestTheme(envelope) |
The intake gate (ยง6). |
registerLocale(lang, catalog) |
Inject a foreign/pseudo locale catalog into the in-page i18n registry (src/i18n/index.js) before rendering with opts.lang; used by the i18n-locale / i18n-layout / pseudo gates. |
makePseudoLong(base) |
Build the layout-stress (pseudo-long) catalog in-page (harness/lib/pseudo-locale.mjs), avoiding a Node-side en.js import. |
semanticTokens |
The registry-derived semantic-color token set (ยง3). |
badgeMarks, markViewBox |
The shape-mark SVGs (ยง5). |
tokenMap, VIEWS, VIEW_ORDER |
Registry + view constants for tests. |
VacuumCardState |
The real src/state/index.js state class, exposed so tooling can drive genuine state (e.g. per-device theme, driven by device-theme.spec.mjs). |
gallery |
The gallery-entry metadata list (id, view, label, tokens, clip, modal) for tests. |
version |
Surface version (1). |
The stub state¶
harness/fixtures/stub-state.js is a recording null-object: any accessor the
fixture doesn't define returns a callable, indexable, iterable, coercible value
that absorbs .map(), .length, property chains, and interpolation without
throwing. The header essentials (name, status, battery) are made real so the
chrome renders honestly.
This is deliberate. The renderers touch ~184 distinct accessors; hand-typing
empties for all of them would be throwaway work. The null-object lets the smoke
test prove the pure-renderer contract (a throw means a renderer reached
outside state/ctx) without realistic data, and records the accessor surface
each tab touches (harness/census.mjs) as the seed for real fixtures.
3. Fixtures and all-states galleries¶
harness/fixtures/gallery.js holds fixtures that force a tab to show every
colored state at once โ the honest instrument for colorblind validation.
Distinguishability is relative: whether error-red is confusable with success-
green can only be judged with the two co-present and adjacent at real size. A
gallery of all states in real layout is that instrument; isolated swatches are
not. Current galleries: rooms (queue + confidence tiers), learning review (job
badges), the External Jobs subtab (app-started runs awaiting review), the
two-step review wizard (modal), and a
status-dot strip โ plus populated single-tab fixtures (metrics, maintenance,
room rules) that stub a tab's data accessors with realistic content. Those last
serve a purpose beyond colorblind validation: the theme-preview gallery (ยง7)
renders them so a shared theme shows on real content, not empty stub tabs. (They
are deliberately date-math-free so the baselines stay deterministic.)
Subtab and modal capture. The per-tab shooter renders each tab at its default
subtab, so non-default surfaces are captured as gallery fixtures instead. A fixture
flips a subtab by overriding its state accessor (e.g. reviewSubtab: () =>
"external"). A body-level modal โ the review wizard mounts to document.body
via main.js _renderModals(), not renderView โ is captured by naming its
renderer in the fixture's modal: field; render() then mounts that one modal
into a shadow-root host with MODAL_HOST_STYLES (faithful to the ship path), and
the entry's clip crops the shot to the modal shell. Modal entries render under
emulated prefers-color-scheme: dark so the modal matches the (dark) card โ the
default bundle doesn't theme the modal host, which would otherwise trip the modal's
light-hardening.
The gallery is enumerated from the token registry, not hand-listed.
harness/semantic-tokens.js derives the semantic-color set from the Status,
Confidence & Alerts and Learning & Metrics groups of THEME_TOKEN_REGISTRY.
The completeness gate asserts every such token is claimed by a gallery entry (or
allowlisted with a reason) โ so a new colored state-token fails the gate until it
has a fixture row.
4. Visual regression¶
harness/tests/visual.spec.mjs renders each tab + gallery and diffs against
committed baselines (harness/tests/__screenshots__/) via Playwright's
toHaveScreenshot. This closes the frontend host-boundary gap the backend tests
can't reach (z-index, shadow DOM, layout, flood).
Determinism is the whole game. Chromium font/anti-alias rendering differs
across OSes, so baselines are generated and gated in one pinned image โ
mcr.microsoft.com/playwright:v1.60.0-noble โ making the comparison byte-for-
byte stable. The visual specs are gated to CI / VISUAL=1 (other platforms
would mismatch); smoke, completeness, CVD, shape, intake, tab-gating, and
device-theme gates run everywhere. See testing/frontend/render-harness for
the regenerate-baselines workflow.
Structural, not color. The diff budget is an absolute maxDiffPixels,
not a ratio โ a ratio lets a small colored-region change hide inside a tall image
(a recolored confidence chip is ~1% of the rooms gallery, so a 1% ratio misses
it). The whole-image gate is for structural regressions (layout / z-index /
missing elements, which move many pixels); small-region color correctness is
the CVD gate's job (ยง5).
5. Colorblind (CVD) validation¶
harness/cvd/ simulates color-vision deficiency and measures whether the
semantic palette stays distinguishable.
- Simulation (
simulate.mjs): Machado et al. 2009 matrices at severity 1.0 for protanopia + deuteranopia; Brettel 1997 two-half-plane projection for tritanopia (Viรฉnot is inaccurate for tritan). All applied to linear RGB. Full dichromat severity โ a pass covers milder anomalous trichromats. Constants verified against DaltonLens / libDaltonLens. - Difference (
color.mjs): CIEDE2000. - The gate: the 10 pairs among the five color groups {success, warning,
error, info, muted}, under each of the three sims = 30 ฮE values, floor
ฮE2000 โฅ 15 (defensible at dot size given the area effect).
warn/likelyare excluded โ they share the warning hue by design; the shape cue (ยง6) carries them. Watch the muted โ status "sleeper": a status loses chroma under protan/deutan and drifts toward grey.
When a pair misses, fix the palette, not the floor. harness/bundles/cvd-safe.mjs
is the validated result (all 30 โฅ 15, worst 18.1):
| group | hex | note |
|---|---|---|
| success | #0C8F86 |
dark cyan-teal |
| warning | #E9A100 |
amber |
| error | #D6403A |
warm red (magenta's blue collides with info-blue under protan) |
| info | #0F4C86 |
deep blue (reference / baseline) |
| muted | #BCC2C7 |
light neutral grey |
The trick: success and error sit at similar lightness but opposite blue-yellow (the one axis protan/deutan preserve), and the five hues are luminance-spread so none desaturates into grey.
The bundle is five overrides. conf-*, color-*, confidence-*,
status-dot-*, and learning-confidence-* all cascade from --evcc-sem-* via
var() chains (see themes/preloaded.py _build_release_theme_colors), so
overriding the five anchors recolors the whole semantic palette. That palette
ships as the selectable "Colorblind Safe" preloaded theme
(custom_components/eufy_vacuum/themes/preloaded.py); see
theme-system.
The five hexes live in both
harness/bundles/cvd-safe.mjs(JS, harness- validated) andthemes/preloaded.py(Python). Cross-language, comment-linked โ keep them in sync.
6. Shape marks โ the redundant cue¶
Color resolves only five groups; the six mapping-bounds badge states need a
sixth distinguisher, and colorblind users shouldn't rely on hue at all. So every
badge carries a per-state SVG mark (src/renderers/badge-marks.js, always
on, every theme):
| state | mark | state | mark |
|---|---|---|---|
| ok | โ | outlier | โ |
| likely | โ | excluded | โ |
| warn | ! | baseline | โ |
All six are authored from one source (shared viewBox + stroke weight,
currentColor) โ no ASCII/symbol-font mixing, which would land glyphs at
inconsistent weights and break the grayscale comparison.
harness/tests/shape-marks.spec.mjs rasterises each mark grey-on-white at dot
size and asserts every pair differs in flat grayscale โ one property that
covers monochromacy and every CVD type at once. likely (โ) โ warn (!) carries
the shared-color pair; ok (โ) โ outlier (โ) is the safety-critical good-vs-bad
pair and is held to a higher bar.
7. Theme-export intake¶
The harness accepts any theme export (the export/import schema, see theme-system) and renders a preview of the real card recolored by it โ the config is the seed, the render is the deliverable.
- Ingest gate (
window.__evcc.ingestTheme,harness/tests/intake.spec.mjs) โ the load-bearing safety. It reuses the same validate + clamp path asimport_theme(clampThemeScalars+ the token registry): keep only known registry--evcc-*keys (drops unknown keys and unknown floor-type namespaces), clamp bounded scalars to range, drop non-primitive values, never eval. Values reach the card viasetProperty(CSS-validated), never HTML. This is the entire reason running a stranger's export in CI is safe โ the export is data, not code. - Preview (
harness/preview.mjs) โ builds the gallery fromgallery/themes/*.json. Per theme, scope drives the detail render: a full theme โ the all-states galleries, the populated single-tab fixtures (so the theme shows on real content, not empty stubs), and a tour of the remaining tabs; a texture-scoped export โ the rooms gallery. It also writes a single-room-card thumbnail per theme and a lobbyindex.htmlthat grids those thumbnails (each linking its detail page) under a "+ Submit a theme" button (ยง8). A per-theme_contact-sheet.pngis produced too โ the submission bot embeds it inline in the PR (ยง8). - Trigger (
.github/workflows/theme-intake.yml) โworkflow_dispatchfor a one-off, orpush/pull_requestongallery/themes/*.jsonfor a versioned gallery. PRs and manual dispatches come back as workflow artifacts; on push to master the rendered gallery โ the lobbyindex.htmlplus the per-theme detail pages and images โ is published to GitHub Pages (actions/deploy-pages) at the repo's Pages URL. One-time setup: enable Pages with the GitHub Actions source in repo settings.
8. Theme submission (issue โ PR)¶
Sections 1โ7 are the engine; this is the front door that lets anyone add a theme to the gallery without touching the repo. It turns a pasted export into a reviewable pull request with a rendered preview, and never auto-merges.
Entry point. The gallery lobby's "+ Submit a theme" button links to a
GitHub issue form (.github/ISSUE_TEMPLATE/theme-submission.yml): a
render: json textarea for the export, optional vibe tags / author / author
URL / submitted-by credit fields, a colorblind-safe claim checkbox, and an
acknowledgement. The form auto-applies the theme-submission label โ the only
signal the bot keys on.
The bot (.github/workflows/theme-submission.yml, on
issues: [opened, reopened, edited], gated if the theme-submission label is
present). Its core is a pure transform โ scripts/process-submission.mjs,
issue-body โ {envelope, report}, unit-tested by process-submission.test.mjs
and sharing the same theme-tags core the card and gallery use, so a
submission is tagged and verified identically to an in-card theme:
- Extract + validate. The form's
render: jsonfield wraps the export in a fenced JSON block; the bot regex-extracts it,JSON.parses it, and sanity-checks it's a theme export (theme.tokens/colors/alpha). On any failure it comments on the issue with the fix and stops โ malformed input never becomes a file. The deeper safety is the ingest gate (ยง7): the render step runs the export through the same validate + clamp path asimport_theme, so a hostile export is data, not code. - Tag + verify + stamp. The accepted theme is stamped
source:"community", keeps the submitter's vibe tags (system words stripped, so a submission can't spoof a derived facet), and carries author / author_url / submitted_by. Facet tags are derived from the palette and colorblind-safety is verified by simulation โ never author-asserted; a failed colorblind claim is non-blocking (badge left off, report says which status pair collapsed). The author URL is policy-checked (isAcceptableAuthorUrl): only a directhttp(s)link to a non-shortener host is kept โ dangerous schemes (javascript:/data:/ โฆ) and URL shorteners are dropped, non-blocking, with the reason in the report (this is also the stored-XSS defense, since the credit becomes a gallery<a href>). The bot then writesgallery/themes/{slug}.json(slug= sanitized theme name +-{issue#}). - Render (
harness/build.mjs+harness/preview.mjs) the preview for that one theme โ its detail page and a_contact-sheet.png. - Open (or update) a PR carrying just
gallery/themes/{slug}.jsonon atheme-submission/{slug}branch, bodyCloses #{issue}. A reopen/edit reuses the same branch + PR rather than piling up new ones. Merging it lands the theme on master and triggers the ยง7 publish. - Comment the full report (tags, credit, colorblind verdict) back on the issue with the PR link.
Why the preview is rendered here, not on the PR. A PR opened with the
built-in GITHUB_TOKEN does not trigger other workflows (GitHub's
anti-recursion rule), so theme-intake.yml won't run on the bot's PR. Instead
the bot pushes the contact sheet to an off-master gallery-previews branch
and embeds it in the PR body via a raw.githubusercontent.com URL โ so the
reviewer sees the render inline, with no artifact download. That branch is
never merged; it only holds preview images and is safe to reset or delete (the
bot recreates it on the next submission).
Reviewing a submission (maintainer): open the PR, look at the inline preview (and the full per-tab artifact on the run if you want it), then merge to publish or close to reject. The human is the only gate.
One-time setup. Both are required, or the bot silently skips / can't open the PR (also documented in the workflow header):
- A
theme-submissionlabel must exist โ issue forms only apply labels that already exist, so without it the gatingifnever matches and the bot no-ops. - Settings โ Actions โ General โ "Allow GitHub Actions to create and approve
pull requests" must be on, or
pulls.createis rejected.
(Plus the ยง7 Pages enablement, for the publish that runs after a merge.)
9. File map¶
| Path | What |
|---|---|
harness/mount-entry.js |
Browser entry; window.__evcc. Bundled by build.mjs. |
harness/fixtures/stub-state.js |
Recording null-object stub. |
harness/fixtures/gallery.js |
All-states gallery fixtures. |
harness/semantic-tokens.js |
Registry-derived semantic-color enum. |
harness/cvd/ |
simulate.mjs (Machado+Brettel), color.mjs (CIEDE2000), report.mjs (matrix), tune.mjs (palette scratchpad). |
harness/bundles/ |
Flat --evcc-* maps: default, cvd-safe. |
harness/lib/mount-page.mjs |
Node helpers: load bundle into a page, render. |
harness/lib/gallery-html.mjs |
Playwright-free gallery HTML generator (index filter bar + per-theme pages) shared by preview.mjs and the dry-run; owns the author-URL XSS-escaping contract. |
harness/lib/gallery-html.test.mjs |
node --test unit tests for the gallery HTML escaping / author-URL contract. |
harness/build.mjs ยท shoot.mjs ยท shoot-gallery.mjs ยท shoot-theme-picker.mjs ยท census.mjs |
esbuild + capture CLIs (shoot-theme-picker.mjs shoots the Themes picker with a real-state fixture). |
harness/preview-index-dryrun.mjs |
Fast no-Chromium gallery-index dry-run: runs committed themes through lib/gallery-html.mjs with cheap swatch thumbnails to eyeball the filter bar. |
harness/preview.mjs |
Builds the gallery: lobby index.html, per-theme detail pages, thumbnails, contact sheets. |
harness/tests/*.spec.mjs |
smoke ยท gallery-completeness ยท visual ยท cvd ยท shape-marks ยท intake ยท tab-gating ยท device-theme ยท i18n-layout ยท i18n-locale (the i18n gates render pseudo/foreign catalogs: i18n-layout asserts no layout overflow under a pseudo-long locale @500px/@390px, i18n-locale asserts the renderers.t wiring actually switches the UI). |
src/renderers/badge-marks.js |
The six per-state shape marks (ships). |
gallery/themes/*.json |
Theme exports published to the gallery (one JSON per theme). |
.github/ISSUE_TEMPLATE/theme-submission.yml |
Submission issue form (the "Submit a theme" target). |
.github/workflows/card-visual.yml ยท theme-intake.yml ยท theme-submission.yml |
CI: visual regression ยท gallery publish ยท submission bot. |
10. Judgment inputs (tunable, by design)¶
Three knobs are spec, not boilerplate โ they live in code with comments, not hidden defaults:
| Input | Where | Current |
|---|---|---|
| Fixture content per tab | harness/fixtures/gallery.js |
all colored branches forced on one screen |
| Diff threshold + masking | harness/playwright.config.mjs |
threshold 0.1, maxDiffPixels 60 (absolute), animations frozen |
| CVD pass criterion | harness/cvd/report.mjs |
10 pairs ร 3 sims, ฮE2000 โฅ 15 |
How to run, regenerate baselines, and read the gates: testing/frontend/render-harness.