# HTML JS Card

> [object Object]

- Skill: `bobsilvio/html-js-card` (Agent Skill)
- Install (CLI): `npx skillmds@latest add bobsilvio/html-js-card`
- Raw SKILL.md: https://api.skillmd.com/api/skills/bobsilvio/html-js-card/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: Bobsilvio (https://skillmd.com/u/bobsilvio)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/bobsilvio/html-js-card

---


## ⚠️ CRITICAL — READ THIS FIRST

**You are in HTML-JS Card mode. ALL cards you generate MUST use `type: custom:html-js-card`.**

**NEVER generate cards of these types:**
- `custom:mushroom-template-card`, `custom:mushroom-*` — NOT allowed here
- `custom:power-flow-card-plus` — NOT allowed here
- `custom:mini-graph-card`, `custom:apexcharts-card` (standalone) — NOT allowed here
- `type: entities`, `type: sensor`, `type: gauge` — NOT allowed here
- Any other card type that is not `custom:html-js-card`

**The ONLY valid card type in this skill is `type: custom:html-js-card`.**
If the user wants energy flow → implement it with **inline SVG inside `content:`**.
If the user wants ApexCharts → load it via **`scripts:`** and render it inside `content:`.
If the user wants a header/title → HTML inside `content:`, not a separate mushroom card.
Everything goes inside one (or more) `custom:html-js-card` cards.

**ALWAYS wrap the complete YAML in a fenced code block:**
````
```yaml
type: custom:html-js-card
...
```
````
**NEVER output YAML as plain text.** Plain-text YAML cannot be copied and breaks the user's workflow.

---

You are an expert in **HTML-JS Card** for Home Assistant Lovelace dashboards.
HTML-JS Card is a custom card (available via HACS) that lets you embed arbitrary HTML, CSS and JavaScript directly in YAML configuration. It provides full access to Home Assistant state and services.

## Installation

1. Place `html-js-card.js` in `/config/www/html-js-card/html-js-card.js`
2. In Home Assistant: **Settings → Dashboards → Resources**
3. Add resource: URL `/local/html-js-card/html-js-card.js`, Type: JavaScript Module
4. Refresh the browser

## Card configuration

```yaml
type: custom:html-js-card
title: "Optional title"          # displayed at the top of the card
height: 400px                    # card height (default: auto)
padding: 12px 16px 16px          # content padding
overflow: hidden                 # hidden | auto | scroll
update_interval: 30              # auto-refresh interval in seconds (optional)
entities:                        # entities to inject (accessible via `entities` variable)
  - sensor.temperature
  - light.living_room
scripts:                         # external JS libraries to load (CDN URLs)
  - https://cdnjs.cloudflare.com/ajax/libs/Chart.js/3.9.1/chart.min.js
content: |
  <!-- HTML + CSS here -->
  <!-- JavaScript can go here inside <script> tags, OR in the separate js: field below -->
js: |
  // Optional — JavaScript executed after content: HTML is injected.
  // Has access to the same variables: hass, entities, card, config, shadow, moreInfo.
  // Useful to keep HTML and JS separated in YAML for readability.
  window._hjc_hass = hass;
  function updateCard(ents) { ... }
  updateCard(entities);
  card.addEventListener('hass-update', (e) => {
    window._hjc_hass = e.detail.hass;
    updateCard(e.detail.entities);
  });
```

The `content` field is **required**. All other fields are optional.

> **`js:` field**: JavaScript in `js:` runs after `content:` HTML is injected and has the same context (`hass`, `entities`, `card`, `config`, `shadow`, `moreInfo`). It is equivalent to putting a `<script>` tag at the end of `content:` — use whichever keeps the YAML more readable. **Both styles are valid.**

## Height guidelines

- **Prefer `height: auto`** for cards with only tiles/text — the card sizes to its content automatically.
- **Set an explicit height only when needed** (charts, SVG gauges, fixed-layout grids).
- **When estimating height, always add a ~20% buffer** — browsers add padding, borders and gap that are easy to undercount. A card that looks like 420px often needs 500px.
- **Rough reference** (adjust to actual content):

| Content | Suggested height |
|---------|-----------------|
| 1 row of 4 tiles | 120px |
| 2 rows of 4 tiles | 240px |
| 3 rows of 4 tiles | 360px |
| Chart.js line chart | +200px |
| SVG gauge / arc | +160px |
| Mermaid diagram | +220px |

- If the user reports content is cut off, increase height by 60–80px.

## Runtime variables

These variables are always available inside `content` scripts:

| Variable | Type | Description |
|----------|------|-------------|
| `hass` | Object | Full Home Assistant instance. Read states, call services. |
| `entities` | Object | Dictionary of declared entity states, keyed by entity_id. |
| `card` | HTMLElement | The `#hjc-content` DOM container. Use for `querySelector`, `innerHTML`, `appendChild`. |
| `config` | Object | The YAML card configuration object. |
| `shadow` | ShadowRoot | Shadow DOM root of the card. |
| `moreInfo(entity_id)` | Function | Opens the HA "more info" dialog for an entity. |

## Accessing DOM elements

The card uses **shadow DOM**, so `document.getElementById` cannot find elements inside the card.
**Always use `card.querySelector('#id')`** to access elements by ID.
When applying styles/classes or calling methods on queried elements, use null-safe access (`?.`)
or explicit `if (el)` guards to prevent runtime crashes if an element is missing.

```javascript
// ✅ CORRECT — use card.querySelector
const el = card.querySelector('#my-element');
if (el) el.textContent = 'Hello';

// ❌ WRONG — document.getElementById returns null inside shadow DOM
const el = document.getElementById('my-element'); // always null!
```

## Reading entity states

```javascript
// From the entities variable (only entities listed under `entities:`)
const temp = entities['sensor.temperature']?.state;
const attr = entities['sensor.temperature']?.attributes?.unit_of_measurement;

// From hass (any entity, even if not listed)
const state = hass.states['light.living_room']?.state;
const brightness = hass.states['light.living_room']?.attributes?.brightness;
```

## Calling HA services

```javascript
// Toggle a light
hass.callService('light', 'toggle', { entity_id: 'light.living_room' });

// Turn on light with brightness
hass.callService('light', 'turn_on', { entity_id: 'light.bedroom', brightness_pct: 80 });

// Set thermostat temperature
hass.callService('climate', 'set_temperature', { entity_id: 'climate.living_room', temperature: 21 });

// Press a button
hass.callService('button', 'press', { entity_id: 'button.reset' });

// Set input_number
hass.callService('input_number', 'set_value', { entity_id: 'input_number.threshold', value: 42 });

// Call any service
hass.callService('domain', 'service_name', { entity_id: '...', ...data });
```

## Reactive updates (hass-update event)

The card fires a `hass-update` event whenever entity states change or `update_interval` fires.

```javascript
card.addEventListener('hass-update', (e) => {
  const { hass, entities } = e.detail;
  // update the UI with fresh data
  card.querySelector('#value').textContent = entities['sensor.temperature']?.state || '—';
});
```

## Accessing hass in click handlers

Since click handlers run outside the initial script scope, save `hass` globally:

```javascript
// Save on first load and on every update
window._hjc_hass = hass;
card.addEventListener('hass-update', (e) => { window._hjc_hass = e.detail.hass; });

// Use inside click handlers
card.querySelector('#btn').addEventListener('click', () => {
  window._hjc_hass.callService('light', 'toggle', { entity_id: 'light.living_room' });
});
```

## CSS and theming

The card uses shadow DOM, so styles are isolated. Use HA CSS variables for light/dark mode compatibility:

```css
var(--primary-color)
var(--primary-text-color)
var(--secondary-text-color)
var(--card-background-color)
var(--divider-color)
var(--success-color)
var(--error-color)
var(--warning-color)
```

## Complete example: entity display with button

```yaml
type: custom:html-js-card
title: Living Room
height: 180px
entities:
  - light.living_room
  - sensor.temperature
content: |
  <style>
    #root { display: flex; flex-direction: column; gap: 12px; }
    .row { display: flex; align-items: center; justify-content: space-between; }
    .label { font-size: 14px; color: var(--secondary-text-color); }
    .value { font-size: 20px; font-weight: 600; color: var(--primary-text-color); }
    button {
      background: var(--primary-color); color: #fff;
      border: none; border-radius: 8px; padding: 8px 18px;
      font-size: 13px; cursor: pointer;
    }
  </style>
  <div id="root">
    <div class="row">
      <span class="label">Temperature</span>
      <span class="value" id="temp">—</span>
    </div>
    <div class="row">
      <span class="label">Light</span>
      <span class="value" id="light-state">—</span>
      <button id="toggle-btn">Toggle</button>
    </div>
  </div>
  <script>
    window._hjc_hass = hass;

    function updateCard(ents) {
      card.querySelector('#temp').textContent =
        (ents['sensor.temperature']?.state || '—') + ' °C';
      card.querySelector('#light-state').textContent =
        ents['light.living_room']?.state || '—';
    }

    card.querySelector('#toggle-btn').addEventListener('click', () => {
      window._hjc_hass.callService('light', 'toggle', { entity_id: 'light.living_room' });
    });

    updateCard(entities); // initial render
    card.addEventListener('hass-update', (e) => {
      window._hjc_hass = e.detail.hass;
      updateCard(e.detail.entities);
    });
  </script>
```

## Complete example: Chart.js graph

```yaml
type: custom:html-js-card
title: Temperature trend
height: 280px
entities:
  - sensor.temperature
scripts:
  - https://cdnjs.cloudflare.com/ajax/libs/Chart.js/3.9.1/chart.min.js
content: |
  <canvas id="chart" style="width:100%;height:100%;"></canvas>
  <script>
    let chart;
    const history = [];

    card.addEventListener('hass-update', (e) => {
      const val = parseFloat(e.detail.entities['sensor.temperature']?.state);
      if (isNaN(val)) return;
      history.push(val);
      if (history.length > 20) history.shift();

      if (!chart) {
        const ctx = card.querySelector('#chart').getContext('2d');
        chart = new Chart(ctx, {
          type: 'line',
          data: {
            labels: history.map((_, i) => i + 1),
            datasets: [{ label: '°C', data: [...history],
              borderColor: 'var(--primary-color)', fill: false, tension: 0.3 }]
          },
          options: { animation: false, plugins: { legend: { display: false } } }
        });
      } else {
        chart.data.labels = history.map((_, i) => i + 1);
        chart.data.datasets[0].data = [...history];
        chart.update();
      }
    });
  </script>
```

## Graphics and visualization support

HTML-JS Card supports all standard web graphics techniques:

| Technique | How to use | Best for |
|-----------|-----------|---------|
| **Inline SVG** | directly in `content:` | static/animated diagrams, flow charts, gauges, icons |
| **Animated SVG** | CSS `@keyframes` or `<animate>` tags | live indicators, spinning icons, progress arcs |
| **Chart.js** | `scripts:` CDN | line, bar, doughnut, radar charts |
| **D3.js** | `scripts:` CDN | complex data-driven SVG, hierarchical diagrams |
| **ApexCharts** | `scripts:` CDN | interactive charts with zoom/tooltip |
| **Mermaid** | `scripts:` CDN | flow charts, sequence diagrams, Gantt |

### Inline SVG example: animated power flow arc

```yaml
type: custom:html-js-card
height: 160px
entities:
  - sensor.epcube_solarpower
content: |
  <style>
    #arc-label { font-size: 22px; font-weight: 700; fill: var(--primary-text-color); }
    #arc-sub   { font-size: 12px; fill: var(--secondary-text-color); }
    #arc-track { stroke: var(--divider-color); }
    #arc-fill  { stroke: var(--success-color); transition: stroke-dashoffset .4s ease; }
  </style>
  <svg viewBox="0 0 120 80" width="100%" style="max-height:140px;display:block;margin:auto;">
    <!-- background arc -->
    <path id="arc-track" d="M15,75 A55,55 0 0,1 105,75"
          fill="none" stroke-width="10" stroke-linecap="round"/>
    <!-- value arc (dasharray=172.8 = half circumference of r=55) -->
    <path id="arc-fill" d="M15,75 A55,55 0 0,1 105,75"
          fill="none" stroke-width="10" stroke-linecap="round"
          stroke-dasharray="172.8" stroke-dashoffset="172.8"/>
    <text id="arc-label" x="60" y="68" text-anchor="middle">—</text>
    <text id="arc-sub"   x="60" y="80" text-anchor="middle">W solare</text>
  </svg>
  <script>
    function updateCard(ents) {
      const val = parseFloat(ents['sensor.epcube_solarpower']?.state);
      const max = 5000; // adjust to inverter peak
      card.querySelector('#arc-label').textContent = isFinite(val) ? Math.round(val) : '—';
      const pct = isFinite(val) ? Math.max(0, Math.min(1, val / max)) : 0;
      card.querySelector('#arc-fill').style.strokeDashoffset = 172.8 * (1 - pct);
    }
    updateCard(entities);
    card.addEventListener('hass-update', e => updateCard(e.detail.entities));
  </script>
```

### ApexCharts bar/line chart example

```yaml
type: custom:html-js-card
title: Potenza solare
height: 300px
entities:
  - sensor.epcube_solarpower
  - sensor.epcube_batterysoc
scripts:
  - https://cdnjs.cloudflare.com/ajax/libs/apexcharts/3.46.0/apexcharts.min.js
content: |
  <div id="chart"></div>
  <script>
    const history = { solar: [], soc: [], labels: [] };
    let apx;

    function updateCard(ents) {
      const solar = parseFloat(ents['sensor.epcube_solarpower']?.state);
      const soc   = parseFloat(ents['sensor.epcube_batterysoc']?.state);
      if (!isNaN(solar)) {
        const now = new Date().toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' });
        history.solar.push(Math.round(solar));
        history.soc.push(Math.round(soc));
        history.labels.push(now);
        if (history.solar.length > 20) { history.solar.shift(); history.soc.shift(); history.labels.shift(); }
      }

      if (!apx) {
        apx = new ApexCharts(card.querySelector('#chart'), {
          chart: { type: 'line', height: 260, toolbar: { show: false }, animations: { enabled: false } },
          series: [
            { name: 'Solare (W)', data: [...history.solar] },
            { name: 'Batteria (%)', data: [...history.soc] }
          ],
          xaxis: { categories: [...history.labels] },
          yaxis: [
            { title: { text: 'W' } },
            { opposite: true, title: { text: '%' }, min: 0, max: 100 }
          ],
          colors: ['var(--warning-color)', 'var(--success-color)'],
          stroke: { curve: 'smooth', width: 2 },
          tooltip: { shared: true }
        });
        apx.render();
      } else {
        apx.updateOptions({
          series: [{ data: [...history.solar] }, { data: [...history.soc] }],
          xaxis: { categories: [...history.labels] }
        });
      }
    }

    updateCard(entities);
    card.addEventListener('hass-update', e => updateCard(e.detail.entities));
  </script>
```

### Inline SVG: energy flow diagram (solar → battery → house → grid)

```yaml
type: custom:html-js-card
height: 220px
entities:
  - sensor.epcube_solarpower
  - sensor.epcube_batterysoc
  - sensor.epcube_gridpower
  - sensor.epcube_homepower
content: |
  <style>
    #flow-svg { width:100%; max-height:200px; display:block; }
    .node-label { font-size:11px; fill:var(--secondary-text-color); text-anchor:middle; }
    .node-value { font-size:14px; font-weight:700; fill:var(--primary-text-color); text-anchor:middle; }
    .flow-line  { stroke:var(--divider-color); stroke-width:2; fill:none; }
    .flow-active{ stroke:var(--success-color); stroke-width:3; fill:none; }
  </style>
  <svg id="flow-svg" viewBox="0 0 320 180">
    <!-- nodes -->
    <circle cx="40"  cy="90" r="28" fill="rgba(255,183,0,0.15)" stroke="var(--warning-color)" stroke-width="2"/>
    <circle cx="160" cy="40" r="28" fill="rgba(0,200,83,0.12)"  stroke="var(--success-color)" stroke-width="2"/>
    <circle cx="280" cy="90" r="28" fill="rgba(33,150,243,0.12)" stroke="var(--info-color,#2196f3)" stroke-width="2"/>
    <circle cx="160" cy="145" r="28" fill="rgba(156,39,176,0.12)" stroke="var(--accent-color,#9c27b0)" stroke-width="2"/>
    <!-- lines -->
    <line class="flow-line" x1="68"  y1="82"  x2="132" y2="52"/>
    <line class="flow-line" x1="68"  y1="98"  x2="132" y2="128"/>
    <line class="flow-line" x1="188" y1="52"  x2="252" y2="82"/>
    <line class="flow-line" x1="188" y1="128" x2="252" y2="98"/>
    <!-- icons / labels -->
    <text class="node-label" x="40"  y="86">☀️</text>
    <text class="node-label" x="40"  y="126">Solare</text>
    <text class="node-value" id="v-solar" x="40" y="108">—</text>
    <text class="node-label" x="160" y="36">🔋</text>
    <text class="node-label" x="160" y="20">Batteria</text>
    <text class="node-value" id="v-bat"   x="160" y="58">—</text>
    <text class="node-label" x="280" y="86">🏠</text>
    <text class="node-label" x="280" y="126">Casa</text>
    <text class="node-value" id="v-home"  x="280" y="108">—</text>
    <text class="node-label" x="160" y="141">⚡</text>
    <text class="node-label" x="160" y="178">Rete</text>
    <text class="node-value" id="v-grid"  x="160" y="163">—</text>
  </svg>
  <script>
    function fmt(v, unit) { return isFinite(v) ? Math.round(v) + unit : '—'; }
    function updateCard(ents) {
      card.querySelector('#v-solar').textContent = fmt(parseFloat(ents['sensor.epcube_solarpower']?.state), 'W');
      card.querySelector('#v-bat').textContent   = fmt(parseFloat(ents['sensor.epcube_batterysoc']?.state), '%');
      card.querySelector('#v-home').textContent  = fmt(parseFloat(ents['sensor.epcube_homepower']?.state), 'W');
      card.querySelector('#v-grid').textContent  = fmt(parseFloat(ents['sensor.epcube_gridpower']?.state), 'W');
    }
    updateCard(entities);
    card.addEventListener('hass-update', e => updateCard(e.detail.entities));
  </script>
```

### Mermaid flow chart example

```yaml
type: custom:html-js-card
height: 300px
scripts:
  - https://cdnjs.cloudflare.com/ajax/libs/mermaid/10.9.1/mermaid.min.js
content: |
  <div id="diagram" style="width:100%;overflow:auto;"></div>
  <script>
    mermaid.initialize({ startOnLoad: false, theme: 'neutral' });
    const def = `flowchart LR
      Solar([☀️ Solare]) --> Battery[(🔋 Batteria)]
      Solar --> House([🏠 Casa])
      Battery --> House
      Grid([⚡ Rete]) --> House`;
    mermaid.render('mmd', def).then(({ svg }) => {
      card.querySelector('#diagram').innerHTML = svg;
    });
  </script>
```

## Rules when generating HTML-JS Card YAML

1. ALWAYS use `type: custom:html-js-card`.
2. List in `entities:` ONLY the entities needed — these become available in the `entities` variable.
3. For external libraries (Chart.js, D3, ApexCharts, Mermaid, etc.) use `scripts:` — they load once and are deduplicated.
4. Always save `window._hjc_hass = hass` if click handlers or async callbacks need to call services.
5. Always listen to `hass-update` for reactive updates — do NOT use `setInterval` to poll HA state.
6. Use HA CSS variables (`var(--primary-color)`, etc.) for proper dark/light mode support.
7. Put CSS in a `<style>` block inside `content`, not inline where avoidable.
8. Always show the complete YAML in a ```yaml code block.
9. Use only entity IDs provided in the DATA/CONTEXT block injected into the message — never invent or guess entity IDs. If no entity data is available, ask the user to specify them.
10. If the user wants a chart, use Chart.js via CDN unless they specify another library.
11. ALWAYS use `card.querySelector('#id')` to access DOM elements — NEVER `document.getElementById`.
12. For energy flow diagrams, animated gauges, or custom shapes: prefer inline SVG — no external library needed.
13. For flow charts and diagrams: use Mermaid.js via `scripts:`.
14. For D3.js: use `https://cdnjs.cloudflare.com/ajax/libs/d3/7.9.0/d3.min.js` via `scripts:`.
15. Every selector used in JavaScript MUST correspond to a real element in the generated HTML/SVG (`id`/class consistency is mandatory).
16. For DOM operations that may fail (`classList`, `style`, `getContext`, `innerHTML`, `textContent`), use null-safe access (`?.`) or guard clauses.
17. Never hardcode a different selector name than the generated markup (example: do not call `#line-home` if only `#line-bat` exists).

## ❌ NEVER do this (common mistakes that break the card)

### Wrong: `document.getElementById` inside shadow DOM
```javascript
// ❌ WRONG — document.getElementById returns null inside the card (shadow DOM)
document.getElementById('solar').textContent = '…';
document.getElementById('btn').addEventListener('click', () => { … });
const ctx = document.getElementById('chart').getContext('2d');
```
```javascript
// ✅ CORRECT — use card.querySelector('#id')
card.querySelector('#solar').textContent = '…';
card.querySelector('#btn').addEventListener('click', () => { … });
const ctx = card.querySelector('#chart').getContext('2d');
```

### Wrong: selector mismatch causes `... is null`
```javascript
// ❌ WRONG — '#line-home' does not exist in SVG, querySelector returns null
card.querySelector('#line-home').classList.toggle('active-home', home > 10);
```
```javascript
// ✅ CORRECT — selector matches real element and is null-safe
const lineBat = card.querySelector('#line-bat');
if (lineBat) lineBat.classList.toggle('active-home', home > 10);
```

### Wrong: `states` variable does not exist
```javascript
// ❌ WRONG — 'states' is undefined in HTML-JS Card
const v = states['sensor.temperature'];
const v = states[eid];
```
```javascript
// ✅ CORRECT — use 'entities' (only listed entities) or 'hass.states' (any entity)
const v = entities['sensor.temperature'];
const v = hass.states['sensor.temperature'];
```

### Wrong: loading CDN scripts inside `content:`
```yaml
# ❌ WRONG — <script src> inside content is blocked by HA Content Security Policy
content: |
  <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
  ...
```
```yaml
# ✅ CORRECT — use the top-level scripts: key
scripts:
  - https://cdnjs.cloudflare.com/ajax/libs/Chart.js/3.9.1/chart.min.js
content: |
  ...
```

### Wrong: calling update once without hass-update listener
```javascript
// ❌ WRONG — updateCard() is called once at startup, then never again
function updateCard() { ... }
updateCard();
```
```javascript
// ✅ CORRECT — call on load AND on every hass-update event
function updateCard(ents) { ... }
updateCard(entities); // initial render
card.addEventListener('hass-update', (e) => updateCard(e.detail.entities));
```

### `js:` field — supported, equivalent to `<script>` in `content:`

The `js:` field **is supported** — JavaScript runs after `content:` HTML is injected, with the same variables (`hass`, `entities`, `card`, `config`, `shadow`, `moreInfo`). Use whichever style keeps the YAML more readable.

```yaml
# ✅ CORRECT — js: field (HTML and JS separated)
type: custom:html-js-card
content: |
  <div id="root">Loading...</div>
js: |
  window._hjc_hass = hass;

  function updateCard(ents) {
    card.querySelector('#root').textContent = ents['sensor.x']?.state || '—';
  }

  updateCard(entities);
  card.addEventListener('hass-update', (e) => {
    window._hjc_hass = e.detail.hass;
    updateCard(e.detail.entities);
  });
```
```yaml
# ✅ ALSO CORRECT — <script> inside content: (equivalent)
type: custom:html-js-card
content: |
  <div id="root">Loading...</div>
  <script>
    window._hjc_hass = hass;

    function updateCard(ents) {
      card.querySelector('#root').textContent = ents['sensor.x']?.state || '—';
    }

    updateCard(entities);
    card.addEventListener('hass-update', (e) => {
      window._hjc_hass = e.detail.hass;
      updateCard(e.detail.entities);
    });
  </script>
```

> ⚠️ In `js:`, never use `this.hass` or IIFE with `this` — `hass`, `entities`, `card` are injected directly.

### Wrong: `this.hass`, `this.querySelector`, or IIFE with `this`
```javascript
// ❌ WRONG — `this` is not the card context; these all return undefined/null
const hass = this.hass;
const el = this.querySelector('#root');
(function() { const hass = this.hass; ... })();
```
```javascript
// ✅ CORRECT — use the injected variables directly
const state = hass.states['sensor.x']?.state;
const el = card.querySelector('#root');
```

### Wrong: inline onclick functions not exposed on `window`
```javascript
// ❌ WRONG — local function is not accessible from inline HTML onclick in shadow DOM
function doAction() { ... }
// <button onclick="doAction()"> → ReferenceError: doAction is not defined
```
```javascript
// ✅ CORRECT — expose on window so inline onclick can find it
window.doAction = function() { window._hjc_hass.callService(...); };
// <button onclick="doAction()"> → works correctly
```

