Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ddc0dba20 | ||
|
|
edca25affa | ||
|
|
7c62c50526 | ||
|
|
ca93176768 | ||
|
|
e508c80465 | ||
|
|
3a2e6faa69 | ||
|
|
5092369d49 |
@@ -4,3 +4,4 @@ __pycache__/
|
||||
.venv/
|
||||
.pytest_cache/
|
||||
*.metadata.json
|
||||
frontend/node_modules/
|
||||
|
||||
@@ -60,13 +60,101 @@ triggers:
|
||||
event_type: nestquest_child_day_complete
|
||||
conditions:
|
||||
- condition: template
|
||||
value_template: "{{ trigger.payload.child_name == 'Declan' }}"
|
||||
value_template: "{{ trigger.event.data.child_name == 'Declan' }}"
|
||||
actions:
|
||||
- action: notify.mobile_app_declans_phone
|
||||
data:
|
||||
message: "Every quest cleared — legendary!"
|
||||
```
|
||||
|
||||
## Automation Blueprints
|
||||
|
||||
NestQuest ships four automation blueprints in
|
||||
`custom_components/nestquest/blueprints/automation/`. Import them in
|
||||
Home Assistant under **Settings → Automations & Scenes → Blueprints**
|
||||
(or place the files in your `config/blueprints/automation/` folder),
|
||||
then create one automation per blueprint, filling the inputs with your
|
||||
household's choices.
|
||||
|
||||
### Wiring to the integration's notification settings
|
||||
|
||||
The integration's options flow records your notification choices —
|
||||
notify target, morning/afternoon/end-of-day times, and a toggle per
|
||||
automation. The blueprints take their own inputs at import time;
|
||||
fill them from the same values you stored in the options flow (the
|
||||
integration does not push its settings into imported automations):
|
||||
|
||||
| Blueprint input | Options-flow counterpart |
|
||||
|---|---|
|
||||
| `morning_time` (default 08:00) | `morning_summary_time` |
|
||||
| `afternoon_time` (default 15:00) | `afternoon_reminder_time` |
|
||||
| `report_time` (default 20:00) | `end_of_day_report_time` |
|
||||
| `send_summary` / `send_reminder` / `send_report` / `celebrate` | the four `*_enabled` toggles |
|
||||
| `notify_target` | `notify_target` (your notify service, e.g. `notify.mobile_app_your_phone`) |
|
||||
|
||||
The end-of-day report deliberately runs BEFORE the midnight missed
|
||||
sweep and reads the same day's data — do not schedule it at the
|
||||
day-rollover time.
|
||||
|
||||
### The four blueprints
|
||||
|
||||
1. **Morning summary** (`morning_summary.yaml`) — at the configured
|
||||
time, lists every child whose `binary_sensor.nestquest_<child>_present_today`
|
||||
is on and what they owe from `sensor.nestquest_<child>_quests_remaining_today`.
|
||||
Children not present today are omitted entirely.
|
||||
|
||||
2. **Afternoon reminder** (`afternoon_reminder.yaml`) — at the
|
||||
configured time, notifies only about present children who still
|
||||
have open quests. Sends nothing at all when every present child is
|
||||
clear.
|
||||
|
||||
3. **End-of-day report** (`end_of_day_report.yaml`) — one message per
|
||||
evening: each present child's completion count and the quests they
|
||||
still owe (the ones the midnight sweep will mark
|
||||
`nestquest_quest_missed`). Absent children are omitted. Sends
|
||||
nothing on an all-clear day unless `send_even_when_clear` is on.
|
||||
|
||||
4. **Day-complete celebration** (`day_complete_celebration.yaml`) —
|
||||
triggers on the `nestquest_child_day_complete` event (which only
|
||||
fires when quests were owed and all are complete, so a zero-quest
|
||||
day never celebrates). Create one `input_text` helper
|
||||
(`nestquest_last_celebrated`) for the dedupe: the automation
|
||||
records each celebrated child as `<child_id>:<today>` and
|
||||
celebrates each child at most once per day — a re-cleared day does
|
||||
not re-fire, but a second child clearing the same day still
|
||||
celebrates.
|
||||
|
||||
### Example package
|
||||
|
||||
```yaml
|
||||
# One automation per blueprint — Settings → Automations & Scenes →
|
||||
# Create → Blueprint, then pick each NestQuest blueprint.
|
||||
#
|
||||
# morning_summary: morning_time 08:00, notify_target notify.mobile_app_your_phone
|
||||
# afternoon_reminder: afternoon_time 15:00, same notify target
|
||||
# end_of_day_report: report_time 20:00, send_even_when_clear false, same target
|
||||
# day_complete_celebration: input_text.nestquest_last_celebrated,
|
||||
# celebration_message "Legendary! {{ trigger.event.data.child_name }} cleared every quest today!"
|
||||
#
|
||||
# Every blueprint has its own send/celebrate toggle input; turn an
|
||||
# automation off entirely from HA's automation menu.
|
||||
```
|
||||
|
||||
### Events the automations rely on
|
||||
|
||||
| Event | Payload |
|
||||
|---|---|
|
||||
| `nestquest_quest_completed` | child_id, child_name, instance_id, quest_title, window, due_date, due_time, occurred_at (UTC ISO-8601), was_on_time |
|
||||
| `nestquest_quest_uncompleted` | child_id, child_name, instance_id, quest_title, window, due_date, due_time, occurred_at |
|
||||
| `nestquest_child_day_complete` | child_id, child_name, quests_due, quests_completed, occurred_at |
|
||||
| `nestquest_quest_missed` | child_id, child_name, instance_id, quest_title, window, due_date, due_time, occurred_at (fired by the nightly sweep) |
|
||||
|
||||
Sensors the automations read: `sensor.nestquest_<child>_quests_due_today`
|
||||
(attributes: `child_id`, `child_name`, `present`, `instances`,
|
||||
`admin_instances`), `..._quests_remaining_today`,
|
||||
`..._quests_completed_today`, and
|
||||
`binary_sensor.nestquest_<child>_present_today`.
|
||||
|
||||
## Developer Setup and Testing
|
||||
|
||||
This project uses [`uv`](https://github.com/astral-sh/uv) for fast Python package and dependency management.
|
||||
@@ -84,6 +172,20 @@ To set up the development environment and install dependencies:
|
||||
uv sync
|
||||
```
|
||||
|
||||
### Frontend cards
|
||||
|
||||
The Lovelace cards live in `frontend/` (TypeScript + Lit) and build to a
|
||||
single bundle at `custom_components/nestquest/www/nestquest-cards.js`. The
|
||||
integration registers that bundle as a Lovelace resource automatically on
|
||||
setup — do not add it by hand. Card YAML is documented in
|
||||
`design/ENTITIES-AND-SERVICES.md` §4.
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
Run the test suite with `pytest`:
|
||||
|
||||
@@ -28,6 +28,7 @@ from .const import (
|
||||
LOGGER,
|
||||
PLATFORMS,
|
||||
)
|
||||
from .frontend import async_register_frontend
|
||||
from .coordinator import NestQuestCoordinator
|
||||
from .db import NestQuestDatabase
|
||||
from .materialize import materialize as _materialize_run
|
||||
@@ -410,11 +411,13 @@ def _deregister_services(hass: HomeAssistant) -> None:
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
|
||||
"""Set up the NestQuest integration. YAML configuration is not used, returns True."""
|
||||
await async_register_frontend(hass)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up NestQuest from a config entry."""
|
||||
await async_register_frontend(hass)
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
existing = hass.data[DOMAIN].get(entry.entry_id)
|
||||
if existing is not None:
|
||||
|
||||
@@ -34,9 +34,9 @@ blueprint:
|
||||
name: Celebration message
|
||||
description: >-
|
||||
The message sent when a child clears every quest today. May
|
||||
use the event payload: trigger.payload.child_name,
|
||||
trigger.payload.quests_due and trigger.payload.quests_completed.
|
||||
default: "Legendary! {{ trigger.payload.child_name }} cleared every quest today!"
|
||||
use the event payload: trigger.event.data.child_name,
|
||||
trigger.event.data.quests_due and trigger.event.data.quests_completed.
|
||||
default: "Legendary! {{ trigger.event.data.child_name }} cleared every quest today!"
|
||||
selector:
|
||||
text: {}
|
||||
celebrate:
|
||||
@@ -80,7 +80,7 @@ conditions:
|
||||
# reads as '' and celebrates.
|
||||
- condition: template
|
||||
value_template: >-
|
||||
{{ (trigger.payload.child_id ~ ':' ~ now().strftime('%Y-%m-%d'))
|
||||
{{ (trigger.event.data.child_id ~ ':' ~ now().strftime('%Y-%m-%d'))
|
||||
not in ((states(last_celebrated_helper) or '')
|
||||
.split(',')
|
||||
| select('ne', '')
|
||||
@@ -106,4 +106,4 @@ actions:
|
||||
{%- set ns.entries = ns.entries + [entry] -%}
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
{{ (ns.entries + [trigger.payload.child_id ~ ':' ~ today]) | join(',') }}
|
||||
{{ (ns.entries + [trigger.event.data.child_id ~ ':' ~ today]) | join(',') }}
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Lovelace card bundle registration for NestQuest."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from homeassistant.components.frontend import add_extra_js_url
|
||||
from homeassistant.components.http import StaticPathConfig
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
WWW_DIR = Path(__file__).parent / "www"
|
||||
FRONTEND_URL_BASE = "/nestquest-static"
|
||||
BUNDLE_FILENAME = "nestquest-cards.js"
|
||||
BUNDLE_URL = f"{FRONTEND_URL_BASE}/{BUNDLE_FILENAME}"
|
||||
_REGISTERED_ATTR = "_nestquest_frontend_registered"
|
||||
|
||||
|
||||
async def async_register_frontend(hass: HomeAssistant) -> None:
|
||||
"""Serve the card bundle and inject it into every Lovelace page.
|
||||
|
||||
Idempotent across config entries: static paths and extra JS URLs are
|
||||
process-global, so a second entry must not register them again.
|
||||
"""
|
||||
if getattr(hass, _REGISTERED_ATTR, False):
|
||||
return
|
||||
await hass.http.async_register_static_paths(
|
||||
[
|
||||
StaticPathConfig(
|
||||
FRONTEND_URL_BASE,
|
||||
str(WWW_DIR),
|
||||
cache_headers=False,
|
||||
)
|
||||
]
|
||||
)
|
||||
add_extra_js_url(hass, BUNDLE_URL)
|
||||
setattr(hass, _REGISTERED_ATTR, True)
|
||||
@@ -5,6 +5,10 @@
|
||||
"@overseer"
|
||||
],
|
||||
"config_flow": true,
|
||||
"dependencies": [
|
||||
"frontend",
|
||||
"http"
|
||||
],
|
||||
"documentation": "https://github.com/talon2king/NestQuest",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,76 @@
|
||||
/* ============================================================
|
||||
NestQuest — Parent admin card tokens
|
||||
Surface: phone, 390x844 reference. Plain CubeCraft styling,
|
||||
no tabletop treatment, plain domain words in the UI.
|
||||
Scope on the card host: nestquest-admin-card.
|
||||
Derived from the approved mockups (admin screens 1d-1i).
|
||||
Two densities were built; COMPACT is the default. Flip
|
||||
--nq-a-density-* to the roomy values for the alternative.
|
||||
============================================================ */
|
||||
|
||||
:host {
|
||||
/* ── Brand + neutrals (CubeCraft tokens, verbatim) ─────── */
|
||||
--nq-brand-blue: #3B3AB8;
|
||||
--nq-brand-purple: #A035CC;
|
||||
--nq-brand-gradient: linear-gradient(135deg, var(--nq-brand-blue), var(--nq-brand-purple));
|
||||
--nq-brand-gradient-h: linear-gradient(90deg, var(--nq-brand-blue), var(--nq-brand-purple));
|
||||
|
||||
--nq-a-page: #F4F4F6;
|
||||
--nq-a-surface: #FFFFFF;
|
||||
--nq-a-surface-subtle: #FAFAFA;
|
||||
--nq-a-selected: #EBEBF8; /* row being edited */
|
||||
--nq-a-border: #E4E4EA;
|
||||
--nq-a-divider: #EDEDF1;
|
||||
|
||||
--nq-a-ink: #18181D;
|
||||
--nq-a-ink-secondary: #52525E;
|
||||
--nq-a-ink-tertiary: #70707E; /* inactive rows, unselected nav icons */
|
||||
|
||||
--nq-a-success: #15803D;
|
||||
--nq-a-danger: #DC2626;
|
||||
--nq-a-danger-strong: #B91C1C;
|
||||
--nq-a-danger-bg: #FEE2E2;
|
||||
--nq-a-warning-ink: #92400E;
|
||||
--nq-a-warning-ink-2: #78350F;
|
||||
--nq-a-warning-bg: #FEF3C7;
|
||||
--nq-a-warning-border: rgba(217,119,6,.35);
|
||||
--nq-a-info-bg: #DBEAFE;
|
||||
--nq-a-info-ink: #1E3A8A;
|
||||
--nq-a-info-icon: #1D4ED8;
|
||||
--nq-a-reversal: #7D28A0; /* un-complete events in history */
|
||||
|
||||
/* ── Type ─────────────────────────────────────────────── */
|
||||
--nq-a-font: Nunito, system-ui, sans-serif;
|
||||
--nq-a-size-screen: 24px; /* 800, screen title */
|
||||
--nq-a-size-hero: 32px; /* 800, roomy "8 of 12 done" */
|
||||
--nq-a-size-card-title: 19px; /* 800, roomy child card */
|
||||
--nq-a-size-stat: 22px; /* 800, compact stat number */
|
||||
--nq-a-size-row: 13.5px;/* 700, list row title */
|
||||
--nq-a-size-meta: 11px; /* 600, row meta */
|
||||
--nq-a-size-label: 10px; /* 700, uppercase field label */
|
||||
--nq-a-track-label: .10em;
|
||||
|
||||
/* ── Density: COMPACT (default) ───────────────────────── */
|
||||
--nq-a-density-page-pad: 14px 16px 92px; /* bottom clears the 70px tab bar */
|
||||
--nq-a-density-gap: 10px;
|
||||
--nq-a-density-card-pad: 13px;
|
||||
--nq-a-density-row-pad: 11px 13px;
|
||||
--nq-a-density-radius: 8px;
|
||||
--nq-a-density-shadow: none;
|
||||
/* Density: ROOMY — page-pad 18px 20px 92px; gap 16px;
|
||||
card-pad 18px; radius 12px; shadow 0 4px 12px rgba(0,0,0,.06) */
|
||||
|
||||
/* ── Metrics ──────────────────────────────────────────── */
|
||||
--nq-a-tabbar-height: 70px; /* + 12px safe-area padding */
|
||||
--nq-a-tap-min: 44px; /* every row/control clears this */
|
||||
--nq-a-radius-pill: 9999px;
|
||||
--nq-a-radius-sheet: 20px 20px 0 0;
|
||||
--nq-a-sheet-shadow: 0 -8px 28px rgba(9,9,11,.18);
|
||||
--nq-a-progress-height: 6px; /* 8px roomy, 10px hero */
|
||||
|
||||
/* ── Motion ───────────────────────────────────────────── */
|
||||
--nq-ease-out: cubic-bezier(0, 0, .2, 1);
|
||||
--nq-dur-micro: 120ms;
|
||||
--nq-dur-base: 200ms;
|
||||
--nq-dur-modal: 350ms;
|
||||
}
|
||||
@@ -0,0 +1,582 @@
|
||||
const k = globalThis, M = k.ShadowRoot && (k.ShadyCSS === void 0 || k.ShadyCSS.nativeShadow) && "adoptedStyleSheets" in Document.prototype && "replace" in CSSStyleSheet.prototype, tt = /* @__PURE__ */ Symbol(), j = /* @__PURE__ */ new WeakMap();
|
||||
let ot = class {
|
||||
constructor(t, e, s) {
|
||||
if (this._$cssResult$ = !0, s !== tt) throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");
|
||||
this.cssText = t, this.t = e;
|
||||
}
|
||||
get styleSheet() {
|
||||
let t = this.o;
|
||||
const e = this.t;
|
||||
if (M && t === void 0) {
|
||||
const s = e !== void 0 && e.length === 1;
|
||||
s && (t = j.get(e)), t === void 0 && ((this.o = t = new CSSStyleSheet()).replaceSync(this.cssText), s && j.set(e, t));
|
||||
}
|
||||
return t;
|
||||
}
|
||||
toString() {
|
||||
return this.cssText;
|
||||
}
|
||||
};
|
||||
const U = (i) => new ot(typeof i == "string" ? i : i + "", void 0, tt), ht = (i, t) => {
|
||||
if (M) i.adoptedStyleSheets = t.map((e) => e instanceof CSSStyleSheet ? e : e.styleSheet);
|
||||
else for (const e of t) {
|
||||
const s = document.createElement("style"), n = k.litNonce;
|
||||
n !== void 0 && s.setAttribute("nonce", n), s.textContent = e.cssText, i.appendChild(s);
|
||||
}
|
||||
}, I = M ? (i) => i : (i) => i instanceof CSSStyleSheet ? ((t) => {
|
||||
let e = "";
|
||||
for (const s of t.cssRules) e += s.cssText;
|
||||
return U(e);
|
||||
})(i) : i;
|
||||
const { is: dt, defineProperty: pt, getOwnPropertyDescriptor: lt, getOwnPropertyNames: ct, getOwnPropertySymbols: ut, getPrototypeOf: $t } = Object, z = globalThis, Q = z.trustedTypes, ft = Q ? Q.emptyScript : "", gt = z.reactiveElementPolyfillSupport, E = (i, t) => i, T = { toAttribute(i, t) {
|
||||
switch (t) {
|
||||
case Boolean:
|
||||
i = i ? ft : null;
|
||||
break;
|
||||
case Object:
|
||||
case Array:
|
||||
i = i == null ? i : JSON.stringify(i);
|
||||
}
|
||||
return i;
|
||||
}, fromAttribute(i, t) {
|
||||
let e = i;
|
||||
switch (t) {
|
||||
case Boolean:
|
||||
e = i !== null;
|
||||
break;
|
||||
case Number:
|
||||
e = i === null ? null : Number(i);
|
||||
break;
|
||||
case Object:
|
||||
case Array:
|
||||
try {
|
||||
e = JSON.parse(i);
|
||||
} catch {
|
||||
e = null;
|
||||
}
|
||||
}
|
||||
return e;
|
||||
} }, et = (i, t) => !dt(i, t), W = { attribute: !0, type: String, converter: T, reflect: !1, useDefault: !1, hasChanged: et };
|
||||
Symbol.metadata ??= /* @__PURE__ */ Symbol("metadata"), z.litPropertyMetadata ??= /* @__PURE__ */ new WeakMap();
|
||||
let b = class extends HTMLElement {
|
||||
static addInitializer(t) {
|
||||
this._$Ei(), (this.l ??= []).push(t);
|
||||
}
|
||||
static get observedAttributes() {
|
||||
return this.finalize(), this._$Eh && [...this._$Eh.keys()];
|
||||
}
|
||||
static createProperty(t, e = W) {
|
||||
if (e.state && (e.attribute = !1), this._$Ei(), this.prototype.hasOwnProperty(t) && ((e = Object.create(e)).wrapped = !0), this.elementProperties.set(t, e), !e.noAccessor) {
|
||||
const s = /* @__PURE__ */ Symbol(), n = this.getPropertyDescriptor(t, s, e);
|
||||
n !== void 0 && pt(this.prototype, t, n);
|
||||
}
|
||||
}
|
||||
static getPropertyDescriptor(t, e, s) {
|
||||
const { get: n, set: r } = lt(this.prototype, t) ?? { get() {
|
||||
return this[e];
|
||||
}, set(a) {
|
||||
this[e] = a;
|
||||
} };
|
||||
return { get: n, set(a) {
|
||||
const d = n?.call(this);
|
||||
r?.call(this, a), this.requestUpdate(t, d, s);
|
||||
}, configurable: !0, enumerable: !0 };
|
||||
}
|
||||
static getPropertyOptions(t) {
|
||||
return this.elementProperties.get(t) ?? W;
|
||||
}
|
||||
static _$Ei() {
|
||||
if (this.hasOwnProperty(E("elementProperties"))) return;
|
||||
const t = $t(this);
|
||||
t.finalize(), t.l !== void 0 && (this.l = [...t.l]), this.elementProperties = new Map(t.elementProperties);
|
||||
}
|
||||
static finalize() {
|
||||
if (this.hasOwnProperty(E("finalized"))) return;
|
||||
if (this.finalized = !0, this._$Ei(), this.hasOwnProperty(E("properties"))) {
|
||||
const e = this.properties, s = [...ct(e), ...ut(e)];
|
||||
for (const n of s) this.createProperty(n, e[n]);
|
||||
}
|
||||
const t = this[Symbol.metadata];
|
||||
if (t !== null) {
|
||||
const e = litPropertyMetadata.get(t);
|
||||
if (e !== void 0) for (const [s, n] of e) this.elementProperties.set(s, n);
|
||||
}
|
||||
this._$Eh = /* @__PURE__ */ new Map();
|
||||
for (const [e, s] of this.elementProperties) {
|
||||
const n = this._$Eu(e, s);
|
||||
n !== void 0 && this._$Eh.set(n, e);
|
||||
}
|
||||
this.elementStyles = this.finalizeStyles(this.styles);
|
||||
}
|
||||
static finalizeStyles(t) {
|
||||
const e = [];
|
||||
if (Array.isArray(t)) {
|
||||
const s = new Set(t.flat(1 / 0).reverse());
|
||||
for (const n of s) e.unshift(I(n));
|
||||
} else t !== void 0 && e.push(I(t));
|
||||
return e;
|
||||
}
|
||||
static _$Eu(t, e) {
|
||||
const s = e.attribute;
|
||||
return s === !1 ? void 0 : typeof s == "string" ? s : typeof t == "string" ? t.toLowerCase() : void 0;
|
||||
}
|
||||
constructor() {
|
||||
super(), this._$Ep = void 0, this.isUpdatePending = !1, this.hasUpdated = !1, this._$Em = null, this._$Ev();
|
||||
}
|
||||
_$Ev() {
|
||||
this._$ES = new Promise((t) => this.enableUpdating = t), this._$AL = /* @__PURE__ */ new Map(), this._$E_(), this.requestUpdate(), this.constructor.l?.forEach((t) => t(this));
|
||||
}
|
||||
addController(t) {
|
||||
(this._$EO ??= /* @__PURE__ */ new Set()).add(t), this.renderRoot !== void 0 && this.isConnected && t.hostConnected?.();
|
||||
}
|
||||
removeController(t) {
|
||||
this._$EO?.delete(t);
|
||||
}
|
||||
_$E_() {
|
||||
const t = /* @__PURE__ */ new Map(), e = this.constructor.elementProperties;
|
||||
for (const s of e.keys()) this.hasOwnProperty(s) && (t.set(s, this[s]), delete this[s]);
|
||||
t.size > 0 && (this._$Ep = t);
|
||||
}
|
||||
createRenderRoot() {
|
||||
const t = this.shadowRoot ?? this.attachShadow(this.constructor.shadowRootOptions);
|
||||
return ht(t, this.constructor.elementStyles), t;
|
||||
}
|
||||
connectedCallback() {
|
||||
this.renderRoot ??= this.createRenderRoot(), this.enableUpdating(!0), this._$EO?.forEach((t) => t.hostConnected?.());
|
||||
}
|
||||
enableUpdating(t) {
|
||||
}
|
||||
disconnectedCallback() {
|
||||
this._$EO?.forEach((t) => t.hostDisconnected?.());
|
||||
}
|
||||
attributeChangedCallback(t, e, s) {
|
||||
this._$AK(t, s);
|
||||
}
|
||||
_$ET(t, e) {
|
||||
const s = this.constructor.elementProperties.get(t), n = this.constructor._$Eu(t, s);
|
||||
if (n !== void 0 && s.reflect === !0) {
|
||||
const r = (s.converter?.toAttribute !== void 0 ? s.converter : T).toAttribute(e, s.type);
|
||||
this._$Em = t, r == null ? this.removeAttribute(n) : this.setAttribute(n, r), this._$Em = null;
|
||||
}
|
||||
}
|
||||
_$AK(t, e) {
|
||||
const s = this.constructor, n = s._$Eh.get(t);
|
||||
if (n !== void 0 && this._$Em !== n) {
|
||||
const r = s.getPropertyOptions(n), a = typeof r.converter == "function" ? { fromAttribute: r.converter } : r.converter?.fromAttribute !== void 0 ? r.converter : T;
|
||||
this._$Em = n;
|
||||
const d = a.fromAttribute(e, r.type);
|
||||
this[n] = d ?? this._$Ej?.get(n) ?? d, this._$Em = null;
|
||||
}
|
||||
}
|
||||
requestUpdate(t, e, s, n = !1, r) {
|
||||
if (t !== void 0) {
|
||||
const a = this.constructor;
|
||||
if (n === !1 && (r = this[t]), s ??= a.getPropertyOptions(t), !((s.hasChanged ?? et)(r, e) || s.useDefault && s.reflect && r === this._$Ej?.get(t) && !this.hasAttribute(a._$Eu(t, s)))) return;
|
||||
this.C(t, e, s);
|
||||
}
|
||||
this.isUpdatePending === !1 && (this._$ES = this._$EP());
|
||||
}
|
||||
C(t, e, { useDefault: s, reflect: n, wrapped: r }, a) {
|
||||
s && !(this._$Ej ??= /* @__PURE__ */ new Map()).has(t) && (this._$Ej.set(t, a ?? e ?? this[t]), r !== !0 || a !== void 0) || (this._$AL.has(t) || (this.hasUpdated || s || (e = void 0), this._$AL.set(t, e)), n === !0 && this._$Em !== t && (this._$Eq ??= /* @__PURE__ */ new Set()).add(t));
|
||||
}
|
||||
async _$EP() {
|
||||
this.isUpdatePending = !0;
|
||||
try {
|
||||
await this._$ES;
|
||||
} catch (e) {
|
||||
Promise.reject(e);
|
||||
}
|
||||
const t = this.scheduleUpdate();
|
||||
return t != null && await t, !this.isUpdatePending;
|
||||
}
|
||||
scheduleUpdate() {
|
||||
return this.performUpdate();
|
||||
}
|
||||
performUpdate() {
|
||||
if (!this.isUpdatePending) return;
|
||||
if (!this.hasUpdated) {
|
||||
if (this.renderRoot ??= this.createRenderRoot(), this._$Ep) {
|
||||
for (const [n, r] of this._$Ep) this[n] = r;
|
||||
this._$Ep = void 0;
|
||||
}
|
||||
const s = this.constructor.elementProperties;
|
||||
if (s.size > 0) for (const [n, r] of s) {
|
||||
const { wrapped: a } = r, d = this[n];
|
||||
a !== !0 || this._$AL.has(n) || d === void 0 || this.C(n, void 0, r, d);
|
||||
}
|
||||
}
|
||||
let t = !1;
|
||||
const e = this._$AL;
|
||||
try {
|
||||
t = this.shouldUpdate(e), t ? (this.willUpdate(e), this._$EO?.forEach((s) => s.hostUpdate?.()), this.update(e)) : this._$EM();
|
||||
} catch (s) {
|
||||
throw t = !1, this._$EM(), s;
|
||||
}
|
||||
t && this._$AE(e);
|
||||
}
|
||||
willUpdate(t) {
|
||||
}
|
||||
_$AE(t) {
|
||||
this._$EO?.forEach((e) => e.hostUpdated?.()), this.hasUpdated || (this.hasUpdated = !0, this.firstUpdated(t)), this.updated(t);
|
||||
}
|
||||
_$EM() {
|
||||
this._$AL = /* @__PURE__ */ new Map(), this.isUpdatePending = !1;
|
||||
}
|
||||
get updateComplete() {
|
||||
return this.getUpdateComplete();
|
||||
}
|
||||
getUpdateComplete() {
|
||||
return this._$ES;
|
||||
}
|
||||
shouldUpdate(t) {
|
||||
return !0;
|
||||
}
|
||||
update(t) {
|
||||
this._$Eq &&= this._$Eq.forEach((e) => this._$ET(e, this[e])), this._$EM();
|
||||
}
|
||||
updated(t) {
|
||||
}
|
||||
firstUpdated(t) {
|
||||
}
|
||||
};
|
||||
b.elementStyles = [], b.shadowRootOptions = { mode: "open" }, b[E("elementProperties")] = /* @__PURE__ */ new Map(), b[E("finalized")] = /* @__PURE__ */ new Map(), gt?.({ ReactiveElement: b }), (z.reactiveElementVersions ??= []).push("2.1.2");
|
||||
const R = globalThis, V = (i) => i, N = R.trustedTypes, K = N ? N.createPolicy("lit-html", { createHTML: (i) => i }) : void 0, st = "$lit$", f = `lit$${Math.random().toFixed(9).slice(2)}$`, nt = "?" + f, _t = `<${nt}>`, y = document, x = () => y.createComment(""), w = (i) => i === null || typeof i != "object" && typeof i != "function", D = Array.isArray, mt = (i) => D(i) || typeof i?.[Symbol.iterator] == "function", H = `[
|
||||
\f\r]`, v = /<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g, Z = /-->/g, J = />/g, g = RegExp(`>|${H}(?:([^\\s"'>=/]+)(${H}*=${H}*(?:[^
|
||||
\f\r"'\`<>=]|("|')|))|$)`, "g"), G = /'/g, X = /"/g, it = /^(?:script|style|textarea|title)$/i, yt = (i) => (t, ...e) => ({ _$litType$: i, strings: t, values: e }), B = yt(1), A = /* @__PURE__ */ Symbol.for("lit-noChange"), l = /* @__PURE__ */ Symbol.for("lit-nothing"), Y = /* @__PURE__ */ new WeakMap(), _ = y.createTreeWalker(y, 129);
|
||||
function rt(i, t) {
|
||||
if (!D(i) || !i.hasOwnProperty("raw")) throw Error("invalid template strings array");
|
||||
return K !== void 0 ? K.createHTML(t) : t;
|
||||
}
|
||||
const bt = (i, t) => {
|
||||
const e = i.length - 1, s = [];
|
||||
let n, r = t === 2 ? "<svg>" : t === 3 ? "<math>" : "", a = v;
|
||||
for (let d = 0; d < e; d++) {
|
||||
const o = i[d];
|
||||
let p, c, h = -1, u = 0;
|
||||
for (; u < o.length && (a.lastIndex = u, c = a.exec(o), c !== null); ) u = a.lastIndex, a === v ? c[1] === "!--" ? a = Z : c[1] !== void 0 ? a = J : c[2] !== void 0 ? (it.test(c[2]) && (n = RegExp("</" + c[2], "g")), a = g) : c[3] !== void 0 && (a = g) : a === g ? c[0] === ">" ? (a = n ?? v, h = -1) : c[1] === void 0 ? h = -2 : (h = a.lastIndex - c[2].length, p = c[1], a = c[3] === void 0 ? g : c[3] === '"' ? X : G) : a === X || a === G ? a = g : a === Z || a === J ? a = v : (a = g, n = void 0);
|
||||
const $ = a === g && i[d + 1].startsWith("/>") ? " " : "";
|
||||
r += a === v ? o + _t : h >= 0 ? (s.push(p), o.slice(0, h) + st + o.slice(h) + f + $) : o + f + (h === -2 ? d : $);
|
||||
}
|
||||
return [rt(i, r + (i[e] || "<?>") + (t === 2 ? "</svg>" : t === 3 ? "</math>" : "")), s];
|
||||
};
|
||||
class S {
|
||||
constructor({ strings: t, _$litType$: e }, s) {
|
||||
let n;
|
||||
this.parts = [];
|
||||
let r = 0, a = 0;
|
||||
const d = t.length - 1, o = this.parts, [p, c] = bt(t, e);
|
||||
if (this.el = S.createElement(p, s), _.currentNode = this.el.content, e === 2 || e === 3) {
|
||||
const h = this.el.content.firstChild;
|
||||
h.replaceWith(...h.childNodes);
|
||||
}
|
||||
for (; (n = _.nextNode()) !== null && o.length < d; ) {
|
||||
if (n.nodeType === 1) {
|
||||
if (n.hasAttributes()) for (const h of n.getAttributeNames()) if (h.endsWith(st)) {
|
||||
const u = c[a++], $ = n.getAttribute(h).split(f), P = /([.?@])?(.*)/.exec(u);
|
||||
o.push({ type: 1, index: r, name: P[2], strings: $, ctor: P[1] === "." ? qt : P[1] === "?" ? vt : P[1] === "@" ? Et : O }), n.removeAttribute(h);
|
||||
} else h.startsWith(f) && (o.push({ type: 6, index: r }), n.removeAttribute(h));
|
||||
if (it.test(n.tagName)) {
|
||||
const h = n.textContent.split(f), u = h.length - 1;
|
||||
if (u > 0) {
|
||||
n.textContent = N ? N.emptyScript : "";
|
||||
for (let $ = 0; $ < u; $++) n.append(h[$], x()), _.nextNode(), o.push({ type: 2, index: ++r });
|
||||
n.append(h[u], x());
|
||||
}
|
||||
}
|
||||
} else if (n.nodeType === 8) if (n.data === nt) o.push({ type: 2, index: r });
|
||||
else {
|
||||
let h = -1;
|
||||
for (; (h = n.data.indexOf(f, h + 1)) !== -1; ) o.push({ type: 7, index: r }), h += f.length - 1;
|
||||
}
|
||||
r++;
|
||||
}
|
||||
}
|
||||
static createElement(t, e) {
|
||||
const s = y.createElement("template");
|
||||
return s.innerHTML = t, s;
|
||||
}
|
||||
}
|
||||
function q(i, t, e = i, s) {
|
||||
if (t === A) return t;
|
||||
let n = s !== void 0 ? e._$Co?.[s] : e._$Cl;
|
||||
const r = w(t) ? void 0 : t._$litDirective$;
|
||||
return n?.constructor !== r && (n?._$AO?.(!1), r === void 0 ? n = void 0 : (n = new r(i), n._$AT(i, e, s)), s !== void 0 ? (e._$Co ??= [])[s] = n : e._$Cl = n), n !== void 0 && (t = q(i, n._$AS(i, t.values), n, s)), t;
|
||||
}
|
||||
class At {
|
||||
constructor(t, e) {
|
||||
this._$AV = [], this._$AN = void 0, this._$AD = t, this._$AM = e;
|
||||
}
|
||||
get parentNode() {
|
||||
return this._$AM.parentNode;
|
||||
}
|
||||
get _$AU() {
|
||||
return this._$AM._$AU;
|
||||
}
|
||||
u(t) {
|
||||
const { el: { content: e }, parts: s } = this._$AD, n = (t?.creationScope ?? y).importNode(e, !0);
|
||||
_.currentNode = n;
|
||||
let r = _.nextNode(), a = 0, d = 0, o = s[0];
|
||||
for (; o !== void 0; ) {
|
||||
if (a === o.index) {
|
||||
let p;
|
||||
o.type === 2 ? p = new C(r, r.nextSibling, this, t) : o.type === 1 ? p = new o.ctor(r, o.name, o.strings, this, t) : o.type === 6 && (p = new xt(r, this, t)), this._$AV.push(p), o = s[++d];
|
||||
}
|
||||
a !== o?.index && (r = _.nextNode(), a++);
|
||||
}
|
||||
return _.currentNode = y, n;
|
||||
}
|
||||
p(t) {
|
||||
let e = 0;
|
||||
for (const s of this._$AV) s !== void 0 && (s.strings !== void 0 ? (s._$AI(t, s, e), e += s.strings.length - 2) : s._$AI(t[e])), e++;
|
||||
}
|
||||
}
|
||||
class C {
|
||||
get _$AU() {
|
||||
return this._$AM?._$AU ?? this._$Cv;
|
||||
}
|
||||
constructor(t, e, s, n) {
|
||||
this.type = 2, this._$AH = l, this._$AN = void 0, this._$AA = t, this._$AB = e, this._$AM = s, this.options = n, this._$Cv = n?.isConnected ?? !0;
|
||||
}
|
||||
get parentNode() {
|
||||
let t = this._$AA.parentNode;
|
||||
const e = this._$AM;
|
||||
return e !== void 0 && t?.nodeType === 11 && (t = e.parentNode), t;
|
||||
}
|
||||
get startNode() {
|
||||
return this._$AA;
|
||||
}
|
||||
get endNode() {
|
||||
return this._$AB;
|
||||
}
|
||||
_$AI(t, e = this) {
|
||||
t = q(this, t, e), w(t) ? t === l || t == null || t === "" ? (this._$AH !== l && this._$AR(), this._$AH = l) : t !== this._$AH && t !== A && this._(t) : t._$litType$ !== void 0 ? this.$(t) : t.nodeType !== void 0 ? this.T(t) : mt(t) ? this.k(t) : this._(t);
|
||||
}
|
||||
O(t) {
|
||||
return this._$AA.parentNode.insertBefore(t, this._$AB);
|
||||
}
|
||||
T(t) {
|
||||
this._$AH !== t && (this._$AR(), this._$AH = this.O(t));
|
||||
}
|
||||
_(t) {
|
||||
this._$AH !== l && w(this._$AH) ? this._$AA.nextSibling.data = t : this.T(y.createTextNode(t)), this._$AH = t;
|
||||
}
|
||||
$(t) {
|
||||
const { values: e, _$litType$: s } = t, n = typeof s == "number" ? this._$AC(t) : (s.el === void 0 && (s.el = S.createElement(rt(s.h, s.h[0]), this.options)), s);
|
||||
if (this._$AH?._$AD === n) this._$AH.p(e);
|
||||
else {
|
||||
const r = new At(n, this), a = r.u(this.options);
|
||||
r.p(e), this.T(a), this._$AH = r;
|
||||
}
|
||||
}
|
||||
_$AC(t) {
|
||||
let e = Y.get(t.strings);
|
||||
return e === void 0 && Y.set(t.strings, e = new S(t)), e;
|
||||
}
|
||||
k(t) {
|
||||
D(this._$AH) || (this._$AH = [], this._$AR());
|
||||
const e = this._$AH;
|
||||
let s, n = 0;
|
||||
for (const r of t) n === e.length ? e.push(s = new C(this.O(x()), this.O(x()), this, this.options)) : s = e[n], s._$AI(r), n++;
|
||||
n < e.length && (this._$AR(s && s._$AB.nextSibling, n), e.length = n);
|
||||
}
|
||||
_$AR(t = this._$AA.nextSibling, e) {
|
||||
for (this._$AP?.(!1, !0, e); t !== this._$AB; ) {
|
||||
const s = V(t).nextSibling;
|
||||
V(t).remove(), t = s;
|
||||
}
|
||||
}
|
||||
setConnected(t) {
|
||||
this._$AM === void 0 && (this._$Cv = t, this._$AP?.(t));
|
||||
}
|
||||
}
|
||||
class O {
|
||||
get tagName() {
|
||||
return this.element.tagName;
|
||||
}
|
||||
get _$AU() {
|
||||
return this._$AM._$AU;
|
||||
}
|
||||
constructor(t, e, s, n, r) {
|
||||
this.type = 1, this._$AH = l, this._$AN = void 0, this.element = t, this.name = e, this._$AM = n, this.options = r, s.length > 2 || s[0] !== "" || s[1] !== "" ? (this._$AH = Array(s.length - 1).fill(new String()), this.strings = s) : this._$AH = l;
|
||||
}
|
||||
_$AI(t, e = this, s, n) {
|
||||
const r = this.strings;
|
||||
let a = !1;
|
||||
if (r === void 0) t = q(this, t, e, 0), a = !w(t) || t !== this._$AH && t !== A, a && (this._$AH = t);
|
||||
else {
|
||||
const d = t;
|
||||
let o, p;
|
||||
for (t = r[0], o = 0; o < r.length - 1; o++) p = q(this, d[s + o], e, o), p === A && (p = this._$AH[o]), a ||= !w(p) || p !== this._$AH[o], p === l ? t = l : t !== l && (t += (p ?? "") + r[o + 1]), this._$AH[o] = p;
|
||||
}
|
||||
a && !n && this.j(t);
|
||||
}
|
||||
j(t) {
|
||||
t === l ? this.element.removeAttribute(this.name) : this.element.setAttribute(this.name, t ?? "");
|
||||
}
|
||||
}
|
||||
class qt extends O {
|
||||
constructor() {
|
||||
super(...arguments), this.type = 3;
|
||||
}
|
||||
j(t) {
|
||||
this.element[this.name] = t === l ? void 0 : t;
|
||||
}
|
||||
}
|
||||
class vt extends O {
|
||||
constructor() {
|
||||
super(...arguments), this.type = 4;
|
||||
}
|
||||
j(t) {
|
||||
this.element.toggleAttribute(this.name, !!t && t !== l);
|
||||
}
|
||||
}
|
||||
class Et extends O {
|
||||
constructor(t, e, s, n, r) {
|
||||
super(t, e, s, n, r), this.type = 5;
|
||||
}
|
||||
_$AI(t, e = this) {
|
||||
if ((t = q(this, t, e, 0) ?? l) === A) return;
|
||||
const s = this._$AH, n = t === l && s !== l || t.capture !== s.capture || t.once !== s.once || t.passive !== s.passive, r = t !== l && (s === l || n);
|
||||
n && this.element.removeEventListener(this.name, this, s), r && this.element.addEventListener(this.name, this, t), this._$AH = t;
|
||||
}
|
||||
handleEvent(t) {
|
||||
typeof this._$AH == "function" ? this._$AH.call(this.options?.host ?? this.element, t) : this._$AH.handleEvent(t);
|
||||
}
|
||||
}
|
||||
class xt {
|
||||
constructor(t, e, s) {
|
||||
this.element = t, this.type = 6, this._$AN = void 0, this._$AM = e, this.options = s;
|
||||
}
|
||||
get _$AU() {
|
||||
return this._$AM._$AU;
|
||||
}
|
||||
_$AI(t) {
|
||||
q(this, t);
|
||||
}
|
||||
}
|
||||
const wt = R.litHtmlPolyfillSupport;
|
||||
wt?.(S, C), (R.litHtmlVersions ??= []).push("3.3.3");
|
||||
const St = (i, t, e) => {
|
||||
const s = e?.renderBefore ?? t;
|
||||
let n = s._$litPart$;
|
||||
if (n === void 0) {
|
||||
const r = e?.renderBefore ?? null;
|
||||
s._$litPart$ = n = new C(t.insertBefore(x(), r), r, void 0, e ?? {});
|
||||
}
|
||||
return n._$AI(i), n;
|
||||
};
|
||||
const F = globalThis;
|
||||
class m extends b {
|
||||
constructor() {
|
||||
super(...arguments), this.renderOptions = { host: this }, this._$Do = void 0;
|
||||
}
|
||||
createRenderRoot() {
|
||||
const t = super.createRenderRoot();
|
||||
return this.renderOptions.renderBefore ??= t.firstChild, t;
|
||||
}
|
||||
update(t) {
|
||||
const e = this.render();
|
||||
this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), super.update(t), this._$Do = St(e, this.renderRoot, this.renderOptions);
|
||||
}
|
||||
connectedCallback() {
|
||||
super.connectedCallback(), this._$Do?.setConnected(!0);
|
||||
}
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback(), this._$Do?.setConnected(!1);
|
||||
}
|
||||
render() {
|
||||
return A;
|
||||
}
|
||||
}
|
||||
m._$litElement$ = !0, m.finalized = !0, F.litElementHydrateSupport?.({ LitElement: m });
|
||||
const Ct = F.litElementPolyfillSupport;
|
||||
Ct?.({ LitElement: m });
|
||||
(F.litElementVersions ??= []).push("4.2.2");
|
||||
const at = ':host{--nq-p-parchment-top: #f0e4c7;--nq-p-parchment-bottom: #ddcca3;--nq-p-parchment: radial-gradient(ellipse at 25% 10%, rgba(255,255,255,.5), transparent 55%), radial-gradient(ellipse at 85% 90%, rgba(120,86,44,.28), transparent 60%), repeating-linear-gradient(93deg, rgba(150,115,70,.05) 0 2px, transparent 2px 6px), repeating-linear-gradient(2deg, rgba(150,115,70,.04) 0 3px, transparent 3px 7px), linear-gradient(var(--nq-p-parchment-top), var(--nq-p-parchment-bottom));--nq-p-vignette: inset 0 0 200px rgba(80,52,20,.35);--nq-p-card-open: linear-gradient(#fdf7e6, #f2e7c9);--nq-p-card-done: linear-gradient(#f0e8d3, #e6dcc2);--nq-p-card-panel: linear-gradient(#fcf6e6, #f1e6ca);--nq-p-card-border: rgba(120,88,48,.38);--nq-p-panel-border: rgba(92,62,26,.4);--nq-p-card-shadow: 0 3px 0 rgba(120,88,48,.2), inset 0 1px 0 rgba(255,255,255,.7);--nq-p-panel-shadow: 0 6px 0 rgba(92,62,26,.18), inset 0 2px 0 rgba(255,255,255,.7);--nq-p-frame-outer: 2px solid rgba(92,62,26,.45);--nq-p-frame-inner: 1px solid rgba(92,62,26,.28);--nq-p-rule: 2px solid rgba(92,62,26,.35);--nq-p-ink: #2b1f14;--nq-p-ink-secondary: #5c452a;--nq-p-ink-muted: #6f6455;--nq-p-ink-away: #4d4433;--nq-p-ink-late: #8f1526;--nq-brand-blue: #3B3AB8;--nq-brand-purple: #A035CC;--nq-brand-gradient: linear-gradient(135deg, var(--nq-brand-blue), var(--nq-brand-purple));--nq-p-icon-tile: var(--nq-brand-gradient);--nq-p-icon-tile-done: rgba(92,62,26,.16);--nq-p-crest: var(--nq-brand-gradient);--nq-p-crest-away: linear-gradient(135deg, #6b6b7a, #7a7286);--nq-p-seal: radial-gradient(circle at 35% 30%, #a8283a, #6d1322);--nq-p-seal-shadow: 0 4px 10px rgba(60,10,20,.4), inset 0 0 0 4px rgba(255,255,255,.14);--nq-p-seal-size: 78px;--nq-p-dock-bg: rgba(43,31,20,.9);--nq-p-dock-ink: #f7efdb;--nq-p-dock-ink-secondary: #d8c9a6;--nq-p-dock-divider: rgba(233,220,189,.3);--nq-p-dock-height: 84px;--nq-p-font-display: "Cinzel Decorative", Cinzel, serif;--nq-p-font-heading: Cinzel, serif;--nq-p-font-body: Nunito, system-ui, sans-serif;--nq-p-size-hero: 86px;--nq-p-size-wordmark: 66px;--nq-p-size-title: 56px;--nq-p-size-name: 48px;--nq-p-size-section: 32px;--nq-p-size-quest: 30px;--nq-p-size-body: 22px;--nq-p-size-label: 21px;--nq-p-track-label: .14em;--nq-p-track-kicker: .3em;--nq-p-quest-min-height: 116px;--nq-p-quest-gap: 24px;--nq-p-button-height: 72px;--nq-p-button-min-width: 150px;--nq-p-confirm-button: 108px;--nq-p-radius-card: 12px;--nq-p-radius-panel: 20px;--nq-p-radius-pill: 9999px;--nq-p-page-inset: 62px;--nq-p-frame-inset: 26px;--nq-ease-out: cubic-bezier(0, 0, .2, 1);--nq-dur-micro: .12s;--nq-dur-base: .2s;--nq-dur-modal: .35s}';
|
||||
function L(i) {
|
||||
window.customCards = window.customCards ?? [], window.customCards.push(i);
|
||||
}
|
||||
class Pt extends m {
|
||||
static properties = {
|
||||
hass: { attribute: !1 },
|
||||
_config: { state: !0 }
|
||||
};
|
||||
static styles = [U(at)];
|
||||
hass;
|
||||
_config;
|
||||
setConfig(t) {
|
||||
if (!t || typeof t != "object")
|
||||
throw new Error("Invalid configuration");
|
||||
this._config = t;
|
||||
}
|
||||
getCardSize() {
|
||||
return 6;
|
||||
}
|
||||
render() {
|
||||
return B`<div>NestQuest Party Board</div>`;
|
||||
}
|
||||
}
|
||||
customElements.define("nestquest-party-board-card", Pt);
|
||||
L({
|
||||
type: "nestquest-party-board-card",
|
||||
name: "NestQuest Party Board",
|
||||
description: "Kids' attract screen for present and away adventurers."
|
||||
});
|
||||
class kt extends m {
|
||||
static properties = {
|
||||
hass: { attribute: !1 },
|
||||
_config: { state: !0 }
|
||||
};
|
||||
static styles = [U(at)];
|
||||
hass;
|
||||
_config;
|
||||
setConfig(t) {
|
||||
if (!t || typeof t != "object")
|
||||
throw new Error("Invalid configuration");
|
||||
this._config = t;
|
||||
}
|
||||
getCardSize() {
|
||||
return 8;
|
||||
}
|
||||
render() {
|
||||
return B`<div>NestQuest Quest Log</div>`;
|
||||
}
|
||||
}
|
||||
customElements.define("nestquest-quest-log-card", kt);
|
||||
L({
|
||||
type: "nestquest-quest-log-card",
|
||||
name: "NestQuest Quest Log",
|
||||
description: "One child's daily quests, grouped by window."
|
||||
});
|
||||
const Nt = ":host{--nq-brand-blue: #3B3AB8;--nq-brand-purple: #A035CC;--nq-brand-gradient: linear-gradient(135deg, var(--nq-brand-blue), var(--nq-brand-purple));--nq-brand-gradient-h: linear-gradient(90deg, var(--nq-brand-blue), var(--nq-brand-purple));--nq-a-page: #F4F4F6;--nq-a-surface: #FFFFFF;--nq-a-surface-subtle: #FAFAFA;--nq-a-selected: #EBEBF8;--nq-a-border: #E4E4EA;--nq-a-divider: #EDEDF1;--nq-a-ink: #18181D;--nq-a-ink-secondary: #52525E;--nq-a-ink-tertiary: #70707E;--nq-a-success: #15803D;--nq-a-danger: #DC2626;--nq-a-danger-strong: #B91C1C;--nq-a-danger-bg: #FEE2E2;--nq-a-warning-ink: #92400E;--nq-a-warning-ink-2: #78350F;--nq-a-warning-bg: #FEF3C7;--nq-a-warning-border: rgba(217,119,6,.35);--nq-a-info-bg: #DBEAFE;--nq-a-info-ink: #1E3A8A;--nq-a-info-icon: #1D4ED8;--nq-a-reversal: #7D28A0;--nq-a-font: Nunito, system-ui, sans-serif;--nq-a-size-screen: 24px;--nq-a-size-hero: 32px;--nq-a-size-card-title: 19px;--nq-a-size-stat: 22px;--nq-a-size-row: 13.5px;--nq-a-size-meta: 11px;--nq-a-size-label: 10px;--nq-a-track-label: .1em;--nq-a-density-page-pad: 14px 16px 92px;--nq-a-density-gap: 10px;--nq-a-density-card-pad: 13px;--nq-a-density-row-pad: 11px 13px;--nq-a-density-radius: 8px;--nq-a-density-shadow: none;--nq-a-tabbar-height: 70px;--nq-a-tap-min: 44px;--nq-a-radius-pill: 9999px;--nq-a-radius-sheet: 20px 20px 0 0;--nq-a-sheet-shadow: 0 -8px 28px rgba(9,9,11,.18);--nq-a-progress-height: 6px;--nq-ease-out: cubic-bezier(0, 0, .2, 1);--nq-dur-micro: .12s;--nq-dur-base: .2s;--nq-dur-modal: .35s}";
|
||||
class Ut extends m {
|
||||
static properties = {
|
||||
hass: { attribute: !1 },
|
||||
_config: { state: !0 }
|
||||
};
|
||||
static styles = [U(Nt)];
|
||||
hass;
|
||||
_config;
|
||||
setConfig(t) {
|
||||
if (!t || typeof t != "object")
|
||||
throw new Error("Invalid configuration");
|
||||
this._config = t;
|
||||
}
|
||||
getCardSize() {
|
||||
return 8;
|
||||
}
|
||||
render() {
|
||||
return B`<div>NestQuest Admin</div>`;
|
||||
}
|
||||
}
|
||||
customElements.define("nestquest-admin-card", Ut);
|
||||
L({
|
||||
type: "nestquest-admin-card",
|
||||
name: "NestQuest Admin",
|
||||
description: "Parent phone view for today, tasks, schedule, and history."
|
||||
});
|
||||
const zt = "/nestquest-static/nestquest-fonts.css";
|
||||
if (!document.querySelector('link[data-nq-fonts=""]')) {
|
||||
const i = document.createElement("link");
|
||||
i.rel = "stylesheet", i.href = zt, i.dataset.nqFonts = "", document.head.appendChild(i);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
@font-face {
|
||||
font-family: "Nunito";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("./fonts/nunito-latin-400-normal.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Nunito";
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url("./fonts/nunito-latin-600-normal.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Nunito";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("./fonts/nunito-latin-700-normal.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Nunito";
|
||||
font-style: normal;
|
||||
font-weight: 800;
|
||||
font-display: swap;
|
||||
src: url("./fonts/nunito-latin-800-normal.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Cinzel";
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url("./fonts/cinzel-latin-600-normal.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Cinzel";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("./fonts/cinzel-latin-700-normal.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Cinzel";
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
font-display: swap;
|
||||
src: url("./fonts/cinzel-latin-900-normal.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Cinzel Decorative";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("./fonts/cinzel-decorative-latin-700-normal.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Cinzel Decorative";
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
font-display: swap;
|
||||
src: url("./fonts/cinzel-decorative-latin-900-normal.woff2") format("woff2");
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/* ============================================================
|
||||
NestQuest — Kids' panel card tokens
|
||||
Surface: DAKboard DAKOS wall panel, 1920x1080 landscape,
|
||||
viewed from ~3 ft, no login, read/complete only.
|
||||
Scope these on the card host: nestquest-quest-log-card,
|
||||
nestquest-party-board-card.
|
||||
Derived from the approved mockups (panel screens 1a, 1b, 1c, 1j).
|
||||
============================================================ */
|
||||
|
||||
:host {
|
||||
/* ── Parchment ground ─────────────────────────────────── */
|
||||
--nq-p-parchment-top: #f0e4c7;
|
||||
--nq-p-parchment-bottom: #ddcca3;
|
||||
--nq-p-parchment: radial-gradient(ellipse at 25% 10%, rgba(255,255,255,.5), transparent 55%),
|
||||
radial-gradient(ellipse at 85% 90%, rgba(120,86,44,.28), transparent 60%),
|
||||
repeating-linear-gradient(93deg, rgba(150,115,70,.05) 0 2px, transparent 2px 6px),
|
||||
repeating-linear-gradient(2deg, rgba(150,115,70,.04) 0 3px, transparent 3px 7px),
|
||||
linear-gradient(var(--nq-p-parchment-top), var(--nq-p-parchment-bottom));
|
||||
--nq-p-vignette: inset 0 0 200px rgba(80,52,20,.35);
|
||||
|
||||
/* Card surfaces sitting on parchment */
|
||||
--nq-p-card-open: linear-gradient(#fdf7e6, #f2e7c9); /* incomplete quest */
|
||||
--nq-p-card-done: linear-gradient(#f0e8d3, #e6dcc2); /* sealed quest */
|
||||
--nq-p-card-panel: linear-gradient(#fcf6e6, #f1e6ca); /* adventurer plate, stat block */
|
||||
--nq-p-card-border: rgba(120,88,48,.38);
|
||||
--nq-p-panel-border: rgba(92,62,26,.40);
|
||||
--nq-p-card-shadow: 0 3px 0 rgba(120,88,48,.20), inset 0 1px 0 rgba(255,255,255,.70);
|
||||
--nq-p-panel-shadow: 0 6px 0 rgba(92,62,26,.18), inset 0 2px 0 rgba(255,255,255,.70);
|
||||
--nq-p-frame-outer: 2px solid rgba(92,62,26,.45);
|
||||
--nq-p-frame-inner: 1px solid rgba(92,62,26,.28);
|
||||
--nq-p-rule: 2px solid rgba(92,62,26,.35);
|
||||
|
||||
/* ── Ink (all values >= 4.5:1 on parchment) ───────────── */
|
||||
--nq-p-ink: #2b1f14; /* titles, quest names */
|
||||
--nq-p-ink-secondary: #5c452a; /* meta, window labels, counts */
|
||||
--nq-p-ink-muted: #6f6455; /* sealed quest titles only */
|
||||
--nq-p-ink-away: #4d4433; /* away adventurer name */
|
||||
--nq-p-ink-late: #8f1526; /* overdue line */
|
||||
|
||||
/* ── Brand (CubeCraft, toned down: accents only) ──────── */
|
||||
--nq-brand-blue: #3B3AB8;
|
||||
--nq-brand-purple: #A035CC;
|
||||
--nq-brand-gradient: linear-gradient(135deg, var(--nq-brand-blue), var(--nq-brand-purple));
|
||||
--nq-p-icon-tile: var(--nq-brand-gradient); /* open quest icon tile */
|
||||
--nq-p-icon-tile-done: rgba(92,62,26,.16);
|
||||
--nq-p-crest: var(--nq-brand-gradient);
|
||||
--nq-p-crest-away: linear-gradient(135deg, #6b6b7a, #7a7286);
|
||||
|
||||
/* ── Wax seal (completed marker) ──────────────────────── */
|
||||
--nq-p-seal: radial-gradient(circle at 35% 30%, #a8283a, #6d1322);
|
||||
--nq-p-seal-shadow: 0 4px 10px rgba(60,10,20,.40), inset 0 0 0 4px rgba(255,255,255,.14);
|
||||
--nq-p-seal-size: 78px; /* 168px on the quest-complete screen */
|
||||
|
||||
/* ── Dock (bottom strip, non-interactive) ─────────────── */
|
||||
--nq-p-dock-bg: rgba(43,31,20,.90);
|
||||
--nq-p-dock-ink: #f7efdb;
|
||||
--nq-p-dock-ink-secondary: #d8c9a6;
|
||||
--nq-p-dock-divider: rgba(233,220,189,.30);
|
||||
--nq-p-dock-height: 84px;
|
||||
|
||||
/* ── Type ─────────────────────────────────────────────── */
|
||||
--nq-p-font-display: 'Cinzel Decorative', Cinzel, serif; /* NestQuest, names, Quest complete */
|
||||
--nq-p-font-heading: Cinzel, serif; /* section heads, quest titles, numerals */
|
||||
--nq-p-font-body: Nunito, system-ui, sans-serif; /* meta lines, dock */
|
||||
|
||||
--nq-p-size-hero: 86px; /* 900 weight, "Quest complete" */
|
||||
--nq-p-size-wordmark: 66px; /* 900 weight */
|
||||
--nq-p-size-title: 56px; /* 700, "Declan's Quest Log" */
|
||||
--nq-p-size-name: 48px; /* 700, adventurer name */
|
||||
--nq-p-size-section: 32px; /* 700, Morning / Afternoon / Evening */
|
||||
--nq-p-size-quest: 30px; /* 700, quest title */
|
||||
--nq-p-size-body: 22px; /* 600 Nunito, meta — PANEL MINIMUM */
|
||||
--nq-p-size-label: 21px; /* 600 Cinzel, uppercase, tracked */
|
||||
--nq-p-track-label: .14em;
|
||||
--nq-p-track-kicker: .30em;
|
||||
|
||||
/* ── Metrics: touch ───────────────────────────────────── */
|
||||
--nq-p-quest-min-height: 116px; /* whole card is the tap target */
|
||||
--nq-p-quest-gap: 24px; /* separation between tap targets */
|
||||
--nq-p-button-height: 72px; /* "Complete" button on a quest card */
|
||||
--nq-p-button-min-width: 150px;
|
||||
--nq-p-confirm-button: 108px; /* confirm dialog buttons */
|
||||
--nq-p-radius-card: 12px;
|
||||
--nq-p-radius-panel: 20px;
|
||||
--nq-p-radius-pill: 9999px;
|
||||
--nq-p-page-inset: 62px;
|
||||
--nq-p-frame-inset: 26px;
|
||||
|
||||
/* ── Motion (CubeCraft easing) ────────────────────────── */
|
||||
--nq-ease-out: cubic-bezier(0, 0, .2, 1);
|
||||
--nq-dur-micro: 120ms;
|
||||
--nq-dur-base: 200ms;
|
||||
--nq-dur-modal: 350ms;
|
||||
}
|
||||
Generated
+1232
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "nestquest-cards",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"lit": "^3.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.1.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { LitElement, html, unsafeCSS } from "lit";
|
||||
import adminTokens from "../../../custom_components/nestquest/www/nestquest-admin-tokens.css?inline";
|
||||
|
||||
import { registerCustomCard, type CardConfig, type HassLike } from "../types";
|
||||
|
||||
export class NestQuestAdminCard extends LitElement {
|
||||
static properties = {
|
||||
hass: { attribute: false },
|
||||
_config: { state: true },
|
||||
};
|
||||
|
||||
static styles = [unsafeCSS(adminTokens)];
|
||||
|
||||
hass?: HassLike;
|
||||
_config?: CardConfig;
|
||||
|
||||
setConfig(config: CardConfig): void {
|
||||
if (!config || typeof config !== "object") {
|
||||
throw new Error("Invalid configuration");
|
||||
}
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
getCardSize(): number {
|
||||
return 8;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`<div>NestQuest Admin</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("nestquest-admin-card", NestQuestAdminCard);
|
||||
registerCustomCard({
|
||||
type: "nestquest-admin-card",
|
||||
name: "NestQuest Admin",
|
||||
description: "Parent phone view for today, tasks, schedule, and history.",
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { LitElement, html, unsafeCSS } from "lit";
|
||||
import panelTokens from "../../../custom_components/nestquest/www/nestquest-panel-tokens.css?inline";
|
||||
|
||||
import { registerCustomCard, type CardConfig, type HassLike } from "../types";
|
||||
|
||||
export class NestQuestPartyBoardCard extends LitElement {
|
||||
static properties = {
|
||||
hass: { attribute: false },
|
||||
_config: { state: true },
|
||||
};
|
||||
|
||||
static styles = [unsafeCSS(panelTokens)];
|
||||
|
||||
hass?: HassLike;
|
||||
_config?: CardConfig;
|
||||
|
||||
setConfig(config: CardConfig): void {
|
||||
if (!config || typeof config !== "object") {
|
||||
throw new Error("Invalid configuration");
|
||||
}
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
getCardSize(): number {
|
||||
return 6;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`<div>NestQuest Party Board</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("nestquest-party-board-card", NestQuestPartyBoardCard);
|
||||
registerCustomCard({
|
||||
type: "nestquest-party-board-card",
|
||||
name: "NestQuest Party Board",
|
||||
description: "Kids' attract screen for present and away adventurers.",
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { LitElement, html, unsafeCSS } from "lit";
|
||||
import panelTokens from "../../../custom_components/nestquest/www/nestquest-panel-tokens.css?inline";
|
||||
|
||||
import { registerCustomCard, type CardConfig, type HassLike } from "../types";
|
||||
|
||||
export class NestQuestQuestLogCard extends LitElement {
|
||||
static properties = {
|
||||
hass: { attribute: false },
|
||||
_config: { state: true },
|
||||
};
|
||||
|
||||
static styles = [unsafeCSS(panelTokens)];
|
||||
|
||||
hass?: HassLike;
|
||||
_config?: CardConfig;
|
||||
|
||||
setConfig(config: CardConfig): void {
|
||||
if (!config || typeof config !== "object") {
|
||||
throw new Error("Invalid configuration");
|
||||
}
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
getCardSize(): number {
|
||||
return 8;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`<div>NestQuest Quest Log</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("nestquest-quest-log-card", NestQuestQuestLogCard);
|
||||
registerCustomCard({
|
||||
type: "nestquest-quest-log-card",
|
||||
name: "NestQuest Quest Log",
|
||||
description: "One child's daily quests, grouped by window.",
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import "./cards/party-board";
|
||||
import "./cards/quest-log";
|
||||
import "./cards/admin";
|
||||
|
||||
const FONTS_HREF = "/nestquest-static/nestquest-fonts.css";
|
||||
if (!document.querySelector('link[data-nq-fonts=""]')) {
|
||||
const link = document.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
link.href = FONTS_HREF;
|
||||
link.dataset.nqFonts = "";
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export type HassLike = Record<string, unknown>;
|
||||
|
||||
export type CardConfig = Record<string, unknown>;
|
||||
|
||||
export type CustomCardInfo = {
|
||||
type: string;
|
||||
name: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
customCards?: CustomCardInfo[];
|
||||
}
|
||||
}
|
||||
|
||||
export function registerCustomCard(info: CustomCardInfo): void {
|
||||
window.customCards = window.customCards ?? [];
|
||||
window.customCards.push(info);
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
outDir: "../custom_components/nestquest/www",
|
||||
emptyOutDir: false,
|
||||
sourcemap: false,
|
||||
lib: {
|
||||
entry: "src/nestquest-cards.ts",
|
||||
formats: ["es"],
|
||||
fileName: () => "nestquest-cards.js",
|
||||
},
|
||||
rollupOptions: {
|
||||
output: {
|
||||
inlineDynamicImports: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -51,6 +51,8 @@ _HA_MODULES = (
|
||||
"homeassistant.components",
|
||||
"homeassistant.components.sensor",
|
||||
"homeassistant.components.binary_sensor",
|
||||
"homeassistant.components.http",
|
||||
"homeassistant.components.frontend",
|
||||
)
|
||||
|
||||
def _find_spec(name: str):
|
||||
@@ -96,6 +98,8 @@ for _parent, _child in (
|
||||
("homeassistant", "components"),
|
||||
("homeassistant.components", "sensor"),
|
||||
("homeassistant.components", "binary_sensor"),
|
||||
("homeassistant.components", "http"),
|
||||
("homeassistant.components", "frontend"),
|
||||
):
|
||||
setattr(sys.modules[_parent], _child, sys.modules[f"{_parent}.{_child}"])
|
||||
|
||||
@@ -429,6 +433,28 @@ _sensor_mock.SensorStateClass = SensorStateClass
|
||||
_binary_sensor_mock = _ha_mock("homeassistant.components.binary_sensor")
|
||||
_binary_sensor_mock.BinarySensorEntity = BinarySensorEntity
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StaticPathConfig:
|
||||
"""Stand-in mirroring homeassistant.components.http.StaticPathConfig."""
|
||||
|
||||
url_path: str
|
||||
path: str
|
||||
cache_headers: bool = True
|
||||
|
||||
|
||||
def _add_extra_js_url(hass, url, es5=False):
|
||||
"""Stand-in mirroring homeassistant.components.frontend.add_extra_js_url."""
|
||||
urls = getattr(hass, "extra_js_urls", None)
|
||||
if urls is None:
|
||||
hass.extra_js_urls = []
|
||||
urls = hass.extra_js_urls
|
||||
urls.append(url)
|
||||
|
||||
|
||||
_ha_mock("homeassistant.components.http").StaticPathConfig = StaticPathConfig
|
||||
_ha_mock("homeassistant.components.frontend").add_extra_js_url = _add_extra_js_url
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
|
||||
@@ -549,6 +575,16 @@ def _async_track_time_change(hass, action, hour=None, minute=None, second=None):
|
||||
_ha_mock("homeassistant.helpers.event").async_track_time_change = _async_track_time_change
|
||||
|
||||
|
||||
class HttpRegistry:
|
||||
"""Models HA's ``hass.http`` static-path registration."""
|
||||
|
||||
def __init__(self):
|
||||
self.static_paths: list[StaticPathConfig] = []
|
||||
|
||||
async def async_register_static_paths(self, configs):
|
||||
self.static_paths.extend(list(configs))
|
||||
|
||||
|
||||
class EventBus:
|
||||
"""Models HA's ``hass.bus`` event bus.
|
||||
|
||||
@@ -697,6 +733,8 @@ def make_hass() -> tuple:
|
||||
hass.time_change = TimeChangeRegistry(hass)
|
||||
hass.services = ServiceRegistry(hass)
|
||||
hass.bus = EventBus()
|
||||
hass.http = HttpRegistry()
|
||||
hass.extra_js_urls: list[str] = []
|
||||
# A valid IANA time zone (the day-rollover listener reads it to
|
||||
# compute "today" in HA local time).
|
||||
hass.config.time_zone = "UTC"
|
||||
|
||||
@@ -241,4 +241,28 @@ def test_afternoon_reminder_never_hard_codes_notify_target() -> None:
|
||||
assert re.search(r"notify\.[a-z_]+", target_description), (
|
||||
"the input description should show a placeholder example"
|
||||
)
|
||||
assert single_action(bp)["action"] == BlueprintInput("notify_target")
|
||||
assert single_action(bp)["action"] == BlueprintInput("notify_target")
|
||||
|
||||
def test_afternoon_reminder_absent_child_regression() -> None:
|
||||
"""The Feature 11 guardrail, stated as one regression test: no
|
||||
reminder message is ever produced for an absent child."""
|
||||
states = make_states(
|
||||
sensors=[
|
||||
TemplateState(
|
||||
"sensor.nestquest_bob_quests_due_today",
|
||||
"3",
|
||||
{"child_name": "Bob", "child_id": 1, "present": False},
|
||||
),
|
||||
TemplateState(
|
||||
"sensor.nestquest_bob_quests_remaining_today",
|
||||
"3",
|
||||
{"child_id": 1},
|
||||
),
|
||||
],
|
||||
)
|
||||
guard = afternoon_conditions(afternoon_reminder())[-1]
|
||||
assert render(guard["value_template"], states=states).strip() == "False"
|
||||
message = render(
|
||||
notify_data(afternoon_reminder())["message"], states=states
|
||||
)
|
||||
assert "Bob" not in message
|
||||
|
||||
@@ -65,14 +65,17 @@ def stamp_value_template(bp: dict) -> str:
|
||||
|
||||
|
||||
def trigger_payload(child_id: int, child_name: str) -> dict:
|
||||
"""A nestquest_child_day_complete trigger payload fixture."""
|
||||
"""A nestquest_child_day_complete trigger fixture: HA exposes an
|
||||
event trigger's payload as trigger.event.data."""
|
||||
return {
|
||||
"payload": {
|
||||
"child_id": child_id,
|
||||
"child_name": child_name,
|
||||
"quests_due": 3,
|
||||
"quests_completed": 3,
|
||||
"occurred_at": "2026-09-19T15:00:00+00:00",
|
||||
"event": {
|
||||
"data": {
|
||||
"child_id": child_id,
|
||||
"child_name": child_name,
|
||||
"quests_due": 3,
|
||||
"quests_completed": 3,
|
||||
"occurred_at": "2026-09-19T15:00:00+00:00",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,3 +275,39 @@ def test_celebration_mode_is_queued() -> None:
|
||||
"""Two children completing at nearly the same time must both be
|
||||
processed: queued mode does not drop the second event."""
|
||||
assert celebration()["mode"] == "queued"
|
||||
|
||||
|
||||
def test_every_blueprint_event_reference_is_real() -> None:
|
||||
"""Every event a blueprint triggers on is one NestQuest actually
|
||||
fires or defines (Feature 10/11) — no invented event names."""
|
||||
from tests.blueprint_helpers import load_blueprints
|
||||
|
||||
real_events = {
|
||||
"nestquest_quest_completed",
|
||||
"nestquest_quest_uncompleted",
|
||||
"nestquest_quest_missed",
|
||||
"nestquest_child_day_complete",
|
||||
}
|
||||
for name, bp in load_blueprints().items():
|
||||
for trigger in bp.get("triggers", []):
|
||||
if trigger.get("trigger") == "event":
|
||||
assert trigger["event_type"] in real_events, (
|
||||
f"{name}: invented event {trigger['event_type']!r}"
|
||||
)
|
||||
assert celebration()["triggers"][0]["event_type"] == (
|
||||
"nestquest_child_day_complete"
|
||||
)
|
||||
|
||||
|
||||
def test_celebration_uses_the_event_trigger_data_path() -> None:
|
||||
"""HA exposes an event trigger's payload as trigger.event.data —
|
||||
never trigger.payload (Codex review regression)."""
|
||||
bp = celebration()
|
||||
for value in iter_strings(bp):
|
||||
assert "trigger.payload" not in value, value
|
||||
default_message = bp["blueprint"]["input"]["celebration_message"][
|
||||
"default"
|
||||
]
|
||||
assert "trigger.event.data.child_name" in default_message
|
||||
for condition in celebration_conditions(bp):
|
||||
assert "trigger.payload" not in condition["value_template"]
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Tests for the Lovelace card toolchain and auto-registered resource."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from conftest import wire_entry_to_registry
|
||||
|
||||
from custom_components.nestquest import async_setup, async_setup_entry
|
||||
from custom_components.nestquest.frontend import (
|
||||
BUNDLE_FILENAME,
|
||||
BUNDLE_URL,
|
||||
FRONTEND_URL_BASE,
|
||||
WWW_DIR,
|
||||
async_register_frontend,
|
||||
)
|
||||
|
||||
REPO_ROOT = Path(__file__).parent.parent
|
||||
FRONTEND_DIR = REPO_ROOT / "frontend"
|
||||
BUNDLE_PATH = WWW_DIR / BUNDLE_FILENAME
|
||||
CARD_TYPES = (
|
||||
"nestquest-party-board-card",
|
||||
"nestquest-quest-log-card",
|
||||
"nestquest-admin-card",
|
||||
)
|
||||
|
||||
|
||||
def test_frontend_package_documents_vite_lit_build() -> None:
|
||||
package = json.loads((FRONTEND_DIR / "package.json").read_text(encoding="utf-8"))
|
||||
assert package["scripts"]["build"] == "vite build"
|
||||
assert "lit" in package["dependencies"]
|
||||
assert "typescript" in package["devDependencies"]
|
||||
assert "vite" in package["devDependencies"]
|
||||
|
||||
|
||||
def test_card_modules_exist_as_separate_sources() -> None:
|
||||
src = FRONTEND_DIR / "src"
|
||||
assert (src / "nestquest-cards.ts").is_file()
|
||||
assert (src / "cards" / "party-board.ts").is_file()
|
||||
assert (src / "cards" / "quest-log.ts").is_file()
|
||||
assert (src / "cards" / "admin.ts").is_file()
|
||||
entry = (src / "nestquest-cards.ts").read_text(encoding="utf-8")
|
||||
assert "./cards/party-board" in entry
|
||||
assert "./cards/quest-log" in entry
|
||||
assert "./cards/admin" in entry
|
||||
|
||||
|
||||
def test_card_sources_have_no_visual_editor() -> None:
|
||||
for path in (FRONTEND_DIR / "src" / "cards").glob("*.ts"):
|
||||
text = path.read_text(encoding="utf-8")
|
||||
assert "getConfigElement" not in text
|
||||
assert "setConfig" in text
|
||||
assert "getCardSize" in text
|
||||
|
||||
|
||||
def test_built_bundle_contains_all_three_cards() -> None:
|
||||
assert BUNDLE_PATH.is_file()
|
||||
bundle = BUNDLE_PATH.read_text(encoding="utf-8")
|
||||
for card_type in CARD_TYPES:
|
||||
assert card_type in bundle
|
||||
|
||||
|
||||
async def test_setup_registers_static_path_and_extra_js(hass, make_entry) -> None:
|
||||
entry = wire_entry_to_registry(make_entry(), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
assert len(hass.http.static_paths) == 1
|
||||
registered = hass.http.static_paths[0]
|
||||
assert registered.url_path == FRONTEND_URL_BASE
|
||||
assert registered.path == str(WWW_DIR)
|
||||
assert hass.extra_js_urls == [BUNDLE_URL]
|
||||
|
||||
|
||||
async def test_async_setup_registers_frontend_without_a_config_entry(hass) -> None:
|
||||
assert await async_setup(hass, {}) is True
|
||||
assert hass.extra_js_urls == [BUNDLE_URL]
|
||||
assert hass.http.static_paths[0].url_path == FRONTEND_URL_BASE
|
||||
|
||||
|
||||
async def test_frontend_registration_is_idempotent(hass, make_entry) -> None:
|
||||
await async_register_frontend(hass)
|
||||
await async_register_frontend(hass)
|
||||
entry_a = wire_entry_to_registry(make_entry(entry_id="a"), hass.registry)
|
||||
entry_b = wire_entry_to_registry(make_entry(entry_id="b"), hass.registry)
|
||||
assert await async_setup_entry(hass, entry_a) is True
|
||||
assert await async_setup_entry(hass, entry_b) is True
|
||||
assert len(hass.http.static_paths) == 1
|
||||
assert hass.extra_js_urls == [BUNDLE_URL]
|
||||
|
||||
|
||||
def test_readme_documents_npm_build() -> None:
|
||||
readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8")
|
||||
assert "npm run build" in readme
|
||||
assert "nestquest-cards.js" in readme
|
||||
|
||||
|
||||
WOFF2_FILES = (
|
||||
"nunito-latin-400-normal.woff2",
|
||||
"nunito-latin-600-normal.woff2",
|
||||
"nunito-latin-700-normal.woff2",
|
||||
"nunito-latin-800-normal.woff2",
|
||||
"cinzel-latin-600-normal.woff2",
|
||||
"cinzel-latin-700-normal.woff2",
|
||||
"cinzel-latin-900-normal.woff2",
|
||||
"cinzel-decorative-latin-700-normal.woff2",
|
||||
"cinzel-decorative-latin-900-normal.woff2",
|
||||
)
|
||||
CDN_MARKERS = (
|
||||
"fonts.googleapis.com",
|
||||
"fonts.gstatic.com",
|
||||
"cdn.jsdelivr.net",
|
||||
"unpkg.com",
|
||||
"cdnjs.cloudflare.com",
|
||||
)
|
||||
|
||||
|
||||
def test_www_contains_required_woff2_fonts() -> None:
|
||||
fonts_dir = WWW_DIR / "fonts"
|
||||
for name in WOFF2_FILES:
|
||||
path = fonts_dir / name
|
||||
assert path.is_file(), name
|
||||
assert path.read_bytes()[:4] == b"wOF2"
|
||||
|
||||
|
||||
def test_font_css_declares_swap_and_local_urls() -> None:
|
||||
css = (WWW_DIR / "nestquest-fonts.css").read_text(encoding="utf-8")
|
||||
assert css.count("font-display: swap") == 9
|
||||
assert css.count("@font-face") == 9
|
||||
assert 'font-family: "Nunito"' in css
|
||||
assert 'font-family: "Cinzel"' in css
|
||||
assert 'font-family: "Cinzel Decorative"' in css
|
||||
for name in WOFF2_FILES:
|
||||
assert f"./fonts/{name}" in css
|
||||
assert "http://" not in css
|
||||
assert "https://" not in css
|
||||
|
||||
|
||||
def test_token_css_copied_verbatim_from_design() -> None:
|
||||
design = REPO_ROOT / "design" / "tokens"
|
||||
for name in ("nestquest-panel-tokens.css", "nestquest-admin-tokens.css"):
|
||||
expected = (design / name).read_bytes()
|
||||
actual = (WWW_DIR / name).read_bytes()
|
||||
assert actual == expected
|
||||
|
||||
|
||||
def test_frontend_and_www_have_no_cdn_font_references() -> None:
|
||||
roots = (FRONTEND_DIR, WWW_DIR)
|
||||
for root in roots:
|
||||
for path in root.rglob("*"):
|
||||
if not path.is_file():
|
||||
continue
|
||||
if "node_modules" in path.parts:
|
||||
continue
|
||||
if path.suffix.lower() in {".woff2", ".png", ".jpg"}:
|
||||
continue
|
||||
text = path.read_text(encoding="utf-8", errors="ignore")
|
||||
for marker in CDN_MARKERS:
|
||||
assert marker not in text, f"{path}: {marker}"
|
||||
Reference in New Issue
Block a user