diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 122bf63c..7dcf4714 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -489,6 +489,7 @@ jobs: needs: [frontend, reuse] if: needs.reuse.outputs.smoke != 'true' runs-on: ubuntu-latest + timeout-minutes: 20 # Смоки шардируются: последовательный прогон занимал ~7.5 минут и был # критическим путём всего Validate. Три шарда режут его примерно вдвое; # цена — трижды `npm ci` и сборка, около двух оплаченных минут раннера. @@ -556,10 +557,14 @@ jobs: if [ $(( (index - 1) % SHARDS + 1 )) -ne "$SHARD" ]; then continue; fi ran=$((ran + 1)) name=$(basename "$f" .mjs) - if node "$f" > "/tmp/smoke-logs/$name.log" 2>&1; then + if timeout --kill-after=10s 180s node "$f" > "/tmp/smoke-logs/$name.log" 2>&1; then echo "ok $name" else + status=$? echo "FAIL $name" + if [ "$status" -eq 124 ]; then + echo "diagnostic smoke-timeout: $name exceeded 180s" + fi # Диагностические строки смока печатаются раньше вердикта, и # `tail -20` их срезал: на #411 сам смок сообщал, НАСКОЛЬКО # разошлись кадры (введено в #302), а в логе прогона осталось diff --git a/PROCESS.md b/PROCESS.md index 0723ebe2..f316e37c 100644 --- a/PROCESS.md +++ b/PROCESS.md @@ -193,6 +193,35 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready → кода отвечает на вопрос «оно вообще работает»: каждый AC либо доказан автотестом — и ревьюер убедился, что **тест умеет падать**, — либо разобран по коду с явной записью «проверено чтением, не исполнением». +- **Защитный AC доказывается таблицей «чем краснеет» (#435).** Для каждого AC, + заявляющего защиту — валидация, гард, лимит, отказ, инвариант, — в документе + ревью обязательна строка из трёх столбцов: **AC · чем доказан** (точная + команда или имя теста) **· чем краснеет** — мутация, снятая защита или + отрицательная проба, с результатом прогона. Пустой третий столбец — находка + Medium, а не примечание. + + «Тест умеет падать» без названной мутации и её вывода доказательством не + является. Аудит v1.71.0-beta.1 нашёл пять контрактов #51 и #423, где тест + оставался зелёным на снятой защите; все пять прошли код-ревью как доказанные, + а два теста были записаны в закрытие coverage-ratchet под именами, обещавшими + то, чего они не проверяли (#430). + + Мутант в `scripts/mutation-gate.mjs` обязателен, когда защита живёт в + продуктовом коде и проверяется дорогим гейтом (смок, бэкенд, golden): там + ревьюер не воспроизведёт отрицательный прогон второй раз. Для чистых юнитов + достаточно прогона со снятой защитой, приведённого в документе. + + Считаются **защитные AC без названного свидетеля**, а не мутанты на + подсистему: у #421 мутанты были, и дыра всё равно проехала. «Сколько мутантов + принесла задача» остаётся признаком — у #423 их ноль, и именно у #423 нашёлся + тест, спрашивавший регулярку, находит ли она подстроку, которую сам же и + вырезал. + + Правило не распространяется на AC, не заявляющие защиту (расположение, текст, + формат вывода): там свидетель — обычное сравнение ожидаемого с фактическим, и + третий столбец превратился бы в ритуал. И не отменяет «проверено чтением»: + тогда во втором столбце стоит «чтением», а не имя теста, и читатель ревью + видит разницу. - **High блокируют.** Medium **в скоупе задачи** чинится в текущем issue: без High это жёлтый вердикт и возврат автору, фикс проходит повторный цикл. Medium **вне скоупа** — отдельный issue (#202). diff --git a/custom_components/houseplan/asset_integrity.py b/custom_components/houseplan/asset_integrity.py new file mode 100644 index 00000000..c641b582 --- /dev/null +++ b/custom_components/houseplan/asset_integrity.py @@ -0,0 +1,139 @@ +"""Bounded, shared integrity verification for content-addressed decor assets.""" +from __future__ import annotations + +import hashlib +import logging +import threading +from collections import OrderedDict +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +ASSET_INTEGRITY_CACHE_ENTRIES = 256 +ASSET_HASH_CHUNK_BYTES = 64 * 1024 +_HASS_DATA_KEY = "asset_integrity_verifier" + + +@dataclass(frozen=True) +class FileSignature: + """File version facts available without reading its content.""" + + size: int + mtime_ns: int + ctime_ns: int + + +@dataclass(frozen=True) +class _CacheEntry: + signature: FileSignature + digest: str + + +@dataclass +class _Flight: + event: threading.Event + digest: str | None = None + + +def _signature(path: Path) -> FileSignature: + stat = path.stat() + return FileSignature( + size=stat.st_size, + mtime_ns=stat.st_mtime_ns, + ctime_ns=stat.st_ctime_ns, + ) + + +def _stream_sha256(path: Path) -> str: + """Hash a blob without retaining its bytes in memory.""" + digest = hashlib.sha256() + with path.open("rb") as stream: + while chunk := stream.read(ASSET_HASH_CHUNK_BYTES): + digest.update(chunk) + return digest.hexdigest() + + +class AssetIntegrityVerifier: + """Thread-safe LRU digest cache with per-file-version single-flight.""" + + def __init__( + self, + max_entries: int = ASSET_INTEGRITY_CACHE_ENTRIES, + *, + hasher: Callable[[Path], str] | None = None, + event_factory: Callable[[], threading.Event] | None = None, + ) -> None: + if max_entries < 1: + raise ValueError("max_entries must be positive") + self._max_entries = max_entries + self._hasher = hasher or _stream_sha256 + self._event_factory = event_factory or threading.Event + self._lock = threading.Lock() + self._cache: OrderedDict[str, _CacheEntry] = OrderedDict() + self._inflight: dict[tuple[str, FileSignature], _Flight] = {} + + def verify(self, path: Path, expected_digest: str) -> bool: + """Return whether one stable file version has the expected digest.""" + try: + canonical = str(path.resolve()) + except (OSError, RuntimeError): + return False + try: + before = _signature(path) + except OSError: + with self._lock: + self._cache.pop(canonical, None) + return False + + key = (canonical, before) + with self._lock: + cached = self._cache.get(canonical) + if cached is not None and cached.signature == before: + self._cache.move_to_end(canonical) + return cached.digest == expected_digest + if cached is not None: + self._cache.pop(canonical, None) + flight = self._inflight.get(key) + owner = flight is None + if owner: + flight = _Flight(self._event_factory()) + self._inflight[key] = flight + + assert flight is not None + if not owner: + flight.event.wait() + return flight.digest == expected_digest + + digest: str | None = None + stable = False + try: + digest = self._hasher(path) + # Never publish a digest for bytes that changed while they were read. + stable = _signature(path) == before + except Exception as err: # noqa: BLE001 - filesystem/hash seam fails dark + _LOGGER.debug("House Plan asset integrity check failed: %s", err) + finally: + with self._lock: + if stable and digest is not None: + self._cache[canonical] = _CacheEntry(before, digest) + self._cache.move_to_end(canonical) + while len(self._cache) > self._max_entries: + self._cache.popitem(last=False) + flight.digest = digest + self._inflight.pop(key, None) + flight.event.set() + return stable and digest == expected_digest + + +def get_asset_integrity_verifier(hass: Any) -> AssetIntegrityVerifier: + """Return the single verifier shared by HTTP and WS on this HA instance.""" + domain_data = hass.data.setdefault(DOMAIN, {}) + verifier = domain_data.get(_HASS_DATA_KEY) + if not isinstance(verifier, AssetIntegrityVerifier): + verifier = AssetIntegrityVerifier() + domain_data[_HASS_DATA_KEY] = verifier + return verifier diff --git a/custom_components/houseplan/const.py b/custom_components/houseplan/const.py index 57907fb4..4970da65 100755 --- a/custom_components/houseplan/const.py +++ b/custom_components/houseplan/const.py @@ -51,7 +51,7 @@ SCHEDULED_GRACE_S = 30 * 24 * 3600 FILES_DIR = "houseplan/files" ASSETS_DIR = "houseplan/assets" CONF_ADMIN_ONLY = "admin_only" -VERSION = "1.71.0-beta.1" +VERSION = "1.71.0-beta.2" DECOR_ASSETS_API_VERSION = 1 # #43: the support transport is deliberately not configurable. A user supplied diff --git a/custom_components/houseplan/coordinate_canonicalization.py b/custom_components/houseplan/coordinate_canonicalization.py index 738c3f86..21fc6d43 100644 --- a/custom_components/houseplan/coordinate_canonicalization.py +++ b/custom_components/houseplan/coordinate_canonicalization.py @@ -14,6 +14,7 @@ COORDINATE_DECIMALS = 9 COORDINATE_FACTOR = 10**COORDINATE_DECIMALS LATTICE_GRID_N = 240 LATTICE_NOISE_STEPS = 1e-4 +DECOR_BOX_KINDS = ("rect", "ellipse", "furniture", "image") def canonicalize_number(value: Any) -> Any: @@ -150,7 +151,7 @@ def canonicalize_config_geometry(config: Any) -> Any: kind = decor.get("kind") if kind == "line": _lattice_fields(decor, ("x1", "y1", "x2", "y2")) - elif kind in ("rect", "ellipse", "furniture"): + elif kind in DECOR_BOX_KINDS: _lattice_fields(decor, ("x", "y", "w", "h")) _scalar_fields(decor, ("angle",)) elif kind == "text": diff --git a/custom_components/houseplan/decor_assets.py b/custom_components/houseplan/decor_assets.py index bac287ac..1ea90a7f 100644 --- a/custom_components/houseplan/decor_assets.py +++ b/custom_components/houseplan/decor_assets.py @@ -10,6 +10,7 @@ import hashlib import json import math import re +import stat import struct import xml.etree.ElementTree as ET from dataclasses import dataclass @@ -372,20 +373,63 @@ def asset_meta_path(root: Path, asset_id: str) -> Path: return root / f"{asset_id}.json" +def physical_asset_blobs(root: Path) -> list[Path]: + """Return exact promoted blob files, independently from their sidecars.""" + if not root.is_dir(): + return [] + blobs: list[Path] = [] + for path in root.iterdir(): + if ( + path.suffix in ASSET_EXTENSIONS + and ASSET_ID_RE.fullmatch(path.stem) + and stat.S_ISREG(path.stat(follow_symlinks=False).st_mode) + ): + blobs.append(path) + return sorted(blobs, key=lambda path: path.name) + + +def physical_asset_usage(root: Path) -> tuple[int, int]: + """Return promoted blob count and actual bytes used for quota checks.""" + blobs = physical_asset_blobs(root) + return len(blobs), sum(path.stat().st_size for path in blobs) + + +def _read_catalog_row(root: Path, path: Path) -> dict[str, Any] | None: + """Read one sidecar through the validation shared by list and resolve.""" + try: + row = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(row, dict): + return None + aid = str(row.get("asset_id") or "") + ext = row.get("ext") + blob = root / f"{aid}{ext}" + if ( + path.stem != aid + or not ASSET_ID_RE.fullmatch(aid) + or ext not in ASSET_EXTENSIONS + or not blob.is_file() + ): + return None + return row + except (OSError, ValueError, TypeError): + return None + + +def read_asset(root: Path, asset_id: str) -> dict[str, Any] | None: + """Read one exact catalog row without scanning unrelated sidecars.""" + if not ASSET_ID_RE.fullmatch(asset_id): + return None + return _read_catalog_row(root, asset_meta_path(root, asset_id)) + + def read_catalog(root: Path) -> list[dict[str, Any]]: rows: list[dict[str, Any]] = [] if not root.is_dir(): return rows for path in root.glob("*.json"): - try: - row = json.loads(path.read_text(encoding="utf-8")) - aid = str(row.get("asset_id") or "") - ext = row.get("ext") - blob = root / f"{aid}{ext}" - if ASSET_ID_RE.fullmatch(aid) and ext in ASSET_EXTENSIONS and blob.is_file(): - rows.append(row) - except (OSError, ValueError, TypeError): - continue + row = _read_catalog_row(root, path) + if row is not None: + rows.append(row) return sorted( rows, key=lambda row: (str(row.get("created_at", "")), str(row["asset_id"])), diff --git a/custom_components/houseplan/frontend/houseplan-assets.json b/custom_components/houseplan/frontend/houseplan-assets.json index 10ecf726..7be98900 100644 --- a/custom_components/houseplan/frontend/houseplan-assets.json +++ b/custom_components/houseplan/frontend/houseplan-assets.json @@ -1,125 +1,125 @@ { "schema": 1, - "fingerprint": "816ff9c893e23b7441e73210f85024741ff6c5bf8dabd2fb0df9e8b8055bc693", + "fingerprint": "e5edb95a1f5817c9035c6418ea0f9cb86cde238466f1f5c3174fb5246662c8ec", "entry": "houseplan-card.js", "initialViewFiles": [ - "houseplan-assets/houseplan-card-Dwkibvgo.js", + "houseplan-assets/houseplan-card-o7i25aVl.js", "houseplan-card.js" ], - "initialViewGzipBytes": 291031, + "initialViewGzipBytes": 291069, "lazyFiles": [ - "houseplan-assets/backdrop-pick-DWD0ZQTc.js", - "houseplan-assets/de-DJX-vLW1.js", - "houseplan-assets/editor-Dvui5H3b.js", - "houseplan-assets/fr-DVmfM6Kw.js", - "houseplan-assets/houseplan-editor-runtime-9AEP51ly.js", - "houseplan-assets/houseplan-onboarding-runtime-CnVK5oOC.js" + "houseplan-assets/backdrop-pick-Dg2Uytel.js", + "houseplan-assets/de-D_5HnJeM.js", + "houseplan-assets/editor-CqQRQq4C.js", + "houseplan-assets/fr-DpKqMyVB.js", + "houseplan-assets/houseplan-editor-runtime-D0JYjEs4.js", + "houseplan-assets/houseplan-onboarding-runtime-C-SJxeJ_.js" ], - "lazyGzipBytes": 214972, + "lazyGzipBytes": 215495, "lazyEditorFiles": [ - "houseplan-assets/backdrop-pick-DWD0ZQTc.js", - "houseplan-assets/editor-Dvui5H3b.js", - "houseplan-assets/houseplan-editor-runtime-9AEP51ly.js" + "houseplan-assets/backdrop-pick-Dg2Uytel.js", + "houseplan-assets/editor-CqQRQq4C.js", + "houseplan-assets/houseplan-editor-runtime-D0JYjEs4.js" ], - "lazyEditorGzipBytes": 159157, + "lazyEditorGzipBytes": 159809, "lazyOnboardingFiles": [ - "houseplan-assets/backdrop-pick-DWD0ZQTc.js", - "houseplan-assets/houseplan-onboarding-runtime-CnVK5oOC.js" + "houseplan-assets/backdrop-pick-Dg2Uytel.js", + "houseplan-assets/houseplan-onboarding-runtime-C-SJxeJ_.js" ], - "lazyOnboardingGzipBytes": 14132, + "lazyOnboardingGzipBytes": 14126, "lazyLocaleFiles": [ - "houseplan-assets/de-DJX-vLW1.js", - "houseplan-assets/fr-DVmfM6Kw.js" + "houseplan-assets/de-D_5HnJeM.js", + "houseplan-assets/fr-DpKqMyVB.js" ], - "lazyLocaleGzipBytes": 48859, + "lazyLocaleGzipBytes": 48734, "files": [ { - "path": "houseplan-assets/backdrop-pick-DWD0ZQTc.js", - "sha256": "455450cbc7b74f052fb112680af9ba4be145af8c3a1061907ec541b63957f22f", - "rawBytes": 20974, - "gzipBytes": 7176, + "path": "houseplan-assets/backdrop-pick-Dg2Uytel.js", + "sha256": "b67eb5e04c63007ff8ca7ff5623a7dcae44ff3c6f3ca5528ac7f1d389f75c4ec", + "rawBytes": 20996, + "gzipBytes": 7174, "isEntry": false, "imports": [ - "houseplan-assets/houseplan-card-Dwkibvgo.js" + "houseplan-assets/houseplan-card-o7i25aVl.js" ], "dynamicImports": [] }, { - "path": "houseplan-assets/de-DJX-vLW1.js", - "sha256": "73d198680c17602addf41855748533ed47233e81e9bdc02a786034aeac0e4e63", - "rawBytes": 81798, - "gzipBytes": 24701, + "path": "houseplan-assets/de-D_5HnJeM.js", + "sha256": "ad2dde1407ae850f963cf81a8c0218c0e6646a86cf3bceb61a305cd54f60dad9", + "rawBytes": 81632, + "gzipBytes": 24639, "isEntry": false, "imports": [], "dynamicImports": [] }, { - "path": "houseplan-assets/editor-Dvui5H3b.js", - "sha256": "11578ded60410e4e879c9342ee74c8a0968477ea2bae98eb18480a0ad02d303c", + "path": "houseplan-assets/editor-CqQRQq4C.js", + "sha256": "5007144deff010072eb0a6a6d3e5565064a60575baa173a0250cec89b1478823", "rawBytes": 3826, "gzipBytes": 1580, "isEntry": false, "imports": [ - "houseplan-assets/houseplan-card-Dwkibvgo.js" + "houseplan-assets/houseplan-card-o7i25aVl.js" ], "dynamicImports": [] }, { - "path": "houseplan-assets/fr-DVmfM6Kw.js", - "sha256": "167bed2822ce7e2b38d47e8dc1d73648d10c04d469979bf05e414000b53487b4", - "rawBytes": 83973, - "gzipBytes": 24158, + "path": "houseplan-assets/fr-DpKqMyVB.js", + "sha256": "3c03580d357a96678d80b012923e7e28e989fe6a612fe0342d5d041e2d37d4f1", + "rawBytes": 83754, + "gzipBytes": 24095, "isEntry": false, "imports": [], "dynamicImports": [] }, { - "path": "houseplan-assets/houseplan-card-Dwkibvgo.js", - "sha256": "d16deb0fc6f591040a2f97d51102e3e355da35cb3d1b69aa79472406c28d3ed2", - "rawBytes": 1033612, - "gzipBytes": 290234, + "path": "houseplan-assets/houseplan-card-o7i25aVl.js", + "sha256": "9b3942532fe83c5d49791859c419fb6f35a8db86c01a09ddccb1a2ec186bfbd3", + "rawBytes": 1033268, + "gzipBytes": 290274, "isEntry": false, "imports": [], "dynamicImports": [ - "houseplan-assets/de-DJX-vLW1.js", - "houseplan-assets/editor-Dvui5H3b.js", - "houseplan-assets/fr-DVmfM6Kw.js", - "houseplan-assets/houseplan-editor-runtime-9AEP51ly.js", - "houseplan-assets/houseplan-onboarding-runtime-CnVK5oOC.js" + "houseplan-assets/de-D_5HnJeM.js", + "houseplan-assets/editor-CqQRQq4C.js", + "houseplan-assets/fr-DpKqMyVB.js", + "houseplan-assets/houseplan-editor-runtime-D0JYjEs4.js", + "houseplan-assets/houseplan-onboarding-runtime-C-SJxeJ_.js" ] }, { - "path": "houseplan-assets/houseplan-editor-runtime-9AEP51ly.js", - "sha256": "c1b8f90458afedddbbdde98570c517d6e66dfcee08ad4764b1ac2473bdf89fb4", - "rawBytes": 574225, - "gzipBytes": 150401, + "path": "houseplan-assets/houseplan-editor-runtime-D0JYjEs4.js", + "sha256": "b2a69132a223d6c5b06b8d4a11b19416d33582bae2d39b2448ecc41f44ba98b2", + "rawBytes": 576032, + "gzipBytes": 151055, "isEntry": false, "imports": [ - "houseplan-assets/backdrop-pick-DWD0ZQTc.js", - "houseplan-assets/houseplan-card-Dwkibvgo.js" + "houseplan-assets/backdrop-pick-Dg2Uytel.js", + "houseplan-assets/houseplan-card-o7i25aVl.js" ], "dynamicImports": [] }, { - "path": "houseplan-assets/houseplan-onboarding-runtime-CnVK5oOC.js", - "sha256": "bd169896116229e76b37243106bde64755589017639890d36dd19282c872db7a", + "path": "houseplan-assets/houseplan-onboarding-runtime-C-SJxeJ_.js", + "sha256": "1324b8003d727c99c6ce6d9e48598032b29782f8c8c7b783c1b1073e9b79d9f2", "rawBytes": 28088, - "gzipBytes": 6956, + "gzipBytes": 6952, "isEntry": false, "imports": [ - "houseplan-assets/backdrop-pick-DWD0ZQTc.js", - "houseplan-assets/houseplan-card-Dwkibvgo.js" + "houseplan-assets/backdrop-pick-Dg2Uytel.js", + "houseplan-assets/houseplan-card-o7i25aVl.js" ], "dynamicImports": [] }, { "path": "houseplan-card.js", - "sha256": "730163666440793e44be0ee5482f7080931370f94555a1d52072d3a54698447e", + "sha256": "d6c8be124ce907703e76f24a5e9fedb3abd945213d7befb1f97d1635d82f4830", "rawBytes": 1183, - "gzipBytes": 797, + "gzipBytes": 795, "isEntry": true, "imports": [ - "houseplan-assets/houseplan-card-Dwkibvgo.js" + "houseplan-assets/houseplan-card-o7i25aVl.js" ], "dynamicImports": [] } diff --git a/custom_components/houseplan/frontend/houseplan-assets/backdrop-pick-DWD0ZQTc.js b/custom_components/houseplan/frontend/houseplan-assets/backdrop-pick-Dg2Uytel.js similarity index 92% rename from custom_components/houseplan/frontend/houseplan-assets/backdrop-pick-DWD0ZQTc.js rename to custom_components/houseplan/frontend/houseplan-assets/backdrop-pick-Dg2Uytel.js index 5ec1945b..61b29249 100644 --- a/custom_components/houseplan/frontend/houseplan-assets/backdrop-pick-DWD0ZQTc.js +++ b/custom_components/houseplan/frontend/houseplan-assets/backdrop-pick-Dg2Uytel.js @@ -1,6 +1,6 @@ -globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="816ff9c893e23b7441e73210f85024741ff6c5bf8dabd2fb0df9e8b8055bc693";import{dd as e,c as t,A as i,a as o,b as n,de as s}from"./houseplan-card-Dwkibvgo.js";function r(e){const t=e.visualViewport;return t&&Number.isFinite(t.width)&&Number.isFinite(t.height)?{left:Number(t.offsetLeft)||0,top:Number(t.offsetTop)||0,width:Math.max(0,Number(t.width)||0),height:Math.max(0,Number(t.height)||0)}:{left:0,top:0,width:e.innerWidth,height:e.innerHeight}}function a(e,t,i,o=7,n=8){const s=i.left+n,r=i.top+n,a=Math.max(s,i.left+i.width-n),l=Math.max(r,i.top+i.height-n),h=Math.max(0,a-s),d=Math.max(0,l-r),c=Math.min(Math.max(0,t.width),h),u=Math.min(Math.max(0,t.height),d);let p=e.left;p+c>a&&(p=e.right-c),p=Math.min(Math.max(s,p),Math.max(s,a-c));const _=e.bottom+o,g=e.top-o-u;let m="bottom",f=_;return _+u>l&&g>=r?(m="top",f=Math.min(Math.max(r,g),Math.max(r,l-u))):f=Math.min(Math.max(r,_),Math.max(r,l-u)),{left:Math.round(p),top:Math.round(f),side:m,maxWidth:Math.round(h),maxHeight:Math.round(d)}}class l{constructor(e,t,i){this.owner=e,this.name=t,this.keydown=i,this.host=null,this.root=null}window(){return this.owner.ownerDocument.defaultView}dialog(){return this.owner.closest("hp-dialog")}usesPopover(e=!1){const t=this.window();return!e&&!!t&&function(e){const t=e.HTMLElement?.prototype;return"function"==typeof t?.showPopover&&"function"==typeof t?.hidePopover}(t)}get hasFallback(){return!!this.host}containsPath(e){return e.includes(this.owner)||!!this.host&&e.includes(this.host)}ownsActiveElement(){let e=this.owner.ownerDocument.activeElement;for(;e?.shadowRoot?.activeElement;)e=e.shadowRoot.activeElement;return!(!e||e!==this.owner&&!this.owner.shadowRoot?.contains(e)&&!this.root?.contains(e))}renderFallback(i,o){const n=this.ensureFallback();return n?(e(t`${i}`,n),n):null}surface(e,t){return t?this.owner.shadowRoot?.querySelector(e)||null:this.root?.querySelector(e)||null}destroy(){this.root&&e(i,this.root),this.host?.remove(),this.host=null,this.root=null}ensureFallback(){if(this.root?.isConnected)return this.root;const e=this.dialog()?.overlayPortal()||this.owner.ownerDocument.body;if(!e)return null;const t=this.owner.ownerDocument.createElement("div");return t.dataset.hpOverlay=this.name,t.style.display="contents",this.keydown&&t.addEventListener("keydown",this.keydown,!0),e.append(t),this.host=t,this.root=t.attachShadow({mode:"open"}),this.root}}let h=0;class d extends n{constructor(){super(...arguments),this.text="",this.ariaLabel="",this.pointerHover=!1,this._open=!1,this._openTimer=0,this._closeTimer=0,this._positionRaf=0,this._forceFallback=!1,this._hoverOwned=!1,this._overlayDispose=null,this._scrollDialog=null,this._descriptionId="hp-help-description-"+ ++h,this._surfacePointerEnter=()=>{const e=this._window();this._closeTimer&&e?.clearTimeout(this._closeTimer),this._closeTimer=0},this._surfacePointerLeave=()=>{this._scheduleClose()},this._outsidePointerDown=e=>{if(!this._open)return;const t=e.composedPath();this._floating.containsPath(t)||this._closeHelp(!1,"outside")},this._dialogScroll=e=>{const t=this._floating.containsPath(e.composedPath()),i=e.target,o=this._dialog(),n=i instanceof Node&&(i===o||i instanceof Element&&i.contains(this));var s;this._open&&(s=n,!t&&s)&&this._closeHelp(!1,"scroll")},this._keyDown=e=>{if(!this._open||e.defaultPrevented)return;if("Escape"===e.key)return e.preventDefault(),e.stopImmediatePropagation(),void this._closeHelp(this._floating.ownsActiveElement(),"escape");const t=this.renderRoot.querySelector(".trigger");if(!t||!e.composedPath().includes(t))return;const i=this._surface();if(!i||i.scrollHeight<=i.clientHeight)return;const o=Math.max(40,.8*i.clientHeight),n={ArrowDown:40,ArrowUp:-40,PageDown:o,PageUp:-o};if("Home"===e.key)i.scrollTop=0;else if("End"===e.key)i.scrollTop=i.scrollHeight;else{if(!(e.key in n))return;i.scrollBy({top:n[e.key],behavior:"auto"})}e.preventDefault(),e.stopImmediatePropagation()},this._floating=new l(this,"help",this._keyDown),this._queuePosition=()=>{if(!this._open||this._positionRaf)return;const e=this._window();e&&(this._positionRaf=e.requestAnimationFrame(()=>{this._positionRaf=0,this._position()||this._closeHelp()}))}}connectedCallback(){super.connectedCallback(),this.addEventListener("keydown",this._keyDown,!0)}willUpdate(){this.toggleAttribute("data-has-content",this._hasContent())}disconnectedCallback(){const e=this.ownerDocument.defaultView;this._unsubscribeOpenListeners(),this.removeEventListener("keydown",this._keyDown,!0),this._clearTimers(),this._positionRaf&&e?.cancelAnimationFrame(this._positionRaf),this._positionRaf=0,this._closeHelp(!1,"disconnect"),super.disconnectedCallback()}updated(e){if(e.has("pointerHover")&&!this.pointerHover){const e=this._window();this._openTimer&&e?.clearTimeout(this._openTimer),this._openTimer=0,this._hoverOwned&&this._closeHelp(!1,"outside")}this._hasContent()||!this._open?this._open&&e.has("text")&&(this._usesPopover()||this._renderFallback(),this._queuePosition()):this._closeHelp()}_window(){return this._floating.window()}_dialog(){return this._floating.dialog()}_usesPopover(){return this._floating.usesPopover(this._forceFallback)}_hasContent(){return e=this.text,t=this.ariaLabel,"string"==typeof e&&e.trim().length>0&&"string"==typeof t&&t.trim().length>0;var e,t}_clearTimers(){const e=this._window();this._openTimer&&e?.clearTimeout(this._openTimer),this._closeTimer&&e?.clearTimeout(this._closeTimer),this._openTimer=0,this._closeTimer=0}_scheduleOpen(){const e=this._window();this.pointerHover&&e&&!this._open&&!this._openTimer&&(this._closeTimer&&e.clearTimeout(this._closeTimer),this._closeTimer=0,this._openTimer=e.setTimeout(()=>{this._openTimer=0,this.isConnected&&(this._hoverOwned=!0,this._openHelp())},300))}_scheduleClose(){const e=this._window(),t=this.renderRoot.querySelector(".trigger");e&&!t?.matches(":focus-visible")&&(this._openTimer&&e.clearTimeout(this._openTimer),this._closeTimer&&e.clearTimeout(this._closeTimer),this._openTimer=0,this._closeTimer=e.setTimeout(()=>{this._closeTimer=0,this.isConnected&&this._closeHelp()},150))}_triggerPointerEnter(e){this.pointerHover&&"mouse"===e.pointerType&&this._scheduleOpen()}_triggerPointerLeave(e){"mouse"===e.pointerType&&this._scheduleClose()}_triggerFocus(){queueMicrotask(()=>{const e=this.renderRoot.querySelector(".trigger");e?.matches(":focus-visible")&&(this._hoverOwned=!1,this._openHelp())})}_triggerBlur(){this._scheduleClose()}_triggerClick(){this._open?this._closeHelp():(this._hoverOwned=!1,this._openHelp())}_subscribeOpenListeners(){this.ownerDocument.addEventListener("pointerdown",this._outsidePointerDown,!0),this.ownerDocument.addEventListener("keydown",this._keyDown,!0);const e=this._window();e?.addEventListener("resize",this._queuePosition),e?.addEventListener("orientationchange",this._queuePosition),e?.visualViewport?.addEventListener("resize",this._queuePosition),e?.visualViewport?.addEventListener("scroll",this._queuePosition),this._scrollDialog=this._dialog(),this._scrollDialog?.addEventListener("scroll",this._dialogScroll,!0)}_unsubscribeOpenListeners(){this.ownerDocument.removeEventListener("pointerdown",this._outsidePointerDown,!0),this.ownerDocument.removeEventListener("keydown",this._keyDown,!0);const e=this._window();e?.removeEventListener("resize",this._queuePosition),e?.removeEventListener("orientationchange",this._queuePosition),e?.visualViewport?.removeEventListener("resize",this._queuePosition),e?.visualViewport?.removeEventListener("scroll",this._queuePosition),this._scrollDialog?.removeEventListener("scroll",this._dialogScroll,!0),this._scrollDialog=null}async _openHelp(){!this._open&&this._hasContent()&&(this._clearTimers(),this._forceFallback=!1,this._open=!0,this._subscribeOpenListeners(),await this.updateComplete,this._open&&(this._usesPopover()||this._renderFallback(),this._position()?this._overlayDispose=this._dialog()?.registerOverlay({owner:this,group:"transient",close:e=>this._closeHelp("escape"===e&&this._floating.ownsActiveElement(),e)})||null:this._closeHelp()))}_closeHelp(e=!1,t="exclusive"){if(!this._open&&!this._floating.hasFallback)return;this._clearTimers();const i=this._overlayDispose;this._overlayDispose=null,i?.();const o=this.renderRoot.querySelector(".tooltip");if(o?.hidePopover)try{o.matches(":popover-open")&&o.hidePopover()}catch{}this._open=!1,this._hoverOwned=!1,this._unsubscribeOpenListeners(),this._floating.destroy(),e&&this.updateComplete.then(()=>this.renderRoot.querySelector(".trigger")?.focus())}_tooltipTemplate(e){return t`