Compare commits

...
Author SHA1 Message Date
orchestrator-agent 86e57627ec release: correct the 0.10.1 reconnect-window wording 2026-10-06 08:36:28 -04:00
orchestrator-agent b000579533 release: advance version to 0.10.1 and add the 0.10.1 release notes 2026-10-06 08:20:26 -04:00
dev_agent d00f4ea173 Read the panel SSE body from response.content (task 4cbf2e96) (#295)
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-06 08:15:45 -04:00
orchestrator-agent f2ff73ea31 Merge main into dev after the 0.10.0 release 2026-10-01 13:33:22 -04:00
overseer 602fe7b220 Merge pull request 'Release: custom Font Awesome icons (0.10.0) (2026-09-30)' (#293) from release/2026-09-30-custom-fa-icons into main
Reviewed-on: #293
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-09-30 20:23:11 -04:00
orchestrator-agent 840bdeb87d release: advance version to 0.10.0 and add the 0.10.0 release notes 2026-09-30 19:21:50 -04:00
dev_agent b629251905 Document the full-set searchable Font Awesome picker (task e2fc2dbc) (#292)
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-09-30 19:16:39 -04:00
dev_agent 966beb4862 Searchable Font Awesome Free Solid picker (task 9ca0bc36) (#290)
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-09-30 18:31:19 -04:00
dev_agent 40cb3f667a Panel renders any Free Solid Font Awesome icon (task 3f21165f) (#291)
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-09-30 18:04:27 -04:00
20 changed files with 3197 additions and 1141 deletions
+92
View File
@@ -1,5 +1,97 @@
# NestQuest Release Notes
## Version 0.10.1 — 2026-10-06
### Scope
Patch release carrying the panel SSE stream fix onto `main` (PR #295, task
4cbf2e96).
### Behaviour
- The Home Assistant integration now reads the panel event stream from the
response body (`response.content`), fixing the logged failure
`'async for' requires an object with __aiter__ method, got HassClientResponse`
that logged ~935 times and prevented live panel events (completions,
un-completions, day-complete, missed) from re-firing from the API's SSE
stream. SSE line-buffering and JSON-frame parsing are unchanged.
### Requirements
- Home Assistant floor 2024.6.0 (unchanged).
### Breaking changes
- None. Schema unchanged at version 9; no migration, no database backup
required.
### Known limitations
- Intermittent `GET /api/v1/panel/events failed with HTTP 502` during API
restarts/redeploys is expected (the integration reconnects with backoff).
- The event stream still reconnects periodically because Home Assistant's
shared aiohttp session applies a default total timeout. During a
disconnect/reconnect window, transitions published by the API are NOT
re-fired on Home Assistant's event bus (the API has no replay), so an
automation could miss a transition in that window; this is tracked as a
follow-up (disable aiohttp's total timeout on the stream).
### Rollback
- No schema change: rolling back to 0.10.0 requires no database restore.
## 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
+5 -5
View File
@@ -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.
+31
View File
@@ -614,3 +614,34 @@
.defs-emoji {
line-height: 1;
}
/* Font Awesome search: glyph + label tiles, inline validation, paging */
.defs-icon-grid--results {
grid-template-columns: repeat(auto-fill, minmax(88px, 1fr));
}
.defs-icon-result {
flex-direction: column;
gap: 4px;
padding: 6px 4px;
font-family: inherit;
}
.defs-icon-label {
max-width: 100%;
overflow: hidden;
font-size: 11px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.defs-hint--error {
color: var(--nq-a-danger-strong);
}
.defs-show-more {
align-self: flex-start;
display: flex;
gap: 8px;
}
+242 -1
View File
@@ -7,8 +7,10 @@ import {
FA_DEFINITION_ICONS,
emojiIconKey,
faIconName,
faTypedIconKey,
findDefinitionIcon,
normalizeDefinitionIconName,
searchFaIcons,
} from "./definitionIcons";
import type { AdminChild, DefinitionRule, QuestDefinition } from "../api/definitions";
import { storeTokens } from "../auth/oidc";
@@ -1353,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");
@@ -1529,6 +1531,245 @@ describe("DefinitionsTab Font Awesome and emoji icons", () => {
});
});
/* ── Font Awesome search ────────────────────────────────────────────── */
describe("DefinitionsTab Font Awesome search", () => {
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.useRealTimers();
});
const CURATED = new Set(FA_DEFINITION_ICONS.map((icon) => icon.name));
function searchInput(s: HTMLElement): HTMLInputElement {
return within(s).getByRole("searchbox", { name: "Search Font Awesome icons" }) as HTMLInputElement;
}
function results(s: HTMLElement): HTMLElement {
return within(s).getByRole("radiogroup", { name: "Font Awesome search results" });
}
function resultKeys(s: HTMLElement): string[] {
return within(results(s))
.getAllByRole("radio")
.map((option) => option.getAttribute("data-icon-option")!);
}
function rowFaIcon(row: HTMLElement): string | null {
const svg = row.querySelector(".defs-row-glyph svg");
return svg!.getAttribute("fill") === "none" ? null : svg!.getAttribute("data-icon");
}
async function save(s: HTMLElement) {
await act(async () => {
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
});
}
function fillNewTask(s: HTMLElement) {
fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
target: { value: "Walk the dog" },
});
fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ }));
fireEvent.click(within(s).getByRole("checkbox", { name: "Declan" }));
fireEvent.click(within(s).getByRole("button", { name: "Morning" }));
}
it("searches the full Free Solid set by name or label, exact and prefix matches first", () => {
expect(searchFaIcons("")).toEqual([]);
expect(searchFaIcons(" ")).toEqual([]);
const dog = searchFaIcons(" DOG ").map((icon) => icon.name);
expect(dog[0]).toBe("dog");
expect(dog).toEqual(expect.arrayContaining(["hotdog", "shield-dog"]));
expect(dog.some((name) => !CURATED.has(name))).toBe(true);
// A label match: "Shield Dog" contains "shield d" though no name does.
expect(searchFaIcons("shield d").map((icon) => icon.name)).toContain("shield-dog");
expect(searchFaIcons("dog-leashed")).toEqual([]);
expect(faTypedIconKey("dragon")).toBe("fa:dragon");
expect(faTypedIconKey(" Dragon ")).toBe("fa:dragon");
for (const bad of ["", "drag", "dgo", "dog-leashed", "github", "fa:dog"]) {
expect(faTypedIconKey(bad), bad).toBeNull();
}
});
it("shows the curated quick picks while the search is empty", async () => {
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "New" }));
const s = sheet();
expect(searchInput(s).value).toBe("");
const quick = within(s).getByRole("radiogroup", { name: "Font Awesome" });
expect(within(quick).getAllByRole("radio")).toHaveLength(FA_DEFINITION_ICONS.length);
expect(within(s).queryByRole("radiogroup", { name: "Font Awesome search results" })).toBeNull();
});
it("a query swaps the quick picks for one bounded, labelled page of results", async () => {
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "New" }));
const s = sheet();
fireEvent.change(searchInput(s), { target: { value: "a" } });
const total = searchFaIcons("a").length;
expect(total).toBeGreaterThan(80);
expect(within(s).queryByRole("radiogroup", { name: "Font Awesome" })).toBeNull();
expect(resultKeys(s)).toHaveLength(40);
expect(within(results(s)).getAllByRole("radio")[0].textContent).toBe(searchFaIcons("a")[0].label);
expect(within(s).getByText(`Showing 1–40 of ${total}`)).toBeTruthy();
expect(within(s).queryByRole("button", { name: "Previous" })).toBeNull();
// "Show more" replaces the page rather than appending to it.
const firstPage = resultKeys(s);
fireEvent.click(within(s).getByRole("button", { name: "Show more" }));
expect(resultKeys(s)).toEqual(searchFaIcons("a").slice(40, 80).map((icon) => `fa:${icon.name}`));
expect(resultKeys(s).some((key) => firstPage.includes(key))).toBe(false);
expect(within(s).getByText(`Showing 41–80 of ${total}`)).toBeTruthy();
fireEvent.click(within(s).getByRole("button", { name: "Previous" }));
expect(resultKeys(s)).toEqual(firstPage);
// A new query starts again from the first page.
fireEvent.change(searchInput(s), { target: { value: "dog" } });
expect(resultKeys(s)[0]).toBe("fa:dog");
expect(resultKeys(s)).toContain("fa:hotdog");
expect(within(s).queryByRole("button", { name: "Show more" })).toBeNull();
fireEvent.change(searchInput(s), { target: { value: "" } });
expect(within(s).getByRole("radiogroup", { name: "Font Awesome" })).toBeTruthy();
});
it("paging through every match never mounts more than one page", async () => {
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "New" }));
const s = sheet();
fireEvent.change(searchInput(s), { target: { value: "-" } });
const all = searchFaIcons("-").map((icon) => `fa:${icon.name}`);
expect(all.length).toBeGreaterThan(900);
const seen: string[] = [];
for (;;) {
expect(results(s).children.length).toBeGreaterThan(0);
expect(results(s).children.length).toBeLessThanOrEqual(40);
seen.push(...resultKeys(s));
const more = within(s).queryByRole("button", { name: "Show more" });
if (!more) break;
fireEvent.click(more);
}
// Every match stays reachable, one page at a time, in order.
expect(seen).toEqual(all);
const lastStart = Math.floor((all.length - 1) / 40) * 40 + 1;
expect(within(s).getByText(`Showing ${lastStart}–${all.length} of ${all.length}`)).toBeTruthy();
});
it("selecting a search result POSTs fa:<name> and renders on its row", async () => {
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "New" }));
const s = sheet();
fillNewTask(s);
fireEvent.change(searchInput(s), { target: { value: "hot" } });
expect(within(s).getByRole("alert").textContent).toMatch(/Select a Font Awesome icon from the results/);
const hotdog = within(results(s)).getByRole("radio", { name: "Hotdog" });
fireEvent.click(hotdog);
expect(hotdog.getAttribute("aria-checked")).toBe("true");
expect(within(s).queryByRole("alert")).toBeNull();
expect(searchInput(s).value).toBe("hot");
await save(s);
expect((api.writes()[0].body as { icon: string }).icon).toBe("fa:hotdog");
await waitFor(() => expect(rowFaIcon(screen.getByTestId("definition-99"))).toBe("hotdog"));
});
it("a typed non-curated Free Solid name is accepted and PATCHed", async () => {
expect(CURATED.has("dragon")).toBe(false);
await renderReady([{ ...BRUSH, id: 70, icon: "lucide:dog" }]);
fireEvent.click(screen.getByTestId("definition-70"));
const s = sheet();
fireEvent.change(searchInput(s), { target: { value: "Dragon" } });
expect(within(s).queryByRole("alert")).toBeNull();
expect(within(results(s)).getByRole("radio", { name: "Dragon" }).getAttribute("aria-checked")).toBe(
"true",
);
const lucide = within(s).getByRole("radiogroup", { name: "Icon" });
expect(within(lucide).queryAllByRole("radio", { checked: true })).toHaveLength(0);
await save(s);
expect((api.writes()[0].body as { icon: string }).icon).toBe("fa:dragon");
await waitFor(() => expect(rowFaIcon(screen.getByTestId("definition-70"))).toBe("dragon"));
});
for (const [typed, message] of [
["do", /Select a Font Awesome icon from the results/],
["dgo", /“dgo” is not a Font Awesome Free Solid icon/],
["dog-leashed", /“dog-leashed” is not a Font Awesome Free Solid icon/],
["github", /“github” is not a Font Awesome Free Solid icon/],
] as const) {
it(`typed ${JSON.stringify(typed)} is rejected with a message and nothing is saved`, async () => {
await renderReady([{ ...BRUSH, id: 71, icon: "lucide:dog" }]);
fireEvent.click(screen.getByTestId("definition-71"));
const s = sheet();
fireEvent.change(searchInput(s), { target: { value: typed } });
expect(within(s).getByRole("alert").textContent).toMatch(message);
expect(within(s).getByRole("radio", { name: "No icon" }).getAttribute("aria-checked")).toBe("false");
await save(s);
expect(api.writes()).toHaveLength(0);
expect(within(s).getByTestId("sheet-error").textContent).toMatch(message);
expect(screen.getByRole("dialog")).toBe(s);
});
}
it("clearing the search, or No icon, removes the icon", async () => {
await renderReady([
{ ...BRUSH, id: 72, icon: "fa:dragon" },
{ ...BRUSH, id: 73, title: "Task 73", icon: "fa:dragon" },
]);
fireEvent.click(screen.getByTestId("definition-72"));
let s = sheet();
fireEvent.change(searchInput(s), { target: { value: "" } });
expect(within(s).getByRole("radio", { name: "No icon" }).getAttribute("aria-checked")).toBe("true");
await save(s);
expect((api.writes()[0].body as { icon: string | null }).icon).toBeNull();
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
fireEvent.click(screen.getByTestId("definition-73"));
s = sheet();
fireEvent.click(within(s).getByRole("radio", { name: "No icon" }));
expect(searchInput(s).value).toBe("");
await save(s);
expect((api.writes()[1].body as { icon: string | null }).icon).toBeNull();
});
it("a saved non-curated fa:<name> renders on its row and opens selected in the results", async () => {
await renderReady([{ ...BRUSH, id: 74, icon: "fa:dragon" }]);
expect(rowFaIcon(screen.getByTestId("definition-74"))).toBe("dragon");
fireEvent.click(screen.getByTestId("definition-74"));
const s = sheet();
expect(searchInput(s).value).toBe("dragon");
expect(within(s).queryByRole("alert")).toBeNull();
expect(within(results(s)).getByRole("radio", { name: "Dragon" }).getAttribute("aria-checked")).toBe(
"true",
);
fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
target: { value: "Feed the dragon" },
});
await save(s);
expect(api.writes()[0].body).not.toHaveProperty("icon");
});
it("a saved curated fa:<name> opens in the quick picks with an empty search", async () => {
await renderReady([{ ...BRUSH, id: 75, icon: "fa:dog" }]);
fireEvent.click(screen.getByTestId("definition-75"));
const s = sheet();
expect(searchInput(s).value).toBe("");
const quick = within(s).getByRole("radiogroup", { name: "Font Awesome" });
expect(within(quick).getByRole("radio", { name: "Dog" }).getAttribute("aria-checked")).toBe("true");
});
});
/* ── Legacy icons ───────────────────────────────────────────────────── */
describe("DefinitionsTab legacy icon edits", () => {
+116 -26
View File
@@ -4,6 +4,7 @@
*/
import {
useEffect,
useMemo,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
@@ -53,8 +54,12 @@ import {
emojiIconText,
faIconKey,
faIconName,
faTextProblem,
faTypedIconKey,
findDefinitionIcon,
lucideIconKey,
normalizeDefinitionIconName,
searchFaIcons,
} from "./definitionIcons";
import { GlyphSvg } from "../icons/GlyphSvg";
import { weekdayOf } from "../schedule/cycle";
@@ -87,6 +92,9 @@ const REPEATS_BY_RULE: Record<DefinitionRule["rule_type"], Repeats> = {
/** How many upcoming dates the edit sheet previews. */
const PREVIEW_COUNT = 5;
/** Font Awesome search results per page; only one page is ever mounted. */
const FA_RESULTS_PAGE = 40;
/** Quiet period after the last rule edit before the preview is requested. */
const PREVIEW_DEBOUNCE_MS = 300;
@@ -101,6 +109,11 @@ interface Draft {
icon: string | null;
/** The emoji field's text; typing in it sets `icon` to `emoji:<text>`. */
emoji: string;
/**
* The Font Awesome search text; typing an exact Free Solid name sets
* `icon` to `fa:<name>`, and picking a search result keeps the text.
*/
faText: string;
assigneeIds: number[];
repeats: Repeats;
interval: string;
@@ -135,6 +148,7 @@ function newDraft(activeChildren: AdminChild[], today: string): Draft {
title: "",
icon: null,
emoji: "",
faText: "",
assigneeIds: activeChildren.length === 1 ? [activeChildren[0].id] : [],
repeats: "daily",
interval: "1",
@@ -157,10 +171,13 @@ function draftFrom(definition: QuestDefinition, activeChildren: AdminChild[], to
const active = new Set(activeChildren.map((child) => child.id));
const dueTimes = emptyDueTimes();
for (const w of definition.windows) dueTimes[w.window] = w.due_time ?? "";
const fa = findDefinitionIcon(definition.icon);
return {
title: definition.title,
icon: definition.icon,
emoji: emojiIconText(definition.icon),
// A Free Solid icon outside the quick picks opens as its search result.
faText: fa?.kind === "fa" && !FA_DEFINITION_ICONS.includes(fa) ? fa.name : "",
// Inactive children cannot be submitted (the API rejects them with 422).
assigneeIds: definition.assignees.map((a) => a.id).filter((id) => active.has(id)),
repeats: REPEATS_BY_RULE[rule.rule_type],
@@ -250,6 +267,7 @@ function buildBody(draft: Draft, keepsAssignees = false): DefinitionCreateBody |
if (draft.emoji && draft.icon === null) {
return "Enter a single emoji (no spaces or < > &), or clear the emoji field.";
}
if (draft.faText.trim() && draft.icon === null) return faTextProblem(draft.faText);
const rule = buildRule(draft);
if (typeof rule === "string") return rule;
const windows: WindowEntry[] = WINDOW_ORDER.filter((w) => draft.windows.includes(w)).map(
@@ -479,6 +497,11 @@ function EditSheet({
? []
: target.assignees.filter((a) => !activeChildren.some((child) => child.id === a.id));
const [pickerOpen, setPickerOpen] = useState(false);
const faResults = useMemo(() => searchFaIcons(draft.faText), [draft.faText]);
const [faPage, setFaPage] = useState(0);
const faStart = faPage * FA_RESULTS_PAGE;
const faEnd = Math.min(faStart + FA_RESULTS_PAGE, faResults.length);
const noIcon = draft.icon === null && !draft.emoji && !draft.faText.trim();
const [saving, setSaving] = useState(false);
const [confirming, setConfirming] = useState(false);
const [confirmingDelete, setConfirmingDelete] = useState(false);
@@ -619,12 +642,10 @@ function EditSheet({
<button
type="button"
role="radio"
aria-checked={draft.icon === null && !draft.emoji}
aria-checked={noIcon}
aria-label="No icon"
className={
draft.icon === null && !draft.emoji ? "defs-icon defs-icon--on" : "defs-icon"
}
onClick={() => update({ icon: null, emoji: "" })}
className={noIcon ? "defs-icon defs-icon--on" : "defs-icon"}
onClick={() => update({ icon: null, emoji: "", faText: "" })}
>
<TaskGlyph size={18} />
</button>
@@ -640,7 +661,7 @@ function EditSheet({
aria-label={label}
data-icon-option={name}
className={on ? "defs-icon defs-icon--on" : "defs-icon"}
onClick={() => update({ icon: lucideIconKey(name), emoji: "" })}
onClick={() => update({ icon: lucideIconKey(name), emoji: "", faText: "" })}
>
<GlyphSvg icon={icon} size={18} />
</button>
@@ -650,26 +671,93 @@ function EditSheet({
<span className="defs-sublabel" id="defs-label-icon-fa">
Font Awesome
</span>
<div className="defs-icon-grid" role="radiogroup" aria-labelledby="defs-label-icon-fa">
{FA_DEFINITION_ICONS.map((icon) => {
const { name, label } = icon;
const on = faIconName(draft.icon) === name;
return (
<button
key={name}
type="button"
role="radio"
aria-checked={on}
aria-label={label}
data-icon-option={faIconKey(name)}
className={on ? "defs-icon defs-icon--on" : "defs-icon"}
onClick={() => update({ icon: faIconKey(name), emoji: "" })}
<input
className="defs-input"
type="search"
aria-label="Search Font Awesome icons"
placeholder="Search or type an icon name, e.g. dog"
value={draft.faText}
onChange={(e) => {
setFaPage(0);
update({ faText: e.target.value, icon: faTypedIconKey(e.target.value), emoji: "" });
}}
/>
{draft.faText.trim() && draft.icon === null ? (
<p className="defs-hint defs-hint--error" role="alert">
{faTextProblem(draft.faText)}
</p>
) : null}
{draft.faText.trim() ? (
faResults.length > 0 ? (
<>
<div
className="defs-icon-grid defs-icon-grid--results"
role="radiogroup"
aria-label="Font Awesome search results"
>
<GlyphSvg icon={icon} size={18} />
</button>
);
})}
</div>
{faResults.slice(faStart, faEnd).map((icon) => {
const { name, label } = icon;
const on = faIconName(draft.icon) === name;
return (
<button
key={name}
type="button"
role="radio"
aria-checked={on}
aria-label={label}
data-icon-option={faIconKey(name)}
className={
on ? "defs-icon defs-icon-result defs-icon--on" : "defs-icon defs-icon-result"
}
onClick={() => update({ icon: faIconKey(name), emoji: "" })}
>
<GlyphSvg icon={icon} size={18} />
<span className="defs-icon-label">{label}</span>
</button>
);
})}
</div>
<p className="defs-hint">
Showing {faStart + 1}–{faEnd} of {faResults.length}
</p>
{faResults.length > FA_RESULTS_PAGE ? (
<div className="defs-show-more">
{faPage > 0 ? (
<button type="button" className="defs-retry" onClick={() => setFaPage((page) => page - 1)}>
Previous
</button>
) : null}
{faEnd < faResults.length ? (
<button type="button" className="defs-retry" onClick={() => setFaPage((page) => page + 1)}>
Show more
</button>
) : null}
</div>
) : null}
</>
) : null
) : (
<div className="defs-icon-grid" role="radiogroup" aria-labelledby="defs-label-icon-fa">
{FA_DEFINITION_ICONS.map((icon) => {
const { name, label } = icon;
const on = faIconName(draft.icon) === name;
return (
<button
key={name}
type="button"
role="radio"
aria-checked={on}
aria-label={label}
data-icon-option={faIconKey(name)}
className={on ? "defs-icon defs-icon--on" : "defs-icon"}
onClick={() => update({ icon: faIconKey(name), emoji: "", faText: "" })}
>
<GlyphSvg icon={icon} size={18} />
</button>
);
})}
</div>
)}
<label className="defs-due">
<span>Emoji</span>
<input
@@ -677,7 +765,9 @@ function EditSheet({
aria-label="Emoji"
placeholder="e.g. 🐶"
value={draft.emoji}
onChange={(e) => update({ emoji: e.target.value, icon: emojiIconKey(e.target.value) })}
onChange={(e) =>
update({ emoji: e.target.value, icon: emojiIconKey(e.target.value), faText: "" })
}
/>
</label>
</Field>
+54 -9
View File
@@ -1,14 +1,23 @@
/**
* 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 { FA_ICONS, LUCIDE_ICONS, resolveIcon, type GlyphIcon } from "../icons/registry.generated";
import {
FA_ICONS,
FA_SOLID_ICONS,
LUCIDE_ICONS,
resolveIcon,
type GlyphIcon,
} from "../icons/registry.generated";
import { TaskGlyph } from "./glyphs";
export type DefinitionIconEntry = GlyphIcon;
@@ -58,14 +67,50 @@ export function faIconName(value: string | null): string | null {
return value?.startsWith(FA_PREFIX) ? value.slice(FA_PREFIX.length) : null;
}
/**
* The Font Awesome Free Solid icons whose name or label contains `query`
* (trimmed, case-insensitive): an exact name first, then name-prefix
* matches, then the rest, each in registry (name) order. An empty query
* gives no results — the curated quick picks cover that case.
*/
export function searchFaIcons(query: string): DefinitionIconEntry[] {
const q = query.trim().toLowerCase();
if (!q) return [];
const rank = (icon: DefinitionIconEntry) => (icon.name === q ? 0 : icon.name.startsWith(q) ? 1 : 2);
return FA_SOLID_ICONS.filter(
(icon) => icon.name.includes(q) || icon.label.toLowerCase().includes(q),
).sort((a, b) => rank(a) - rank(b));
}
/**
* The stored key for a typed Font Awesome name, e.g. `Dog` → `fa:dog`: the
* trimmed, lower-cased text must be the exact name of a Free Solid icon,
* else null (a misspelling, a partial search, or a Pro/Brands name).
*/
export function faTypedIconKey(text: string): string | null {
const key = faIconKey(text.trim().toLowerCase());
return resolveIcon(key).kind === "fa" ? key : null;
}
/**
* Why Font Awesome search text cannot be saved as it stands: it matches
* icons but names none of them, or it matches nothing in the Free Solid set.
*/
export function faTextProblem(text: string): string {
return searchFaIcons(text).length > 0
? "Select a Font Awesome icon from the results, or type its full name."
: `“${text.trim()}” is not a Font Awesome Free Solid icon.`;
}
/** The emoji text an `emoji:<grapheme>` value holds; anything else gives "". */
export function emojiIconText(value: string | null): string {
return value?.startsWith(EMOJI_PREFIX) ? value.slice(EMOJI_PREFIX.length) : "";
}
/**
* 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);
+1 -1
View File
@@ -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"));
+4 -1
View File
@@ -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
+1 -1
View File
@@ -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.1"
}
File diff suppressed because it is too large Load Diff
+16 -5
View File
@@ -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**:
+2 -2
View File
@@ -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.
+2 -1
View File
@@ -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
+3 -2
View File
@@ -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
View File
@@ -1,6 +1,6 @@
[project]
name = "nestquest"
version = "0.9.8"
version = "0.10.1"
description = "NestQuest Home Assistant Integration"
readme = "README.md"
requires-python = ">=3.11"
+85 -1
View File
@@ -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)
+20
View File
@@ -1431,6 +1431,26 @@ def test_open_quest_tile_falls_back_to_the_star() -> None:
assert tile["icon"] is None
def test_open_quest_tile_renders_any_free_solid_icon() -> None:
"""A Font Awesome Free Solid name outside the curated quick-pick list
still renders its glyph from the shipped bundle; an unknown ``fa:`` name
keeps the star."""
tiles = _quest_log_tiles(
[
_quest_row(1, "fa:rocket"),
_quest_row(2, "fa:mug-hot"),
_quest_row(3, "fa:definitely-not-an-icon"),
]
)
for instance_id, key in ((1, "fa:rocket"), (2, "fa:mug-hot")):
assert tiles[instance_id]["icon"] == key
assert tiles[instance_id]["fill"] == "currentColor"
assert tiles[instance_id]["paths"] > 0
assert not tiles[instance_id]["star"]
assert tiles[3]["star"]
assert tiles[3]["icon"] is None
def test_sealed_quest_tile_keeps_the_star() -> None:
"""A sealed card keeps its star tile (and its wax seal) whatever the
definition's icon; only the open card shows the quest's icon."""
+16 -6
View File
@@ -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()
+5 -3
View File
@@ -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";
Generated
+1 -1
View File
@@ -373,7 +373,7 @@ wheels = [
[[package]]
name = "nestquest"
version = "0.9.8"
version = "0.10.1"
source = { virtual = "." }
[package.dev-dependencies]