Release: custom Font Awesome icons (0.10.0) (2026-09-30) #293
@@ -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.
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,14 @@ import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
// The offline generator is the only writer of both registry artefacts.
|
||||
import { OUTPUT_PATHS, REPO_ROOT, SOURCE_PATH, renderRegistry } from "../../../tools/icons/generate.mjs";
|
||||
import { EMOJI_MAX_CODE_POINTS, FALLBACK_ICON, FA_ICONS, LUCIDE_ICONS, resolveIcon } from "./registry.generated";
|
||||
import {
|
||||
EMOJI_MAX_CODE_POINTS,
|
||||
FALLBACK_ICON,
|
||||
FA_ICONS,
|
||||
FA_SOLID_ICONS,
|
||||
LUCIDE_ICONS,
|
||||
resolveIcon,
|
||||
} from "./registry.generated";
|
||||
|
||||
type Curated = { lucide: { name: string; label: string }[]; fa: { name: string; label: string }[] };
|
||||
|
||||
@@ -45,7 +52,18 @@ describe("shared icon registry — one source of truth", () => {
|
||||
expect(icon.viewBox).toMatch(/^0 0 \d+ 512$/);
|
||||
expect(icon.paths.length, icon.name).toBeGreaterThan(0);
|
||||
}
|
||||
for (const icon of [...LUCIDE_ICONS, ...FA_ICONS]) {
|
||||
const solidNames = FA_SOLID_ICONS.map((icon) => icon.name);
|
||||
expect(solidNames.length).toBeGreaterThanOrEqual(1400);
|
||||
expect(solidNames).toEqual([...solidNames].sort());
|
||||
expect(new Set(solidNames).size).toBe(solidNames.length);
|
||||
for (const { name } of FA_ICONS) expect(solidNames, name).toContain(name);
|
||||
for (const icon of FA_SOLID_ICONS) {
|
||||
expect(icon.kind).toBe("fa");
|
||||
expect(icon.key).toBe(`fa:${icon.name}`);
|
||||
expect(icon.viewBox).toMatch(/^0 0 \d+ 512$/);
|
||||
expect(icon.paths.length, icon.name).toBeGreaterThan(0);
|
||||
}
|
||||
for (const icon of [...LUCIDE_ICONS, ...FA_ICONS, ...FA_SOLID_ICONS]) {
|
||||
for (const d of icon.paths) expect(d, icon.key).toMatch(/^[Mm][-0-9.,\sA-Za-z]+$/);
|
||||
}
|
||||
});
|
||||
@@ -61,6 +79,12 @@ describe("resolveIcon", () => {
|
||||
expect(broom.kind).toBe("fa");
|
||||
expect(broom).toBe(FA_ICONS.find((icon) => icon.name === "broom"));
|
||||
|
||||
// Any Free Solid name resolves, not just the curated quick-pick set.
|
||||
const rocket = resolveIcon("fa:rocket");
|
||||
expect(rocket).toBe(FA_SOLID_ICONS.find((icon) => icon.name === "rocket"));
|
||||
expect(rocket).toMatchObject({ kind: "fa", key: "fa:rocket", label: "Rocket" });
|
||||
expect(resolveIcon("fa:person-dress-burst")).toMatchObject({ kind: "fa", label: "Person Dress Burst" });
|
||||
|
||||
expect(resolveIcon("emoji:🐶")).toEqual({ kind: "emoji", key: "emoji:🐶", text: "🐶" });
|
||||
expect(resolveIcon("emoji:👨👩👧")).toEqual({ kind: "emoji", key: "emoji:👨👩👧", text: "👨👩👧" });
|
||||
});
|
||||
@@ -133,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"));
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
@@ -63,6 +63,75 @@ def test_artefacts_carry_exactly_the_curated_names_and_labels() -> None:
|
||||
assert 15 <= len(curated["fa"]) <= 25
|
||||
|
||||
|
||||
def test_artefacts_carry_the_full_font_awesome_free_solid_set() -> None:
|
||||
text = ARTEFACTS[0].read_text(encoding="utf-8")
|
||||
solid = _generated_set(text, "FA_SOLID_ICONS")
|
||||
names = [entry["name"] for entry in solid]
|
||||
assert names == sorted(names)
|
||||
assert len(set(names)) == len(names)
|
||||
# The complete Free Solid set, not a curated pick (7.x ships ~1,400).
|
||||
assert len(solid) >= 1400
|
||||
for entry in solid:
|
||||
assert set(entry) == {"kind", "key", "name", "label", "viewBox", "paths"}
|
||||
assert entry["kind"] == "fa"
|
||||
assert entry["key"] == f"fa:{entry['name']}"
|
||||
assert entry["label"] == " ".join(
|
||||
part[0].upper() + part[1:] for part in entry["name"].split("-")
|
||||
)
|
||||
assert re.fullmatch(r"0 0 \d+ \d+", entry["viewBox"])
|
||||
assert entry["paths"] and all(entry["paths"])
|
||||
# A well-known glyph, a barely-known one, and every curated quick-pick.
|
||||
assert {"rocket", "mug-hot", "person-dress-burst"} <= set(names)
|
||||
assert {e["name"] for e in _curated()["fa"]} <= set(names)
|
||||
assert "rocket-ship-9000" not in names
|
||||
|
||||
|
||||
def test_resolve_icon_resolves_any_free_solid_name() -> None:
|
||||
node = shutil.which("node")
|
||||
if node is None:
|
||||
pytest.skip("needs node")
|
||||
script = (
|
||||
"const m = await import(process.argv[1]);"
|
||||
"const out = {};"
|
||||
"for (const v of JSON.parse(process.argv[2])) {"
|
||||
" const r = m.resolveIcon(v); out[v] = [r.kind, r.label ?? null]; }"
|
||||
"console.log(JSON.stringify(out));"
|
||||
)
|
||||
values = [
|
||||
"fa:rocket",
|
||||
"fa:mug-hot",
|
||||
"fa:person-dress-burst",
|
||||
"fa:paw",
|
||||
"fa:rocket-ship-9000",
|
||||
"fa:Rocket",
|
||||
"fa:",
|
||||
"lucide:dog",
|
||||
"dog",
|
||||
"lucide:rocket",
|
||||
]
|
||||
result = subprocess.run(
|
||||
[node, "--input-type=module", "-e", script,
|
||||
ARTEFACTS[0].as_uri(), json.dumps(values)],
|
||||
cwd=REPO_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0 and "Unknown file extension" in result.stderr:
|
||||
pytest.skip("node cannot strip TypeScript types (needs Node 22.18+)")
|
||||
assert result.returncode == 0, result.stderr
|
||||
resolved = json.loads(result.stdout)
|
||||
assert resolved["fa:rocket"] == ["fa", "Rocket"]
|
||||
assert resolved["fa:mug-hot"] == ["fa", "Mug Hot"]
|
||||
assert resolved["fa:person-dress-burst"] == ["fa", "Person Dress Burst"]
|
||||
# Curated entries keep their quick-pick label.
|
||||
assert resolved["fa:paw"] == ["fa", "Pets"]
|
||||
assert resolved["lucide:dog"][0] == "lucide"
|
||||
assert resolved["dog"][0] == "lucide"
|
||||
for unknown in ("fa:rocket-ship-9000", "fa:Rocket", "fa:", "lucide:rocket"):
|
||||
assert resolved[unknown] == ["fallback", None], unknown
|
||||
|
||||
|
||||
def test_registry_has_no_remote_url_cdn_or_lucide_runtime() -> None:
|
||||
for path in (SOURCE, GENERATOR, *ARTEFACTS):
|
||||
text = path.read_text(encoding="utf-8")
|
||||
|
||||
@@ -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."""
|
||||
|
||||
+60
-10
@@ -2,9 +2,10 @@
|
||||
* Offline generator for the shared quest-icon registry.
|
||||
*
|
||||
* Reads the curated names + labels in `curated-icons.json` (the one source of
|
||||
* truth), pulls each glyph's SVG path data from locally installed packages in
|
||||
* `admin/node_modules` (lucide-react and @fortawesome/free-solid-svg-icons —
|
||||
* no network), and writes the same TypeScript module into both bundles:
|
||||
* truth for the quick-pick lists), pulls each glyph's SVG path data from
|
||||
* locally installed packages in `admin/node_modules` (lucide-react and
|
||||
* @fortawesome/free-solid-svg-icons — no network), and writes the same
|
||||
* TypeScript module into both bundles:
|
||||
*
|
||||
* admin/src/icons/registry.generated.ts
|
||||
* frontend/src/icons/registry.generated.ts
|
||||
@@ -14,6 +15,16 @@
|
||||
*
|
||||
* 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 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"). 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";
|
||||
@@ -89,6 +100,37 @@ function faExportName(name) {
|
||||
return "fa" + name.split("-").map((part) => part[0].toUpperCase() + part.slice(1)).join("");
|
||||
}
|
||||
|
||||
function faGlyph(definition, label) {
|
||||
const { iconName: name } = definition;
|
||||
const [width, height, , , pathData] = definition.icon;
|
||||
const paths = Array.isArray(pathData) ? pathData.filter(Boolean) : [pathData];
|
||||
return { kind: "fa", key: `fa:${name}`, name, label, viewBox: `0 0 ${width} ${height}`, paths };
|
||||
}
|
||||
|
||||
function titleCase(name) {
|
||||
return name.split("-").map((part) => part[0].toUpperCase() + part.slice(1)).join(" ");
|
||||
}
|
||||
|
||||
/** Every Free Solid glyph, one per canonical name, sorted by name. */
|
||||
function allSolidGlyphs(faPackage) {
|
||||
const byName = new Map();
|
||||
for (const definition of Object.values(faPackage)) {
|
||||
if (!definition || typeof definition !== "object" || !Array.isArray(definition.icon)) continue;
|
||||
if (definition.prefix !== "fas") throw new Error(`fa ${definition.iconName}: unexpected prefix ${definition.prefix}`);
|
||||
const name = definition.iconName;
|
||||
if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(name)) throw new Error(`fa ${name}: not a kebab name`);
|
||||
const seen = byName.get(name);
|
||||
if (seen && JSON.stringify(seen.icon) !== JSON.stringify(definition.icon)) {
|
||||
throw new Error(`fa ${name}: two exports disagree on the glyph`);
|
||||
}
|
||||
byName.set(name, definition);
|
||||
}
|
||||
if (byName.size === 0) throw new Error("fa: no icons found in @fortawesome/free-solid-svg-icons");
|
||||
return [...byName.keys()]
|
||||
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
|
||||
.map((name) => faGlyph(byName.get(name), titleCase(name)));
|
||||
}
|
||||
|
||||
function checkCurated(list, namespace) {
|
||||
const seen = new Set();
|
||||
for (const { name, label } of list) {
|
||||
@@ -116,11 +158,9 @@ async function buildEntries() {
|
||||
for (const { name, label } of curated.fa) {
|
||||
const definition = faPackage[faExportName(name)];
|
||||
if (!definition || definition.iconName !== name) throw new Error(`fa ${name}: not in @fortawesome/free-solid-svg-icons`);
|
||||
const [width, height, , , pathData] = definition.icon;
|
||||
const paths = Array.isArray(pathData) ? pathData.filter(Boolean) : [pathData];
|
||||
fa.push({ kind: "fa", key: `fa:${name}`, name, label, viewBox: `0 0 ${width} ${height}`, paths });
|
||||
fa.push(faGlyph(definition, label));
|
||||
}
|
||||
return { lucide, fa };
|
||||
return { lucide, fa, faSolid: allSolidGlyphs(faPackage) };
|
||||
}
|
||||
|
||||
const RESOLVER = `
|
||||
@@ -130,7 +170,10 @@ export const FALLBACK_ICON: FallbackIcon = { kind: "fallback", key: null };
|
||||
export const EMOJI_MAX_CODE_POINTS = 8;
|
||||
|
||||
const LUCIDE_BY_NAME: ReadonlyMap<string, GlyphIcon> = new Map(LUCIDE_ICONS.map((icon) => [icon.name, icon]));
|
||||
const FA_BY_NAME: ReadonlyMap<string, GlyphIcon> = new Map(FA_ICONS.map((icon) => [icon.name, icon]));
|
||||
/** The full Free Solid set, with each curated entry (and its quick-pick label) taking precedence. */
|
||||
const FA_BY_NAME: ReadonlyMap<string, GlyphIcon> = new Map(
|
||||
[...FA_SOLID_ICONS, ...FA_ICONS].map((icon) => [icon.name, icon]),
|
||||
);
|
||||
|
||||
function isEmojiPayload(text: string): boolean {
|
||||
const codePoints = [...text];
|
||||
@@ -142,7 +185,8 @@ function isEmojiPayload(text: string): boolean {
|
||||
* The renderable icon for a stored definition \`icon\` value.
|
||||
*
|
||||
* \`lucide:<name>\` and a legacy bare \`<name>\` resolve against the curated
|
||||
* Lucide set, \`fa:<name>\` against the curated Font Awesome set, and
|
||||
* Lucide set, \`fa:<name>\` against the full Font Awesome Free Solid set
|
||||
* (curated entries keep their quick-pick labels), and
|
||||
* \`emoji:<grapheme>\` to its text. Like the backend's
|
||||
* normalize_icon_for_read, nothing here throws: null, empty, an unknown
|
||||
* namespace or name, or a malformed emoji all resolve to FALLBACK_ICON.
|
||||
@@ -162,7 +206,7 @@ export function resolveIcon(value: string | null | undefined): ResolvedIcon {
|
||||
|
||||
/** The generated module's full text (identical for both bundles). */
|
||||
export async function renderRegistry() {
|
||||
const { lucide, fa } = await buildEntries();
|
||||
const { lucide, fa, faSolid } = await buildEntries();
|
||||
const source = relative(REPO_ROOT, SOURCE_PATH);
|
||||
return `// GENERATED by tools/icons/generate.mjs from ${source} — do not edit.
|
||||
// Regenerate with \`node tools/icons/generate.mjs\` (the drift tests fail if this
|
||||
@@ -202,7 +246,13 @@ export type ResolvedIcon = GlyphIcon | EmojiIcon | FallbackIcon;
|
||||
|
||||
export const LUCIDE_ICONS: readonly GlyphIcon[] = ${JSON.stringify(lucide, null, 2)};
|
||||
|
||||
/** The curated Font Awesome quick-pick set. */
|
||||
export const FA_ICONS: readonly GlyphIcon[] = ${JSON.stringify(fa, null, 2)};
|
||||
|
||||
/** Every Font Awesome Free Solid glyph (${faSolid.length}), sorted by name; one entry per line. */
|
||||
export const FA_SOLID_ICONS: readonly GlyphIcon[] = [
|
||||
${faSolid.map((icon) => JSON.stringify(icon)).join(",\n")}
|
||||
];
|
||||
${RESOLVER}`;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user