Dashboard Studio 2.2.0: guest invites, personal looks, more themes, Haven widgets and tiles

- Vacuum card: the robot drawn beside its dock by state, last clean, maintenance
- Formula One card: the official F1 look with a user-supplied logo
- Nine more themes; per-person theme, text size and language on Admin, People
- Guest invites: an expiring link and QR code for one dashboard, served on a
  LAN-only guest port, with the Home Assistant connection filtered both ways
- Haven 2.2: home screen widgets, three quick settings tiles, several homes,
  and self-updates from the Haven that Dashboard Studio serves

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 00:31:50 +01:00
parent f4c98e27af
commit a5823b4726
26 changed files with 1514 additions and 164 deletions
+9
View File
@@ -1,5 +1,14 @@
# Changelog
## 2.2.0
- **Robot vacuum:** the robot drawn from above beside its dock, placed by what it does (charging on the dock, cleaning with its path behind it, on its way back), the last clean, and maintenance bars for the filter and brushes.
- **Formula One:** Formula 1's own look, with the race in large type, a countdown, the weekend's sessions with the next one in red, and standings set like the timing tower with the team colours. Add the official logo yourself (it's a trademark, so it doesn't come with the app).
- **Nine more themes:** Midnight, Espresso, Ember, Lagoon, Paper, Arctic, Moss, Sakura and Concrete.
- **Each person's own look:** on **Admin → People**, give someone their own theme, a larger text size or their own language.
- **Guest invites:** a link and a QR code that opens one dashboard for guests without a Home Assistant login, until the time you choose, on your home Wi-Fi. Turn guest links on in the app's Network settings first.
- **Haven 2.2:** home screen widgets for a light, scene or thermostat; three quick settings tiles; several homes (a holiday home, your parents'); and it offers to update itself when Dashboard Studio brings a newer Haven.
## 2.1.0
- **Every card redesigned,** with a desktop, a tablet and a phone design that apply by themselves: compact with hover states on a computer; larger type and 44px buttons on tablets and wall panels; touch sizes in a tighter frame on phones, with rows that scroll sideways instead of wrapping. Narrow cards put their figures under the names.
+22 -4
View File
@@ -19,14 +19,25 @@ Everyone with a login sees Dashboard Studio in the Home Assistant sidebar.
## The Admin screen
- **Dashboards:** every dashboard as a card, with who sees it. **Open** shows it, **Build** opens it in the builder, and **⋯** renames, duplicates, shows the version history, shares, exports or deletes it. At the top: a new dashboard, one made from your devices, importing a file, and importing one of Home Assistant's own dashboards.
- **People:** everyone with a Home Assistant login. Admins see everything. For everyone else, choose **All dashboards** or **Only these** and tick the dashboards, plus the one that **Opens first**. **Everyone else** at the top sets this for people without their own choice, and for anyone added to Home Assistant later. **Preview** shows the dashboards exactly as that person gets them; **Stop preview** brings you back.
- **People:** everyone with a Home Assistant login. Admins see everything. For everyone else, choose **All dashboards** or **Only these** and tick the dashboards, plus the one that **Opens first**. **Everyone else** at the top sets this for people without their own choice, and for anyone added to Home Assistant later. **Preview** shows the dashboards exactly as that person gets them; **Stop preview** brings you back. Each person can also get their own **Theme** (it replaces the dashboard's colours on their devices), a **Text** size (*Large* or *Extra large*, handy for grandparents) and a **Language**. A language chosen on a device itself (under **This device**) still comes first.
- **Devices:** every phone, tablet and computer that opened Dashboard Studio, with who used it, whether it was Haven or a browser, and when. Name a device, give it its own dashboard (that comes before the person's choice, handy for a wall tablet), have admins start it in kiosk mode, or forget it. **Send a message to the screens** puts a full-screen message on all screens in kiosk mode, or on one named device.
- **Guests:** invites for people without a Home Assistant login (see below).
- **Haven app:** a QR code to download Haven on a phone, the address to enter in it, and how signing in works.
- **Activity:** who changed which dashboard, and when, with a shortcut to its versions.
- **About:** the version, what's new and the source code.
Screens pick up what an admin changes, a dashboard or who sees what, within a minute, without reloading.
## Guest invites
An invite is a link and a QR code that opens one dashboard on a guest's phone, without a Home Assistant login, until the time you choose: four hours, tonight, 24 hours, until after the weekend, or a week. Guests can only see and control what is on that dashboard; Dashboard Studio filters everything else out. Invites only work on your home Wi-Fi.
1. Turn guest links on once: in Home Assistant, open **Settings → Apps → Dashboard Studio → Configuration**, and under **Network** give *Guest invite links* a port, for example 8199. Save.
2. Make a dashboard for guests (lights, the thermostat, the TV), or use one you have.
3. Open **Admin → Guests**, fill in who it's for, the dashboard and until when, and choose **Make invite**. Let your guests scan the QR code.
Under **Address for the link**, use the address of Home Assistant on your home network, such as `homeassistant.local` or `192.168.1.20`. **End** stops an invite at once, also on phones that have it open.
## Haven, the Android app
Haven shows the dashboards and nothing else: no builder and no Admin screen, for admins too.
@@ -37,6 +48,11 @@ Haven shows the dashboards and nothing else: no builder and no Admin screen, for
From then on Haven opens straight on your dashboard, full screen with its own icon, and keeps you signed in. It works with plain http addresses too. Kiosk mode keeps the screen on and hides Android's bars. To connect to another Home Assistant: tap the phone button in the top bar (admins) or the kiosk corner five times (everyone else), then **Change address**. Haven needs Android 7 or newer. The earlier *Dashboard Studio* Android app is replaced by Haven; you can uninstall it.
- **Several homes:** under **Change address**, **Add another home** connects Haven to another Home Assistant, such as a holiday home or your parents'. Each home keeps its own sign-in; tap a home to open it, × removes it.
- **Home screen widgets:** long-press your home screen, choose **Widgets → Haven**, and pick a light, switch, scene or thermostat. A tap does what it does on the dashboard; a thermostat gets − and + for its temperature.
- **Quick settings:** Haven offers three tiles for Android's pull-down menu. Edit the menu, drag a Haven tile in, and tap it to choose what it controls. Long-press a tile to choose again.
- **Updates:** when Dashboard Studio is updated with a newer Haven, Haven offers to install it the next time it opens. Android asks once to allow installs from Haven.
## Building
1. Open **Dashboard Studio** in the Home Assistant sidebar.
@@ -65,7 +81,7 @@ More editing tools:
- **Cards for every screen:** each card has a desktop, a tablet and a phone design, applied by itself. On a computer with a mouse, cards are compact with hover states. On a tablet or wall panel (any touch screen, or a screen 641-1100px wide) the type is larger and every button, switch and key is at least 44px, so it can be read and used from across the room. On a phone the same touch sizes sit in a tighter frame, and rows such as a light's colours scroll sideways instead of wrapping. Within that, each card still adapts to its own size: a narrow card puts its figures under the names instead of beside them.
- **How cards look:** the answer is the largest thing on each card (the temperature, the battery, *Closed*), in thin, even figures with the unit in grey. Icons are plain outlines that take colour only when something is on or needs attention; status shows as a pale pill; buttons are flat.
- **Light and dark versions:** turn on **Switch to a dark or light version with the device** under a theme to use a matching dark version of a light theme when the device is in dark mode, and the other way round. *System* always does this.
- **Theme:** *System* follows your device's light or dark appearance. The other 14 themes are fixed: Light, Dark, Nord, Dusk, Forest, Graphite, Indigo, Rose, Ocean, Sunrise, Lavender, Mint, Sand, and High contrast for maximum readability. Text meets accessibility contrast levels in every theme. Changing any color switches the theme to *Custom*.
- **Theme:** *System* follows your device's light or dark appearance. The other 23 themes are fixed: Light, Dark, Nord, Dusk, Forest, Graphite, Indigo, Rose, Ocean, Sunrise, Lavender, Mint, Sand, Midnight (true black, for OLED screens), Espresso, Ember, Lagoon, Paper, Arctic, Moss, Sakura, Concrete, and High contrast for maximum readability. Text meets accessibility contrast levels in every theme. Changing any color switches the theme to *Custom*.
- **Tile style:** *Cards* puts every widget on a tile. *Outlined* draws a thin border instead. *Plain* removes the cards completely, so widgets sit straight on the background. You can also turn off the background for a single widget under **Style → No background**.
- **Lights that are on** glow: the tile turns warm, or takes on the bulb's color for color lights. In Plain style, the light's icon and brightness bar glow instead.
- **Icons:** widgets pick an icon automatically. To change one, click the icon next to the field to open the icon picker (over 130 icons for rooms, appliances, tools and devices, searchable in English and Dutch), type an icon name, or paste an emoji.
@@ -172,11 +188,13 @@ For the TeamTracker integration: the next match with both teams, their logos, th
### Formula One
For the F1 Sensor integration. Pick the *next race* sensor and the card finds the standings and results sensors itself. It shows the next race with a countdown and the times of every session in your own time zone, and tabs for the driver standings, the constructor standings and the last race's results.
For the F1 Sensor integration. Pick the *next race* sensor and the card finds the standings and results sensors itself. It has Formula 1's own look: carbon black, F1 red and white, whatever your dashboard's theme (choose **Look → Theme** to use your theme instead). It shows the round, the race in large type, a countdown in days, hours and minutes, the weekend's sessions in your own time zone with the next one marked in red, and tabs for the drivers, the teams and the last race, set like the timing tower: position, the team's colour, the driver's three-letter code and the points.
**The logo:** the official Formula 1 logo is a trademark, so it doesn't come with Dashboard Studio. To show it, put the logo file in Home Assistant's `www` folder (for example as `f1-logo.svg`) and enter `/local/f1-logo.svg` under **Logo image**. Without it, a red mark takes its place.
### Vacuum
The card grows with its size. On a small card it's a compact row with the status, battery and buttons. On a bigger card, the robot sits in the middle with a battery ring around it, a pulse while it cleans and a bolt while it charges. Below it are tiles for battery, cleaned area, cleaning time and any extra sensors you add (such as filter or brush life), the suction power levels, and labelled buttons to start or pause, send it back to the dock and locate it. Optionally add a map: pick the camera or image entity that your vacuum integration provides (for example Roborock or Valetudo), and it refreshes every few seconds. If the battery level is a separate sensor in your integration, pick it under **Battery sensor**.
The card shows the robot from above next to its dock, placed by what it is doing: on the dock with a green light while it charges, out on the floor with a dotted path behind it and a turning brush while it cleans, on its way back while it returns, and outlined in red when it is stuck. Above it are the state in words and the battery with a bar. Below it: the last clean (area and time), **Maintenance** with a bar for every sensor in percent you add under **More sensors** (filter, main brush, side brush; amber below 15%), the suction power levels, and buttons to start or pause, send it back to the dock and locate it. The smaller the card, the more it leaves out, from the bottom up. Optionally add a map: pick the camera or image entity that your vacuum integration provides (for example Roborock or Valetudo), and it refreshes every few seconds. If the battery level is a separate sensor in your integration, pick it under **Battery sensor**.
### Energy
+5 -1
View File
@@ -1,5 +1,5 @@
name: Dashboard Studio
version: "2.1.0"
version: "2.2.0"
slug: dashboard_studio
description: Drag-and-drop dashboard builder with a free-form grid, live entity widgets and themes.
url: https://git.melvanveen.nl/veenm/ha-dashboard-studio
@@ -18,6 +18,10 @@ panel_title: Dashboard Studio
# Everyone with a login gets it in the sidebar; Dashboard Studio itself decides what each person sees.
panel_admin: false
homeassistant_api: true
ports:
8199/tcp: null
ports_description:
8199/tcp: Guest invite links (answers the local network only)
options:
immich_url: ""
immich_api_key: ""
+305 -26
View File
@@ -9,10 +9,12 @@ Assistant with a handful of fake entities.
"""
import asyncio
import ipaddress
import json
import logging
import os
import re
import secrets
import time
from pathlib import Path
@@ -187,11 +189,12 @@ async def ha_call(app, msg):
USERS_TTL = 60
USERS_CACHE = {}
async def ha_users(app):
"""Home Assistant's people (not its system users), with whether each is an administrator. Cached for a minute."""
cached = app.get("users_cache")
cached = USERS_CACHE.get("users")
if cached and time.time() - cached[0] < USERS_TTL:
return cached[1]
rows = MockHA.USERS_LIST() if MOCK else await ha_call(app, {"type": "config/auth/list"})
@@ -199,7 +202,7 @@ async def ha_users(app):
"admin": bool(u.get("is_owner")) or "system-admin" in (u.get("group_ids") or []),
"active": u.get("is_active", True)}
for u in rows if not u.get("system_generated")}
app["users_cache"] = (time.time(), users)
USERS_CACHE["users"] = (time.time(), users)
return users
@@ -349,6 +352,11 @@ async def service_worker(request):
return web.Response(text=js, content_type="application/javascript", headers={"Cache-Control": "no-store"})
async def haven_version(request):
"""Which Haven this Dashboard Studio serves, so Haven can offer itself the update."""
return web.FileResponse(WEB_DIR / "haven.json", headers={"Content-Type": "application/json", "Cache-Control": "no-store"})
async def haven_app(request):
"""Haven, the Android app for dashboards (built from haven/ in the repository), to download on a phone."""
return web.FileResponse(WEB_DIR / "haven.apk", headers={
@@ -384,6 +392,7 @@ async def get_dashboards(request):
"dashboards": [d for d in store.get("dashboards", []) if d.get("id") in ids],
"assign": {"devices": {request.query["device"]: dev} if dev else {}, "users": {}},
"access": {"me": access_of(store, me.get("id"))},
"look": (store.get("looks") or {}).get(me.get("id")) or {},
"role": "basic",
}, headers={"Cache-Control": "no-store"})
@@ -450,12 +459,15 @@ async def activity(request):
async def camera(request):
entity_id = request.match_info["entity_id"]
return await camera_image(request.app, request.match_info["entity_id"])
async def camera_image(app, entity_id):
if not entity_id.startswith("camera.") or "/" in entity_id:
return web.Response(status=400)
if MOCK:
return web.Response(body=request.app["mock"].camera_svg(entity_id), content_type="image/svg+xml")
session = request.app["http"]
return web.Response(body=app["mock"].camera_svg(entity_id), content_type="image/svg+xml")
session = app["http"]
async with session.get(
f"{HA_API_URL}/camera_proxy/{entity_id}",
headers={"Authorization": f"Bearer {SUPERVISOR_TOKEN}"},
@@ -495,28 +507,39 @@ def denied(req):
"error": {"code": "unauthorized", "message": "Only Home Assistant administrators can do this"}}
async def websocket(request):
client = web.WebSocketResponse(heartbeat=30, max_msg_size=0)
await client.prepare(request)
class GuardedSocket:
"""The browser's socket as the mock Home Assistant sees it, with an outbound filter in between."""
def __init__(self, ws, shape):
self.ws, self.shape = ws, shape
allow = None if await role_of(request) == "admin" else allowed_for_basic
def __aiter__(self):
return self.ws.__aiter__()
async def send_json(self, data):
data = self.shape(data)
if data is not None:
await self.ws.send_json(data)
def __getattr__(self, name):
return getattr(self.ws, name)
async def serve_ha(app, client, allow=None, shape=None):
"""Connect a browser socket to Home Assistant. `allow` vets what the browser asks; `shape` filters what comes back."""
if MOCK:
await request.app["mock"].serve(client, allow)
return client
await app["mock"].serve(GuardedSocket(client, shape) if shape else client, allow)
return
if not SUPERVISOR_TOKEN:
await client.send_json({"type": "error", "message": "Dashboard Studio has no access to Home Assistant (SUPERVISOR_TOKEN is missing). Restart the app; if this stays, reinstall it."})
await client.close()
return client
session = request.app["http"]
return
try:
upstream = await session.ws_connect(HA_WS_URL, heartbeat=30, max_msg_size=0)
upstream = await app["http"].ws_connect(HA_WS_URL, heartbeat=30, max_msg_size=0)
except aiohttp.ClientError as err:
LOG.error("Cannot reach Home Assistant websocket: %s", err)
await client.send_json({"type": "error", "message": "Cannot reach Home Assistant"})
await client.close()
return client
return
try:
msg = await upstream.receive_json() # auth_required
await upstream.send_json({"type": "auth", "access_token": SUPERVISOR_TOKEN})
@@ -524,34 +547,275 @@ async def websocket(request):
if msg.get("type") != "auth_ok":
LOG.error("Home Assistant rejected the Supervisor token: %s", msg)
await client.send_json({"type": "error", "message": "Home Assistant auth failed"})
return client
return
await client.send_json({"type": "ready", "ha_version": msg.get("ha_version")})
async def pump(src, dst, check=None):
async for m in src:
async def inbound():
async for m in client:
if m.type == aiohttp.WSMsgType.TEXT:
if check:
if allow:
try:
req = json.loads(m.data)
except json.JSONDecodeError:
continue
if not isinstance(req, dict) or not check(req):
if not isinstance(req, dict) or not allow(req):
await client.send_json(denied(req if isinstance(req, dict) else {}))
continue
await dst.send_str(m.data)
await upstream.send_str(m.data)
elif m.type in (aiohttp.WSMsgType.ERROR, aiohttp.WSMsgType.CLOSE):
break
tasks = [asyncio.create_task(pump(client, upstream, allow)), asyncio.create_task(pump(upstream, client))]
async def outbound():
async for m in upstream:
if m.type == aiohttp.WSMsgType.TEXT:
if shape:
out = shape(json.loads(m.data))
if out is not None:
await client.send_json(out)
else:
await client.send_str(m.data)
elif m.type in (aiohttp.WSMsgType.ERROR, aiohttp.WSMsgType.CLOSE):
break
tasks = [asyncio.create_task(inbound()), asyncio.create_task(outbound())]
_, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
for t in pending:
t.cancel()
finally:
await upstream.close()
await client.close()
async def websocket(request):
client = web.WebSocketResponse(heartbeat=30, max_msg_size=0)
await client.prepare(request)
await serve_ha(request.app, client, None if await role_of(request) == "admin" else allowed_for_basic)
return client
# ---------------------------------------------------------------- guest links
# An admin makes an invite: a link with a code to one dashboard, until a set time. It is served on a second
# port that only answers the local network, and it shows that dashboard in kiosk mode. A guest can read and
# control only the entities on that dashboard: everything else is filtered out in both directions.
INVITES_FILE = DATA_DIR / "invites.json"
GUEST_PORT = int(os.environ.get("GUEST_PORT", "8199"))
ENTITY_ID = re.compile(r"^[a-z_]+\.[a-z0-9_]+$")
def load_invites():
now = time.time()
return {k: v for k, v in load_json(INVITES_FILE, {}).items() if v.get("until", 0) > now - 7 * 86400} # ended ones linger a week
def entities_in(obj, found=None):
"""Every entity ID (and service name) mentioned anywhere in a dashboard."""
found = set() if found is None else found
if isinstance(obj, dict):
for v in obj.values():
entities_in(v, found)
elif isinstance(obj, list):
for v in obj:
entities_in(v, found)
elif isinstance(obj, str) and ENTITY_ID.match(obj):
found.add(obj)
return found
def guest_policy(allowed):
"""What a guest may ask Home Assistant, and what it may hear back."""
asked = {}
def ids_of(req):
out = []
for src in (req.get("target") or {}, req.get("service_data") or {}):
v = src.get("entity_id")
out += [v] if isinstance(v, str) else list(v or [])
return out
def allow(req):
t = req.get("type")
asked[req.get("id")] = t
if t in ("get_states", "get_config", "ping", "unsubscribe_events", "config/area_registry/list", "config/entity_registry/list", "config/device_registry/list"):
return True
if t == "subscribe_events":
return req.get("event_type") in ("state_changed", "dashboard_studio_announce")
if t == "call_service":
ids = ids_of(req)
if req.get("domain") in BLOCKED_DOMAINS or req.get("domain") == "homeassistant" and req.get("service") not in ("toggle", "turn_on", "turn_off"):
return False
return all(i in allowed for i in ids) if ids else f"{req.get('domain')}.{req.get('service')}" in allowed
if t == "todo/item/list":
return req.get("entity_id") in allowed
if t == "history/history_during_period":
return all(i in allowed for i in req.get("entity_ids") or [])
if t == "recorder/statistics_during_period":
return all(i in allowed for i in req.get("statistic_ids") or [])
return False
def shape(msg):
if not isinstance(msg, dict):
return msg
kind = asked.get(msg.get("id"))
if msg.get("type") == "result" and isinstance(msg.get("result"), list):
if kind == "get_states":
msg["result"] = [x for x in msg["result"] if x.get("entity_id") in allowed]
elif kind == "config/entity_registry/list":
msg["result"] = [x for x in msg["result"] if x.get("entity_id") in allowed]
if msg.get("type") == "event":
ev = msg.get("event") or {}
if ev.get("event_type") == "state_changed" and (ev.get("data") or {}).get("entity_id") not in allowed:
return None
return msg
return allow, shape
def lan_only(handler):
"""Guest links answer the local network only."""
async def wrapped(request):
try:
ip = ipaddress.ip_address((request.remote or "").split("%")[0])
except ValueError:
return web.Response(status=403, text="Guest links work on the local network only")
if not (ip.is_private or ip.is_loopback or ip.is_link_local):
return web.Response(status=403, text="Guest links work on the local network only")
return await handler(request)
return wrapped
ENDED_PAGE = """<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Invite ended</title><body style="margin:0;min-height:100vh;display:grid;place-items:center;background:#111;color:#eee;font:17px/1.5 system-ui,sans-serif;text-align:center;padding:24px">
<div><h1 style="font-size:24px;margin:0 0 8px">This invite has ended</h1><p style="margin:0;color:#aaa">Ask your host for a new one.</p></div>"""
def invite_of(request):
inv = load_invites().get(request.match_info.get("token", ""))
if not inv or inv.get("until", 0) < time.time():
return None, None
dash = next((d for d in load_store().get("dashboards", []) if d.get("id") == inv.get("dashboard")), None)
return (inv, dash) if dash else (None, None)
@lan_only
async def guest_page(request):
inv, _ = invite_of(request)
if not inv:
return web.Response(text=ENDED_PAGE, content_type="text/html", status=410)
if not request.path.endswith("/"):
raise web.HTTPFound(request.path + "/")
return await index(request)
@lan_only
async def guest_api(request):
inv, dash = invite_of(request)
if not inv:
return web.json_response({"error": "This invite has ended"}, status=410)
what = request.match_info["what"]
if what == "whoami":
return web.json_response({"id": "guest", "name": "guest", "display": inv.get("name", ""), "admin": False, "guest": True, "until": inv["until"]})
if what == "dashboards":
return web.json_response({"version": 1, "dashboards": [dash], "assign": {"devices": {}, "users": {}},
"access": {"me": {"all": False, "dashboards": [dash["id"]], "home": dash["id"]}}, "role": "guest", "look": {}},
headers={"Cache-Control": "no-store"})
if what == "version":
return web.json_response({"version": VERSION})
if what == "devices/seen":
return web.json_response({"ok": True})
return web.Response(status=404)
@lan_only
async def guest_camera(request):
inv, dash = invite_of(request)
if not inv or request.match_info["entity_id"] not in entities_in(dash):
return web.Response(status=403)
return await camera_image(request.app["main"], request.match_info["entity_id"])
@lan_only
async def guest_static(request):
inv, _ = invite_of(request)
path = (WEB_DIR / request.match_info["path"]).resolve()
if not inv or WEB_DIR.resolve() not in path.parents or not path.is_file():
return web.Response(status=404)
return web.FileResponse(path, headers={"Cache-Control": "no-cache"})
@lan_only
async def guest_socket(request):
inv, dash = invite_of(request)
client = web.WebSocketResponse(heartbeat=30, max_msg_size=0)
await client.prepare(request)
if not inv:
await client.close()
return client
allow, shape = guest_policy(entities_in(dash))
# the connection ends when the invite does
ender = asyncio.get_event_loop().call_later(max(1, inv["until"] - time.time()), lambda: asyncio.ensure_future(client.close()))
try:
await serve_ha(request.app["main"], client, allow, shape)
finally:
ender.cancel()
return client
def make_guest_app(main):
app = web.Application()
app["main"] = main
app.router.add_get("/g/{token}", guest_page)
app.router.add_get("/g/{token}/", guest_page)
app.router.add_get("/g/{token}/static/{path:.+}", guest_static)
app.router.add_get("/g/{token}/api/camera/{entity_id}", guest_camera)
app.router.add_get("/g/{token}/ws", guest_socket)
app.router.add_route("*", "/g/{token}/api/{what:.+}", guest_api)
return app
@admin_only
async def list_invites(request):
names = {d.get("id"): d.get("name", "") for d in load_store().get("dashboards", [])}
port = await guest_port(request.app)
rows = [{"token": k, **v, "dashboardName": names.get(v.get("dashboard"), ""), "ended": v.get("until", 0) < time.time()}
for k, v in sorted(load_invites().items(), key=lambda kv: -kv[1].get("created", 0))]
return web.json_response({"invites": rows, "port": port}, headers={"Cache-Control": "no-store"})
@admin_only
async def create_invite(request):
body = await request.json()
hours = float(body.get("hours") or 0)
until = float(body.get("until") or 0) or time.time() + hours * 3600
if not body.get("dashboard") or until <= time.time() or until > time.time() + 31 * 86400:
return web.json_response({"error": "Choose a dashboard and an end within a month"}, status=400)
invites = load_invites()
token = secrets.token_urlsafe(12)
invites[token] = {"dashboard": str(body["dashboard"]), "name": str(body.get("name") or "")[:60], "until": until,
"created": time.time(), "by": (who(request) or {}).get("display", "")}
save_json(INVITES_FILE, invites)
return web.json_response({"token": token, **invites[token]})
@admin_only
async def delete_invite(request):
invites = load_invites()
invites.pop(request.match_info["token"], None)
save_json(INVITES_FILE, invites)
return web.json_response({"ok": True})
async def guest_port(app):
"""The host port the guest links are reachable on, from the app's Network settings (None while off)."""
if MOCK:
return GUEST_PORT
try:
async with app["http"].get("http://supervisor/addons/self/info", headers={"Authorization": f"Bearer {SUPERVISOR_TOKEN}"}) as r:
net = ((await r.json()).get("data") or {}).get("network") or {}
return net.get(f"{GUEST_PORT}/tcp")
except Exception: # noqa: BLE001
return None
async def on_startup(app):
app["http"] = aiohttp.ClientSession()
await app["immich"].start()
@@ -575,6 +839,7 @@ def make_app():
app.router.add_get("/manifest.webmanifest", manifest)
app.router.add_get("/sw.js", service_worker)
app.router.add_get("/haven.apk", haven_app)
app.router.add_get("/haven.json", haven_version)
app.router.add_get("/api/changelog", changelog)
app.router.add_get("/api/whoami", whoami)
app.router.add_get("/api/people", people)
@@ -582,6 +847,9 @@ def make_app():
app.router.add_get("/api/devices", get_devices)
app.router.add_delete("/api/devices/{device}", forget_device)
app.router.add_get("/api/activity", activity)
app.router.add_get("/api/invites", list_invites)
app.router.add_post("/api/invites", create_invite)
app.router.add_delete("/api/invites/{token}", delete_invite)
app.router.add_get("/api/dashboards", get_dashboards)
app.router.add_put("/api/dashboards", put_dashboards)
app.router.add_get("/api/history/{dash_id}", get_history)
@@ -599,5 +867,16 @@ def make_app():
if __name__ == "__main__":
if IN_HOME_ASSISTANT and not SUPERVISOR_TOKEN:
LOG.error("SUPERVISOR_TOKEN is missing; the app can't talk to Home Assistant")
LOG.info("Dashboard Studio %s listening on port %s (data in %s)", VERSION, PORT, DATA_DIR)
web.run_app(make_app(), port=PORT, access_log=None)
LOG.info("Dashboard Studio %s listening on port %s, guest links on %s (data in %s)", VERSION, PORT, GUEST_PORT, DATA_DIR)
async def main():
app = make_app()
runner = web.AppRunner(app, access_log=None)
await runner.setup()
await web.TCPSite(runner, port=PORT).start()
guest = web.AppRunner(make_guest_app(app), access_log=None)
await guest.setup()
await web.TCPSite(guest, port=GUEST_PORT).start()
await asyncio.Event().wait()
asyncio.run(main())
+2
View File
@@ -56,6 +56,8 @@ class MockHA:
entity("sensor.robot_vacuum_cleaned_area", "23", friendly_name="Robot Vacuum Cleaned area", unit_of_measurement="m²"),
entity("sensor.robot_vacuum_cleaning_time", "34", friendly_name="Robot Vacuum Cleaning time", unit_of_measurement="min", device_class="duration"),
entity("sensor.robot_vacuum_filter_left", "72", friendly_name="Robot Vacuum Filter left", unit_of_measurement="%"),
entity("sensor.robot_vacuum_main_brush_left", "41", friendly_name="Robot Vacuum Main brush left", unit_of_measurement="%"),
entity("sensor.robot_vacuum_side_brush_left", "9", friendly_name="Robot Vacuum Side brush left", unit_of_measurement="%"),
entity("switch.coffee_machine", "off", friendly_name="Coffee Machine"),
entity("fan.office", "off", friendly_name="Office Fan"),
entity("sensor.living_room_temperature", "21.4", friendly_name="Living Room Temperature", unit_of_measurement="°C", device_class="temperature"),
+113 -13
View File
@@ -187,17 +187,26 @@
Light: { bg: "#f2f2f7", tile: "#ffffff", text: "#1d1d1f", text2: "#6e6e73", accent: "#0066cc", separator: "rgba(60,60,67,0.16)", fill: "rgba(120,120,128,0.14)", lit: "#ffedc7", shadow: true, blur: 0 },
Dark: { bg: "#0f0f11", tile: "#1c1c1e", text: "#f5f5f7", text2: "#a1a1a6", accent: "#0a84ff", separator: "rgba(84,84,88,0.55)", fill: "rgba(118,118,128,0.24)", lit: "#ffe2a8", shadow: false, blur: 0 },
Nord: { bg: "#2e3440", tile: "#3b4252", text: "#eceff4", text2: "#c2c9d6", accent: "#88c0d0", separator: "rgba(216,222,233,0.14)", fill: "rgba(216,222,233,0.12)", lit: "#f3e3bf", shadow: false, blur: 0 },
Dusk: { bg: "linear-gradient(160deg, #1d2b53 0%, #4b2a59 55%, #9a4d55 100%)", tile: "rgba(255,255,255,0.12)", text: "#ffffff", text2: "rgba(255,255,255,0.78)", accent: "#ffcf6b", separator: "rgba(255,255,255,0.18)", fill: "rgba(255,255,255,0.16)", lit: "#ffe2a8", shadow: false, blur: 20 },
Dusk: { bg: "linear-gradient(160deg, #1d2b53 0%, #4b2a59 55%, #9a4d55 100%)", tile: "rgba(255,255,255,0.12)", text: "#ffffff", text2: "rgba(255,255,255,0.78)", accent: "#ffcf6b", separator: "rgba(255,255,255,0.18)", fill: "rgba(255,255,255,0.16)", lit: "#ffe2a8", shadow: false, blur: 20 }, // taste-skill-ignore: the Dusk theme's own background, chosen by the user
Forest: { bg: "#0e1a14", tile: "#17271e", text: "#e6f2ea", text2: "#a9c2b2", accent: "#5fd38d", onAccent: "#0b1f14", separator: "rgba(230,242,234,0.12)", fill: "rgba(230,242,234,0.1)", lit: "#f1e6b8", shadow: false, blur: 0 },
// Text on tiles is 5.3:1 or better in every theme; bright accents get dark text (onAccent).
Graphite: { bg: "#18181b", tile: "#27272a", text: "#fafafa", text2: "#a1a1aa", accent: "#f59e0b", onAccent: "#1c1204", separator: "rgba(250,250,250,0.1)", fill: "rgba(250,250,250,0.1)", lit: "#ffe2a8", shadow: false, blur: 0 },
Indigo: { bg: "#0b1020", tile: "#151c33", text: "#eef1ff", text2: "#a7b0d6", accent: "#8aa4ff", onAccent: "#0b1020", separator: "rgba(238,241,255,0.1)", fill: "rgba(238,241,255,0.1)", lit: "#ffe2a8", shadow: false, blur: 0 },
Rose: { bg: "#1d1216", tile: "#2c1d23", text: "#fdf2f5", text2: "#d4b3bf", accent: "#ff7aa6", onAccent: "#2a0a15", separator: "rgba(253,242,245,0.1)", fill: "rgba(253,242,245,0.1)", lit: "#ffe2a8", shadow: false, blur: 0 },
Ocean: { bg: "linear-gradient(160deg, #0b2a3c 0%, #0e4d64 60%, #137a7f 100%)", tile: "rgba(255,255,255,0.1)", text: "#f2fbfc", text2: "rgba(242,251,252,0.8)", accent: "#5ce1e6", onAccent: "#04282b", separator: "rgba(255,255,255,0.16)", fill: "rgba(255,255,255,0.14)", lit: "#ffe2a8", shadow: false, blur: 18 },
Sunrise: { bg: "linear-gradient(160deg, #fff4e6 0%, #ffe1e8 100%)", tile: "#ffffff", text: "#2b1d14", text2: "#7a5640", accent: "#c2410c", separator: "rgba(43,29,20,0.1)", fill: "rgba(43,29,20,0.07)", lit: "#ffe0a3", shadow: true, blur: 0 },
Ocean: { bg: "linear-gradient(160deg, #0b2a3c 0%, #0e4d64 60%, #137a7f 100%)", tile: "rgba(255,255,255,0.1)", text: "#f2fbfc", text2: "rgba(242,251,252,0.8)", accent: "#5ce1e6", onAccent: "#04282b", separator: "rgba(255,255,255,0.16)", fill: "rgba(255,255,255,0.14)", lit: "#ffe2a8", shadow: false, blur: 18 }, // taste-skill-ignore: the Ocean theme's own background, chosen by the user
Sunrise: { bg: "linear-gradient(160deg, #fff4e6 0%, #ffe1e8 100%)", tile: "#ffffff", text: "#2b1d14", text2: "#7a5640", accent: "#c2410c", separator: "rgba(43,29,20,0.1)", fill: "rgba(43,29,20,0.07)", lit: "#ffe0a3", shadow: true, blur: 0 }, // taste-skill-ignore: the Sunrise theme's own background, chosen by the user
Lavender: { bg: "#f3f0fa", tile: "#ffffff", text: "#231a3a", text2: "#675b84", accent: "#5b3fc4", separator: "rgba(35,26,58,0.1)", fill: "rgba(35,26,58,0.07)", lit: "#ffe9c2", shadow: true, blur: 0 },
Mint: { bg: "#eaf6f0", tile: "#ffffff", text: "#10291d", text2: "#4a6657", accent: "#0b7a53", separator: "rgba(16,41,29,0.1)", fill: "rgba(16,41,29,0.07)", lit: "#ffeec2", shadow: true, blur: 0 },
Sand: { bg: "#f3ebdf", tile: "#fffaf2", text: "#2d261d", text2: "#6b5e4c", accent: "#9a5416", separator: "rgba(45,38,29,0.1)", fill: "rgba(45,38,29,0.07)", lit: "#ffe2a8", shadow: true, blur: 0 },
Midnight: { bg: "#000000", tile: "#0f0f10", text: "#f2f2f2", text2: "#a3a3a8", accent: "#4da3ff", onAccent: "#001b33", separator: "rgba(255,255,255,0.1)", fill: "rgba(255,255,255,0.1)", lit: "#ffe2a8", shadow: false, blur: 0 },
Espresso: { bg: "#1a1411", tile: "#261d18", text: "#f3ebe4", text2: "#c0ada0", accent: "#e0a35a", onAccent: "#24170b", separator: "rgba(243,235,228,0.12)", fill: "rgba(243,235,228,0.1)", lit: "#ffd9a0", shadow: false, blur: 0 },
Ember: { bg: "#151515", tile: "#212121", text: "#f5f5f5", text2: "#aaaaaa", accent: "#ff7a45", onAccent: "#2a0e02", separator: "rgba(255,255,255,0.1)", fill: "rgba(255,255,255,0.1)", lit: "#ffd9a8", shadow: false, blur: 0 },
Lagoon: { bg: "#0c1a1c", tile: "#142729", text: "#e8f5f4", text2: "#9fbcb9", accent: "#3cc9b8", onAccent: "#04201d", separator: "rgba(232,245,244,0.12)", fill: "rgba(232,245,244,0.1)", lit: "#ffe2a8", shadow: false, blur: 0 },
Paper: { bg: "#f4f1ea", tile: "#fbfaf6", text: "#22201c", text2: "#5f5a52", accent: "#1f5f8b", separator: "rgba(34,32,28,0.12)", fill: "rgba(34,32,28,0.07)", lit: "#ffe6b0", shadow: false, blur: 0 },
Arctic: { bg: "#eef3f8", tile: "#ffffff", text: "#10202e", text2: "#4a5d6e", accent: "#0b6bcb", separator: "rgba(16,32,46,0.1)", fill: "rgba(16,32,46,0.06)", lit: "#ffecc4", shadow: true, blur: 0 },
Moss: { bg: "#e9ede4", tile: "#f7f9f4", text: "#1d2619", text2: "#4f5c48", accent: "#3f6f2a", separator: "rgba(29,38,25,0.12)", fill: "rgba(29,38,25,0.07)", lit: "#f6e7b4", shadow: false, blur: 0 },
Sakura: { bg: "#fbf0f2", tile: "#ffffff", text: "#2a1a1f", text2: "#6e5560", accent: "#c2185b", separator: "rgba(42,26,31,0.1)", fill: "rgba(42,26,31,0.06)", lit: "#ffe5c4", shadow: true, blur: 0 },
Concrete: { bg: "#e4e4e2", tile: "#f2f2f0", text: "#1c1c1b", text2: "#555553", accent: "#2b2b2a", onAccent: "#ffffff", separator: "rgba(28,28,27,0.12)", fill: "rgba(28,28,27,0.07)", lit: "#ffe9b8", shadow: false, blur: 0 },
"High contrast": { bg: "#000000", tile: "#141414", text: "#ffffff", text2: "#ffffff", accent: "#ffd60a", onAccent: "#000000", separator: "rgba(255,255,255,0.7)", fill: "rgba(255,255,255,0.2)", lit: "#ffd60a", shadow: false, blur: 0, outline: true },
};
@@ -676,6 +685,8 @@
let previewAs = null; // Admin → People → Preview: { id, name, ids }
let lockedKiosk = false; // people who aren't admins can't leave kiosk mode
/** What a person may see: { all: true } or { dashboards: [...] }, plus the one that opens first (home). */
/** A person's own look, chosen by an admin: a theme, a text size, a language. */
const lookOf = () => (previewAs ? store.looks?.[previewAs.id] : isAdmin() ? store.looks?.[me?.id] : store.look) || {};
const accessFor = (uid) => store.access?.users?.[uid] || store.access?.everyone || { all: true };
const visibleFor = (uid) => { const a = accessFor(uid); return store.dashboards.filter((d) => a.all || (a.dashboards || []).includes(d.id)).map((d) => d.id); };
let current = null;
@@ -821,7 +832,10 @@
}
function applyTheme() {
const t = themeVariant(nightDark ? { ...current.theme, ...PRESETS.Dark, preset: "Dark" } : current.theme), s = stageEl.style;
const own = lookOf().theme, base = own && PRESETS[own] ? { ...current.theme, ...PRESETS[own], preset: own, bgImage: "" } : current.theme;
const t = themeVariant(nightDark ? { ...base, ...PRESETS.Dark, preset: "Dark" } : base), s = stageEl.style;
document.body.classList.toggle("text-large", lookOf().scale === "large");
document.body.classList.toggle("text-xl", lookOf().scale === "xl");
stageEl.classList.remove("surface-cards", "surface-outline", "surface-plain");
stageEl.classList.add(`surface-${t.surface || "cards"}`);
for (const k of COLOR_KEYS) {
@@ -1318,7 +1332,7 @@
</div>
<div class="section"><div class="section-title">${tr("Appearance")}</div>
<div class="presets" role="group" aria-label="${tr("Theme")}">${Object.entries(PRESETS).map(([n, p]) => {
const bg = p.swatch ? `linear-gradient(135deg, ${p.swatch[0]} 50%, ${p.swatch[1]} 50%)` : p.bg;
const bg = p.swatch ? `linear-gradient(135deg, ${p.swatch[0]} 50%, ${p.swatch[1]} 50%)` : p.bg; // taste-skill-ignore: the System swatch: half light, half dark, with a hard edge
const fg = p.swatch ? "#6e6e73" : p.text;
return `<button class="preset" data-preset="${n}" aria-pressed="${t.preset === n}" style="background:${bg};color:${fg}"><i style="background:${p.accent || "#0a84ff"}"></i><span>${tr(n)}</span></button>`;
}).join("")}</div>
@@ -2519,8 +2533,8 @@
// People who aren't admins stay in kiosk mode: the corner switches between the dashboards they may see.
const others = lockedKiosk ? store.dashboards.filter((d) => d.id !== current.id) : [];
const choice = await openModal(lockedKiosk
? `<h3>${tr("Dashboards")}</h3>${others.length ? `<div class="kp-dashes">${others.map((d) => `<button class="btn" data-x="dash:${d.id}">${Icons.svg("layout")}${esc(d.name)}</button>`).join("")}</div>` : `<p>${tr("This is the only dashboard you can see. An admin can add more for you.")}</p>`}
<div class="modal-actions">${IN_APP ? `<button class="btn" data-x="haven">${tr("Change address")}</button>` : ""}<button class="btn" data-x="guest">${guestOn ? tr("End guest mode") : tr("Guest mode")}</button><button class="btn" data-x="full">${document.fullscreenElement ? tr("Exit full screen") : tr("Full screen")}</button><button class="btn primary" data-x="stay" autofocus>${tr("Close")}</button></div>`
? `<h3>${tr("Dashboards")}</h3>${me?.guest && me.until ? `<p class="muted small">${esc(tr("This invite ends {0}.", new Date(me.until * 1000).toLocaleString(LOCALE, { weekday: "long", hour: "2-digit", minute: "2-digit" })))}</p>` : ""}${others.length ? `<div class="kp-dashes">${others.map((d) => `<button class="btn" data-x="dash:${d.id}">${Icons.svg("layout")}${esc(d.name)}</button>`).join("")}</div>` : `<p>${tr("This is the only dashboard you can see. An admin can add more for you.")}</p>`}
<div class="modal-actions">${IN_APP ? `<button class="btn" data-x="haven">${tr("Change address")}</button>` : ""}<button class="btn guest-hide" data-x="guest">${guestOn ? tr("End guest mode") : tr("Guest mode")}</button><button class="btn" data-x="full">${document.fullscreenElement ? tr("Exit full screen") : tr("Full screen")}</button><button class="btn primary" data-x="stay" autofocus>${tr("Close")}</button></div>`
: `<h3>${tr("Leave kiosk mode?")}</h3><p>${tr("This closes by itself in a few seconds.")}</p>
<div class="modal-actions"><button class="btn" data-x="guest">${guestOn ? tr("End guest mode") : tr("Guest mode")}</button><button class="btn" data-x="full">${document.fullscreenElement ? tr("Exit full screen") : tr("Full screen")}</button><button class="btn" data-x="stay" autofocus>${tr("Stay")}</button><button class="btn primary" data-x="exit">${tr("Leave kiosk")}</button></div>`,
(e, close) => { const x = e.target.closest("[data-x]")?.dataset.x; if (x) close(x); });
@@ -2977,6 +2991,14 @@
scheduleSave();
}
/** A language an admin chose for this person; this device's own choice still comes first. */
function syncPersonLang() {
const want = lookOf().lang || "";
if ((lsGet("ds.personLang") || "") === want) return;
lsSet("ds.personLang", want);
if (window.I18n.preference() === "auto" && (want || window.I18n.lang) !== window.I18n.lang) location.reload();
}
/** People who aren't admins, with no dashboard chosen for them yet. */
function showEmpty() {
const el = $("#empty-note");
@@ -3283,10 +3305,10 @@
// Dashboards, the people who use them and what each may see, devices, Haven, and who changed what.
const ADMIN_SECTIONS = [
["dashboards", "layout", "Dashboards"], ["people", "people", "People"], ["devices", "tv", "Devices"],
["haven", "phone", "Haven app"], ["activity", "history", "Activity"], ["about", "sparkles", "About"],
["guests", "qr", "Guests"], ["haven", "phone", "Haven app"], ["activity", "history", "Activity"], ["about", "sparkles", "About"],
];
let adminSection = ADMIN_SECTIONS.some(([k]) => k === lsGet("ds.admin")) ? lsGet("ds.admin") : "dashboards";
let peopleList = null, devicesSeen = null;
let peopleList = null, devicesSeen = null, lastInvite = null;
const ago = (at) => (at ? relTime(new Date(at * 1000).toISOString()) : tr("never"));
const initials = (n) => esc((n || "?").trim().slice(0, 1).toUpperCase());
async function loadPeople(force) {
@@ -3339,6 +3361,14 @@
${(own || key === "everyone") && !entry.all ? `<div class="adm-chips">${dashChecks(key, entry)}</div>` : ""}
${own || key === "everyone" ? `<label class="field"><span>${tr("Opens first")}</span>${homeSel(key, entry)}</label>` : ""}
</div>`;
const lookBlock = (uid) => {
const l = store.looks?.[uid] || {};
const sel = (k, opts, label) => `<label class="field"><span>${tr(label)}</span><select class="input" data-k="look-${k}" data-who="${esc(uid)}">${opts.map(([v, n]) => `<option value="${v}" ${(l[k] || "") === v ? "selected" : ""}>${esc(tr(n))}</option>`).join("")}</select></label>`;
return `<div class="p-look">
${sel("theme", [["", "The dashboard’s theme"], ...Object.keys(PRESETS).filter((n) => n !== "System").map((n) => [n, n])], "Theme")}
${sel("scale", [["", "Normal"], ["large", "Large"], ["xl", "Extra large"]], "Text")}
${sel("lang", [["", "Automatic"], ["en", "English"], ["nl", "Nederlands"]], "Language")}</div>`;
};
const rows = list.map((p) => {
const own = acc.users[p.id];
const entry = own || acc.everyone || { all: true };
@@ -3348,6 +3378,7 @@
<div class="muted small">${p.admin ? tr("Home Assistant administrator · sees and builds everything") : tr("Sees {0} · always in kiosk mode", visibleFor(p.id).length === store.dashboards.length ? tr("all dashboards") : tr("{0} of {1} dashboards", visibleFor(p.id).length, store.dashboards.length))} · ${esc(tr("last opened {0}", ago(p.seen)))}</div></div>
${p.admin ? "" : `<button class="btn" data-a="preview">${Icons.svg("eye")}<span class="label-wide">${tr("Preview")}</span></button>`}</div>
${p.admin ? "" : accessBlock(p.id, entry, !!own)}
${lookBlock(p.id)}
</article>`;
}).join("");
return `<header class="adm-head"><div><h2>${tr("People")}</h2>
@@ -3388,6 +3419,36 @@
<button class="btn primary" data-a="announce">${tr("Send")}</button></div></section>`;
},
async guests() {
let data;
try { data = await api("api/invites"); } catch (e) { return `<p class="muted">${esc(e.message)}</p>`; }
const host = lsGet("ds.guestHost") || location.hostname;
const link = (tok) => (data.port ? `http://${host}:${data.port}/g/${tok}/` : "");
const when = (at) => new Date(at * 1000).toLocaleString(LOCALE, { weekday: "short", day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });
const fresh = data.invites.find((i) => i.token === lastInvite && !i.ended);
const rows = data.invites.map((i) => `<div class="act-row inv-row ${i.ended ? "ended" : ""}" data-token="${esc(i.token)}"><span class="adm-avatar">${Icons.svg("qr")}</span>
<div class="grow"><div><b>${esc(i.name || tr("Guests"))}</b> · ${esc(i.dashboardName)}</div>
<div class="muted small">${i.ended ? esc(tr("Ended {0}", when(i.until))) : esc(tr("Until {0}", when(i.until)))}${i.by ? ` · ${esc(tr("by {0}", i.by))}` : ""}</div></div>
${i.ended ? "" : `<button class="btn" data-a="inv-show">${tr("QR code")}</button><button class="btn danger" data-a="inv-end">${tr("End")}</button>`}</div>`).join("");
return `<header class="adm-head"><div><h2>${tr("Guests")}</h2>
<p class="muted">${tr("An invite opens one dashboard on a guest’s phone, without a Home Assistant login, until the time you choose. Guests can only see and control what is on that dashboard, and only on your home Wi-Fi.")}</p></div></header>
${data.port ? "" : `<section class="adm-panel notice"><h3>${Icons.svg("wifi")}${tr("Turn on guest links first")}</h3>
<p class="muted small">${tr("In Home Assistant, open Settings, Apps, Dashboard Studio, Configuration. Under Network, give “Guest invite links” a port, for example 8199, and save. Then come back here.")}</p></section>`}
<section class="adm-panel"><h3>${Icons.svg("plus")}${tr("New invite")}</h3>
<div class="inv-form">
<label class="field"><span>${tr("For")}</span><input class="input" id="inv-name" placeholder="${tr("e.g. Babysitter, Grandma and Grandpa")}"></label>
<label class="field"><span>${tr("Dashboard")}</span><select class="input" id="inv-dash">${store.dashboards.map((d) => `<option value="${d.id}">${esc(d.name)}</option>`).join("")}</select></label>
<label class="field"><span>${tr("Works until")}</span><select class="input" id="inv-for">
<option value="4">${tr("4 hours from now")}</option><option value="tonight">${tr("Tonight, 23:59")}</option><option value="24" selected>${tr("24 hours from now")}</option>
<option value="weekend">${tr("After the weekend (Monday 10:00)")}</option><option value="168">${tr("A week from now")}</option></select></label>
<button class="btn primary" data-a="inv-new" ${data.port ? "" : "disabled"}>${tr("Make invite")}</button></div>
<label class="field inv-host"><span>${tr("Address for the link")}</span><input class="input" id="inv-host" value="${esc(host)}" aria-describedby="inv-host-hint"></label>
<p class="muted small" id="inv-host-hint">${tr("The address of Home Assistant on your home network, such as homeassistant.local or 192.168.1.20.")}</p>
${fresh ? `<div class="inv-fresh"><div class="share-qr">${window.QR.svg(link(fresh.token), { label: tr("QR code for the invite") })}</div>
<div class="share-row"><input class="input" readonly value="${esc(link(fresh.token))}" id="inv-link"><button class="btn" data-a="inv-copy">${Icons.svg("copy")}${tr("Copy")}</button></div></div>` : ""}</section>
<div class="adm-list" style="margin-top:18px">${rows || `<p class="muted">${tr("No invites yet.")}</p>`}</div>`;
},
async haven() {
const addr = location.origin;
return `<header class="adm-head haven-head"><img src="static/haven-icon.svg" alt=""><div><h2>${tr("Haven app")}</h2>
@@ -3521,10 +3582,36 @@
if (act === "copy-addr") { navigator.clipboard?.writeText($("#haven-addr").value).then(() => toast(tr("Copied."))).catch(() => $("#haven-addr").select()); return; }
if (act === "history") { setCurrent(b.closest("[data-dash]").dataset.dash); await historyDialog(); return; }
if (act === "whatsnew") whatsNew();
if (act === "inv-new") {
const v = $("#inv-for").value, now = new Date();
let until;
if (v === "tonight") { until = new Date(now); until.setHours(23, 59, 0, 0); }
else if (v === "weekend") { until = new Date(now); until.setDate(now.getDate() + ((8 - now.getDay()) % 7 || 7)); until.setHours(10, 0, 0, 0); }
else until = new Date(now.getTime() + Number(v) * 3600e3);
try {
const r = await fetch(new URL("api/invites", BASE), { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: $("#inv-name").value.trim(), dashboard: $("#inv-dash").value, until: until.getTime() / 1000 }) });
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || r.status);
lastInvite = (await r.json()).token;
} catch (err) { toast(tr("Couldn’t make the invite: {0}", err.message), true); return; }
renderAdmin("guests");
return;
}
if (act === "inv-show") { lastInvite = b.closest("[data-token]").dataset.token; renderAdmin("guests"); $("#admin .adm-body").scrollTop = 0; return; }
if (act === "inv-end") {
const tok = b.closest("[data-token]").dataset.token;
if (!(await confirmDestructive(tr("End this invite?"), tr("The link stops working at once, also on phones that have it open."), tr("End")))) return;
await fetch(new URL(`api/invites/${encodeURIComponent(tok)}`, BASE), { method: "DELETE" }).catch(() => {});
if (lastInvite === tok) lastInvite = null;
renderAdmin("guests");
return;
}
if (act === "inv-copy") { navigator.clipboard?.writeText($("#inv-link").value).then(() => toast(tr("Copied."))).catch(() => $("#inv-link").select()); return; }
});
$("#admin").addEventListener("change", (e) => {
const f = e.target, k = f.dataset.k;
if (f.id === "inv-host") { lsSet("ds.guestHost", f.value.trim()); renderAdmin("guests"); return; }
if (f.closest("[data-dev]")) {
const id = f.closest("[data-dev]").dataset.dev, d = (store.assign.devices[id] ||= {});
d[k] = f.type === "checkbox" ? f.checked : f.value;
@@ -3532,6 +3619,16 @@
scheduleSave();
return;
}
if (k?.startsWith("look-")) {
const uid = f.dataset.who, key = k.slice(5);
store.looks ||= {};
const l = { ...(store.looks[uid] || {}) };
if (f.value) l[key] = f.value; else delete l[key];
if (Object.keys(l).length) store.looks[uid] = l; else delete store.looks[uid];
scheduleSave();
if (uid === me?.id) { applyTheme(); syncPersonLang(); }
return;
}
if (k === "dash") {
const boxes = [...f.closest(".adm-chips").querySelectorAll("input:checked")].map((x) => x.value);
setAccess(f.dataset.who, (cur) => ({ ...cur, all: false, dashboards: boxes, home: boxes.includes(cur.home) ? cur.home : undefined }));
@@ -3588,7 +3685,7 @@
// Screens pick up what an admin changes (a dashboard, or who sees what) within a minute, without a reload.
let storeKey = "";
const keyOf = (s) => JSON.stringify([s.dashboards, s.access, s.assign?.devices]);
const keyOf = (s) => JSON.stringify([s.dashboards, s.access, s.assign?.devices, s.look]);
async function refreshStore() {
if (editMode || mode === "admin" || savePending || previewAs || modalClose) return;
let next;
@@ -3603,6 +3700,7 @@
const home = isAdmin() ? store.access?.users?.[me?.id]?.home : store.access?.me?.home;
const keep = [current?.id, store.assign.devices[DEVICE_ID]?.dashboard, home].find((id) => id && store.dashboards.some((d) => d.id === id));
setCurrent(keep);
syncPersonLang();
}
setInterval(refreshStore, 60 * 1000);
ha.onChange = onStateChange;
@@ -3623,9 +3721,10 @@
// In Haven, a session that couldn't be renewed means signing in again on Home Assistant's page.
if (IN_APP && (await fetch(new URL("api/version", BASE)).then((r) => r.status === 401).catch(() => false))) { window.HavenApp.reconnect?.(); return; }
loadCachedStates();
if ("serviceWorker" in navigator && APP_VERSION !== "dev") navigator.serviceWorker.register(new URL("sw.js", BASE), { scope: BASE.pathname }).catch(() => {});
if ("serviceWorker" in navigator && APP_VERSION !== "dev" && !location.pathname.startsWith("/g/")) navigator.serviceWorker.register(new URL("sw.js", BASE), { scope: BASE.pathname }).catch(() => {});
me = await api("api/whoami").catch(() => null);
if (me) { role = me.admin ? "admin" : "basic"; lsSet("ds.role", role); }
if (me) { role = me.admin ? "admin" : "basic"; if (!me.guest) lsSet("ds.role", role); }
document.body.classList.toggle("guest", !!me?.guest);
try {
const r = await fetch(new URL(`api/dashboards?device=${DEVICE_ID}`, BASE));
store = await r.json();
@@ -3634,7 +3733,7 @@
store.assign = { devices: {}, users: {}, ...(store.assign || {}) };
} catch { toast(tr("Couldn’t load your dashboards. Check that the app is running."), true); }
// Let admins see this device on the Admin screen, to give it a dashboard.
fetch(new URL("api/devices/seen", BASE), { method: "POST", headers: { "Content-Type": "application/json" },
if (!me?.guest) fetch(new URL("api/devices/seen", BASE), { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ device: DEVICE_ID, app: IN_APP ? "haven" : "browser", kind: phoneScreen.matches ? "phone" : matchMedia("(pointer: coarse)").matches ? "tablet" : "computer" }) }).catch(() => {});
const firstReady = new Promise((res) => {
@@ -3645,6 +3744,7 @@
ha.connect();
if (isAdmin()) migrateAccess();
syncPersonLang();
storeKey = keyOf(store);
if (!store.dashboards?.length) {
if (!isAdmin()) { showEmpty(); return; }
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
{"version": "2.2.0", "code": 2}
+52
View File
@@ -723,6 +723,56 @@
"Energy now": "Energie nu",
"No power is flowing right now.": "Er stroomt nu geen vermogen.",
"Battery {0}%, charging": "Batterij {0}%, laadt",
"This invite ends {0}.": "Deze uitnodiging eindigt {0}.",
"Guests": "Gasten",
"Ended {0}": "Afgelopen {0}",
"Until {0}": "Tot {0}",
"QR code": "QR-code",
"End": "Beëindigen",
"An invite opens one dashboard on a guest’s phone, without a Home Assistant login, until the time you choose. Guests can only see and control what is on that dashboard, and only on your home Wi-Fi.": "Een uitnodiging opent één dashboard op de telefoon van een gast, zonder Home Assistant-account, tot het moment dat je kiest. Gasten zien en bedienen alleen wat op dat dashboard staat, en alleen via je wifi thuis.",
"Turn on guest links first": "Zet eerst gastlinks aan",
"In Home Assistant, open Settings, Apps, Dashboard Studio, Configuration. Under Network, give “Guest invite links” a port, for example 8199, and save. Then come back here.": "Open in Home Assistant Instellingen, Apps, Dashboard Studio, Configuratie. Geef onder Netwerk “Guest invite links” een poort, bijvoorbeeld 8199, en sla op. Kom daarna hier terug.",
"New invite": "Nieuwe uitnodiging",
"For": "Voor",
"e.g. Babysitter, Grandma and Grandpa": "bijv. Oppas, Opa en oma",
"Works until": "Werkt tot",
"4 hours from now": "Over 4 uur",
"Tonight, 23:59": "Vanavond, 23:59",
"24 hours from now": "Over 24 uur",
"After the weekend (Monday 10:00)": "Na het weekend (maandag 10:00)",
"A week from now": "Over een week",
"Make invite": "Uitnodiging maken",
"Address for the link": "Adres voor de link",
"The address of Home Assistant on your home network, such as homeassistant.local or 192.168.1.20.": "Het adres van Home Assistant in je thuisnetwerk, zoals homeassistant.local of 192.168.1.20.",
"QR code for the invite": "QR-code voor de uitnodiging",
"No invites yet.": "Nog geen uitnodigingen.",
"Couldn’t make the invite: {0}": "Uitnodiging maken lukte niet: {0}",
"End this invite?": "Deze uitnodiging beëindigen?",
"The link stops working at once, also on phones that have it open.": "De link werkt direct niet meer, ook op telefoons waar hij open staat.",
"The dashboard’s theme": "Thema van het dashboard",
"Normal": "Normaal",
"Large": "Groot",
"Extra large": "Extra groot",
"English": "English",
"Nederlands": "Nederlands",
"Cleaned": "Schoongemaakt",
"Time": "Tijd",
"Last clean": "Laatste schoonmaak",
"days": "dagen",
"hrs": "uur",
"mins": "min",
"Round {0} · {1}": "Ronde {0} · {1}",
"Look": "Uiterlijk",
"Official": "Officieel",
"Logo image (put the official logo in Home Assistant's www folder)": "Logo-afbeelding (zet het officiële logo in de www-map van Home Assistant)",
"Midnight": "Middernacht",
"Espresso": "Espresso",
"Ember": "Gloed",
"Lagoon": "Lagune",
"Arctic": "Arctisch",
"Moss": "Mos",
"Sakura": "Sakura",
"Concrete": "Beton",
"Today": "Vandaag", "Tomorrow": "Morgen", "All day": "Hele dag", "Busy": "Bezet",
};
@@ -745,6 +795,8 @@
function detect() {
const pref = read("ds.lang");
if (pref && pref !== "auto") return pref;
const person = read("ds.personLang"); // chosen for this person by an admin
if (person) return person;
let ha = read("selectedLanguage"); // Home Assistant stores it as a JSON string
try { ha = ha && JSON.parse(ha); } catch { /* plain string */ }
return String(ha || navigator.language || "en").toLowerCase().startsWith("nl") ? "nl" : "en";
+116 -84
View File
@@ -908,47 +908,7 @@ body.kiosk #tabnav { top: 6px; max-height: calc(100vh - 20px); }
@container (max-height: 200px) { .room-devices { display: none; } }
/* vacuum: compact on small cards, a robot with stats on bigger ones */
.vac { display: flex; flex-direction: column; height: 100%; gap: 12px; }
.vac-head { display: flex; align-items: center; gap: 12px; min-width: 0; }
.vac-batt { margin-left: auto; display: inline-flex; align-items: center; gap: 4px; font-size: 14px; font-weight: 600; color: var(--d-text-2); white-space: nowrap; }
.vac-batt .i { font-size: 18px; }
.vac-hero { flex: 1; min-height: 0; display: flex; align-items: center; justify-content: center; gap: 18px; }
.vac-map { width: 100%; height: 100%; object-fit: contain; border-radius: 14px; background: var(--d-fill); }
.vac-robot { position: relative; width: clamp(84px, 32cqh, 150px); aspect-ratio: 1; flex: none; display: grid; place-items: center; }
.vac-ring { position: absolute; inset: 0; transform: rotate(-90deg); }
.vac-ring circle { fill: none; stroke-width: 5; }
.vac-ring .track { stroke: var(--d-fill); }
.vac-ring .fill { stroke: var(--ok); stroke-linecap: round; transition: stroke-dasharray .6s; }
.vac-ring .fill.low { stroke: var(--crit); }
.vac-robot-ico { position: relative; z-index: 1; width: 62%; aspect-ratio: 1; border-radius: 50%; display: grid; place-items: center; font-size: clamp(30px, 12cqh, 54px); background: var(--d-fill); }
.state-cleaning .vac-robot-ico, .state-returning .vac-robot-ico { background: var(--accent-w); color: var(--d-on-accent, #fff); }
.vac-pulse { position: absolute; inset: 19%; border-radius: 50%; }
.vac-robot.is-cleaning .vac-pulse { animation: vac-pulse 1.8s ease-out infinite; border: 2px solid var(--accent-w); }
@keyframes vac-pulse { from { transform: scale(1); opacity: .7; } to { transform: scale(1.45); opacity: 0; } }
.vac-charge { position: absolute; z-index: 2; right: 8%; bottom: 8%; width: 28%; aspect-ratio: 1; border-radius: 50%; display: grid; place-items: center; background: var(--ok); color: #04210f; font-size: clamp(14px, 4cqh, 20px); box-shadow: 0 0 0 3px var(--w-bg, var(--d-tile)); }
.vac-hero-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.vac-headline { font-size: clamp(18px, 6cqh, 26px); font-weight: 700; line-height: 1.15; }
.vac-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(96px, 1fr)); gap: 8px; flex: none; }
.vac-stat { display: grid; grid-template-columns: auto 1fr; grid-template-rows: auto auto; column-gap: 8px; align-items: center; padding: 10px 12px; border-radius: 14px; background: var(--d-fill); min-width: 0; }
.vac-stat .i { grid-row: span 2; font-size: 18px; color: var(--d-text-2); }
.vac-stat-v { font-size: 17px; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.vac-stat-l { font-size: 12px; color: var(--d-text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.vac-fan { flex: none; background: var(--d-fill); }
.vac-fan button { color: inherit; display: grid; place-items: center; }
.vac-fan button[aria-pressed="true"] { background: var(--d-tile); color: var(--d-text); }
.vac-actions { display: flex; gap: 8px; flex: none; }
.vac-btn { flex: 1; display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 44px; padding: 0 12px; border-radius: 14px; background: var(--d-fill); font-size: 14px; font-weight: 600; }
.vac-btn .i { font-size: 18px; flex: none; }
.vac-btn.primary { background: var(--d-text); color: var(--d-tile); }
.vac-btn:disabled { opacity: .4; cursor: default; }
.vac.state-error .state { color: var(--crit); font-weight: 600; }
/* the more room, the more it shows */
@container (max-width: 330px) { .vac-btn span { display: none; } .vac-hero-text { display: none; } }
@container (max-height: 370px) { .vac-stats { display: none; } }
@container (max-height: 330px) { .vac-fan { display: none; } }
@container (max-height: 250px) { .vac-hero { display: none; } }
@container (min-height: 251px) { .vac-head .vac-batt, .vac-head .state { display: none; } }
@container (min-height: 251px) and (max-width: 330px) { .vac-head .vac-batt { display: inline-flex; } }
/* photos */
.photos { position: absolute; inset: 0; background: #000; overflow: hidden; }
@@ -1210,30 +1170,6 @@ body.kiosk #tabnav { top: 6px; max-height: calc(100vh - 20px); }
.sp-extra { font-size: 12px; color: var(--d-text-2); text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Formula One */
.f1 { display: flex; flex-direction: column; gap: 10px; height: 100%; min-height: 0; }
.f1-next { padding: 12px; border-radius: 14px; }
.f1-top { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--d-text-2); }
.f1-top .i { color: #e10600; font-size: 15px; }
.f1-top b { margin-left: auto; color: var(--d-text); font-weight: 600; }
.f1-race { font-size: 18px; font-weight: 700; margin-top: 4px; letter-spacing: -0.01em; }
.f1-place { font-size: 12px; color: var(--d-text-2); }
.f1-sessions { display: flex; flex-direction: column; gap: 2px; margin-top: 8px; font-size: 12px; }
.f1-sessions > div { display: flex; justify-content: space-between; gap: 8px; }
.f1-sessions .past { opacity: .5; }
.f1-sessions .next { font-weight: 700; color: var(--d-text); }
.f1-tabs { display: flex; gap: 4px; padding: 3px; border-radius: 10px; background: var(--d-fill); }
.f1-tabs button { flex: 1; padding: 5px 4px; border-radius: 8px; font-size: 12px; font-weight: 600; color: var(--d-text-2); }
.f1-tabs button[aria-selected="true"] { background: var(--d-tile); color: var(--d-text); box-shadow: 0 1px 3px rgba(0,0,0,.12); }
.f1-list { display: flex; flex-direction: column; overflow-y: auto; min-height: 0; }
.f1-row { display: grid; grid-template-columns: 26px minmax(0, 1fr) auto; align-items: center; gap: 8px; padding: 6px 2px; border-bottom: 1px solid var(--d-separator); }
.f1-row:last-child { border-bottom: 0; }
.f1-pos { font-weight: 700; color: var(--d-text-2); text-align: center; }
.f1-row:first-child .f1-pos { color: #e10600; }
.f1-name { display: flex; flex-direction: column; min-width: 0; }
.f1-name b { font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.f1-name span { font-size: 11px; color: var(--d-text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.f1-val { font-size: 13px; font-weight: 600; }
@container (max-height: 380px) { .f1-sessions { display: none; } }
/* announcements */
#announce { position: fixed; inset: 0; z-index: 95; display: grid; place-items: center; padding: 24px; background: rgba(0, 0, 0, .72); backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px); }
@@ -1697,18 +1633,6 @@ body.in-haven #btn-new { display: none; }
.room-dev.on { background: color-mix(in srgb, var(--d-text) 88%, transparent); }
/* vacuum: the state in words, the battery as a bar */
.vac-hero { justify-content: flex-start; align-items: center; }
.vac-hero-text { gap: 8px; width: 100%; }
.vac-headline { font-size: clamp(26px, 9cqh, 40px); line-height: 1.1; }
.vac-meter { display: block; height: 6px; border-radius: 3px; background: var(--d-fill); overflow: hidden; max-width: 320px; }
.vac-meter i { display: block; height: 100%; border-radius: 3px; background: var(--ok); }
.vac-meter.low i { background: var(--crit); }
.vac-stat { padding: 10px 12px; border-radius: var(--c-r); grid-template-columns: 1fr; }
.vac-stat .i { display: none; }
.vac-stat-v { font-size: var(--c-fs-mid); font-weight: 400; }
.vac-stat-l { font-size: var(--c-fs-meta); }
.vac-btn { min-height: var(--c-ctl); border-radius: var(--c-r); font-size: var(--c-fs-body); }
@container (max-width: 330px) { .vac-hero-text { display: flex; } }
/* appliance: a thin progress ring with a plain icon inside */
.appl-ring circle { stroke-width: 4; }
@@ -1783,14 +1707,6 @@ body.in-haven #btn-new { display: none; }
.sp-sub, .sp-extra { font-size: var(--c-fs-meta); }
/* Formula One: the next race on one flat step, no gradient */
.f1-next { background: var(--d-fill); border-radius: var(--c-r); }
.f1-race { font-weight: 600; font-size: var(--c-fs-mid); }
.f1-place, .f1-top, .f1-sessions { font-size: var(--c-fs-meta); }
.f1-name span { font-size: 12px; }
.f1-name b { font-size: var(--c-fs-body); font-weight: 600; }
.f1-tabs button { font-size: var(--c-fs-meta); min-height: 30px; }
.f1-tabs button[aria-selected="true"] { box-shadow: none; }
.f1-pos { font-weight: 500; }
/* calendar */
.cal-sum { font-size: var(--c-fs-body); }
@@ -1928,3 +1844,119 @@ body:not([style*="--night-dim: 0."]):not([style*="--night-dim: 1"]) #night-dim {
.climate .round { width: 38px; height: 38px; }
}
.tm-time { font-size: clamp(20px, min(14cqh, 15cqw), 44px); }
/* vacuum: the robot beside its dock, the last clean, maintenance */
.vac { display: flex; flex-direction: column; height: 100%; gap: 12px; min-height: 0; }
.vac-head { display: flex; align-items: flex-start; gap: 12px; min-width: 0; }
.vac-title { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.vac-title .name { font-size: var(--c-fs-title); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.vac-status { font-size: clamp(22px, 8cqw, 30px); font-weight: 400; letter-spacing: -0.02em; line-height: 1.15; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.vac.state-error .vac-status { color: var(--crit); }
.vac-batt { flex: none; display: flex; flex-direction: column; align-items: flex-end; gap: 6px; padding-top: 2px; }
.vac-batt-v { font-size: var(--c-fs-mid); line-height: 1; }
.vac-batt-v small { font-size: .6em; color: var(--d-text-2); margin-left: 1px; }
.vac-meter { display: block; width: 64px; height: 5px; border-radius: 3px; background: var(--d-fill); overflow: hidden; }
.vac-meter i { display: block; height: 100%; border-radius: 3px; background: var(--d-text); }
.charging .vac-meter i { background: var(--ok); }
.vac-meter.low i { background: var(--crit); }
.vac-art { flex: 1; min-height: 64px; display: grid; place-items: center; }
.vac-map { width: 100%; height: 100%; object-fit: contain; border-radius: var(--c-r); background: var(--d-fill); }
.vac-scene { width: 100%; height: 100%; max-height: 132px; overflow: visible; }
.vs-dock { fill: color-mix(in srgb, var(--d-text) 12%, var(--w-bg, var(--d-tile))); }
.vs-plate { fill: color-mix(in srgb, var(--d-text) 30%, transparent); }
.vs-led { fill: color-mix(in srgb, var(--d-text) 30%, transparent); }
.vs-led.on { fill: var(--ok); }
.vs-trail { fill: none; stroke: var(--d-accent); stroke-width: 2; stroke-linecap: round; stroke-dasharray: 1 8; opacity: .8; }
.vs-robot { transition: transform .8s cubic-bezier(.3, .7, .3, 1); }
.vs-body { fill: color-mix(in srgb, var(--d-text) 7%, var(--w-bg, var(--d-tile))); stroke: color-mix(in srgb, var(--d-text) 26%, transparent); stroke-width: 2; }
.vs-bumper { fill: none; stroke: color-mix(in srgb, var(--d-text) 45%, transparent); stroke-width: 3; stroke-linecap: round; }
.vs-lidar { fill: color-mix(in srgb, var(--d-text) 20%, var(--w-bg, var(--d-tile))); stroke: color-mix(in srgb, var(--d-text) 35%, transparent); stroke-width: 1.5; }
.vs-brush line { stroke: color-mix(in srgb, var(--d-text) 40%, transparent); stroke-width: 1.5; stroke-linecap: round; }
.vs-robot.busy .vs-brush { animation: vs-spin .5s linear infinite; transform-box: fill-box; transform-origin: center; }
.vs-robot.busy .vs-lidar { stroke: var(--d-accent); }
.vs-robot.err .vs-body { stroke: var(--crit); }
@keyframes vs-spin { to { rotate: 360deg; } }
@media (prefers-reduced-motion: reduce) { .vs-robot.busy .vs-brush { animation: none; } .vs-robot { transition: none; } }
.vac-facts { display: flex; gap: 24px; flex-wrap: wrap; }
.vac-fact { display: flex; flex-direction: column; gap: 1px; }
.vac-fv { font-size: var(--c-fs-mid); line-height: 1.1; }
.vac-fl { font-size: var(--c-fs-meta); color: var(--d-text-2); }
.vac-maint { display: flex; flex-direction: column; gap: 6px; }
.vac-mrow { display: grid; grid-template-columns: minmax(0, 7em) minmax(0, 1fr) 3em; align-items: center; gap: 10px; font-size: var(--c-fs-meta); }
.vac-ml { color: var(--d-text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.vac-mbar { height: 4px; border-radius: 2px; background: var(--d-fill); overflow: hidden; }
.vac-mbar i { display: block; height: 100%; background: color-mix(in srgb, var(--d-text) 55%, transparent); }
.vac-mrow.low .vac-mbar i { background: var(--warn); }
.vac-mrow.low .vac-mv { color: var(--warn); }
.vac-mv { text-align: right; }
.vac-fan { flex: none; }
.vac-actions { display: flex; gap: 8px; flex: none; }
.vac-btn { flex: 1; display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: var(--c-ctl); padding: 0 12px; border-radius: var(--c-r); background: var(--d-fill); font-size: var(--c-fs-body); font-weight: 600; }
.vac-btn .i { font-size: 18px; flex: none; }
.vac-btn.primary { background: var(--d-text); color: var(--d-tile); }
.vac-btn:disabled { opacity: .4; cursor: default; }
@media (hover: hover) { .vac-btn:not(.primary):not(:disabled):hover { background: color-mix(in srgb, var(--d-text) 14%, transparent); } }
/* the more room, the more it shows */
@container (max-width: 330px) { .vac-btn span { display: none; } }
@container (max-height: 460px) { .vac-maint { display: none; } }
@container (max-height: 380px) { .vac-facts { display: none; } }
@container (max-height: 320px) { .vac-fan { display: none; } }
@container (max-height: 240px) { .vac-art { display: none; } }
@media (max-width: 640px) { .vac-btn span { display: none; } }
/* Formula One: the official look (carbon black, F1 red, white), or the dashboard's theme */
.f1 { display: flex; flex-direction: column; gap: 12px; height: 100%; min-height: 0; --f1-red: #e10600; --f1-ink: var(--d-text); --f1-mute: var(--d-text-2); --f1-step: var(--d-fill); }
.f1.f1-official { margin: calc(-1 * var(--c-pad)); padding: var(--c-pad); height: calc(100% + 2 * var(--c-pad)); border-radius: inherit;
background: #15151e; color: #fff; --f1-ink: #fff; --f1-mute: #b5b5c0; --f1-step: #26262f; --d-separator: #2c2c36; }
.f1-brand { display: flex; align-items: center; gap: 10px; min-height: 22px; }
.f1-logo { height: 20px; width: auto; max-width: 96px; object-fit: contain; }
.f1-mark { width: 30px; height: 8px; border-radius: 2px; background: var(--f1-red); transform: skewX(-24deg); }
.f1-round { font-size: var(--c-fs-meta); color: var(--f1-mute); margin-left: auto; }
.f1-next { display: flex; flex-direction: column; gap: 4px; }
.f1-race { font-size: clamp(20px, 7.5cqw, 30px); font-weight: 800; letter-spacing: -0.02em; line-height: 1.05; }
.f1-place { font-size: var(--c-fs-meta); color: var(--f1-mute); }
.f1-clock { display: flex; gap: 6px; margin-top: 8px; }
.f1-cd { display: flex; flex-direction: column; align-items: center; min-width: 52px; padding: 6px 8px; border-radius: 6px; background: var(--f1-step); }
.f1-cd b { font-size: var(--c-fs-mid); font-weight: 700; line-height: 1.1; }
.f1-cd small { font-size: 12px; color: var(--f1-mute); }
.f1-sessions { display: flex; flex-direction: column; margin-top: 8px; }
.f1-s { display: grid; grid-template-columns: 4.6em minmax(0, 1fr) auto; align-items: center; gap: 8px; min-height: 28px; padding: 0 8px; font-size: var(--c-fs-meta); border-left: 3px solid transparent; }
.f1-sd { color: var(--f1-mute); }
.f1-sn { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.f1-s.past { opacity: .45; }
.f1-s.next { border-left-color: var(--f1-red); background: var(--f1-step); font-weight: 700; }
.f1-tabs { display: flex; gap: 16px; border-bottom: 1px solid var(--d-separator); flex: none; }
.f1-tabs button { padding: 8px 0; font-size: var(--c-fs-meta); font-weight: 600; color: var(--f1-mute); border-bottom: 3px solid transparent; margin-bottom: -1px; }
.f1-tabs button[aria-selected="true"] { color: var(--f1-ink); border-bottom-color: var(--f1-red); }
.f1-list { display: flex; flex-direction: column; overflow-y: auto; min-height: 0; }
.f1-row { display: grid; grid-template-columns: 22px 4px minmax(0, 1fr) auto; align-items: center; gap: 10px; min-height: 40px; border-bottom: 1px solid var(--d-separator); }
.f1-row:last-child { border-bottom: 0; }
.f1-pos { font-weight: 700; text-align: right; }
.f1-stripe { display: block; width: 4px; height: 22px; border-radius: 1px; background: var(--team); }
.f1-name { display: flex; flex-direction: column; min-width: 0; }
.f1-name b { font-size: var(--c-fs-body); font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.f1-code { font-weight: 800; }
.f1-name > span { font-size: 12px; color: var(--f1-mute); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.f1-val { font-size: var(--c-fs-body); font-weight: 700; text-align: right; }
.f1-val small { font-size: 12px; font-weight: 400; color: var(--f1-mute); margin-left: 3px; }
@container (max-height: 520px) { .f1-sessions { display: none; } }
@container (max-height: 380px) { .f1-clock { display: none; } }
/* a person's text size, chosen by an admin */
body.text-large #grid { --c-fs-title: 17px; --c-fs-body: 17px; --c-fs-name: 18px; --c-fs-meta: 15px; --c-fs-mid: 25px; --c-ctl: 48px; --c-row: 52px; }
body.text-xl #grid { --c-fs-title: 19px; --c-fs-body: 19px; --c-fs-name: 21px; --c-fs-meta: 17px; --c-fs-mid: 28px; --c-ctl: 54px; --c-row: 58px; }
.p-look { margin: 12px 0 0 52px; display: flex; flex-wrap: wrap; gap: 8px 20px; }
.p-look .field { display: flex; align-items: center; gap: 8px; margin: 0; font-size: 13px; color: var(--text-2); }
.p-look .input { width: auto; min-width: 120px; }
@media (max-width: 760px) { .p-look { margin-left: 0; } }
/* guests */
body.guest .guest-hide { display: none; }
.inv-form { display: grid; grid-template-columns: 1.2fr 1fr 1fr auto; gap: 10px; align-items: end; margin-top: 12px; }
.inv-form .field { margin: 0; display: flex; flex-direction: column; gap: 6px; font-size: 13px; color: var(--text-2); }
.inv-host { margin: 14px 0 4px; display: flex; flex-direction: column; gap: 6px; font-size: 13px; color: var(--text-2); max-width: 360px; }
.inv-fresh { margin-top: 16px; max-width: 420px; }
.inv-fresh .share-qr { margin-left: 0; }
.inv-row.ended { opacity: .6; }
.adm-panel.notice { background: color-mix(in srgb, var(--warn) 10%, var(--surface)); }
@media (max-width: 760px) { .inv-form { grid-template-columns: 1fr; } }
+70 -28
View File
@@ -897,6 +897,24 @@
// ---------------------------------------------------------------- vacuum
// One layout that grows with the card: compact on small cards; a robot with a battery ring,
// stats and labelled buttons on bigger ones (see the container queries in style.css).
/** A robot vacuum seen from above beside its dock, placed and drawn by its state. */
function vacScene(state, charging) {
const docked = state === "docked", cleaning = state === "cleaning", returning = state === "returning", error = state === "error";
const x = docked ? 196 : returning ? 150 : cleaning ? 92 : 124;
const trail = cleaning || returning ? `<path class="vs-trail" d="M224 62 C 196 104, 150 18, ${x + 30} 60"/>` : "";
return `<svg class="vac-scene" viewBox="0 0 300 120" preserveAspectRatio="xMidYMid meet" role="img" aria-label="${esc(st(state))}">
<rect class="vs-dock" x="232" y="18" width="52" height="84" rx="10"/>
<rect class="vs-plate" x="226" y="44" width="10" height="32" rx="3"/>
<circle class="vs-led ${charging ? "on" : ""}" cx="266" cy="34" r="4"/>
${trail}
<g class="vs-robot ${cleaning ? "busy" : ""} ${error ? "err" : ""}" transform="translate(${x} 60)">
<circle class="vs-body" r="36"/>
<path class="vs-bumper" d="M -14 -32.4 A 35 35 0 0 0 -14 32.4"/>
<circle class="vs-lidar" cx="12" r="11"/>
<g class="vs-brush" transform="translate(-21 -21)"><line x1="0" y1="-6" x2="0" y2="6"/><line x1="-5.2" y1="3" x2="5.2" y2="-3"/><line x1="-5.2" y1="-3" x2="5.2" y2="3"/></g>
</g></svg>`;
}
W.vacuum = {
label: "Vacuum", icon: "vacuum", size: [4, 5],
fields: [
@@ -920,31 +938,32 @@
const hasBatt = isFinite(batt);
const charging = s.state === "docked" && hasBatt && batt < 100;
const headline = cleaning ? t("Cleaning…") : returning ? t("On its way to the dock") : charging ? t("Charging") : st(s.state);
const detail = a.status && !headline.toLowerCase().includes(String(a.status).toLowerCase()) ? a.status : "";
// the robot's own status only when it adds something (an error, a paused job), not "Returning home" twice
const detail = a.status && !["cleaning", "returning", "docked"].includes(s.state) && !headline.toLowerCase().includes(String(a.status).toLowerCase()) ? a.status : "";
const mapUrl = c.map ? (domainOf(c.map) === "image" ? ha.states[c.map]?.attributes.entity_picture : `api/camera/${encodeURIComponent(c.map)}?t=${Date.now()}`) : "";
const speeds = c.fan !== false && Array.isArray(a.fan_speed_list) ? a.fan_speed_list : [];
const stat = (label, value, ico) => `<div class="vac-stat">${Icons.svg(ico)}<span class="vac-stat-v num">${esc(value)}</span><span class="vac-stat-l">${esc(label)}</span></div>`;
const stats = [
hasBatt ? stat(t("Battery"), `${Math.round(batt)}%`, "battery") : "",
c.area && ha.states[c.area] ? stat(t("Cleaned area"), fmtState(ha.states[c.area]), "tile") : "",
c.duration && ha.states[c.duration] ? stat(t("Cleaning time"), fmtState(ha.states[c.duration]), "clock") : "",
...(c.entities || []).map((x) => ha.states[x] && stat(friendly(ha.states[x]).replace(new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*`, "i"), ""), fmtState(ha.states[x]), "pulse")),
].filter(Boolean).join("");
// The robot's own sensors: life left in percent goes under Maintenance, the rest with the last clean.
const S = (x) => ha.states[x];
const prefix = new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*`, "i");
const label = (x) => cap(friendly(S(x)).replace(prefix, "").replace(/\s+(left|life|remaining)$/i, ""));
const extra = (c.entities || []).filter((x) => S(x));
const maint = extra.filter((x) => S(x).attributes.unit_of_measurement === "%" && isFinite(Number(S(x).state)));
const facts = [
c.area && S(c.area) ? [t("Cleaned"), fmtState(S(c.area))] : null,
c.duration && S(c.duration) ? [t("Time"), fmtState(S(c.duration))] : null,
...extra.filter((x) => !maint.includes(x)).map((x) => [label(x), fmtState(S(x))]),
].filter(Boolean);
const btn = (svc, label, ico, primary, disabled) => `<button class="vac-btn wbtn ${primary ? "primary" : ""}" data-act="svc" data-e="${e}" data-v="vacuum.${svc}" ${disabled ? "disabled" : ""}>${Icons.svg(ico)}<span>${esc(label)}</span></button>`;
const btn = (svc, label, ico, primary, disabled) => `<button class="vac-btn wbtn ${primary ? "primary" : ""}" data-act="svc" data-e="${e}" data-v="vacuum.${svc}" ${disabled ? "disabled" : ""} aria-label="${esc(label)}">${Icons.svg(ico)}<span>${esc(label)}</span></button>`;
return `<div class="vac state-${esc(s.state)}">
<div class="vac-head"><span class="badge">${Icons.svg("vacuum")}</span>
<span class="tile-text"><span class="name">${esc(name)}</span><span class="state">${esc(headline)}${detail ? ` · ${esc(detail)}` : ""}</span></span>
${hasBatt ? `<span class="vac-batt">${Icons.svg(charging ? "bolt" : "battery")}<span class="num">${Math.round(batt)}%</span></span>` : ""}</div>
<div class="vac-hero">
${mapUrl ? `<img class="vac-map" src="${esc(mapUrl)}" ${domainOf(c.map) === "camera" ? `data-cam="${esc(c.map)}" data-refresh="${Number(c.refresh) || 5}"` : ""} alt="${t("Cleaning map")}">`
: `<div class="vac-hero-text"><span class="vac-headline">${esc(headline)}</span>
${hasBatt ? `<span class="vac-meter ${batt < 20 ? "low" : ""}" role="meter" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${Math.round(batt)}" aria-label="${t("Battery")}"><i style="width:${Math.round(batt)}%"></i></span>
<span class="wt num">${charging ? t("Battery {0}%, charging", Math.round(batt)) : t("Battery {0}%", Math.round(batt))}</span>` : ""}</div>`}
</div>
${stats ? `<div class="vac-stats">${stats}</div>` : ""}
return `<div class="vac state-${esc(s.state)} ${charging ? "charging" : ""}">
<div class="vac-head"><div class="vac-title"><span class="name">${esc(name)}</span><span class="vac-status">${esc(headline)}${detail ? ` · ${esc(detail)}` : ""}</span></div>
${hasBatt ? `<div class="vac-batt"><span class="vac-batt-v num">${Math.round(batt)}<small>%</small></span>
<span class="vac-meter ${batt < 20 ? "low" : ""}" role="meter" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${Math.round(batt)}" aria-label="${t("Battery")}"><i style="width:${Math.round(batt)}%"></i></span></div>` : ""}</div>
<div class="vac-art">${mapUrl ? `<img class="vac-map" src="${esc(mapUrl)}" ${domainOf(c.map) === "camera" ? `data-cam="${esc(c.map)}" data-refresh="${Number(c.refresh) || 5}"` : ""} alt="${t("Cleaning map")}">` : vacScene(s.state, charging)}</div>
${facts.length ? `<div class="vac-facts" aria-label="${t("Last clean")}">${facts.map(([l, v]) => `<div class="vac-fact"><span class="vac-fv num">${esc(v)}</span><span class="vac-fl">${esc(l)}</span></div>`).join("")}</div>` : ""}
${maint.length ? `<div class="vac-maint">${maint.map((x) => { const v = Math.max(0, Math.min(100, Number(S(x).state))); return `<div class="vac-mrow ${v < 15 ? "low" : ""}"><span class="vac-ml">${esc(label(x))}</span><span class="vac-mbar"><i style="width:${v}%"></i></span><span class="vac-mv num">${Math.round(v)}%</span></div>`; }).join("")}</div>` : ""}
${speeds.length ? `<div class="seg vac-fan" role="group" aria-label="${t("Suction power")}">${speeds.map((f) => `<button class="wbtn" aria-pressed="${f === a.fan_speed}" data-act="vac-fan" data-e="${e}" data-v="${esc(f)}">${esc(cap(f))}</button>`).join("")}</div>` : ""}
<div class="vac-actions">
${btn(cleaning ? "pause" : "start", cleaning ? t("Pause") : returning ? t("Resume") : t("Start"), cleaning ? "pause" : "play", true)}
@@ -1549,6 +1568,12 @@
const f1List = (s, ...keys) => { const a = s?.attributes || {}; for (const k of keys) if (Array.isArray(a[k])) return a[k]; return []; };
const f1Driver = (r) => { const d = r.Driver || r.driver || {}; return typeof d === "string" ? d : [d.givenName, d.familyName].filter(Boolean).join(" ") || d.code || ""; };
const f1Team = (r) => { const c = r.Constructor || (r.Constructors || [])[0] || r.constructor_name || r.team; return typeof c === "string" ? c : c?.name || ""; };
const f1Code = (r) => { const d = r.Driver || r.driver || {}; return (typeof d === "object" && d.code) || f1Driver(r).split(" ").pop().slice(0, 3).toUpperCase(); };
/** The teams' own colours, as on the timing tower. */
const F1_TEAMS = [["red bull", "#3671c6"], ["mclaren", "#ff8000"], ["ferrari", "#e80020"], ["mercedes", "#27f4d2"], ["aston", "#229971"],
["alpine", "#0093cc"], ["williams", "#64c4ff"], ["racing bulls", "#6692ff"], ["rb f1", "#6692ff"], ["visa", "#6692ff"], ["sauber", "#52e252"],
["kick", "#52e252"], ["audi", "#bb0a30"], ["haas", "#b6babd"], ["cadillac", "#c0a062"]];
const f1Colour = (team) => (F1_TEAMS.find(([k]) => String(team).toLowerCase().includes(k)) || [, "#8e8e93"])[1];
W.f1 = {
label: "Formula One", icon: "flag", size: [4, 5],
@@ -1559,6 +1584,8 @@
{ k: "results", t: "entity", l: "Last race results", domains: ["sensor"] },
{ k: "show", t: "seg", l: "Show first", o: ["drivers", "teams", "results"], d: "drivers" },
{ k: "top", t: "number", l: "Positions to show", d: 5 },
{ k: "look", t: "seg", l: "Look", o: ["official", "theme"], d: "official" },
{ k: "logo", t: "text", l: "Logo image (put the official logo in Home Assistant's www folder)", ph: "/local/f1-logo.svg" },
],
autofill: {
from: "next",
@@ -1579,7 +1606,8 @@
const m = Math.round((raceAt - Date.now()) / 60000), dd = Math.floor(m / 1440), hh = Math.floor((m % 1440) / 60);
countdown = dd ? t("in {0} d {1} h", dd, hh) : t("in {0} h {1} min", hh, m % 60);
}
const when = (d) => `${d.toLocaleDateString(LOCALE, { weekday: "short", day: "numeric", month: "short" })} ${d.toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" })}`;
const day = (d) => d.toLocaleDateString(LOCALE, { weekday: "short", day: "numeric" });
const time = (d) => d.toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" });
const sessions = F1_SESSIONS.map(([k, l]) => [l, iso(k)]).filter(([, v]) => v && !isNaN(new Date(v)));
const nextSession = sessions.find(([, v]) => new Date(v) > Date.now());
const place = [a.circuit_name || a.Circuit?.circuitName, a.circuit_locality || a.Circuit?.Location?.locality, a.circuit_country || a.Circuit?.Location?.country].filter(Boolean).join(" · ");
@@ -1592,17 +1620,31 @@
const list = cur[0] === "drivers" ? f1List(s, "driver_standings", "standings", "DriverStandings")
: cur[0] === "teams" ? f1List(s, "constructor_standings", "standings", "ConstructorStandings") : f1List(s, "results", "Results", "race_results");
rows = list.slice(0, top).map((r) => {
const name = cur[0] === "teams" ? f1Team(r) : f1Driver(r);
const sub = cur[0] === "teams" ? (r.wins != null ? t("{0} wins", r.wins) : "") : f1Team(r);
const val = cur[0] === "results" ? (r.Time?.time || r.time || r.status || "") : `${r.points ?? ""} ${t("pts")}`;
return `<div class="f1-row"><span class="f1-pos num">${esc(r.position ?? r.positionText ?? "")}</span><span class="f1-name"><b>${esc(name)}</b><span>${esc(sub)}</span></span><span class="f1-val num">${esc(val)}</span></div>`;
const team = f1Team(r), isTeam = cur[0] === "teams";
const name = isTeam ? team : f1Driver(r);
const sub = isTeam ? (r.wins != null ? t("{0} wins", r.wins) : "") : team;
const val = cur[0] === "results" ? (r.Time?.time || r.time || r.status || "") : `${r.points ?? ""}`;
return `<div class="f1-row" style="--team:${f1Colour(team)}"><span class="f1-pos num">${esc(r.position ?? r.positionText ?? "")}</span><i class="f1-stripe" aria-hidden="true"></i>
<span class="f1-name">${isTeam ? `<b>${esc(name)}</b>` : `<b><span class="f1-code">${esc(f1Code(r))}</span> ${esc(name)}</b>`}<span>${esc(sub)}</span></span>
<span class="f1-val num">${esc(val)}${cur[0] === "results" ? "" : `<small>${t("pts")}</small>`}</span></div>`;
}).join("");
}
// The countdown as the timing screens show it: days, hours, minutes.
let clock = "";
if (raceAt && raceAt > Date.now()) {
const m = Math.round((raceAt - Date.now()) / 60000);
clock = [[Math.floor(m / 1440), t("days")], [Math.floor((m % 1440) / 60), t("hrs")], [m % 60, t("mins")]]
.map(([v, l]) => `<span class="f1-cd"><b class="num">${String(v).padStart(2, "0")}</b><small>${esc(l)}</small></span>`).join("");
}
const raceName = a.race_name || a.raceName || (next && !/^\d{4}-/.test(next.state) ? next.state : "");
return `<div class="f1">
${next ? `<div class="f1-next"><div class="f1-top">${Icons.svg("flag")}<span>${esc(a.season ? `${a.season} · ${t("Round {0}", a.round)}` : t("Next race"))}</span>${countdown ? `<b>${esc(countdown)}</b>` : ""}</div>
const official = c.look !== "theme";
return `<div class="f1 ${official ? "f1-official" : ""}">
<div class="f1-brand">${c.logo ? `<img class="f1-logo" src="${esc(c.logo)}" alt="Formula 1">` : `<span class="f1-mark" aria-hidden="true"></span>`}
<span class="f1-round">${esc(a.season ? t("Round {0} · {1}", a.round, a.season) : t("Next race"))}</span></div>
${next ? `<div class="f1-next">
<div class="f1-race">${esc(raceName || t("Next race"))}</div>${place ? `<div class="f1-place">${esc(place)}</div>` : ""}
${sessions.length ? `<div class="f1-sessions">${sessions.map(([l, v]) => `<div class="${nextSession && nextSession[1] === v ? "next" : ""} ${new Date(v) < Date.now() ? "past" : ""}"><span>${esc(t(l))}</span><span class="num">${esc(when(new Date(v)))}</span></div>`).join("")}</div>` : ""}</div>` : ""}
${clock ? `<div class="f1-clock" aria-label="${esc(countdown)}">${clock}</div>` : ""}
${sessions.length ? `<div class="f1-sessions">${sessions.map(([l, v]) => { const d = new Date(v); return `<div class="f1-s ${nextSession && nextSession[1] === v ? "next" : ""} ${d < Date.now() ? "past" : ""}"><span class="f1-sd num">${esc(day(d))}</span><span class="f1-sn">${esc(t(l))}</span><span class="f1-st num">${esc(time(d))}</span></div>`; }).join("")}</div>` : ""}</div>` : ""}
${tabs.length ? `<div class="f1-tabs" role="tablist">${tabs.map(([k, l]) => `<button class="wbtn" role="tab" data-act="card-tab" data-v="${k}" aria-selected="${cur[0] === k}">${esc(t(l))}</button>`).join("")}</div>
<div class="f1-list">${rows || `<div class="empty">${t("No standings yet.")}</div>`}</div>` : ""}</div>`;
},