Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4834f19118 | ||
|
|
f2ff73ea31 | ||
|
|
602fe7b220 | ||
|
|
840bdeb87d | ||
|
|
b629251905 |
@@ -1,5 +1,57 @@
|
||||
# NestQuest Release Notes
|
||||
|
||||
## Version 0.10.0 — 2026-09-30
|
||||
|
||||
### Scope
|
||||
|
||||
Feature release: choose task icons by typing any Font Awesome Free **solid**
|
||||
icon name, not just the curated subset. The shared icon registry now embeds the
|
||||
full Font Awesome Free solid set offline (no CDN — the panel may be offline),
|
||||
the admin icon picker is searchable with free-text entry and validation, and the
|
||||
kids' panel renders any Free Solid icon. (Feature `6c681b17`; PRs #289–#292.)
|
||||
|
||||
### Behaviour
|
||||
|
||||
- **Full Free Solid set**: the panel and admin bundles now carry ~1,422 Font
|
||||
Awesome Free solid glyphs as inline SVG path data, in addition to the existing
|
||||
curated quick-pick lists (Lucide and Font Awesome). `fa:<name>` resolves any
|
||||
Free Solid icon; unknown names still render the fallback glyph.
|
||||
- **Searchable picker**: the admin definition icon picker's Font Awesome tab has
|
||||
a search input. Empty shows the curated quick picks; typing filters the full
|
||||
set into a bounded (≤40 per page) result grid, with Previous / Show more.
|
||||
- **Free-text entry**: typing an exact Free Solid name selects and stores
|
||||
`fa:<name>`. A partial, misspelled, Pro, or Brands name is rejected with an
|
||||
inline message and nothing is saved.
|
||||
- Stored format is unchanged: `lucide:<name>`, `fa:<name>`, `emoji:<grapheme>`,
|
||||
or a legacy bare name.
|
||||
|
||||
### Requirements
|
||||
|
||||
- Home Assistant floor 2024.6.0 (unchanged).
|
||||
|
||||
### Breaking changes
|
||||
|
||||
- None to the database: schema unchanged at version 9, no migration and no
|
||||
database backup required.
|
||||
- Font Awesome **Pro** and **Brands/Regular** icons are NOT included — only the
|
||||
Free Solid set. A Pro/Brands name is rejected in the picker (and an unknown
|
||||
`fa:` name falls back on the panel).
|
||||
|
||||
### Known limitations
|
||||
|
||||
- **Bundle size**: the embedded icon set increases the committed panel bundle to
|
||||
~1.06 MB raw (~276 kB gzip) and the admin PWA bundle similarly. This is
|
||||
expected for the full Free Solid set.
|
||||
- Alias names (e.g. Font Awesome's `fa:home` alias of `fa:house`) are
|
||||
de-duplicated to the canonical name, so `fa:house` resolves but `fa:home`
|
||||
falls back.
|
||||
- The backend still validates only the `fa:<kebab-name>` format; unknown names
|
||||
are caught in the picker and render as the fallback on the panel.
|
||||
|
||||
### Rollback
|
||||
|
||||
- No schema change: rolling back to 0.9.8 requires no database restore.
|
||||
|
||||
## Version 0.9.8 — 2026-09-30
|
||||
|
||||
### Scope
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Third-party notices
|
||||
|
||||
NestQuest bundles glyph data from the icon sets below. The curated names are listed in
|
||||
`tools/icons/curated-icons.json`; `tools/icons/generate.mjs` reads each glyph's SVG path
|
||||
data from the locally installed packages and generates it into both bundles
|
||||
NestQuest bundles glyph data from the icon sets below. The curated Lucide names and the
|
||||
Font Awesome quick-pick names are listed in `tools/icons/curated-icons.json`;
|
||||
`tools/icons/generate.mjs` reads each glyph's SVG path data from the locally installed packages and generates it into both bundles
|
||||
(`admin/src/icons/registry.generated.ts` and `frontend/src/icons/registry.generated.ts`).
|
||||
Nothing is loaded from a CDN or fetched at runtime. The package versions used are recorded
|
||||
in the header of each generated file.
|
||||
@@ -15,8 +15,8 @@ in the header of each generated file.
|
||||
- Icons: [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)
|
||||
- Fonts: [SIL OFL 1.1](https://openfontlicense.org)
|
||||
- Code: [MIT](https://opensource.org/licenses/MIT)
|
||||
- Use in NestQuest: a curated subset of the Free **solid** icon set, embedded as SVG path
|
||||
data. No fonts are bundled.
|
||||
- Use in NestQuest: the full Free **solid** icon set, embedded as SVG path data (a curated
|
||||
subset doubles as the admin picker's quick picks). No fonts are bundled.
|
||||
|
||||
Font Awesome Free by @fontawesome — https://fontawesome.com — icons licensed under CC BY 4.0.
|
||||
|
||||
|
||||
@@ -1355,7 +1355,7 @@ describe("DefinitionsTab Font Awesome and emoji icons", () => {
|
||||
fireEvent.click(within(s).getByRole("button", { name: "Morning" }));
|
||||
}
|
||||
|
||||
it("ships the curated Font Awesome subset, each drawn as a filled glyph", () => {
|
||||
it("ships the curated Font Awesome quick picks, each drawn as a filled glyph", () => {
|
||||
expect(FA_DEFINITION_ICONS.length).toBeGreaterThan(0);
|
||||
for (const entry of FA_DEFINITION_ICONS) {
|
||||
expect(entry.kind).toBe("fa");
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
/**
|
||||
* The icons a definition's `icon` may name (ADMIN-SPEC §3.3): the curated
|
||||
* Lucide set, the curated Font Awesome Free solid subset, or an emoji. The
|
||||
* names, labels and path data come from the shared icon registry
|
||||
* (`src/icons/registry.generated.ts`, generated from
|
||||
* `tools/icons/curated-icons.json`) — no CDN, no runtime fetch. The stored
|
||||
* value is namespaced — `lucide:<name>`, `fa:<name>` or `emoji:<grapheme>` —
|
||||
* and a legacy bare `<kebab-name>` still names the same Lucide entry.
|
||||
* Lucide set, any icon in the full Font Awesome Free Solid set, or an emoji.
|
||||
* The Font Awesome quick picks (`FA_DEFINITION_ICONS`) are only a curated
|
||||
* shortcut list; `fa:<name>` resolves against the full set
|
||||
* (`FA_SOLID_ICONS`). The names, labels and path data come from the shared
|
||||
* icon registry (`src/icons/registry.generated.ts`, generated by
|
||||
* `tools/icons/generate.mjs` from `tools/icons/curated-icons.json` and the
|
||||
* installed Free Solid package) — no CDN, no runtime fetch. The stored value
|
||||
* is namespaced — `lucide:<name>`, `fa:<name>` or `emoji:<grapheme>` — and a
|
||||
* legacy bare `<kebab-name>` still names the same Lucide entry.
|
||||
*/
|
||||
import { GlyphSvg } from "../icons/GlyphSvg";
|
||||
import {
|
||||
@@ -105,8 +108,9 @@ export function emojiIconText(value: string | null): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* The curated glyph (Lucide or Font Awesome) an icon value resolves to via
|
||||
* the shared parser; emoji, unknown and null values give null.
|
||||
* The glyph an icon value resolves to via the shared parser: a curated
|
||||
* Lucide entry or any Font Awesome Free Solid icon (quick pick or not);
|
||||
* emoji, unknown and null values give null.
|
||||
*/
|
||||
export function findDefinitionIcon(value: string | null): DefinitionIconEntry | null {
|
||||
const icon = resolveIcon(value);
|
||||
|
||||
@@ -157,7 +157,7 @@ describe("offline bundling", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("no bundled source imports an icon package — only the curated generated data ships", () => {
|
||||
it("no bundled source imports an icon package — only the generated registry data ships", () => {
|
||||
const sources = [join(REPO_ROOT, "admin", "src"), join(REPO_ROOT, "frontend", "src")]
|
||||
.flatMap(filesUnder)
|
||||
.filter((path) => /\.(ts|tsx)$/.test(path) && !path.endsWith(".test.ts") && !path.endsWith(".test.tsx"));
|
||||
|
||||
@@ -315,7 +315,10 @@ class NestQuestApiClient:
|
||||
event_type: str | None = None
|
||||
data_lines: list[str] = []
|
||||
buffer = ""
|
||||
async for chunk in response:
|
||||
# The body is ``response.content`` (aiohttp's StreamReader,
|
||||
# yielding bytes); the response object itself — HA's
|
||||
# ``HassClientResponse`` included — is NOT async-iterable.
|
||||
async for chunk in response.content:
|
||||
if isinstance(chunk, (bytes, bytearray)):
|
||||
chunk = chunk.decode("utf-8")
|
||||
buffer += chunk
|
||||
|
||||
@@ -15,5 +15,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"issue_tracker": "https://github.com/Maniacs-Mansion/NestQuest/issues",
|
||||
"requirements": [],
|
||||
"version": "0.9.8"
|
||||
"version": "0.10.0"
|
||||
}
|
||||
|
||||
+16
-5
@@ -105,14 +105,25 @@ Each definition carries one optional icon, shown on both surfaces. The picker of
|
||||
sources:
|
||||
|
||||
- **Lucide** — a grid of the curated Lucide glyphs, drawn stroked.
|
||||
- **Font Awesome** — a grid of a curated subset of the Font Awesome Free *solid* set,
|
||||
drawn filled.
|
||||
- **Font Awesome** — a search over the **full** Font Awesome Free *solid* set (every
|
||||
glyph in `@fortawesome/free-solid-svg-icons`), plus a curated quick-pick grid, all drawn
|
||||
filled. With the search field empty the quick-pick grid shows; typing filters the full
|
||||
set by name or label (an exact name ranks first, then name prefixes, then the rest),
|
||||
shown 40 results per page with *Previous* / *Show more*.
|
||||
**Free-text rule:** an admin may type a Free Solid icon name directly (trimmed,
|
||||
case-insensitive — `Dog` stores `fa:dog`). Text that is not the exact name of a Free
|
||||
Solid icon — a partial search, a misspelling, or a Pro or Brands name — is rejected in
|
||||
the picker with an inline error and cannot be saved until a result is selected or the
|
||||
full name is typed.
|
||||
- **Emoji** — a text input; the typed emoji is stored as entered (not trimmed) and must be
|
||||
1–8 code points with no whitespace, control characters, `<`, `>` or `&`.
|
||||
|
||||
Both curated lists come from `tools/icons/curated-icons.json`; their glyph path data is
|
||||
generated into the admin and panel bundles by `tools/icons/generate.mjs` (no CDN, no
|
||||
runtime fetch). See `THIRD-PARTY-NOTICES.md` for licences.
|
||||
The curated Lucide set and the Font Awesome quick-pick list come from
|
||||
`tools/icons/curated-icons.json`; `tools/icons/generate.mjs` generates their glyph path
|
||||
data, together with the full Font Awesome Free Solid set, into the admin and panel bundles
|
||||
(no CDN, no runtime fetch). Embedding the full set makes both bundles heavier — the panel
|
||||
bundle (`nestquest-cards.js`) is about 1 MB raw / 276 kB gzip. See
|
||||
`THIRD-PARTY-NOTICES.md` for licences.
|
||||
|
||||
The stored value is a **namespaced key**:
|
||||
|
||||
|
||||
@@ -78,6 +78,6 @@ The panel speaks tabletop; the admin speaks plainly. Both sit on the same `quest
|
||||
decide before shipping the card.
|
||||
- No gamification is in scope. XP, levels, streaks and rarity were explicitly deferred,
|
||||
but the append-only event log already supports computing them later.
|
||||
- Icon per quest definition is chosen by the parent from curated Lucide and Font Awesome
|
||||
Free sets or as an emoji, stored as a namespaced key (`lucide:`/`fa:`/`emoji:`) —
|
||||
- Icon per quest definition is chosen by the parent from the curated Lucide set, the full
|
||||
Font Awesome Free Solid set (searchable, with curated quick picks) or as an emoji, stored as a namespaced key (`lucide:`/`fa:`/`emoji:`) —
|
||||
resolved 2026-09-26, see `ADMIN-SPEC.md` §3.3.
|
||||
|
||||
@@ -106,7 +106,8 @@ collapse the column, the three-column rhythm is the layout.
|
||||
1. **Icon tile** 64×64, radius 10px, `--nq-p-icon-tile`, glyph 34px white — the
|
||||
definition's icon, resolved from its namespaced key (`ADMIN-SPEC.md` §3.3):
|
||||
`lucide:<name>` draws the curated Lucide glyph **stroked** (fill none, stroke-width 2,
|
||||
round caps and joins); `fa:<name>` draws the curated Font Awesome glyph **filled**;
|
||||
round caps and joins); `fa:<name>` draws the Font Awesome Free Solid glyph **filled** (any icon in the full
|
||||
set, not only the quick picks);
|
||||
`emoji:<grapheme>` renders the emoji **as text**. A legacy bare name resolves as
|
||||
`lucide:<name>`. No icon, or any key that does not resolve (unknown namespace or name,
|
||||
malformed emoji), falls back to the **star** glyph. Glyph data is bundled from the
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Lit rendering for the shared icon registry (`registry.generated.ts`,
|
||||
* generated from tools/icons/curated-icons.json). The glyph path data is
|
||||
* bundled — no CDN and no runtime fetch.
|
||||
* generated by tools/icons/generate.mjs: the curated Lucide set plus the full
|
||||
* Font Awesome Free Solid set). The glyph path data is bundled — no CDN and
|
||||
* no runtime fetch.
|
||||
*/
|
||||
import { html, svg, type TemplateResult } from "lit";
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "nestquest"
|
||||
version = "0.9.8"
|
||||
version = "0.10.0"
|
||||
description = "NestQuest Home Assistant Integration"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -472,4 +472,88 @@ async def test_client_logs_nothing() -> None:
|
||||
await client.get_snapshot()
|
||||
finally:
|
||||
logging.getLogger().removeHandler(handler)
|
||||
assert records == []
|
||||
assert records == []
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# stream_events: the body is read from ``response.content``
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _StubStreamContent:
|
||||
"""A fake aiohttp ``StreamReader``: async-iterable over byte chunks."""
|
||||
|
||||
def __init__(self, chunks) -> None:
|
||||
self._chunks = list(chunks)
|
||||
|
||||
def __aiter__(self):
|
||||
return self._iterate()
|
||||
|
||||
async def _iterate(self):
|
||||
for chunk in self._chunks:
|
||||
yield chunk
|
||||
|
||||
|
||||
class StubStreamResponse:
|
||||
"""A fake aiohttp/HA ``ClientResponse`` for a streaming body.
|
||||
|
||||
Like the real ``HassClientResponse`` it does NOT implement
|
||||
``__aiter__`` — the body is iterated through ``.content`` — so a
|
||||
client that iterates the response object itself raises the
|
||||
``'async for' requires an object with __aiter__ method`` TypeError.
|
||||
"""
|
||||
|
||||
def __init__(self, status: int, chunks) -> None:
|
||||
self.status = status
|
||||
self.content = _StubStreamContent(chunks)
|
||||
|
||||
|
||||
async def test_stream_events_reads_frames_from_response_content() -> None:
|
||||
"""Frames are parsed from ``response.content`` byte chunks, including
|
||||
a frame split across chunk boundaries; a trailing incomplete frame
|
||||
is discarded."""
|
||||
assert not hasattr(StubStreamResponse, "__aiter__")
|
||||
chunks = [
|
||||
b'event: nestquest_quest_completed\ndata: {"instance_id"',
|
||||
b': 7}\n',
|
||||
b'\nevent: nestquest_child_day_complete\n',
|
||||
b'data: {"child_id": 1}\n\n',
|
||||
b'event: nestquest_quest_missed\ndata: {"instance_id": 9}\n',
|
||||
]
|
||||
transport = StubTransport(StubStreamResponse(200, chunks))
|
||||
client = make_client(transport)
|
||||
|
||||
frames = [frame async for frame in client.stream_events()]
|
||||
|
||||
assert frames == [
|
||||
("nestquest_quest_completed", {"instance_id": 7}),
|
||||
("nestquest_child_day_complete", {"child_id": 1}),
|
||||
]
|
||||
assert transport.calls[0]["method"] == "GET"
|
||||
assert transport.calls[0]["url"] == f"{BASE_URL}/api/v1/panel/events"
|
||||
|
||||
|
||||
async def test_stream_events_non_2xx_raises_typed_with_status() -> None:
|
||||
transport = StubTransport(StubStreamResponse(502, []))
|
||||
client = make_client(transport)
|
||||
|
||||
with pytest.raises(NestQuestApiError) as excinfo:
|
||||
async for _ in client.stream_events():
|
||||
pass
|
||||
|
||||
assert excinfo.value.status == 502
|
||||
assert not excinfo.value.is_transport_error
|
||||
|
||||
|
||||
async def test_stream_events_invalid_json_frame_raises_typed() -> None:
|
||||
transport = StubTransport(
|
||||
StubStreamResponse(200, [b"event: nestquest_quest_completed\n", b"data: {nope\n\n"])
|
||||
)
|
||||
client = make_client(transport)
|
||||
|
||||
with pytest.raises(NestQuestApiError) as excinfo:
|
||||
async for _ in client.stream_events():
|
||||
pass
|
||||
|
||||
assert excinfo.value.status == 200
|
||||
assert not excinfo.value.is_transport_error
|
||||
assert isinstance(excinfo.value.__cause__, ValueError)
|
||||
|
||||
@@ -165,19 +165,29 @@ MINIMAL_SNAPSHOT = {
|
||||
|
||||
|
||||
class _StreamingResponse:
|
||||
"""A fake aiohttp streaming response: ``status`` + chunk iteration.
|
||||
"""A fake aiohttp streaming response: ``status`` + ``.content``.
|
||||
|
||||
``chunks`` are the body pieces the stream yields in order; a chunk
|
||||
may be an exception, raised when the iteration reaches it (a
|
||||
mid-stream transport drop). ``hold_open`` keeps the stream open
|
||||
forever after the chunks run out, for cancellation tests.
|
||||
Like aiohttp's ``ClientResponse`` (and HA's ``HassClientResponse``)
|
||||
the response itself is NOT async-iterable; the body is iterated
|
||||
through ``.content``. ``chunks`` are the body pieces the stream
|
||||
yields in order; a chunk may be an exception, raised when the
|
||||
iteration reaches it (a mid-stream transport drop). ``hold_open``
|
||||
keeps the stream open forever after the chunks run out, for
|
||||
cancellation tests.
|
||||
"""
|
||||
|
||||
def __init__(self, status: int, chunks, *, hold_open: bool = False) -> None:
|
||||
self.status = status
|
||||
self.content = _StreamingContent(chunks, hold_open)
|
||||
self.closed = False
|
||||
|
||||
|
||||
class _StreamingContent:
|
||||
"""The fake response body: an async-iterable chunk stream."""
|
||||
|
||||
def __init__(self, chunks, hold_open: bool) -> None:
|
||||
self._chunks = list(chunks)
|
||||
self._hold_open = hold_open
|
||||
self.closed = False
|
||||
|
||||
def __aiter__(self):
|
||||
return self._iterate()
|
||||
|
||||
@@ -16,13 +16,15 @@
|
||||
* Lucide circles, rects and lines are converted to path `d` strings so every
|
||||
* glyph is plain `{ viewBox, paths }` data any renderer can draw.
|
||||
*
|
||||
* Besides the curated sets, the module embeds the complete Font Awesome Free
|
||||
* Solid set (`FA_SOLID_ICONS`). Its source is the installed
|
||||
* Besides the curated sets (the Lucide set and the Font Awesome quick picks),
|
||||
* the module embeds the complete Font Awesome Free Solid set (`FA_SOLID_ICONS`),
|
||||
* which is what `fa:<name>` resolves against. Its source is the installed
|
||||
* @fortawesome/free-solid-svg-icons package itself: every exported icon
|
||||
* definition, de-duplicated by canonical `iconName` (the package also exports
|
||||
* each legacy alias, e.g. faHome for `house`, as the same definition). The
|
||||
* package carries no human labels, so each label is the kebab name in Title
|
||||
* Case (`spray-can-sparkles` → "Spray Can Sparkles").
|
||||
* Case (`spray-can-sparkles` → "Spray Can Sparkles"). Embedding the full set
|
||||
* makes both bundles heavier: the panel bundle is about 1 MB raw / 276 kB gzip.
|
||||
*/
|
||||
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
Reference in New Issue
Block a user