From 89ff17a3bce565da8a54c5bc8629ab5c0e7ff254 Mon Sep 17 00:00:00 2001 From: Kaloyan Danchev Date: Tue, 27 Oct 2026 23:11:43 +0200 Subject: [PATCH] Add HA Lovelace cards replicating Luma glass UI Custom card bundle (luma-card, luma-weather-card) with slide-in panels for light/climate/cover/media/vacuum, matching the TV launcher's design tokens. Includes theme, dashboard YAML for all 8 rooms, and deployment handoff doc. Co-Authored-By: Claude Opus 4.6 --- ha-cards/HANDOFF.md | 112 ++++ ha-cards/README.md | 108 ++++ ha-cards/dashboard.yaml | 248 ++++++++ ha-cards/luma-cards.js | 1013 +++++++++++++++++++++++++++++++++ ha-cards/luma-theme.yaml | 78 +++ ha-cards/luma-weather-card.js | 207 +++++++ 6 files changed, 1766 insertions(+) create mode 100644 ha-cards/HANDOFF.md create mode 100644 ha-cards/README.md create mode 100644 ha-cards/dashboard.yaml create mode 100644 ha-cards/luma-cards.js create mode 100644 ha-cards/luma-theme.yaml create mode 100644 ha-cards/luma-weather-card.js diff --git a/ha-cards/HANDOFF.md b/ha-cards/HANDOFF.md new file mode 100644 index 0000000..693f637 --- /dev/null +++ b/ha-cards/HANDOFF.md @@ -0,0 +1,112 @@ +# Luma HA Cards — Deployment Handoff + +## What this is + +Custom Lovelace card bundle that replicates the Luma TV launcher's glass UI as a +Home Assistant dashboard. Built in a single session on 2026-10-27. + +## Files in `ha-cards/` + +| File | Size | What it does | +|------|------|-------------| +| `luma-cards.js` | 43 KB | Main card element (``) — glass cards with accent glow per domain, brightness bars, curtain SVGs, media artwork. Click opens a slide-in panel (420px, right edge) with full controls for each domain. | +| `luma-weather-card.js` | 7 KB | Weather card (``) — current conditions + 5-day forecast row. | +| `luma-theme.yaml` | 2.5 KB | HA theme (`luma_glass`) — dark glass palette, gold accent, Inter font. | +| `dashboard.yaml` | 7 KB | Full dashboard YAML — all 8 rooms from `ha-home/config.json`, native HA grid cards, no HACS dependencies. | +| `README.md` | 3 KB | Installation steps (also summarised below). | + +## Slide-in panel controls per domain + +| Domain | Panel sections | +|--------|---------------| +| **light** | Brightness slider + presets (Off/25/50/100%) + colour temp + colour swatches + effects grid | +| **climate** | Target temp + HVAC mode icons + fan auto (Auto/Quiet/Turbo) + fan manual slider + airflow buttons | +| **cover** | Position slider + Open/Stop/Close + presets (Open/60/45/30/Closed) | +| **media_player** | Now playing (art + title + artist) + progress bar + transport (prev/play-pause/next) + volume | +| **vacuum** | Status + battery + fan mode buttons + Start/Pause/Dock | +| **switch/scene/script** | No panel — click toggles directly | + +## Design token mapping + +All colours, radii, shadows, and sizes come from `design-system/tokens.json`. +Key values hardcoded in the JS `T` object: + +- Card fill: `rgba(34,38,46,0.88)`, border `rgba(255,255,255,0.10)`, radius 22 +- Panel fill: `rgba(30,34,42,0.98)`, width 420, radius 28 +- Accents: light `#F5B85A`, heat `#F06A4A`, cool `#4C9BF0`, curtain `#DCD2B4`, media `#9B7DDB`, vacuum `#60C090` + +## Deployment steps (on Unraid SSH → HA) + +### 1. Copy JS + theme files to HA + +From Unraid (which has the repo or can pull from Gitea): + +```bash +# Clone or pull on Unraid +cd /tmp && git clone https://jazzymc:@git.xtrm-lab.org/jazzymc/luma-home.git 2>/dev/null || (cd /tmp/luma-home && git pull) + +# SCP to HA (needs HA SSH addon password or key) +scp -P 22 /tmp/luma-home/ha-cards/luma-cards.js /tmp/luma-home/ha-cards/luma-weather-card.js root@192.168.10.102:/config/www/luma/ + +# Or if the HA Terminal addon is used, from inside HA: +mkdir -p /config/www/luma +# then pull files via scp from Unraid +``` + +### 2. Register Lovelace resources + +In HA → **Settings → Dashboards → Resources** (top-right ⋮ menu), add: + +| URL | Type | +|-----|------| +| `/local/luma/luma-cards.js` | JavaScript Module | +| `/local/luma/luma-weather-card.js` | JavaScript Module | + +Or in `configuration.yaml`: +```yaml +lovelace: + resources: + - url: /local/luma/luma-cards.js + type: module + - url: /local/luma/luma-weather-card.js + type: module +``` + +### 3. Install theme + +Copy theme file: +```bash +mkdir -p /config/themes +cp /tmp/luma-home/ha-cards/luma-theme.yaml /config/themes/luma_glass.yaml +``` + +Or merge into existing themes config. Then reload themes in **Developer Tools → YAML → Themes**. + +### 4. Create dashboard + +**Settings → Dashboards → Add Dashboard** → set title "Luma Home", icon `mdi:television`. +Switch to YAML mode and paste `dashboard.yaml` contents. + +Or add to `configuration.yaml`: +```yaml +lovelace: + dashboards: + luma: + mode: yaml + title: Luma Home + icon: mdi:television + show_in_sidebar: true + filename: dashboards/luma.yaml +``` +Then `cp ha-cards/dashboard.yaml /config/dashboards/luma.yaml`. + +### 5. Activate theme + +Profile (avatar) → Theme → **Luma Glass**. + +## What's NOT deployed yet (TV binary) + +The Luma TV binary with all 11 fixes from this session was compiled and is at +`/mnt/user/appdata/luma-build/target/armv7-unknown-linux-gnueabi/release/luma` on Unraid +and `/tmp/luma-deploy` on Mac. The TV (192.168.30.124) was offline during this session. +Deploy when it comes back online — see the main project README for the deploy process. diff --git a/ha-cards/README.md b/ha-cards/README.md new file mode 100644 index 0000000..e745e40 --- /dev/null +++ b/ha-cards/README.md @@ -0,0 +1,108 @@ +# Luma Home — HA Lovelace Cards + +Custom Lovelace cards that replicate the Luma TV launcher design for Home Assistant. + +## Files + +| File | Purpose | +|------|---------| +| `luma-cards.js` | Main card bundle — `luma-card` element with slide-in panels | +| `luma-weather-card.js` | Weather card with 5-day forecast | +| `luma-theme.yaml` | HA theme matching the Luma glass aesthetic | +| `dashboard.yaml` | Full dashboard config with all rooms | + +## Installation + +### 1. Copy card JS files + +Copy both JS files to your HA `www/` directory: + +```bash +scp ha-cards/luma-cards.js ha-cards/luma-weather-card.js \ + root@192.168.10.102:/config/www/luma/ +``` + +Or via the File Editor addon, create `/config/www/luma/` and upload both files. + +### 2. Register as resources + +Go to **Settings → Dashboards → Resources** (or add to `configuration.yaml`): + +```yaml +# configuration.yaml +lovelace: + resources: + - url: /local/luma/luma-cards.js + type: module + - url: /local/luma/luma-weather-card.js + type: module +``` + +### 3. Install the theme + +Add to your `themes:` section in `configuration.yaml`, or merge into an existing themes file: + +```yaml +# configuration.yaml +frontend: + themes: !include_dir_merge_named themes/ +``` + +Then copy `luma-theme.yaml` to `/config/themes/luma_glass.yaml`. + +### 4. Import the dashboard + +Go to **Settings → Dashboards → Add Dashboard**, then switch to YAML mode and paste the contents of `dashboard.yaml`. + +Or add it as a separate dashboard in `configuration.yaml`: + +```yaml +lovelace: + mode: storage + dashboards: + luma: + mode: yaml + title: Luma Home + icon: mdi:television + show_in_sidebar: true + filename: dashboards/luma.yaml +``` + +Then copy `dashboard.yaml` to `/config/dashboards/luma.yaml`. + +### 5. Activate the theme + +Go to your **Profile** (click your avatar) → **Theme** → select **Luma Glass**. + +### Dependencies + +None — uses only native HA grid cards and custom elements. No HACS cards required. + +## Card types + +### `luma-card` + +General-purpose card for lights, climate, covers, switches, scenes, media, and vacuum. + +```yaml +type: custom:luma-card +entity: light.living_room +name: Living Room # optional, defaults to friendly_name +icon: mdi:lightbulb # optional, auto-detected from domain +type_hint: range # optional, helps with icon/accent selection +``` + +Click opens a slide-in panel with full controls (brightness, color temp, color swatches, presets, effects for lights; target temp, mode, fan speed, airflow for climate; position/presets for covers; transport/volume for media; rooms/mode/controls for vacuum). + +Toggle-only entities (switches, scenes, scripts, lights without color modes) toggle on click instead of opening a panel. + +### `luma-weather-card` + +```yaml +type: custom:luma-weather-card +entity: weather.forecast_home +``` + +## Customization + +All colors are defined in the JS files' `T` (tokens) object and in the theme YAML. Adjust there to match your preference. diff --git a/ha-cards/dashboard.yaml b/ha-cards/dashboard.yaml new file mode 100644 index 0000000..9950ae1 --- /dev/null +++ b/ha-cards/dashboard.yaml @@ -0,0 +1,248 @@ +title: Luma Home +views: + # ── Home ────────────────────────────────────────────── + - title: Home + path: home + cards: + - type: grid + columns: 3 + square: false + cards: + - type: custom:luma-weather-card + entity: weather.forecast_home + + - type: custom:luma-card + entity: media_player.music_assistant + name: Music + icon: mdi:music + type_hint: media + + - type: custom:luma-card + entity: vacuum.living_room_roborock_s7_pro_ultra + name: Roborock + icon: mdi:robot-vacuum + type_hint: vacuum + + # ── Living Area ─────────────────────────────────────── + - title: Living Area + path: living-area + cards: + - type: grid + columns: 3 + square: false + cards: + # Climate + - type: custom:luma-card + entity: climate.living_room_thermostat + name: Thermostat + + - type: custom:luma-card + entity: climate.living_room_radiator + name: Radiator + + # Lights + - type: custom:luma-card + entity: light.living_room_light + name: Ceiling Light + + - type: custom:luma-card + entity: light.tv_lights + name: TV Lights + + - type: custom:luma-card + entity: light.rgbic_strip_lights_z1 + name: LED Strip 1 + + - type: custom:luma-card + entity: light.rgbic_strip_lights_z2 + name: LED Strip 2 + + - type: custom:luma-card + entity: light.picture_frame_lamp + name: Picture Lamp + + # Curtain + - type: custom:luma-card + entity: cover.living_room_curtain + name: Curtain + icon: mdi:curtains + type_hint: curtain + + # More lights + - type: custom:luma-card + entity: light.dining_room_light + name: Dining Light + + - type: custom:luma-card + entity: light.entrance_light_1 + name: Entrance + + - type: custom:luma-card + entity: light.entrance_mirror_light + name: Mirror + + # Scenes & Actions row + - type: grid + columns: 4 + square: false + cards: + - type: custom:luma-card + entity: scene.tv_mode + name: TV Mode + icon: mdi:television + type_hint: action + + - type: custom:luma-card + entity: scene.living_room_100 + name: "100%" + icon: mdi:brightness-7 + type_hint: action + + - type: custom:luma-card + entity: scene.living_room_lights_10 + name: "10%" + icon: mdi:brightness-4 + type_hint: action + + - type: custom:luma-card + entity: script.tv_ambient_on + name: Ambient On + icon: mdi:led-strip-variant + type_hint: action + + - type: custom:luma-card + entity: script.tv_ambient_off + name: Ambient Off + icon: mdi:led-strip-variant + type_hint: action + + - type: custom:luma-card + entity: script.living_dining_all_off + name: All Off + icon: mdi:lightbulb-off + type_hint: action + + - type: custom:luma-card + entity: scene.curtains_close + name: Close Curtains + icon: mdi:curtains-closed + type_hint: action + + # ── Kitchen ─────────────────────────────────────────── + - title: Kitchen + path: kitchen + cards: + - type: grid + columns: 3 + square: false + cards: + - type: custom:luma-card + entity: climate.kitchen_termostat + name: Thermostat + + - type: custom:luma-card + entity: switch.dishwasher_power + name: Dishwasher + icon: mdi:dishwasher + + # ── Dad's Bedroom ───────────────────────────────────── + - title: Dad's Bedroom + path: bedroom + cards: + - type: grid + columns: 3 + square: false + cards: + - type: custom:luma-card + entity: climate.main_bedroom_radiator + name: Radiator + + - type: custom:luma-card + entity: light.bedroom_ceiling_light + name: Ceiling + + - type: custom:luma-card + entity: light.bedroom_desk_lights + name: Desk + + - type: custom:luma-card + entity: light.bedroom_led_strip + name: LED Strip + + - type: custom:luma-card + entity: scene.desk_mac + name: "Desk: Mac" + icon: mdi:apple + type_hint: action + + - type: custom:luma-card + entity: scene.desk_pc + name: "Desk: PC" + icon: mdi:desktop-tower-monitor + type_hint: action + + # ── Dari's Bedroom ──────────────────────────────────── + - title: Dari's Bedroom + path: dari-bedroom + cards: + - type: grid + columns: 3 + square: false + cards: + - type: custom:luma-card + entity: climate.girls_bedroom_radiator + name: Radiator + + # ── Teo's Bedroom ───────────────────────────────────── + - title: Teo's Bedroom + path: teo-bedroom + cards: + - type: grid + columns: 3 + square: false + cards: + - type: custom:luma-card + entity: light.boys_smartlight + name: Light + + # ── Main Bathroom ───────────────────────────────────── + - title: Main Bathroom + path: main-bathroom + cards: + - type: grid + columns: 3 + square: false + cards: + - type: custom:luma-card + entity: light.bathroom_light_1 + name: Light 1 + + - type: custom:luma-card + entity: light.bathroom_light_2 + name: Light 2 + + # ── Kids Bathroom ───────────────────────────────────── + - title: Kids Bathroom + path: kids-bathroom + cards: + - type: grid + columns: 3 + square: false + cards: + - type: custom:luma-card + entity: light.kids_bathroom_light_1 + name: Light 1 + + - type: custom:luma-card + entity: light.kids_bathroom_light_2 + name: Light 2 + + - type: custom:luma-card + entity: switch.washing_machine_power + name: Washing Machine + icon: mdi:washing-machine + + - type: custom:luma-card + entity: switch.dryer_power + name: Dryer + icon: mdi:tumble-dryer diff --git a/ha-cards/luma-cards.js b/ha-cards/luma-cards.js new file mode 100644 index 0000000..7d5fe56 --- /dev/null +++ b/ha-cards/luma-cards.js @@ -0,0 +1,1013 @@ +/** + * Luma Home — Custom Lovelace Cards + * Replicates the 10-foot Luma TV UI as HA dashboard cards. + * Cards: luma-card, luma-room-card + * Panels: slide-in from right, matching Luma panel design. + */ + +const LUMA_VERSION = '1.0.0'; + +/* ── Design tokens (from design-system/tokens.json) ──────────── */ +const T = { + card: { + fill: 'rgba(34,38,46,0.88)', + border: 'rgba(255,255,255,0.10)', + radius: 22, + padding: 18, + iconOff: 'rgba(255,255,255,0.10)', + barTrack: 'rgba(255,255,255,0.14)', + }, + panel: { + fill: 'rgba(30,34,42,0.98)', + width: 420, + radius: 28, + control: 'rgba(255,255,255,0.10)', + label: 'rgba(255,255,255,0.72)', + }, + accent: { + light: '#F5B85A', + heat: '#F06A4A', + cool: '#4C9BF0', + switch: '#7EB8DA', + curtain: '#DCD2B4', + led: '#E07A5F', + media: '#9B7DDB', + vacuum: '#60C090', + warning: '#F2B84B', + }, + shadow: { + card: '0 2px 8px rgba(0,0,0,0.25)', + panel: '0 24px 60px rgba(0,0,0,0.50)', + }, +}; + +/* ── Helpers ───────────────────────────────────────────────────── */ +function accentForDomain(entity_id, state) { + if (!entity_id) return T.accent.switch; + const d = entity_id.split('.')[0]; + if (d === 'light') return T.accent.light; + if (d === 'climate') { + const s = state?.state; + if (s === 'heat') return T.accent.heat; + if (s === 'cool') return T.accent.cool; + return T.accent.heat; + } + if (d === 'cover') return T.accent.curtain; + if (d === 'media_player') return T.accent.media; + if (d === 'vacuum') return T.accent.vacuum; + if (d === 'switch') return T.accent.switch; + return T.accent.switch; +} + +function iconForEntity(entity_id, type, stateObj) { + if (type === 'weather') return 'mdi:weather-partly-cloudy'; + if (type === 'media') return 'mdi:music'; + if (type === 'vacuum') return 'mdi:robot-vacuum'; + if (type === 'curtain') return 'mdi:curtains'; + if (type === 'action') return 'mdi:play-circle-outline'; + const d = entity_id?.split('.')[0]; + if (d === 'light') return 'mdi:lightbulb'; + if (d === 'climate') return 'mdi:thermometer'; + if (d === 'switch') return 'mdi:power-plug'; + if (d === 'cover') return 'mdi:curtains'; + return 'mdi:toggle-switch'; +} + +function isOn(stateObj) { + if (!stateObj) return false; + const s = stateObj.state; + return s === 'on' || s === 'heat' || s === 'cool' || s === 'auto' || + s === 'heat_cool' || s === 'dry' || s === 'fan_only' || + s === 'playing' || s === 'cleaning' || s === 'open' || s === 'opening'; +} + +function hexToRgb(hex) { + hex = hex.replace('#', ''); + return { + r: parseInt(hex.slice(0, 2), 16), + g: parseInt(hex.slice(2, 4), 16), + b: parseInt(hex.slice(4, 6), 16), + }; +} + +/* ── Panel overlay (singleton) ─────────────────────────────────── */ +let panelOverlay = null; + +function closeLumaPanel() { + if (!panelOverlay) return; + panelOverlay.classList.remove('open'); + setTimeout(() => { + panelOverlay.remove(); + panelOverlay = null; + }, 250); +} + +function openLumaPanel(hass, entityId, cardType, cardName) { + closeLumaPanel(); + const stateObj = hass.states[entityId]; + if (!stateObj) return; + + const accent = accentForDomain(entityId, stateObj); + const rgb = hexToRgb(accent); + + const overlay = document.createElement('div'); + overlay.className = 'luma-panel-overlay'; + overlay.innerHTML = ` +
+
+
+
+ `; + overlay.querySelector('.luma-panel-backdrop').addEventListener('click', closeLumaPanel); + document.body.appendChild(overlay); + panelOverlay = overlay; + + const inner = overlay.querySelector('.luma-panel-inner'); + const domain = entityId.split('.')[0]; + + // Header + const friendly = stateObj.attributes.friendly_name || cardName || entityId; + const on = isOn(stateObj); + inner.innerHTML = buildPanelHeader(friendly, stateObj, accent, rgb, cardType, domain) + + buildPanelSections(hass, entityId, stateObj, cardType, domain, accent, rgb); + + // Wire up controls + wirePanel(inner, hass, entityId, stateObj, cardType, domain, accent, rgb); + + requestAnimationFrame(() => overlay.classList.add('open')); +} + +function buildPanelHeader(name, stateObj, accent, rgb, cardType, domain) { + const on = isOn(stateObj); + const iconBg = on ? accent : 'rgba(255,255,255,0.10)'; + const iconColor = on ? '#15181E' : 'rgba(255,255,255,0.80)'; + let stateLine = stateObj.state; + if (domain === 'climate') { + const cur = stateObj.attributes.current_temperature; + const mode = stateObj.state; + const label = { heat: 'Heating', cool: 'Cooling', auto: 'Auto', dry: 'Dry', fan_only: 'Fan', off: 'Off' }[mode] || mode; + stateLine = cur ? `${label} · ${cur}° inside` : label; + } else if (domain === 'light') { + const bri = stateObj.attributes.brightness; + if (on && bri !== undefined) { + stateLine = `${Math.round(bri / 255 * 100)}%`; + } else { + stateLine = on ? 'On' : 'Off'; + } + } else if (domain === 'cover') { + const pos = stateObj.attributes.current_position; + stateLine = pos !== undefined ? `${pos}%` : stateObj.state; + } else if (domain === 'media_player') { + const title = stateObj.attributes.media_title; + stateLine = title ? `Playing · ${title}` : stateObj.state; + } + const icon = iconForEntity(stateObj.entity_id, cardType, stateObj); + return ` +
+
+ +
+
${name}
+
${stateLine}
+
+ `; +} + +function buildPanelSections(hass, entityId, stateObj, cardType, domain, accent, rgb) { + if (domain === 'light') return buildLightPanel(hass, entityId, stateObj, accent, rgb); + if (domain === 'climate') return buildClimatePanel(hass, entityId, stateObj, accent, rgb, cardType); + if (domain === 'cover') return buildCoverPanel(hass, entityId, stateObj, accent, rgb); + if (domain === 'media_player') return buildMediaPanel(hass, entityId, stateObj, accent, rgb); + if (domain === 'vacuum') return buildVacuumPanel(hass, entityId, stateObj, accent, rgb); + return `
${stateObj.state}
`; +} + +/* ── Light panel ──────────────────────────────────────────────── */ +function buildLightPanel(hass, entityId, stateObj, accent, rgb) { + const bri = stateObj.attributes.brightness || 0; + const pct = Math.round(bri / 255 * 100); + const on = isOn(stateObj); + const modes = stateObj.attributes.supported_color_modes || []; + const hasColorTemp = modes.includes('color_temp'); + const hasColor = modes.includes('hs') || modes.includes('rgb') || modes.includes('xy'); + const hasEffects = (stateObj.attributes.effect_list || []).length > 0; + + let html = ` +
+ +
+ +
${pct}%
+
+
+
+ +
+ + + + +
+
+ `; + + if (hasColorTemp) { + const ct = stateObj.attributes.color_temp_kelvin || 4000; + const minK = stateObj.attributes.min_color_temp_kelvin || 2000; + const maxK = stateObj.attributes.max_color_temp_kelvin || 6500; + const ctPct = Math.round((ct - minK) / (maxK - minK) * 100); + html += ` +
+ +
+ +
${ct} K
+
+
+ `; + } + + if (hasColor) { + const swatches = ['#FF5A40','#F0A050','#F2D04B','#3DDC97','#4C9BF0','#7B5BE0','#E45AD8','#FFFFFF']; + html += ` +
+ +
+ ${swatches.map(c => ``).join('')} +
+
+ `; + } + + if (hasEffects) { + const effects = stateObj.attributes.effect_list || []; + const cur = stateObj.attributes.effect || ''; + html += ` +
+ +
+ ${effects.slice(0, 12).map(e => ``).join('')} +
+
+ `; + } + + return html; +} + +/* ── Climate panel ────────────────────────────────────────────── */ +function buildClimatePanel(hass, entityId, stateObj, accent, rgb, cardType) { + const target = stateObj.attributes.temperature || 22; + const min = stateObj.attributes.min_temp || 16; + const max = stateObj.attributes.max_temp || 30; + const pct = Math.round((target - min) / (max - min) * 100); + const modes = (stateObj.attributes.hvac_modes || []).filter(m => m !== 'off'); + const curMode = stateObj.state; + const fanModes = stateObj.attributes.fan_modes || []; + const curFan = stateObj.attributes.fan_mode || 'auto'; + const swingModes = stateObj.attributes.swing_modes || []; + const curSwing = stateObj.attributes.swing_mode || 'off'; + + const modeIcons = { heat: 'mdi:fire', cool: 'mdi:snowflake', auto: 'mdi:autorenew', heat_cool: 'mdi:autorenew', dry: 'mdi:water-percent', fan_only: 'mdi:fan' }; + const modeLabels = { heat: 'Heat', cool: 'Cool', auto: 'Auto', heat_cool: 'Auto', dry: 'Dry', fan_only: 'Fan' }; + + let html = ` +
+ +
+ +
${target}°
+
+
${min}° – ${max}°
+
+ `; + + if (modes.length > 0) { + html += ` +
+ +
+ ${modes.map(m => { + const mAccent = m === 'heat' || m === 'heat_cool' || m === 'auto' ? T.accent.heat : + m === 'cool' ? T.accent.cool : + m === 'dry' ? '#e0c060' : m === 'fan_only' ? '#80d0a0' : '#bbb'; + return ``; + }).join('')} +
+
+ `; + } + + // Fan speed auto + if (fanModes.length > 0) { + const autoModes = [['Auto', 'auto'], ['Quiet', 'low'], ['Turbo', 'high']].filter( + ([, v]) => fanModes.includes(v) + ); + if (autoModes.length > 0) { + html += ` +
+ +
+ ${autoModes.map(([label, val]) => ``).join('')} +
+
+ `; + } + + // Fan speed manual + const manualModes = ['low', 'medium-low', 'medium', 'medium-high', 'high'].filter(m => fanModes.includes(m)); + if (manualModes.length >= 2) { + const idx = manualModes.indexOf(curFan); + const fanPct = idx >= 0 ? Math.round(idx / (manualModes.length - 1) * 100) : 50; + html += ` +
+ +
+ +
${idx >= 0 ? idx + 1 : '·'}
+
+
1 — ${manualModes.length}
+
+ `; + } + } + + // Airflow + if (swingModes.length > 0) { + const swingLabels = { off: 'Off', default: 'Off', vertical: 'Swing', horizontal: 'Horiz.', both: 'Both', swing_ud: 'Swing', swing: 'Swing' }; + html += ` +
+ +
+ ${swingModes.map(m => { + const label = swingLabels[m] || (m.startsWith('fixed_') ? `Pos ${m.split('_')[1]}` : m); + return ``; + }).join('')} +
+
+ `; + } + + return html; +} + +/* ── Cover panel ──────────────────────────────────────────────── */ +function buildCoverPanel(hass, entityId, stateObj, accent, rgb) { + const pos = stateObj.attributes.current_position ?? 0; + const presets = [['Open', 100], ['60%', 60], ['45%', 45], ['30%', 30], ['Closed', 0]]; + + return ` +
+ +
+ +
${pos}%
+
+
+
+ +
+ + + +
+
+
+ +
+ ${presets.map(([l, v]) => ``).join('')} +
+
+ `; +} + +/* ── Media panel ──────────────────────────────────────────────── */ +function buildMediaPanel(hass, entityId, stateObj, accent, rgb) { + const title = stateObj.attributes.media_title || ''; + const artist = stateObj.attributes.media_artist || ''; + const artUrl = stateObj.attributes.entity_picture; + const isPlaying = stateObj.state === 'playing'; + const vol = Math.round((stateObj.attributes.volume_level || 0) * 100); + const duration = stateObj.attributes.media_duration || 0; + const position = stateObj.attributes.media_position || 0; + const progress = duration > 0 ? Math.round(position / duration * 100) : 0; + const fmt = s => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`; + + return ` +
+ ${artUrl ? `` : '
'} +
+
${title || 'Nothing playing'}
+
${artist}
+
+
+ ${duration > 0 ? ` +
+
+
${fmt(position)}-${fmt(Math.max(0, duration - position))}
+
+ ` : ''} +
+
+ + + +
+
+
+ +
+ +
${vol}%
+
+
+ `; +} + +/* ── Vacuum panel ─────────────────────────────────────────────── */ +function buildVacuumPanel(hass, entityId, stateObj, accent, rgb) { + const status = stateObj.state; + const battery = stateObj.attributes.battery_level; + const fan = stateObj.attributes.fan_speed || ''; + const fanModes = stateObj.attributes.fan_speed_list || []; + + return ` +
+ +
+ ${status.charAt(0).toUpperCase() + status.slice(1)} + ${battery !== undefined ? ` · ${battery}%` : ''} +
+
+ ${fanModes.length > 0 ? ` +
+ +
+ ${fanModes.map(m => ``).join('')} +
+
+ ` : ''} +
+ +
+ + + +
+
+ `; +} + +/* ── Wire panel interactions ──────────────────────────────────── */ +function wirePanel(inner, hass, entityId, stateObj, cardType, domain, accent, rgb) { + // Sliders + inner.querySelectorAll('.panel-slider').forEach(slider => { + const action = slider.dataset.action; + const valueEl = slider.closest('.panel-slider-wrap')?.querySelector('.panel-slider-value'); + slider.addEventListener('input', () => { + const v = parseFloat(slider.value); + const min = parseFloat(slider.min); + const max = parseFloat(slider.max); + const pct = Math.round((v - min) / (max - min) * 100); + slider.style.setProperty('--fill', pct + '%'); + if (action === 'brightness') { if (valueEl) valueEl.textContent = Math.round(v / 255 * 100) + '%'; } + else if (action === 'temperature') { if (valueEl) valueEl.textContent = v + '°'; } + else if (action === 'color_temp') { if (valueEl) valueEl.textContent = v + ' K'; } + else if (action === 'cover_position') { if (valueEl) valueEl.textContent = v + '%'; } + else if (action === 'volume') { if (valueEl) valueEl.textContent = v + '%'; } + else if (action === 'fan_manual') { if (valueEl) valueEl.textContent = (v + 1); } + }); + let debounce = null; + slider.addEventListener('change', () => { + clearTimeout(debounce); + debounce = setTimeout(() => { + const v = parseFloat(slider.value); + if (action === 'brightness') { + hass.callService('light', 'turn_on', { entity_id: entityId, brightness: v }); + } else if (action === 'temperature') { + hass.callService('climate', 'set_temperature', { entity_id: entityId, temperature: v }); + } else if (action === 'color_temp') { + hass.callService('light', 'turn_on', { entity_id: entityId, color_temp_kelvin: v }); + } else if (action === 'cover_position') { + hass.callService('cover', 'set_cover_position', { entity_id: entityId, position: v }); + } else if (action === 'volume') { + hass.callService('media_player', 'volume_set', { entity_id: entityId, volume_level: v / 100 }); + } else if (action === 'fan_manual') { + const modes = slider.dataset.modes.split(','); + hass.callService('climate', 'set_fan_mode', { entity_id: entityId, fan_mode: modes[v] }); + } + }, 200); + }); + }); + + // Buttons + inner.querySelectorAll('[data-action]').forEach(btn => { + if (btn.tagName === 'INPUT') return; + btn.addEventListener('click', () => { + const action = btn.dataset.action; + const value = btn.dataset.value; + if (action === 'preset') { + if (value === 'off') { hass.callService('light', 'turn_off', { entity_id: entityId }); } + else { hass.callService('light', 'turn_on', { entity_id: entityId, brightness_pct: parseInt(value) }); } + } else if (action === 'hvac_mode') { + hass.callService('climate', 'set_hvac_mode', { entity_id: entityId, hvac_mode: value }); + } else if (action === 'fan_mode') { + hass.callService('climate', 'set_fan_mode', { entity_id: entityId, fan_mode: value }); + } else if (action === 'swing_mode') { + hass.callService('climate', 'set_swing_mode', { entity_id: entityId, swing_mode: value }); + } else if (action === 'color') { + const h = hexToRgb(value); + hass.callService('light', 'turn_on', { entity_id: entityId, rgb_color: [h.r, h.g, h.b] }); + } else if (action === 'effect') { + hass.callService('light', 'turn_on', { entity_id: entityId, effect: value }); + } else if (action === 'cover_cmd') { + const svc = { open: 'open_cover', close: 'close_cover', stop: 'stop_cover' }[value]; + if (svc) hass.callService('cover', svc, { entity_id: entityId }); + } else if (action === 'cover_position_btn') { + hass.callService('cover', 'set_cover_position', { entity_id: entityId, position: parseInt(value) }); + } else if (action === 'media_cmd') { + const svcMap = { play: 'media_play', pause: 'media_pause', previous: 'media_previous_track', next: 'media_next_track' }; + hass.callService('media_player', svcMap[value] || value, { entity_id: entityId }); + } else if (action === 'vacuum_cmd') { + hass.callService('vacuum', value, { entity_id: entityId }); + } else if (action === 'vacuum_fan') { + hass.callService('vacuum', 'set_fan_speed', { entity_id: entityId, fan_speed: value }); + } + // Update active states after a delay + setTimeout(() => { + if (panelOverlay) { + const freshState = hass.states[entityId]; + if (freshState) { + const inner2 = panelOverlay.querySelector('.luma-panel-inner'); + if (inner2) { + inner2.innerHTML = buildPanelHeader(freshState.attributes.friendly_name || entityId, freshState, accent, rgb, cardType, domain) + + buildPanelSections(hass, entityId, freshState, cardType, domain, accent, rgb); + wirePanel(inner2, hass, entityId, freshState, cardType, domain, accent, rgb); + } + } + } + }, 600); + }); + }); +} + + +/* ── luma-card custom element ─────────────────────────────────── */ +class LumaCard extends HTMLElement { + set hass(hass) { + this._hass = hass; + if (!this._config) return; + this._render(); + } + + setConfig(config) { + if (!config.entity) throw new Error('Missing entity'); + this._config = config; + } + + _render() { + const config = this._config; + const hass = this._hass; + const entityId = config.entity; + const stateObj = hass.states[entityId]; + if (!stateObj) { + this.innerHTML = `
Entity not found: ${entityId}
`; + return; + } + + const cardType = config.type_hint || entityId.split('.')[0]; + const on = isOn(stateObj); + const accent = accentForDomain(entityId, stateObj); + const rgb = hexToRgb(accent); + const domain = entityId.split('.')[0]; + const name = config.name || stateObj.attributes.friendly_name || entityId; + const icon = config.icon || iconForEntity(entityId, cardType, stateObj); + + // Value text + let value = ''; + if (domain === 'light') { + const bri = stateObj.attributes.brightness; + value = on ? (bri !== undefined ? Math.round(bri / 255 * 100) + '%' : 'On') : 'Off'; + } else if (domain === 'climate') { + const t = stateObj.attributes.temperature; + const cur = stateObj.attributes.current_temperature; + value = on ? `${t || '–'}°` : 'Off'; + } else if (domain === 'cover') { + const pos = stateObj.attributes.current_position; + value = pos >= 99 ? 'Open' : pos < 1 ? 'Closed' : `${pos}%`; + } else if (domain === 'media_player') { + value = stateObj.state === 'playing' ? '▶' : stateObj.state === 'paused' ? '❚❚' : 'Off'; + } else if (domain === 'vacuum') { + value = stateObj.state.charAt(0).toUpperCase() + stateObj.state.slice(1); + } else if (domain === 'switch') { + value = on ? 'On' : 'Off'; + } else if (domain === 'scene' || domain === 'script') { + value = ''; + } else { + value = stateObj.state; + } + + // Subtitle + let subtitle = ''; + if (domain === 'light' && on) { + const cm = stateObj.attributes.color_mode; + if (cm === 'color_temp') { + const k = stateObj.attributes.color_temp_kelvin; + subtitle = k ? `White · ${k} K` : ''; + } else if (['hs', 'rgb', 'xy'].includes(cm)) { + subtitle = 'Colour'; + } else { + const bri = stateObj.attributes.brightness; + subtitle = bri !== undefined ? `On · ${Math.round(bri / 255 * 100)}%` : 'On'; + } + } else if (domain === 'climate') { + const cur = stateObj.attributes.current_temperature; + const mode = { heat: 'Heating', cool: 'Cooling', auto: 'Auto', dry: 'Dry', fan_only: 'Fan', off: 'Off' }[stateObj.state] || stateObj.state; + subtitle = cur ? `${mode} · ${cur}°` : mode; + } else if (domain === 'media_player' && stateObj.state === 'playing') { + subtitle = stateObj.attributes.media_artist || ''; + } else if (domain === 'vacuum') { + const batt = stateObj.attributes.battery_level; + subtitle = batt !== undefined ? `Battery ${batt}%` : ''; + } + + // Middle band + let middleBand = ''; + if (domain === 'light' && on) { + const bri = stateObj.attributes.brightness || 0; + const pct = Math.round(bri / 255 * 100); + middleBand = `
`; + } else if (domain === 'cover') { + const pos = stateObj.attributes.current_position ?? 0; + middleBand = buildCurtainSvg(pos, on, accent); + } else if (domain === 'climate') { + middleBand = `
`; + } else if (domain === 'media_player') { + const artUrl = stateObj.attributes.entity_picture; + if (artUrl) middleBand = `
`; + } + + const glowStyle = on ? `background: radial-gradient(circle at 42px 42px, rgba(${rgb.r},${rgb.g},${rgb.b},0.35), transparent 72%);` : ''; + + if (!this.shadowRoot) { + this.attachShadow({ mode: 'open' }); + const style = document.createElement('style'); + style.textContent = CARD_STYLES; + this.shadowRoot.appendChild(style); + const wrap = document.createElement('div'); + wrap.className = 'luma-card-wrap'; + this.shadowRoot.appendChild(wrap); + } + + const isToggle = domain === 'switch' || domain === 'scene' || domain === 'script' || + (domain === 'light' && !stateObj.attributes.supported_color_modes?.length); + const wrap = this.shadowRoot.querySelector('.luma-card-wrap'); + wrap.innerHTML = ` +
+
+
+ +
+
${name}
+
${value}
+
+ ${middleBand ? `
${middleBand}
` : '
'} + ${subtitle ? `
${subtitle}
` : ''} +
+ `; + + // Click handler + wrap.onclick = () => { + if (isToggle) { + const svc = domain === 'scene' ? 'scene' : domain === 'script' ? 'script' : domain; + const action = domain === 'scene' ? 'turn_on' : domain === 'script' ? 'turn_on' : 'toggle'; + hass.callService(svc, action, { entity_id: entityId }); + } else { + openLumaPanel(hass, entityId, cardType, name); + } + }; + } + + getCardSize() { return 3; } + + static getConfigElement() { return document.createElement('div'); } + static getStubConfig() { return { entity: '' }; } +} + +function buildCurtainSvg(pos, isOpen, accent) { + const openFrac = pos / 100; + const halfW = 86; + const curtainW = Math.round(halfW * (1 - openFrac)); + const rgb = hexToRgb(accent); + const curtainColor = isOpen ? `rgba(${rgb.r},${rgb.g},${rgb.b},0.50)` : 'rgba(255,255,255,0.18)'; + const edgeColor = isOpen ? accent : 'rgba(255,255,255,0.30)'; + return ` + + + + + + ${curtainW > 1 ? ` + ` : ''} + ${curtainW > 1 ? ` + ` : ''} + + `; +} + + +/* ── CSS ─────────────────────────────────────────────────────── */ +const CARD_STYLES = ` + :host { + --luma-card-fill: ${T.card.fill}; + --luma-card-border: ${T.card.border}; + --luma-card-radius: ${T.card.radius}px; + display: block; + } + .luma-card-wrap { cursor: pointer; } + .luma-card { + position: relative; + background: var(--luma-card-fill); + border: 2px solid var(--luma-card-border); + border-radius: var(--luma-card-radius); + padding: 18px; + min-height: 140px; + box-shadow: ${T.shadow.card}; + transition: transform 120ms ease-out, box-shadow 120ms ease-out; + overflow: hidden; + display: flex; + flex-direction: column; + box-sizing: border-box; + } + .luma-card:hover { + transform: scale(1.02); + box-shadow: 0 8px 24px rgba(0,0,0,0.35); + border-color: rgba(255,255,255,0.20); + } + .luma-card:active { transform: scale(0.98); } + .luma-top-row { + display: flex; + align-items: center; + gap: 14px; + } + .luma-icon-circle { + width: 48px; height: 48px; border-radius: 50%; + display: flex; align-items: center; justify-content: center; + flex-shrink: 0; + } + .luma-name { + flex: 1; font-size: 19px; font-weight: 600; color: #fff; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + letter-spacing: -0.02em; + } + .luma-value { + font-size: 20px; font-weight: 700; white-space: nowrap; + letter-spacing: -0.02em; + } + .luma-middle { margin: 12px 0 10px; flex: 1; display: flex; align-items: center; } + .luma-middle-empty { flex: 1; min-height: 12px; } + .luma-bar { + width: 100%; height: 12px; border-radius: 6px; + background: ${T.card.barTrack}; + } + .luma-bar-fill { + height: 100%; border-radius: 6px; + transition: width 300ms ease; + } + .luma-subtitle { + font-size: 14px; font-weight: 500; color: rgba(255,255,255,0.72); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + } + .luma-curtain-svg { width: 100%; height: auto; } + .luma-media-band { display: flex; align-items: center; gap: 12px; } + .luma-media-art { + width: 64px; height: 64px; border-radius: 8px; object-fit: cover; + flex-shrink: 0; + } +`; + +/* ── Panel overlay CSS (injected once into document) ─────────── */ +const PANEL_CSS = ` +.luma-panel-overlay { + position: fixed; inset: 0; z-index: 99999; + display: flex; justify-content: flex-end; + pointer-events: none; opacity: 0; + transition: opacity 200ms ease-out; + font-family: Inter, system-ui, -apple-system, sans-serif; +} +.luma-panel-overlay.open { opacity: 1; pointer-events: auto; } +.luma-panel-backdrop { + position: absolute; inset: 0; + background: rgba(0,0,0,0.45); +} +.luma-panel { + position: relative; + width: ${T.panel.width}px; max-width: 90vw; + height: 100vh; + background: ${T.panel.fill}; + border-radius: ${T.panel.radius}px 0 0 ${T.panel.radius}px; + box-shadow: ${T.shadow.panel}; + overflow-y: auto; overflow-x: hidden; + transform: translateX(100%); + transition: transform 200ms ease-out; + -webkit-overflow-scrolling: touch; +} +.luma-panel-overlay.open .luma-panel { transform: translateX(0); } +.luma-panel-inner { padding: 48px 40px 40px; } + +/* Panel header */ +.panel-header { margin-bottom: 36px; } +.panel-icon { + width: 56px; height: 56px; border-radius: 50%; + display: flex; align-items: center; justify-content: center; + margin-bottom: 12px; +} +.panel-title { font-size: 30px; font-weight: 700; color: #fff; margin-bottom: 4px; } +.panel-state { font-size: 18px; font-weight: 600; } + +/* Sections */ +.panel-section { margin-bottom: 28px; } +.panel-section-label { + font-size: 17px; font-weight: 600; color: ${T.panel.label}; + margin-bottom: 12px; +} + +/* Slider */ +.panel-slider-wrap { position: relative; } +.panel-slider { + -webkit-appearance: none; appearance: none; + width: 100%; height: 56px; + background: linear-gradient(to right, var(--accent) 0%, var(--accent) var(--fill), rgba(255,255,255,0.12) var(--fill)); + border-radius: 16px; outline: none; + cursor: pointer; +} +.panel-slider::-webkit-slider-thumb { + -webkit-appearance: none; appearance: none; + width: 4px; height: 40px; background: rgba(255,255,255,0.5); + border-radius: 2px; cursor: pointer; +} +.panel-slider::-moz-range-thumb { + width: 4px; height: 40px; background: rgba(255,255,255,0.5); + border-radius: 2px; border: 0; cursor: pointer; +} +.panel-slider.warm { + background: linear-gradient(to right, #FFB46B 0%, #FFB46B var(--fill), rgba(255,255,255,0.12) var(--fill)); +} +.panel-slider-value { + position: absolute; left: 16px; top: 50%; transform: translateY(-50%); + font-size: 24px; font-weight: 700; color: #fff; pointer-events: none; +} +.panel-help { + font-size: 14px; color: rgba(255,255,255,0.60); margin-top: 6px; +} + +/* Button row */ +.panel-btn-row { + display: flex; gap: 10px; +} +.panel-btn-row.wrap { flex-wrap: wrap; } +.panel-btn { + flex: 1; min-width: 0; + height: 52px; border-radius: 14px; + background: ${T.panel.control}; border: 2px solid transparent; + color: rgba(255,255,255,0.72); font-size: 17px; font-weight: 600; + cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 6px; + transition: background 120ms, border-color 120ms; +} +.panel-btn:hover { background: rgba(255,255,255,0.16); } +.panel-btn.active { + background: rgba(var(--r,245),var(--g,184),var(--b,90),0.22); + border-color: var(--accent, ${T.accent.light}); + color: #fff; +} + +/* Icon button row (mode circles) */ +.panel-icon-row { + display: flex; gap: 14px; justify-content: center; +} +.panel-icon-btn { + width: 56px; display: flex; flex-direction: column; align-items: center; gap: 6px; + background: none; border: none; cursor: pointer; color: rgba(255,255,255,0.60); +} +.panel-icon-btn .panel-icon-btn-circle, +.panel-icon-btn ha-icon { + width: 48px; height: 48px; border-radius: 50%; + background: ${T.panel.control}; + display: flex; align-items: center; justify-content: center; + transition: background 120ms; +} +.panel-icon-btn ha-icon { + color: rgba(255,255,255,0.80); +} +.panel-icon-btn.active ha-icon { + background: rgba(var(--r,240),var(--g,106),var(--b,74),0.22); + color: #fff; +} +.panel-icon-btn.active { color: #fff; } +.panel-icon-btn span { font-size: 12px; font-weight: 500; } +.panel-icon-btn.small { width: 48px; } +.panel-icon-btn.small ha-icon { width: 40px; height: 40px; } + +/* Swatches */ +.panel-swatch-row { display: flex; gap: 10px; justify-content: center; } +.panel-swatch { + width: 40px; height: 40px; border-radius: 50%; border: 2px solid transparent; + cursor: pointer; transition: transform 120ms; +} +.panel-swatch:hover { transform: scale(1.15); border-color: rgba(255,255,255,0.4); } + +/* Effects grid */ +.panel-effect-grid { + display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; +} +.panel-effect-btn { + height: 44px; border-radius: 12px; + background: ${T.panel.control}; border: 2px solid transparent; + color: rgba(255,255,255,0.60); font-size: 12px; font-weight: 500; + cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + padding: 0 6px; +} +.panel-effect-btn.active { + border-color: var(--accent, ${T.accent.led}); + color: #fff; +} + +/* Media */ +.media-now { + display: flex; gap: 16px; align-items: center; + margin-bottom: 20px; +} +.media-art { + width: 80px; height: 80px; border-radius: 12px; object-fit: cover; flex-shrink: 0; +} +.media-art-placeholder { + width: 80px; height: 80px; border-radius: 12px; background: rgba(255,255,255,0.06); + display: flex; align-items: center; justify-content: center; flex-shrink: 0; +} +.media-info { flex: 1; min-width: 0; } +.media-title { font-size: 20px; font-weight: 700; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.media-artist { font-size: 15px; color: rgba(255,255,255,0.72); margin-top: 2px; } +.media-progress-bar { + height: 4px; border-radius: 2px; + background: rgba(255,255,255,0.14); + position: relative; overflow: hidden; +} +.media-progress-bar::after { + content: ''; position: absolute; left: 0; top: 0; bottom: 0; + width: var(--fill); background: rgba(255,255,255,0.70); + border-radius: 2px; +} +.media-times { + display: flex; justify-content: space-between; + font-size: 11px; color: rgba(255,255,255,0.45); margin-top: 4px; +} +.media-controls { + display: flex; align-items: center; justify-content: center; gap: 24px; +} +.media-btn { + width: 48px; height: 48px; border-radius: 50%; + background: rgba(255,255,255,0.08); border: none; + display: flex; align-items: center; justify-content: center; + cursor: pointer; color: rgba(255,255,255,0.60); + transition: background 120ms; +} +.media-btn:hover { background: rgba(255,255,255,0.16); } +.media-btn.play { width: 56px; height: 56px; } +`; + +/* ── Inject panel CSS once ───────────────────────────────────── */ +if (!document.getElementById('luma-panel-styles')) { + const s = document.createElement('style'); + s.id = 'luma-panel-styles'; + s.textContent = PANEL_CSS; + document.head.appendChild(s); +} + +/* ── Register ─────────────────────────────────────────────────── */ +customElements.define('luma-card', LumaCard); + +window.customCards = window.customCards || []; +window.customCards.push({ + type: 'luma-card', + name: 'Luma Card', + description: 'Glass-effect device card matching the Luma TV launcher design.', + preview: true, +}); + +console.info(`%c LUMA-CARDS %c v${LUMA_VERSION} `, 'background:#F5B85A;color:#15181E;font-weight:700', 'background:#22262E;color:#F5B85A'); diff --git a/ha-cards/luma-theme.yaml b/ha-cards/luma-theme.yaml new file mode 100644 index 0000000..0677ed3 --- /dev/null +++ b/ha-cards/luma-theme.yaml @@ -0,0 +1,78 @@ +luma_glass: + # ── Background ── + lovelace-background: "linear-gradient(135deg, #080B12 0%, #11141C 50%, #080B12 100%)" + primary-background-color: "#0D1017" + secondary-background-color: "#11141C" + card-background-color: "rgba(34,38,46,0.88)" + + # ── Text ── + primary-text-color: "#FFFFFF" + secondary-text-color: "rgba(255,255,255,0.72)" + text-primary-color: "#FFFFFF" + disabled-text-color: "rgba(255,255,255,0.40)" + + # ── Accent ── + primary-color: "#F5B85A" + accent-color: "#F5B85A" + light-primary-color: "rgba(245,184,90,0.24)" + + # ── Cards ── + ha-card-background: "rgba(34,38,46,0.88)" + ha-card-border-color: "rgba(255,255,255,0.10)" + ha-card-border-radius: "22px" + ha-card-box-shadow: "0 2px 8px rgba(0,0,0,0.25)" + ha-card-header-color: "#FFFFFF" + ha-card-header-font-size: "19px" + + # ── Sidebar ── + sidebar-background-color: "#0D1017" + sidebar-text-color: "#FFFFFF" + sidebar-icon-color: "rgba(255,255,255,0.60)" + sidebar-selected-icon-color: "#F5B85A" + sidebar-selected-text-color: "#F5B85A" + + # ── Header ── + app-header-background-color: "rgba(13,16,23,0.95)" + app-header-text-color: "#FFFFFF" + + # ── Dividers ── + divider-color: "rgba(255,255,255,0.10)" + + # ── Inputs / controls ── + input-fill-color: "rgba(255,255,255,0.06)" + input-ink-color: "#FFFFFF" + input-label-ink-color: "rgba(255,255,255,0.60)" + input-idle-line-color: "rgba(255,255,255,0.20)" + input-hover-line-color: "rgba(255,255,255,0.40)" + input-dropdown-icon-color: "rgba(255,255,255,0.60)" + + # ── Toggles ── + switch-checked-color: "#F5B85A" + switch-checked-track-color: "rgba(245,184,90,0.40)" + switch-unchecked-button-color: "rgba(255,255,255,0.60)" + switch-unchecked-track-color: "rgba(255,255,255,0.20)" + + # ── Sliders ── + paper-slider-knob-color: "#F5B85A" + paper-slider-active-color: "#F5B85A" + paper-slider-container-color: "rgba(255,255,255,0.14)" + + # ── Badges / chips ── + label-badge-background-color: "#1E222A" + label-badge-text-color: "#FFFFFF" + + # ── Scrollbar ── + scrollbar-thumb-color: "rgba(255,255,255,0.20)" + + # ── Dialogs / popups ── + dialog-backdrop-color: "rgba(0,0,0,0.45)" + + # ── Misc ── + state-icon-color: "rgba(255,255,255,0.60)" + state-icon-active-color: "#F5B85A" + paper-item-icon-color: "rgba(255,255,255,0.60)" + paper-item-icon-active-color: "#F5B85A" + mdc-theme-primary: "#F5B85A" + + # ── Font ── + primary-font-family: "'Inter', system-ui, -apple-system, sans-serif" diff --git a/ha-cards/luma-weather-card.js b/ha-cards/luma-weather-card.js new file mode 100644 index 0000000..c6a9eef --- /dev/null +++ b/ha-cards/luma-weather-card.js @@ -0,0 +1,207 @@ +/** + * Luma Weather Card — custom Lovelace card + * Shows current conditions + 5-day forecast in the Luma glass style. + */ + +const WEATHER_VERSION = '1.0.0'; + +const WEATHER_ICONS = { + 'clear-night': 'mdi:weather-night', + 'cloudy': 'mdi:weather-cloudy', + 'fog': 'mdi:weather-fog', + 'hail': 'mdi:weather-hail', + 'lightning': 'mdi:weather-lightning', + 'lightning-rainy': 'mdi:weather-lightning-rainy', + 'partlycloudy': 'mdi:weather-partly-cloudy', + 'pouring': 'mdi:weather-pouring', + 'rainy': 'mdi:weather-rainy', + 'snowy': 'mdi:weather-snowy', + 'snowy-rainy': 'mdi:weather-snowy-rainy', + 'sunny': 'mdi:weather-sunny', + 'windy': 'mdi:weather-windy', + 'windy-variant': 'mdi:weather-windy-variant', + 'exceptional': 'mdi:alert-circle-outline', +}; + +const CONDITION_LABELS = { + 'clear-night': 'Clear Night', + 'cloudy': 'Cloudy', + 'fog': 'Foggy', + 'hail': 'Hail', + 'lightning': 'Thunderstorm', + 'lightning-rainy': 'Thunderstorm', + 'partlycloudy': 'Partly Cloudy', + 'pouring': 'Heavy Rain', + 'rainy': 'Rainy', + 'snowy': 'Snowy', + 'snowy-rainy': 'Sleet', + 'sunny': 'Sunny', + 'windy': 'Windy', + 'windy-variant': 'Windy', + 'exceptional': 'Exceptional', +}; + +class LumaWeatherCard extends HTMLElement { + set hass(hass) { + this._hass = hass; + if (!this._config) return; + this._render(); + } + + setConfig(config) { + if (!config.entity) throw new Error('Missing entity'); + this._config = config; + } + + _render() { + const hass = this._hass; + const config = this._config; + const stateObj = hass.states[config.entity]; + if (!stateObj) { + this.innerHTML = `
Entity not found: ${config.entity}
`; + return; + } + + const condition = stateObj.state; + const temp = stateObj.attributes.temperature; + const unit = stateObj.attributes.temperature_unit || '°C'; + const humidity = stateObj.attributes.humidity; + const windSpeed = stateObj.attributes.wind_speed; + const windUnit = stateObj.attributes.wind_speed_unit || 'km/h'; + const icon = WEATHER_ICONS[condition] || 'mdi:weather-partly-cloudy'; + const label = CONDITION_LABELS[condition] || condition; + const forecast = stateObj.attributes.forecast || []; + + if (!this.shadowRoot) { + this.attachShadow({ mode: 'open' }); + const style = document.createElement('style'); + style.textContent = WEATHER_STYLES; + this.shadowRoot.appendChild(style); + const wrap = document.createElement('div'); + wrap.className = 'lw-wrap'; + this.shadowRoot.appendChild(wrap); + } + + const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + const forecastDays = forecast.slice(0, 5); + + this.shadowRoot.querySelector('.lw-wrap').innerHTML = ` +
+
+
+ +
+
+
${temp !== undefined ? Math.round(temp) : '–'}°
+
${label}
+
+
+
+
+ + ${humidity !== undefined ? humidity + '%' : '–'} +
+
+ + ${windSpeed !== undefined ? windSpeed + ' ' + windUnit : '–'} +
+
+ ${forecastDays.length > 0 ? ` +
+
+ ${forecastDays.map(f => { + const d = new Date(f.datetime); + const day = days[d.getDay()]; + const fIcon = WEATHER_ICONS[f.condition] || 'mdi:weather-partly-cloudy'; + const lo = f.templow !== undefined ? Math.round(f.templow) : '–'; + const hi = f.temperature !== undefined ? Math.round(f.temperature) : '–'; + return ` +
+
${day}
+ +
+ ${hi}° + ${lo}° +
+
+ `; + }).join('')} +
+ ` : ''} +
+ `; + } + + getCardSize() { return 4; } + static getStubConfig() { return { entity: 'weather.forecast_home' }; } +} + +const WEATHER_STYLES = ` + :host { display: block; } + .lw-card { + background: rgba(34,38,46,0.88); + border: 2px solid rgba(255,255,255,0.10); + border-radius: 22px; + padding: 22px; + box-shadow: 0 2px 8px rgba(0,0,0,0.25); + box-sizing: border-box; + } + .lw-now { + display: flex; align-items: center; gap: 16px; + margin-bottom: 16px; + } + .lw-icon-wrap { + width: 72px; height: 72px; border-radius: 50%; + background: rgba(245,184,90,0.18); + display: flex; align-items: center; justify-content: center; + flex-shrink: 0; + } + .lw-now-info { flex: 1; } + .lw-temp { + font-size: 48px; font-weight: 700; color: #fff; + line-height: 1; letter-spacing: -0.03em; + } + .lw-condition { + font-size: 17px; font-weight: 500; color: rgba(255,255,255,0.72); + margin-top: 4px; + } + .lw-details { + display: flex; gap: 20px; margin-bottom: 16px; + } + .lw-detail { + display: flex; align-items: center; gap: 6px; + font-size: 15px; font-weight: 500; color: rgba(255,255,255,0.72); + } + .lw-divider { + height: 1px; background: rgba(255,255,255,0.10); + margin-bottom: 16px; + } + .lw-forecast { + display: flex; justify-content: space-between; + } + .lw-day { + display: flex; flex-direction: column; align-items: center; gap: 6px; + flex: 1; + } + .lw-day-name { + font-size: 13px; font-weight: 600; color: rgba(255,255,255,0.60); + text-transform: uppercase; letter-spacing: 0.04em; + } + .lw-day-range { + display: flex; flex-direction: column; align-items: center; gap: 2px; + } + .lw-hi { font-size: 16px; font-weight: 700; color: #fff; } + .lw-lo { font-size: 13px; font-weight: 500; color: rgba(255,255,255,0.50); } +`; + +customElements.define('luma-weather-card', LumaWeatherCard); + +window.customCards = window.customCards || []; +window.customCards.push({ + type: 'luma-weather-card', + name: 'Luma Weather Card', + description: 'Weather card with 5-day forecast in the Luma glass design.', + preview: true, +}); + +console.info(`%c LUMA-WEATHER %c v${WEATHER_VERSION} `, 'background:#F5B85A;color:#15181E;font-weight:700', 'background:#22262E;color:#F5B85A');