Assert the panel motion contract and hard rules, not just build them

The confirm dialog (fade + scale .96->1 over --nq-dur-modal/--nq-ease-out),
the seal (fade + scale 1.15->1 over --nq-dur-base), the quest-complete
12 s countdown return and the 40 s idle return already matched
PANEL-SPEC, but nothing pinned them: a token or keyframe edit could
silently drift the panel off the approved mockup timing.

tests/test_panel_cards.py now asserts the entrance timing and easing,
the byte-identical served token copy, and the section 1/6 hard rules
(22px body floor, token-only inks, no hover states, no animation loops,
inert dock strip, whole-plate tap target, control-free away plate), and
the quest-log harness drives the 1c countdown return end-to-end against
the bundle HACS ships.
This commit is contained in:
2026-09-23 11:25:49 -04:00
parent 881f48de43
commit 999597bfd7
2 changed files with 357 additions and 13 deletions
+25 -11
View File
@@ -3,12 +3,15 @@
*
* Usage: node render-quest-log.mjs < spec.json
*
* The spec JSON carries {bundle, url, config, hass, idle_ms?}; the card is
* mounted at the given URL (it resolves its child from the pathname's last
* segment), and after the optional idle wait stdout receives one JSON object
* {location, headline, html} — the window pathname and the rendered title.
* The Python panel tests drive this harness so the assertions run against
* the exact bundle HACS ships.
* The spec JSON carries {bundle, url, config, hass, idle_ms?, probe_ms?};
* the card is mounted at the given URL (it resolves its child from the
* pathname's last segment), after the optional probe wait one snapshot of
* the render is taken mid-wait, then after the optional idle wait stdout
* receives one JSON object {location, headline, countdown, html, probe} —
* the final window pathname, the rendered title, the complete-screen
* countdown text, the rendered shadow DOM, and the probe snapshot when
* probe_ms was given. The Python panel tests drive this harness so the
* assertions run against the exact bundle HACS ships.
*/
import { readFileSync } from "node:fs";
import { pathToFileURL } from "node:url";
@@ -46,14 +49,25 @@ element.hass = spec.hass;
document.body.appendChild(element);
await element.updateComplete;
const snapshot = () => ({
location: window.location.pathname,
headline: element.shadowRoot.querySelector(".title")?.textContent?.trim() ?? "",
countdown:
element.shadowRoot.querySelector(".complete-countdown")?.textContent?.trim() ??
"",
html: element.shadowRoot.innerHTML,
});
let probe = null;
if (spec.probe_ms !== undefined) {
await new Promise((resolve) => setTimeout(resolve, spec.probe_ms));
probe = snapshot();
}
if (spec.idle_ms !== undefined) {
await new Promise((resolve) => setTimeout(resolve, spec.idle_ms));
}
const payload = JSON.stringify({
location: window.location.pathname,
headline: element.shadowRoot.querySelector(".title")?.textContent?.trim() ?? "",
html: element.shadowRoot.innerHTML,
});
const payload = JSON.stringify({ ...snapshot(), probe });
await new Promise((resolve) => process.stdout.write(payload, () => resolve()));
process.exit(0);
+332 -2
View File
@@ -12,10 +12,16 @@ jsdom window and reports the ordered plates the shadow DOM renders plus
where a crest tap navigates, and render-quest-log.mjs reports where the
quest log's idle return navigates — so the zero-config discovery and
navigation contracts are asserted against the bundle HACS ships.
The motion contract (PANEL-SPEC §3.3/§3.5/§4/§5 entrances and timings) and
the §1/§6 hard rules are asserted on the card sources in the motion
section below, and the quest-complete screen's countdown return is driven
end-to-end through the quest-log harness.
"""
from __future__ import annotations
import json
import re
import subprocess
from pathlib import Path
@@ -52,11 +58,106 @@ ADMIN_ONLY_SERVICES = (
WINDOW_KEYS = ("morning", "afternoon", "evening")
WINDOW_NAMES = ("Morning", "Afternoon", "Evening")
# The approved token pair: the bundle must serve the design copy
# verbatim. The values below are what PANEL-SPEC times the panel with
# (§3.3 seal entrance, §4 dialog entrance, §3.2 press feedback) and the
# ink set §1 pins at >=4.5:1 on parchment.
DESIGN_TOKENS = REPO_ROOT / "design" / "tokens" / "nestquest-panel-tokens.css"
REWARD_TOKENS = (
REPO_ROOT
/ "custom_components"
/ "nestquest"
/ "www"
/ "nestquest-panel-tokens.css"
)
MOTION_TOKENS = {
"--nq-ease-out": "cubic-bezier(0, 0, .2, 1)",
"--nq-dur-micro": "120ms",
"--nq-dur-base": "200ms",
"--nq-dur-modal": "350ms",
}
PANEL_INK_TOKENS = {
"--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-p-dock-ink": "#f7efdb",
"--nq-p-dock-ink-secondary": "#d8c9a6",
}
# Text colors the panel styles may use beyond the token inks: the
# spec's own literals (§2.1 pill, §3.3 seal check, §4 dialog body),
# glyphs on the brand gradient, gradient text fills (§2 wordmark, §5
# title), and the plate button inheriting its plate's ink.
APPROVED_NON_TOKEN_INKS = {
"inherit",
"transparent",
"#ffffff",
"rgba(255, 235, 235, 0.95)",
"#3f2f1c",
"#146b36",
"#157a3c",
}
#: Each card's styles live in one css template literal, keyed by the
#: constant that holds it.
STYLE_CONSTANTS = {
PARTY_BOARD: "boardStyles",
QUEST_LOG: "logStyles",
}
def _read(path: Path) -> str:
return path.read_text(encoding="utf-8")
def _style_literal(source: str, constant: str) -> str:
"""A card's css template literal — the styles between the backticks."""
marker = f"const {constant} = css`"
start = source.index(marker) + len(marker)
return source[start : source.index("`;", start)]
def _keyframes_block(style: str, name: str) -> str:
"""A @keyframes rule's text, from its name through its to-block."""
start = style.index(f"@keyframes {name}")
return style[start : style.index("}", style.index("to {", start))]
def _css_rules(style: str) -> list[tuple[str, str]]:
"""(selector, declarations) pairs for every rule in a card's styles.
Lit interpolations are blanked first so the clip-path/unsafeCSS
braces cannot confuse the parse; @keyframes' from/to count as rules
but carry no font-size, so the size checks skip them.
"""
flattened = re.sub(r"\$\{[^}]*\}", " ", style)
return re.findall(r"([^{}]+)\{([^{}]*)\}", flattened)
def _size_tokens(tokens: str) -> dict[str, int]:
"""Every px length token declared in the panel tokens."""
return {
name: int(px)
for name, px in re.findall(r"(--[a-z0-9-]+):\s*(\d+)px", tokens)
}
def _font_size_px(declaration: str, sizes: dict[str, int]) -> int | None:
"""A font-size value's pixel amount; None when it is neither a px
length nor one of the panel size tokens."""
declaration = declaration.strip()
if declaration.endswith("px"):
return round(float(declaration[:-2]))
match = re.fullmatch(r"var\((--[a-z0-9-]+)\)", declaration)
if match and match.group(1) in sizes:
return sizes[match.group(1)]
return None
def test_party_board_renders_present_and_away_plates() -> None:
source = _read(PARTY_BOARD)
assert "nestquest-party-board-card" in source
@@ -160,6 +261,198 @@ def test_quest_complete_trigger() -> None:
assert "complete_screen_seconds" in source
# --- Panel motion and hard rules (PANEL-SPEC §1, §3.3, §3.5, §4–§6) -----------
def test_panel_serves_the_exact_design_token_copy() -> None:
"""The bundle's served tokens are byte-for-byte the approved design
copy. The motion tokens time every panel entrance and the ink set is
the documented >=4.5:1 set — the cards only reference the tokens, so
a drifted value fails here before it can reach the panel."""
assert _read(REWARD_TOKENS) == _read(DESIGN_TOKENS)
tokens = _read(REWARD_TOKENS)
for declared in (MOTION_TOKENS, PANEL_INK_TOKENS):
for name, value in declared.items():
pattern = rf"^\s*{re.escape(name)}:\s*{re.escape(value)};"
assert re.search(pattern, tokens, re.MULTILINE), name
# The 22px meta floor is the declared panel minimum (§1).
assert re.search(
r"^\s*--nq-p-size-body:\s*22px;.*PANEL MINIMUM", tokens, re.MULTILINE
)
def test_quest_log_entrances_use_the_design_timing_and_easing() -> None:
"""PANEL-SPEC §3.3/§4: the seal enters with fade + scale(1.15 → 1)
over --nq-dur-base and the confirm dialog with fade + scale(.96 → 1)
over --nq-dur-modal, both --nq-ease-out; press feedback is scale(.98)
over --nq-dur-micro."""
style = _style_literal(_read(QUEST_LOG), STYLE_CONSTANTS[QUEST_LOG])
assert "animation: dialog-in var(--nq-dur-modal) var(--nq-ease-out);" in style
dialog = _keyframes_block(style, "dialog-in")
assert "opacity: 0;" in dialog and "transform: scale(0.96);" in dialog
assert "opacity: 1;" in dialog and "transform: scale(1);" in dialog
assert "animation: seal-in var(--nq-dur-base) var(--nq-ease-out);" in style
seal = _keyframes_block(style, "seal-in")
assert "opacity: 0;" in seal
# The per-card rotation is preserved through the stamp animation.
assert "transform: scale(1.15) rotate(var(--seal-rot, 0deg));" in seal
assert "transform: scale(1) rotate(var(--seal-rot, 0deg));" in seal
assert "animation: toast-in var(--nq-dur-base) var(--nq-ease-out);" in style
for selector in (
".quest.tappable:active",
"button.complete:active",
"button.confirm-button:active",
):
start = style.index(selector)
block = style[start : style.index("}", start)]
assert "transform: scale(0.98);" in block, selector
assert (
style.count("transition: transform var(--nq-dur-micro) var(--nq-ease-out);")
== 3
) # quest card, Complete button, confirm buttons
board_style = _style_literal(_read(PARTY_BOARD), STYLE_CONSTANTS[PARTY_BOARD])
start = board_style.index("button.plate:active")
block = board_style[start : board_style.index("}", start)]
assert "transform: scale(0.98);" in block
assert (
"transition: transform var(--nq-dur-micro) var(--nq-ease-out);" in board_style
)
def test_quest_complete_screen_times_the_12_second_return() -> None:
"""PANEL-SPEC §5: the 1c screen auto-returns to the board 12 s after
the day completes, driven by the per-second countdown line."""
source = _read(QUEST_LOG)
assert "asNumber(this._config?.complete_screen_seconds, 12)" in source
assert "this._countdown = this._completeSeconds();" in source
assert "}, 1000);" in source
assert "Returning to The Party in ${remaining} seconds" in source
# Zero reaches the idle-return navigation itself.
assert "this._returnToBoard();" in source
def test_idle_return_uses_the_40_second_design_timing() -> None:
"""PANEL-SPEC §3.5: 40 s with no touch returns to the party board;
any pointer or touch event re-arms the timer. The config knob only
shortens the default (tests do; the strategy never sets it)."""
source = _read(QUEST_LOG)
assert "asNumber(this._config?.idle_return_seconds, 40)" in source
for event in ("pointerdown", "touchstart"):
assert (
f'window.addEventListener("{event}", this._onActivity, true)' in source
), event
def test_panel_hard_rules_hold_in_the_card_sources() -> None:
"""PANEL-SPEC §1/§6 hard rules on both panel card sources: no hover
states (touch-only surface), no animation loops (entrances only,
each timed on a design duration token with --nq-ease-out), an inert
dock strip and pointer-none frames, the whole plate as one tap
target with the away plate control-free, and both the quest card
and its button opening the confirm dialog. The away plate's
navigation contract is asserted against the bundle by
test_away_plate_does_not_navigate."""
board = _read(PARTY_BOARD)
log = _read(QUEST_LOG)
for source in (board, log):
assert ":hover" not in source
assert "infinite" not in source
for animation in re.findall(r"animation:[^;]+;", source):
assert re.fullmatch(
r"animation: [a-z-]+ var\(--nq-dur-(?:micro|base|modal)\) "
r"var\(--nq-ease-out\);",
" ".join(animation.split()),
), animation
for transition in re.findall(r"transition:[^;]+;", source):
assert transition in (
"transition: transform var(--nq-dur-micro) var(--nq-ease-out);",
), transition
dock = re.search(r"\n \.dock \{([^}]*)\}", source)
assert dock is not None
assert "pointer-events: none" in dock.group(1)
assert "user-select: none" in dock.group(1)
frame = re.search(r"\n \.frame \{([^}]*)\}", source)
assert frame is not None
assert "pointer-events: none" in frame.group(1)
# Nothing in either dock's markup can be tapped: no handlers, no
# button, no focusable or role-bearing element.
board_dock = board[board.index("private _renderDock") :]
log_dock = log[
log.index("private _renderDock") : log.index("private _returnToBoard")
]
for markup in (board_dock, log_dock):
for tappable in ("@click", "@keydown", "<button", 'role="button"', "tabindex"):
assert tappable not in markup, tappable
# The whole plate is the tap target: the button wraps crest, name,
# pill, progress line and bar, and it is the board's only button.
plate = board[board.index('class="plate"') : board.index('class="plate away"')]
for part in (
'class="crest"',
'class="name"',
'class="pill"',
'class="progress-line"',
'class="bar"',
):
assert part in plate, part
assert board.count("<button") == 1
# The away plate is a plain div — nothing tappable, never navigates.
away = board[
board.index('class="plate away"') : board.index("private _openQuestLog")
]
assert "<button" not in away
assert "@click" not in away
# On the log, the whole quest card AND its button open the confirm
# dialog (§3.2); the sealed card renders no control at all.
quest = log[log.index("private _renderQuest") : log.index("private _renderRollup")]
assert quest.count("() => this._openConfirm(instance.id)") == 2
sealed_template = quest[
quest.index('class="quest sealed"') : quest.index('class="quest tappable"')
]
assert "@click" not in sealed_template
def test_panel_text_sizes_respect_the_22px_floor() -> None:
"""PANEL-SPEC §1: no body text below 22px. Every font-size in the
panel styles resolves against the panel size tokens or an explicit
px value, and the only text under the floor is the spec's own
tracked uppercase Cinzel labels (§3 header sub, §3.1 range, §3.4
roll-up label, §4 kicker, §5 stat labels) and the d20 mark numerals
the spec sizes by hand (§2, §3) — never a Nunito body line."""
sizes = _size_tokens(_read(REWARD_TOKENS))
for path in (PARTY_BOARD, QUEST_LOG):
style = _style_literal(_read(path), STYLE_CONSTANTS[path])
for selector, body in _css_rules(style):
declared = re.search(r"font-size:\s*([^;]+);", body)
if declared is None:
continue
px = _font_size_px(declared.group(1), sizes)
assert px is not None, (
f"{path.name}: {selector.strip()} {declared.group(1)}"
)
if px >= 22:
continue
assert (
"text-transform: uppercase" in body or ".d20-numeral" in selector
), f"{path.name}: {selector.strip()} is {px}px below the panel floor"
def test_panel_text_inks_come_from_the_token_set() -> None:
"""PANEL-SPEC §1: every ink is a token from the approved >=4.5:1 set
or one of the spec's own literals — no alpha-muted text is ever
introduced."""
declared = set(
re.findall(r"^\s*(--[a-z0-9-]+):", _read(REWARD_TOKENS), re.MULTILINE)
)
for path in (PARTY_BOARD, QUEST_LOG):
style = _style_literal(_read(path), STYLE_CONSTANTS[path])
for value in re.findall(r"(?<![-\w])color:\s*([^;]+);", style):
value = " ".join(value.split())
if value.startswith("var("):
assert value[4:-1] in declared, f"{path.name}: {value}"
else:
assert value in APPROVED_NON_TOKEN_INKS, f"{path.name}: {value}"
def test_panel_cards_reference_no_admin_only_services() -> None:
# Only the kid panel sources are scanned: the bundle also contains the
# sibling admin card, so its parent-only service names are expected there.
@@ -470,14 +763,17 @@ def _render_quest_log(
states: dict,
url: str,
idle_ms: int | None = None,
probe_ms: int | None = None,
) -> dict:
"""Render the built bundle's quest log and report its location.
Drives frontend/tests/render-quest-log.mjs: the harness imports the
committed bundle (the artifact HACS ships) into a jsdom window at
``url`` — the card resolves its child from the pathname's last
segment — waits the optional idle window, and prints the pathname
and the rendered title.
segment — waits the optional probe window and captures one snapshot
of the render mid-wait, then the optional idle window, and prints
the final pathname and rendered title (plus the probe snapshot when
``probe_ms`` was given).
"""
assert BUNDLE_PATH.is_file(), "bundle missing: run cd frontend && npm run build"
assert QUEST_LOG_HARNESS.is_file()
@@ -489,6 +785,8 @@ def _render_quest_log(
}
if idle_ms is not None:
spec["idle_ms"] = idle_ms
if probe_ms is not None:
spec["probe_ms"] = probe_ms
completed = subprocess.run(
["node", str(QUEST_LOG_HARNESS)],
input=json.dumps(spec),
@@ -556,3 +854,35 @@ def test_quest_log_idle_return_navigates_to_the_board() -> None:
)
assert result["headline"] == "Ada's Quest Log"
assert result["location"] == "/nestquest"
def test_quest_complete_screen_returns_to_the_board_after_the_countdown() -> None:
"""The 1c screen renders with its countdown line and auto-returns to
the strategy-computed board path when it reaches zero. The design
shows it for 12 s; the test shortens complete_screen_seconds to 1
the same way the idle test shortens idle_return_seconds."""
generated = _generate_dashboard(
{}, _three_child_states(), url="http://homeassistant.local/nestquest"
)
log_config = {
**generated["views"][1]["cards"][0],
"complete_screen_seconds": 1,
}
states = {
**_three_child_states(),
"binary_sensor.nestquest_ada_all_done": _state("on"),
}
result = _render_quest_log(
log_config,
states,
url="http://homeassistant.local/nestquest/ada",
probe_ms=400,
idle_ms=2400,
)
# Mid-wait the complete screen is up with its countdown line…
assert result["probe"] is not None
assert result["probe"]["location"] == "/nestquest/ada"
assert "Quest complete" in result["probe"]["html"]
assert result["probe"]["countdown"] == "Returning to The Party in 1 seconds"
# …and at zero the card has returned to the party board.
assert result["location"] == "/nestquest"